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.
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user