Add MODE=pcb layout exam job with AI and deterministic checks.
Parallel pcb_status pipeline: parse board, classify domains/groups, inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review. Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""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
|
||||
|
||||
|
||||
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_pcb_start_requires_board(tmp_path: Path):
|
||||
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"]
|
||||
@@ -278,3 +278,14 @@ def test_keepout_without_courtyard_is_silent():
|
||||
_xtal_keepout_cons(),
|
||||
_x1_keepout_layout(net="GND", courtyard=False),
|
||||
) == []
|
||||
|
||||
|
||||
def test_schema_deterministic_runner_omits_layout_checks():
|
||||
"""PCB placement/SI belong to MODE=pcb, not the schematic review seed."""
|
||||
import inspect
|
||||
|
||||
from backend.services.validation import _run_deterministic_checks
|
||||
|
||||
src = inspect.getsource(_run_deterministic_checks)
|
||||
assert "check_placement" not in src
|
||||
assert "check_si" not in src
|
||||
|
||||
Reference in New Issue
Block a user