Layout and AI findings always get action (fallback recommendation). The card renders that sentence in the body. PCB review context now includes vias under the footprint, copper thickness, nearby widths, courtyard, and keepout polygons.
683 lines
23 KiB
Python
683 lines
23 KiB
Python
"""PCB review checks, inventory, report merge — no invented millimetres."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 = Path(__file__).resolve().parents[1] / "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.test_placement_check import _xtal_cons, _x1_c9_layout
|
|
|
|
findings = run_pcb_checks(
|
|
_graph(),
|
|
_xtal_cons(max_distance_mm=2.0),
|
|
_x1_c9_layout(cap_x=10.0),
|
|
)
|
|
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.test_placement_check import _xtal_cons, _x1_c9_layout
|
|
|
|
findings = run_pcb_checks(
|
|
_graph(),
|
|
_xtal_cons(max_distance_mm=2.0),
|
|
_x1_c9_layout(cap_x=0.5),
|
|
)
|
|
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.validate import _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
|
|
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_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
|
|
|