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:
2026-09-19 22:54:17 +02:00
parent a2011dad91
commit 16c606ae3d
28 changed files with 1906 additions and 32 deletions
+3
View File
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
"placement_complete",
"placement_error",
"placement_cancelled",
"pcb_complete",
"pcb_error",
"pcb_cancelled",
})
+20
View File
@@ -350,6 +350,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
if execution_name.startswith("local/placement/"):
project_id = execution_name.split("/", 2)[-1]
return _local_state(f"placement:{project_id}")
if execution_name.startswith("local/pcb/"):
project_id = execution_name.split("/", 2)[-1]
return _local_state(f"pcb:{project_id}")
return _cloud_run_state(execution_name)
@@ -369,6 +372,10 @@ def cancel_execution(execution_name: str | None) -> None:
project_id = execution_name.split("/", 2)[-1]
_local_cancel(f"placement:{project_id}")
return
if execution_name.startswith("local/pcb/"):
project_id = execution_name.split("/", 2)[-1]
_local_cancel(f"pcb:{project_id}")
return
_cloud_run_cancel(execution_name)
@@ -383,3 +390,16 @@ def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
proc_key=f"placement:{project_id}",
execution_name=f"local/placement/{project_id}",
)
def enqueue_pcb_pipeline(project_id: str, user_id: str) -> str:
"""Dispatch the parallel PCB review pipeline (deterministic + AI exam)."""
if use_cloud_run_jobs():
return _enqueue_cloud_run_job(
project_id, user_id, resume=False, free=False, mode="pcb",
)
return _spawn_local_subprocess(
project_id, user_id, resume=False, free=False, mode="pcb",
proc_key=f"pcb:{project_id}",
execution_name=f"local/pcb/{project_id}",
)
+346
View File
@@ -0,0 +1,346 @@
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from backend.periscopex.cad_bridge import (
annotate_findings_cad,
build_cad_bridge,
cad_index_from_graph,
write_cad_bridge,
)
from backend.periscopex.functional_groups import build_placement_plan
from backend.periscopex.graph import build_graph
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
from backend.periscopex.pcb_inventory import build_pcb_inventory
from backend.services import projects as proj_svc
from backend.services.pipeline import PipelineWorkspace, broker
from backend.services.storage import StorageBackend
logger = logging.getLogger(__name__)
_PCB_ACTIVE = frozenset({"queued", "running"})
_ANALYSIS_BUSY = frozenset({
proj_svc.STATUS_QUEUED,
proj_svc.STATUS_RUNNING,
})
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
result: dict[str, ComponentConstraints] = {}
if not extracted_dir.is_dir():
return result
for f in extracted_dir.glob("*.json"):
try:
c = ComponentConstraints.model_validate_json(
f.read_text(encoding="utf-8"),
)
except Exception:
logger.exception("skipping bad extraction %s", f)
continue
result[c.mpn] = c
return result
def _publish(project_id: str, event: str, data: dict) -> None:
broker.publish(project_id, event, data)
def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
payload: dict = {"stage": stage, "status": status}
if detail:
payload["detail"] = detail
_publish(project_id, "pcb_step_update", payload)
async def run_pcb_pipeline(
storage: StorageBackend, user_id: str, project_id: str,
) -> None:
meta = proj_svc.get_project(storage, user_id, project_id)
if not meta:
raise ValueError(f"Project {project_id} not found")
if (meta.pcb_status or "draft") not in _PCB_ACTIVE:
logger.warning(
"pcb worker booted with pcb_status=%s for %s; exiting",
meta.pcb_status, project_id,
)
return
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="running",
pcb_cancel_requested=False,
pcb_state=None,
)
try:
async with PipelineWorkspace(storage, user_id, project_id) as ws:
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
graph = await _ensure_graph(ws, meta, project_id)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "parse_pcb", "running")
layout = _load_layout(ws)
if layout is None:
raise FileNotFoundError(
"Missing or unreadable uploads/pcb.kicad_pcb"
)
_step(
project_id, "parse_pcb", "complete",
f"{len(layout.footprints)} footprints, {len(layout.nets)} nets",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "classify", "running", "domains and groups")
cmap = _load_constraints_map(ws.local_path("extracted"))
plan = build_placement_plan(graph, cmap)
fg_path = ws.local_path("functional_groups.json")
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
ws._upload_file("functional_groups.json")
_step(
project_id, "classify", "complete",
f"{len(plan.domains)} domains, {len(plan.groups)} groups",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "inventory", "running")
z0_by_net: dict[str, float] = {}
try:
from backend.periscopex.impedance_traces import analyze_where_needed
zrep = analyze_where_needed(layout, graph)
for row in zrep.get("nets") or []:
if not isinstance(row, dict) or row.get("error"):
continue
name = str(row.get("net_name") or row.get("name") or "")
z = row.get("z0_ohm") or row.get("mean_z0") or row.get("z0")
if name and isinstance(z, (int, float)):
z0_by_net[name] = float(z)
except Exception:
logger.exception("Z0 inventory skipped")
inventory = build_pcb_inventory(
layout, graph,
domain_ids=[d.domain_id for d in plan.domains],
group_count=len(plan.groups),
z0_by_net=z0_by_net,
)
inv_path = ws.local_path("pcb_inventory.json")
inv_path.write_text(inventory.model_dump_json(indent=2) + "\n")
ws._upload_file("pcb_inventory.json")
_step(
project_id, "inventory", "complete",
f"{len(inventory.nets)} routed nets",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "checks", "running")
findings = run_pcb_checks(graph, cmap, layout)
_step(
project_id, "checks", "complete",
f"{len(findings)} deterministic findings",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "ai_review", "running", "per-IC datasheet vs layout")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
try:
from backend.services.api_logs import ApiLogger
from backend.services.pcb_validation import review_pcb_ics
pdf_dir = ws.local_path("uploads/datasheets")
pdf_dir.mkdir(parents=True, exist_ok=True)
logger_api = ApiLogger()
async def _prog(ref, turn, tool, detail):
_step(
project_id, "ai_review", "running",
f"{ref} {tool} {detail}".strip(),
)
ai_findings, coverage, skipped = await review_pcb_ics(
graph, cmap, layout, plan, inventory,
pdf_dir, storage=storage, api_logger=logger_api,
on_progress=_prog,
)
findings.extend(ai_findings)
logger_api.flush(storage, user_id, project_id)
detail = (
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
)
except Exception:
logger.exception("PCB AI review failed — keeping deterministic findings")
detail = "AI skipped (error)"
_step(project_id, "ai_review", "complete", detail)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "write_report", "running")
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
summary[f.status] = summary.get(f.status, 0) + 1
report = ValidationReport(
project=project_id,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=findings,
summary=summary,
coverage=coverage,
not_reviewed=skipped,
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
ws._upload_file("pcb_report.json")
bridge = build_cad_bridge(report, project_id)
write_cad_bridge(ws.local_path("periscope-findings.json"), bridge)
ws._upload_file("periscope-findings.json")
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="complete",
pcb_state={
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
"skipped": len(skipped),
},
pcb_cancel_requested=False,
)
_publish(project_id, "pcb_complete", {
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
})
except Exception as e:
logger.exception("pcb pipeline failed for %s", project_id)
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="error",
pcb_state={"error": str(e)},
)
_publish(project_id, "pcb_error", {"error": str(e)})
async def _ensure_graph(ws: PipelineWorkspace, meta, project_id: str) -> DesignGraph:
graph_path = ws.local_path("design_graph.json")
if graph_path.is_file():
_step(project_id, "ensure_graph", "running", "reusing design_graph.json")
graph = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
_step(
project_id, "ensure_graph", "complete",
f"{len(graph.components)} components (cached)",
)
return graph
_step(project_id, "ensure_graph", "running", "building design graph")
bom_path = ws.local_path("uploads/bom.csv")
netlist_path = ws.netlist_local_path()
if not bom_path.is_file() or not Path(netlist_path).is_file():
raise FileNotFoundError("Missing BOM or netlist for PCB graph_build")
col_map = meta.bom_columns or {}
graph = build_graph(
str(netlist_path),
str(bom_path),
str(ws.local_path("extracted")),
str(ws.local_path("patterns")),
str(ws.local_path("models")),
reference_col=col_map.get("reference", "Reference"),
mpn_col=col_map.get("mpn", "Manufacturer Part Number"),
include_subdesigns=(
set(meta.netlist_subdesigns)
if meta.netlist_subdesigns is not None
else None
),
pcb_path=ws.local_path("uploads/pcb.kicad_pcb"),
)
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
ws._upload_file("design_graph.json")
_step(
project_id, "ensure_graph", "complete",
f"{len(graph.components)} components, {len(graph.nets)} nets",
)
return graph
def _load_layout(ws: PipelineWorkspace) -> LayoutGraph | None:
cached = ws.local_path("layout_graph.json")
if cached.is_file():
try:
return LayoutGraph.model_validate_json(
cached.read_text(encoding="utf-8"),
)
except Exception:
logger.exception("bad layout_graph.json — trying pcb parse")
pcb = ws.local_path("uploads/pcb.kicad_pcb")
if not pcb.is_file():
return None
try:
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
layout = parse_kicad_pcb(pcb)
cached.write_text(layout.model_dump_json(indent=2) + "\n")
ws._upload_file("layout_graph.json")
return layout
except Exception:
logger.exception("kicad_pcb parse failed during PCB review")
return None
def _cancelled(storage: StorageBackend, user_id: str, project_id: str) -> bool:
meta = proj_svc.get_project(storage, user_id, project_id)
return bool(meta and meta.pcb_cancel_requested)
def _finish_cancelled(storage: StorageBackend, user_id: str, project_id: str) -> None:
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="cancelled",
pcb_cancel_requested=False,
)
_publish(project_id, "pcb_cancelled", {})
def pcb_busy(meta: proj_svc.ProjectMeta) -> bool:
return (meta.pcb_status or "draft") in _PCB_ACTIVE
def analysis_busy(meta: proj_svc.ProjectMeta) -> bool:
return meta.status in _ANALYSIS_BUSY
def placement_busy(meta: proj_svc.ProjectMeta) -> bool:
return (meta.placement_status or "draft") in _PLACEMENT_ACTIVE
+81
View File
@@ -0,0 +1,81 @@
"""Per-IC PCB datasheet review — same agentic loop as schematic, layout context."""
from __future__ import annotations
import logging
from pathlib import Path
from backend.periscopex.cad_bridge import annotate_findings_cad, cad_index_from_graph
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, ValidationReport
from backend.periscopex.pcb_inventory import PcbInventoryReport
from backend.periscopex.pcb_review import PCB_SYSTEM_PROMPT, build_pcb_layout_context
from backend.periscopex.validate import ReviewResult
from backend.services.api_logs import ApiLogger
from backend.services.validation import _find_pdf, review_ic_async
log = logging.getLogger(__name__)
_FIX = "Adjust the layout to match the datasheet recommendation, then re-run PCB review."
def _ensure_recs(findings: list[Finding]) -> None:
for f in findings:
if f.source in (None, "review"):
f.source = "pcb_review"
if not (f.recommendation or "").strip():
f.recommendation = _FIX
async def review_pcb_ics(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
plan: FunctionalGroupsReport | None,
inventory: PcbInventoryReport | None,
pdf_dir: Path,
storage=None,
api_logger: ApiLogger | None = None,
on_progress=None,
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
"""Review each IC with a datasheet. Fail-soft per IC. No auto-place."""
findings: list[Finding] = []
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
cache: dict = {}
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
mpn = (comp.mpn or "").strip() or (comp.value or "").strip()
if not mpn:
skipped.append({"designator": ref, "reason": "no MPN in BOM"})
continue
pdf = _find_pdf(mpn, pdf_dir, storage=storage)
if pdf is None:
skipped.append({"designator": ref, "reason": "no datasheet PDF"})
continue
extra = build_pcb_layout_context(ref, graph, layout, plan, inventory)
try:
result, _trace = await review_ic_async(
graph, constraints_map, ref, str(pdf),
on_progress=on_progress,
api_logger=api_logger,
pdf_dir=pdf_dir,
storage=storage,
excerpt_cache=cache,
extra_context=extra,
system_prompt=PCB_SYSTEM_PROMPT,
log_stage="pcb_review",
)
except Exception:
log.exception("PCB AI review failed for %s — skipping", ref)
skipped.append({"designator": ref, "reason": "pcb_review error"})
continue
if not isinstance(result, ReviewResult):
continue
_ensure_recs(result.findings)
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
findings.extend(result.findings)
if result.checked_areas:
coverage[ref] = list(result.checked_areas)
return findings, coverage, skipped
+2
View File
@@ -264,6 +264,8 @@ class PipelineWorkspace:
self._upload_file("bom_summary.json")
self._upload_file("derating.json")
self._upload_file("report.json")
self._upload_file("pcb_report.json")
self._upload_file("pcb_inventory.json")
self._upload_file("periscope-findings.json")
self._upload_file("review_fingerprints.json")
self._upload_file("api_logs.jsonl")
+70
View File
@@ -143,6 +143,12 @@ class ProjectMeta(BaseModel):
placement_execution_name: str | None = None
placement_cancel_requested: bool = False
# PCB review pipeline (parallel exam — does not overwrite analysis status).
pcb_status: str = "draft"
pcb_state: dict[str, Any] | None = None
pcb_execution_name: str | None = None
pcb_cancel_requested: bool = False
def completed_review_refs_for_retry(
storage: StorageBackend, user_id: str, project_id: str,
@@ -401,6 +407,70 @@ def heal_if_placement_stuck(
)
def heal_if_pcb_stuck(
storage: StorageBackend, user_id: str, project_id: str,
) -> ProjectMeta | None:
"""Unstick pcb_status queued/running when the worker is gone."""
meta = get_project(storage, user_id, project_id)
if meta is None:
return None
pst = meta.pcb_status or "draft"
if pst not in ("queued", "running"):
return None
prefix = _project_prefix(user_id, project_id)
events_prefix = f"{prefix}/events/"
last_event = None
try:
keys = sorted(
k for k in storage.list_prefix(events_prefix)
if k.endswith(".json") and "/events/" in k
)
if keys:
last_event = storage.read_json(keys[-1])
except Exception:
last_event = None
if (last_event or {}).get("event") == "pcb_complete":
data = (last_event or {}).get("data") or {}
return update_project(
storage, user_id, project_id,
pcb_status="complete",
pcb_cancel_requested=False,
pcb_state={
"findings": data.get("findings"),
"domains": data.get("domains"),
"groups": data.get("groups"),
},
)
from backend.services import job_runner
exec_name = meta.pcb_execution_name or f"local/pcb/{project_id}"
try:
state = job_runner.get_execution_state(exec_name)
except Exception:
state = "unknown"
if state in ("pending", "running"):
return None
has_report = storage.exists(f"{prefix}/pcb_report.json")
if has_report:
return update_project(
storage, user_id, project_id,
pcb_status="complete",
pcb_cancel_requested=False,
pcb_state=meta.pcb_state,
)
return update_project(
storage, user_id, project_id,
pcb_status="error",
pcb_cancel_requested=False,
pcb_state={"error": f"PCB worker terminated ({state})"},
)
# --- CRUD ---
+11 -22
View File
@@ -26,7 +26,6 @@ from backend.periscopex.models import (
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
NetType,
ValidationReport,
)
@@ -61,8 +60,6 @@ from backend.periscopex.dnp_check import check_dnp_enables
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.errata_check import check_errata
from backend.periscopex.internal_features_check import check_internal_features
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
from backend.periscopex.crystal_cl_check import check_crystal_cl
from backend.periscopex.nc_pin_check import check_nc_pins
@@ -77,7 +74,6 @@ def _is_deterministic(f: Finding) -> bool:
def _run_deterministic_checks(
graph: DesignGraph, constraints_map: dict,
lifecycle_map: dict | None = None,
layout: LayoutGraph | None = None,
) -> list[Finding]:
"""Run the deterministic graph checks, fail-soft per check — a check bug
can never break the review or the report."""
@@ -100,8 +96,6 @@ def _run_deterministic_checks(
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
("errata_check", lambda: check_errata(graph, constraints_map)),
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout)),
("crystal_cl_check", lambda: check_crystal_cl(graph)),
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
):
@@ -112,17 +106,6 @@ def _run_deterministic_checks(
return out
def _load_layout_graph(graph_path: str) -> LayoutGraph | None:
path = Path(graph_path).with_name("layout_graph.json")
if not path.is_file():
return None
try:
return LayoutGraph.model_validate_json(path.read_text())
except Exception:
log.exception("layout_graph.json invalid — skipping placement_check")
return None
def _assistant_text(blocks) -> str:
"""Best-effort extraction of text content from a completion's raw
assistant blocks. Provider-agnostic and never raises."""
@@ -312,6 +295,9 @@ async def review_ic_async(
pdf_dir: Path | None = None,
storage=None,
excerpt_cache: dict | None = None,
extra_context: str = "",
system_prompt: str | None = None,
log_stage: str = "review",
) -> tuple[ReviewResult, dict]:
"""Review one IC against its datasheet. Async, multi-turn.
@@ -373,7 +359,7 @@ async def review_ic_async(
session = await provider.create_session(
model=model,
system=SYSTEM_PROMPT,
system=system_prompt or SYSTEM_PROMPT,
# Gemini 2.5/3 thinking models count thoughts against this cap.
# 4096 was too tight: U3 (largest IC) burned the entire budget
# on thinking and emitted zero visible output, dropping its
@@ -387,13 +373,16 @@ async def review_ic_async(
)
try:
context = build_component_context(graph, constraints_map, ic_ref)
user_text = f"Review this component's usage:\n\n{context}"
if extra_context.strip():
user_text += "\n\n" + extra_context.strip()
initial_msg = Message(
role="user",
content=[
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
TextBlock(
text=f"Review this component's usage:\n\n{context}",
text=user_text,
cacheable=True,
),
],
@@ -503,7 +492,7 @@ async def review_ic_async(
)
if api_logger:
api_logger.log(
stage="review", identifier=ic_ref,
stage=log_stage, identifier=ic_ref,
model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation,
@@ -607,7 +596,7 @@ async def review_ic_async(
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
if api_logger:
api_logger.log(
stage="review", identifier=ic_ref,
stage=log_stage, identifier=ic_ref,
model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation,
@@ -730,7 +719,7 @@ async def validate_design_async(
if loaded:
lifecycle_map.update(loaded)
deterministic_findings = _run_deterministic_checks(
graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path),
graph, constraints_map, lifecycle_map,
)
pdf_dir_path = Path(pdf_dir)