"""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_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 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_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) == [] 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