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,730 @@
|
||||
"""PCB review checks, inventory, report merge — no invented millimetres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.paths import SIMPLE_PROJECT, TAXONOMY
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementDomain, PlacementIcGroup
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
Net,
|
||||
NetType,
|
||||
)
|
||||
from backend.periscopex.pcb_checks import (
|
||||
assign_pcb_finding_ids,
|
||||
merge_schema_pcb_reports,
|
||||
run_pcb_checks,
|
||||
)
|
||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||
from backend.periscopex.pcb_review import build_pcb_layout_context
|
||||
from backend.services.projects import ProjectMeta, STATUS_QUEUED, STATUS_RUNNING
|
||||
|
||||
SIMPLE = SIMPLE_PROJECT
|
||||
|
||||
|
||||
def _graph() -> DesignGraph:
|
||||
return DesignGraph.model_validate_json(
|
||||
(SIMPLE / "design_graph.json").read_text()
|
||||
)
|
||||
|
||||
|
||||
def test_pad_net_mismatch_is_pe_lay_001():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PE-LAY-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
assert findings[0].recommendation
|
||||
assert "Reconnect pad" in findings[0].recommendation
|
||||
|
||||
|
||||
def test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad():
|
||||
"""PCB /X vs schematic X (Emmaforo table) — one PE-LAY-003, pads treated as same net."""
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
|
||||
pairs = [
|
||||
("/ESP32_EN", "ESP32_EN"),
|
||||
("/ESP_32_BOOT", "ESP_32_BOOT"),
|
||||
("/REGN", "REGN"),
|
||||
("/LED_SDATA", "LED_SDATA"),
|
||||
("/power/REGN", "REGN"),
|
||||
]
|
||||
for pcb, sch in pairs:
|
||||
assert kicad_nets_match(pcb, sch), (pcb, sch)
|
||||
assert not kicad_nets_match("/sheet1/CLK", "/sheet2/CLK")
|
||||
assert not kicad_nets_match("/ESP32_EN", "ESP32_BOOT")
|
||||
|
||||
pins = {
|
||||
"1": "ESP32_EN",
|
||||
"2": "ESP_32_BOOT",
|
||||
"3": "REGN",
|
||||
"4": "LED_SDATA",
|
||||
}
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="ESP",
|
||||
pins=pins,
|
||||
),
|
||||
},
|
||||
nets={n: Net(name=n, net_type=NetType.SIGNAL, pins=[]) for n in pins.values()},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[
|
||||
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
|
||||
LayoutPad(number="2", x=1, y=0, net="/ESP_32_BOOT"),
|
||||
LayoutPad(number="3", x=2, y=0, net="/REGN"),
|
||||
LayoutPad(number="4", x=3, y=0, net="/LED_SDATA"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert [f.rule_id for f in findings] == ["PE-LAY-003"]
|
||||
assert findings[0].status == "INFO"
|
||||
assert "individual pads" in findings[0].recommendation.lower() or "Do not retouch" in findings[0].recommendation
|
||||
assert "Won't fix" not in findings[0].recommendation
|
||||
assert "Reconnect pad" not in findings[0].recommendation
|
||||
|
||||
|
||||
def test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "ESP32_EN", "2": "GND"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"ESP32_EN": Net(name="ESP32_EN", net_type=NetType.SIGNAL, pins=[]),
|
||||
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[
|
||||
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
|
||||
LayoutPad(number="2", x=1, y=0, net="+3V3"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert [f.rule_id for f in findings] == ["PE-LAY-001"]
|
||||
assert findings[0].pins == ["2"]
|
||||
assert "+3V3" in findings[0].finding
|
||||
|
||||
|
||||
def test_missing_footprint_is_pe_lay_002():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"R1": Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
|
||||
)
|
||||
layout = LayoutGraph(footprints={
|
||||
"U9": LayoutFootprint(reference="U9", x=0, y=0, layer="F.Cu"),
|
||||
})
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert any(f.rule_id == "PE-LAY-002" for f in findings)
|
||||
assert all(f.recommendation for f in findings)
|
||||
|
||||
|
||||
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
from backend.periscopex.models import LayoutSegment
|
||||
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=10.0, segments=segs),
|
||||
)
|
||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||
assert plc
|
||||
assert plc[0].finding_id.startswith("PCB-")
|
||||
assert plc[0].recommendation
|
||||
|
||||
|
||||
def test_close_decoupling_has_no_plc_001():
|
||||
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
from backend.periscopex.models import LayoutSegment
|
||||
|
||||
segs = [
|
||||
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
|
||||
]
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=0.5, segments=segs),
|
||||
)
|
||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||
|
||||
|
||||
def test_inventory_lists_net_length_and_pair():
|
||||
layout = LayoutGraph(
|
||||
nets={"USB_DP": 1, "USB_DM": 2},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_DP"),
|
||||
LayoutSegment(start=(0, 1), end=(8, 1), width=0.2, layer="F.Cu", net="USB_DM"),
|
||||
],
|
||||
)
|
||||
inv = build_pcb_inventory(layout)
|
||||
names = {n.name: n for n in inv.nets}
|
||||
assert names["USB_DP"].length_mm == 10.0
|
||||
assert names["USB_DP"].pair == "USB_DM"
|
||||
|
||||
|
||||
def test_layout_context_includes_via_counts():
|
||||
from backend.periscopex.models import LayoutStackup, LayoutVia, LayoutZone
|
||||
|
||||
graph = _graph()
|
||||
plan = FunctionalGroupsReport(
|
||||
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
|
||||
groups=[PlacementIcGroup(ref="U3", satellites=[])],
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
stackup=LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[],
|
||||
copper_thickness_mm=0.035,
|
||||
),
|
||||
footprints={
|
||||
"U3": LayoutFootprint(
|
||||
reference="U3", x=0, y=0, layer="F.Cu",
|
||||
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||
),
|
||||
},
|
||||
vias=[
|
||||
LayoutVia(x=0.2, y=0.1, net="GND", drill=0.3),
|
||||
LayoutVia(x=-0.4, y=0.0, net="GND", drill=0.3),
|
||||
],
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(5, 0), width=0.45, layer="F.Cu", net="+3V3"),
|
||||
],
|
||||
zones=[
|
||||
LayoutZone(
|
||||
net="",
|
||||
layer="F.Cu",
|
||||
keepout=True,
|
||||
name="ANT_KEEPOUT",
|
||||
outlines=[[(20, 20), (30, 20), (30, 30), (20, 30)]],
|
||||
),
|
||||
],
|
||||
)
|
||||
text = build_pcb_layout_context("U3", graph, layout, plan)
|
||||
assert "Vias under footprint: 2" in text
|
||||
assert "count=2" in text
|
||||
assert "0.3 mm" in text
|
||||
assert "Copper thickness: 35 µm" in text
|
||||
assert "width=0.45 mm" in text
|
||||
assert "ANT_KEEPOUT" in text
|
||||
assert "Board keepout polygons" in text
|
||||
|
||||
|
||||
def test_pcb_ai_finding_gets_action():
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.pcb_validation import _ensure_recs
|
||||
|
||||
f = Finding(
|
||||
designator="U1",
|
||||
mpn="PADIC",
|
||||
finding="PowerPAD under U1",
|
||||
why="datasheet thermal pad",
|
||||
status="INFO",
|
||||
recommendation="",
|
||||
action="",
|
||||
source="pcb_review",
|
||||
)
|
||||
_ensure_recs([f])
|
||||
complete_finding(f)
|
||||
assert f.action.strip()
|
||||
assert f.recommendation.strip()
|
||||
assert f.finding_class == "REVIEW"
|
||||
assert f.facts
|
||||
assert f.requirement
|
||||
|
||||
|
||||
def test_parse_review_action_field_without_recommendation():
|
||||
from backend.periscopex.review_parse import parse_submit_review as _parse_review
|
||||
|
||||
result = _parse_review(
|
||||
{
|
||||
"findings": [{
|
||||
"finding": "EPAD vias present",
|
||||
"why": "layout note",
|
||||
"status": "INFO",
|
||||
"source_page": 12,
|
||||
"source_quote": "Connect EPAD with vias to GND.",
|
||||
"action": "Keep the nine 0.3 mm vias under U5 EPAD.",
|
||||
}],
|
||||
"checked_areas": ["thermal"],
|
||||
},
|
||||
"U5",
|
||||
"ESP",
|
||||
)
|
||||
f = result.findings[0]
|
||||
assert f.action == "Keep the nine 0.3 mm vias under U5 EPAD."
|
||||
assert f.recommendation == "Keep the nine 0.3 mm vias under U5 EPAD."
|
||||
|
||||
|
||||
def test_layout_context_includes_domain_and_group():
|
||||
graph = _graph()
|
||||
plan = FunctionalGroupsReport(
|
||||
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
|
||||
groups=[PlacementIcGroup(ref="U3", satellites=[])],
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={"U3": LayoutFootprint(reference="U3", x=1, y=2, layer="F.Cu")},
|
||||
)
|
||||
text = build_pcb_layout_context("U3", graph, layout, plan)
|
||||
assert "Domain: 3v3" in text
|
||||
assert "Functional group: U3" in text
|
||||
assert "Footprint U3" in text
|
||||
assert "Vias under footprint" in text
|
||||
|
||||
|
||||
def test_merge_reports_prefixes_do_not_collide():
|
||||
schema = {"findings": [{"finding_id": "U3-001", "status": "WARNING"}]}
|
||||
pcb = {"findings": [{"finding_id": "PCB-U3-001", "status": "ERROR"}]}
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
ids = [f["finding_id"] for f in merged["findings"]]
|
||||
assert ids == ["U3-001", "PCB-U3-001"]
|
||||
assert merged["summary"]["ERROR"] == 1
|
||||
assert merged["summary"]["WARNING"] == 1
|
||||
|
||||
|
||||
def test_assign_fills_empty_recommendation():
|
||||
from backend.periscopex.models import Finding
|
||||
|
||||
f = Finding(
|
||||
designator="U1", mpn="", finding="x", why="y", status="INFO",
|
||||
recommendation="",
|
||||
)
|
||||
assign_pcb_finding_ids([f])
|
||||
assert f.finding_id == "PCB-U1-001"
|
||||
assert f.recommendation
|
||||
|
||||
|
||||
def test_pcb_busy_helpers():
|
||||
active = frozenset({"queued", "running"})
|
||||
analysis = frozenset({STATUS_QUEUED, STATUS_RUNNING})
|
||||
draft = ProjectMeta(id="p", name="t", created="2026-01-01", user_id="u")
|
||||
assert (draft.pcb_status or "draft") not in active
|
||||
draft.pcb_status = "queued"
|
||||
assert (draft.pcb_status or "draft") in active
|
||||
draft.status = STATUS_RUNNING
|
||||
assert draft.status in analysis
|
||||
|
||||
|
||||
def test_heal_dead_analysis_worker_unblocks_pcb(tmp_path: Path, monkeypatch):
|
||||
from backend.services import job_runner, projects as proj_svc
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
storage = LocalStorageBackend(tmp_path)
|
||||
meta = proj_svc.create_project(storage, "usr_jwt", "board")
|
||||
proj_svc.update_project(
|
||||
storage, "usr_jwt", meta.id,
|
||||
status=STATUS_RUNNING,
|
||||
has_pcb=True,
|
||||
has_bom=True,
|
||||
has_netlist=True,
|
||||
pcb_status="draft",
|
||||
)
|
||||
monkeypatch.setattr(job_runner, "get_execution_state", lambda _n: "failed")
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, "usr_jwt", meta.id)
|
||||
assert healed is not None
|
||||
assert healed.status == "error"
|
||||
from backend.services.pcb_pipeline import analysis_busy
|
||||
|
||||
assert not analysis_busy(healed)
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
client = TestClient(app)
|
||||
pid = client.post("/api/projects", json={"name": "noboard"}).json()["id"]
|
||||
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
|
||||
assert resp.status_code == 400
|
||||
assert "kicad_pcb" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
|
||||
"""JWT owner path with JSON still saying user_id=local (live Emmaforo)."""
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
storage = LocalStorageBackend(tmp_path)
|
||||
pid = "759a4dea9612"
|
||||
owner = "usr_jwt_owner"
|
||||
storage.write_json(
|
||||
f"users/{owner}/projects/{pid}/project.json",
|
||||
{
|
||||
"id": pid,
|
||||
"name": "Emmaforo",
|
||||
"user_id": "local",
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"status": "complete",
|
||||
"has_pcb": True,
|
||||
"has_bom": True,
|
||||
"has_netlist": True,
|
||||
"pcb_status": "draft",
|
||||
},
|
||||
)
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
proj_svc.update_project(storage, owner, pid, pcb_status="queued")
|
||||
jwt_meta = proj_svc.get_project(storage, owner, pid)
|
||||
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_uses_parsed_width_and_thickness():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=0.05, width=1.0, thickness=0.035)
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings == []
|
||||
|
||||
|
||||
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]
|
||||
graph.components["U1"].specs = None
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_power_trace_skips_iout_max_as_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.clear() # type: ignore[union-attr]
|
||||
graph.components["U1"].specs.values["iout_max_a"] = 10.0 # type: ignore[union-attr]
|
||||
graph.components["U1"].specs.values["tj_max"] = 150.0 # type: ignore[union-attr]
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_power_trace_uses_imax_when_i_load_absent():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
||||
vals = graph.components["U1"].specs.values # type: ignore[union-attr]
|
||||
vals.pop("i_load_a", None)
|
||||
vals["i_max"] = 10.0
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-PWR-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
|
||||
|
||||
def test_power_trace_uses_i_abs_when_i_load_absent():
|
||||
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
|
||||
|
||||
graph, cmap, layout = _ldo_graph_layout(i_load=10)
|
||||
vals = graph.components["U1"].specs.values # type: ignore[union-attr]
|
||||
vals.pop("i_load_a", None)
|
||||
vals["i_abs"] = 10.0
|
||||
findings = check_pcb_power_traces(graph, cmap, layout)
|
||||
assert findings
|
||||
assert findings[0].rule_id == "PE-PWR-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
|
||||
|
||||
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_skips_tiny_gpio():
|
||||
from backend.periscopex.models import 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": "GPIO4"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"GPIO4": Net(
|
||||
name="GPIO4", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(0.1, 3), width=0.2, layer="F.Cu", net="GPIO4"),
|
||||
],
|
||||
zones=[
|
||||
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
|
||||
],
|
||||
vias=[],
|
||||
)
|
||||
assert check_pcb_gnd_stitch(graph, layout) == []
|
||||
|
||||
|
||||
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": "USB_DP"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"USB_DP": Net(
|
||||
name="USB_DP", net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref="U1", pin_number="1")],
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
|
||||
],
|
||||
zones=[
|
||||
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
|
||||
],
|
||||
vias=[],
|
||||
)
|
||||
findings = check_pcb_gnd_stitch(graph, layout)
|
||||
assert findings and findings[0].rule_id == "PE-STCH-001"
|
||||
assert findings[0].status == "INFO"
|
||||
assert findings[0].action
|
||||
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
|
||||
assert check_pcb_gnd_stitch(graph, layout) == []
|
||||
|
||||
|
||||
def test_pcb_pipeline_imports_build_placement_plan():
|
||||
import backend.services.pcb_pipeline as pcb_pipeline
|
||||
from backend.periscopex.functional_groups import build_placement_plan as expected
|
||||
|
||||
assert pcb_pipeline.build_placement_plan is expected
|
||||
|
||||
Reference in New Issue
Block a user