Share library/extracted for schematic and PCB review.
PCB AI skips a second PDF exam (pintable cache, pdf_path=None) and the report gains a PCB exam section plus power-trace, thermal, via, Kelvin, and GND-stitch checks grounded in board geometry and extracted specs.
This commit is contained in:
@@ -317,3 +317,223 @@ def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
|
||||
assert jwt_meta is not None
|
||||
assert jwt_meta.pcb_status == "queued"
|
||||
assert proj_svc.get_project(storage, "local", pid) is None
|
||||
|
||||
|
||||
def _ldo_graph_layout(*, i_load=10.0, tj_max=150.0, width=0.15, thickness=0.035):
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
LayoutStackup,
|
||||
Pin,
|
||||
PinConnection,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
|
||||
pins = {"1": "VIN", "2": "VOUT"}
|
||||
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=pins,
|
||||
specs=SimpleComponentSpecs(
|
||||
specs_type="discrete",
|
||||
values={"i_load_a": i_load, "tj_max": tj_max, "theta_ja": 50},
|
||||
),
|
||||
),
|
||||
},
|
||||
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", "B.Cu"],
|
||||
dielectrics=[],
|
||||
copper_thickness_mm=thickness,
|
||||
),
|
||||
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=(20, 0), width=width, layer="F.Cu", net="VOUT"),
|
||||
],
|
||||
)
|
||||
return graph, {"LDO1": cons}, layout
|
||||
|
||||
|
||||
def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout()
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-PWR-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
assert "IPC-2221" in findings[0].finding
|
||||
assert findings[0].recommendation
|
||||
|
||||
|
||||
def test_power_trace_skips_without_i_load():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
||||
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
|
||||
# drop i_load
|
||||
graph.components["U1"].specs = None
|
||||
assert check_pcb_power_traces(graph, cmap, layout) == []
|
||||
|
||||
|
||||
def test_kelvin_sense_pin_on_shared_net():
|
||||
from backend.periscopex.models import ComponentConstraints, Pin, PinConnection
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_kelvin
|
||||
|
||||
cons = ComponentConstraints(
|
||||
mpn="AMP",
|
||||
pintable=[Pin(number="4", name="ISNS", description="current sense")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U2": Component(
|
||||
reference="U2", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="AMP",
|
||||
pins={"4": "ISNS_NET"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="10m", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="",
|
||||
pins={"1": "ISNS_NET", "2": "GND"},
|
||||
),
|
||||
"U9": Component(
|
||||
reference="U9", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="LOAD",
|
||||
pins={"1": "ISNS_NET"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"ISNS_NET": Net(
|
||||
name="ISNS_NET", net_type=NetType.SIGNAL,
|
||||
pins=[
|
||||
PinConnection(component_ref="U2", pin_number="4"),
|
||||
PinConnection(component_ref="R1", pin_number="1"),
|
||||
PinConnection(component_ref="U9", pin_number="1"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_kelvin(graph, {"AMP": cons})
|
||||
assert findings and findings[0].rule_id == "PE-KEL-001"
|
||||
assert findings[0].status == "WARNING"
|
||||
|
||||
|
||||
def test_pcb_ai_skips_without_library_extraction():
|
||||
import asyncio
|
||||
from backend.services.pcb_validation import review_pcb_ics
|
||||
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="X", footprint="",
|
||||
component_type=ComponentType.IC, mpn="NOEXT",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={},
|
||||
)
|
||||
|
||||
async def _run():
|
||||
return await review_pcb_ics(
|
||||
graph, {}, None, None, None, Path("/tmp"),
|
||||
)
|
||||
|
||||
_f, _c, skipped = asyncio.run(_run())
|
||||
assert skipped
|
||||
assert "library extraction" in skipped[0]["reason"]
|
||||
|
||||
|
||||
def test_library_constraints_fill_from_storage(tmp_path: Path):
|
||||
from backend.periscopex.models import ComponentConstraints, Pin
|
||||
from backend.services.pcb_pipeline import _load_constraints_map
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
storage = LocalStorageBackend(tmp_path)
|
||||
cons = ComponentConstraints(
|
||||
mpn="LIBIC",
|
||||
pintable=[Pin(number="1", name="VCC")],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
storage.write_json("library/extracted/LIBIC.json", cons.model_dump())
|
||||
cmap = _load_constraints_map(tmp_path / "missing", storage)
|
||||
assert "LIBIC" in cmap
|
||||
assert cmap["LIBIC"].pintable[0].name == "VCC"
|
||||
|
||||
|
||||
def test_thermal_copper_warns_without_pour_or_vias():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_thermal_copper
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=0.5, width=1.0)
|
||||
findings = check_pcb_thermal_copper(graph, cmap, layout)
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-THM-001"
|
||||
assert findings[0].status == "WARNING"
|
||||
assert findings[0].recommendation
|
||||
|
||||
|
||||
def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
|
||||
from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
|
||||
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "SDA"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"SDA": Net(
|
||||
name="SDA", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="SDA"),
|
||||
],
|
||||
zones=[
|
||||
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
|
||||
],
|
||||
vias=[],
|
||||
)
|
||||
findings = check_pcb_gnd_stitch(graph, layout)
|
||||
assert findings and findings[0].rule_id == "PE-STCH-001"
|
||||
assert findings[0].status == "INFO"
|
||||
layout.vias = [LayoutVia(x=10, y=0, net="GND", drill=0.3)]
|
||||
assert check_pcb_gnd_stitch(graph, layout) == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user