Files
periscope/tests/test_pcb_review.py
T
michele facbaa2305 Show Action on finding cards and inject PCB geometry into AI context.
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.
2026-09-20 09:13:51 +02:00

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