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
+1 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
CAD_BRIDGE_VERSION = 1
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR")
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR", "PE-DRT", "PE-Z0")
def annotate_findings_cad(
+110
View File
@@ -0,0 +1,110 @@
"""Deterministic PCB review checks (no LLM). Fail-soft at the caller."""
from __future__ import annotations
import logging
from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
log = logging.getLogger(__name__)
_FALLBACK_FIX = "Confirm the layout against the datasheet and adjust the board."
def assign_pcb_finding_ids(findings: list[Finding]) -> None:
"""IDs ``PCB-{designator}-{001}`` so they never collide with schema review."""
counter: Counter[str] = Counter()
for f in findings:
counter[f.designator] += 1
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
if not (f.recommendation or "").strip():
f.recommendation = _FALLBACK_FIX
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
"""ERROR only when both Vop and Vrated exist and Vop exceeds Vrated."""
out: list[Finding] = []
for row in build_derating_table(graph):
rated = row.get("rated_voltage_v")
op = row.get("operating_voltage_v")
if rated is None or op is None:
continue
if float(op) <= float(rated):
continue
ref = str(row.get("designator") or "")
out.append(Finding(
designator=ref,
mpn=str(row.get("mpn") or ""),
aspect="derating",
finding=(
f"{ref} operates at {op:g} V with a {rated:g} V rating."
),
why="Capacitor voltage rating must exceed the rail (no invented derating %).",
status="ERROR",
recommendation=(
f"Replace {ref} with a capacitor rated above {op:g} V, "
"or lower the rail."
),
source="pcb_derating",
rule_id="PE-DRT-001",
net=row.get("net_plus"),
pins=[],
))
return out
def merge_schema_pcb_reports(
schema: dict | None, pcb: dict | None,
) -> dict | None:
"""Combine schematic report.json with pcb_report.json for GET /report."""
if not schema and not pcb:
return None
if not pcb:
out = dict(schema or {})
out["findings"] = [
f for f in (out.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
return out
if not schema:
return dict(pcb)
findings = [
f for f in (schema.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
findings.extend(pcb.get("findings") or [])
out = dict(schema)
out["findings"] = findings
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
st = f.get("status")
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
return out
def run_pcb_checks(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Placement, SI, pad-net match, derating — skip individually on error."""
out: list[Finding] = []
for name, fn in (
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout)),
("pcb_derating", lambda: check_pcb_derating(graph)),
):
try:
out.extend(fn())
except Exception:
log.exception("PCB check %s failed — skipping", name)
assign_pcb_finding_ids(out)
return out
+97
View File
@@ -0,0 +1,97 @@
"""Trace / bus inventory from a LayoutGraph. Numbers only, no findings."""
from __future__ import annotations
import re
from collections import defaultdict
from pydantic import BaseModel
from backend.periscopex.models import DesignGraph, LayoutGraph, NetType
from backend.periscopex.si_check import net_length_mm, partner_net
_BUS_RE = re.compile(r"^(.*?)(\d+)$")
class PcbNetInventory(BaseModel):
name: str
length_mm: float = 0.0
via_count: int = 0
layers: list[str] = []
width_min_mm: float | None = None
width_max_mm: float | None = None
pair: str | None = None
bus: str | None = None
net_type: str | None = None
z0_ohm: float | None = None
class PcbInventoryReport(BaseModel):
nets: list[PcbNetInventory] = []
domains: list[str] = []
group_count: int = 0
def _bus_name(net: str) -> str | None:
m = _BUS_RE.match(net.split("/")[-1].replace(".", "_"))
if not m:
return None
prefix, digits = m.group(1), m.group(2)
if not prefix or len(digits) > 3:
return None
return prefix.rstrip("_") or None
def build_pcb_inventory(
layout: LayoutGraph,
graph: DesignGraph | None = None,
*,
domain_ids: list[str] | None = None,
group_count: int = 0,
z0_by_net: dict[str, float] | None = None,
) -> PcbInventoryReport:
names: set[str] = set()
layers: dict[str, set[str]] = defaultdict(set)
widths: dict[str, list[float]] = defaultdict(list)
vias: dict[str, int] = defaultdict(int)
for s in layout.segments:
if not s.net:
continue
names.add(s.net)
if s.layer:
layers[s.net].add(s.layer)
if s.width > 0:
widths[s.net].append(s.width)
for v in layout.vias:
if v.net:
names.add(v.net)
vias[v.net] += 1
z0_by_net = z0_by_net or {}
rows: list[PcbNetInventory] = []
for name in sorted(names):
w = widths.get(name) or []
sch = graph.nets.get(name) if graph else None
nt = sch.net_type.value if sch and isinstance(sch.net_type, NetType) else (
str(sch.net_type) if sch and sch.net_type else None
)
partner = partner_net(name)
if partner and partner not in names:
partner = None
rows.append(PcbNetInventory(
name=name,
length_mm=round(net_length_mm(layout, name), 3),
via_count=vias.get(name, 0),
layers=sorted(layers.get(name, ())),
width_min_mm=min(w) if w else None,
width_max_mm=max(w) if w else None,
pair=partner,
bus=_bus_name(name),
net_type=nt,
z0_ohm=z0_by_net.get(name),
))
return PcbInventoryReport(
nets=rows,
domains=list(domain_ids or []),
group_count=group_count,
)
+86
View File
@@ -0,0 +1,86 @@
"""Pad net on the PCB vs pin net on the schematic graph.
Silent when either side has no name. Power-flag footprints (#PWR) skipped.
"""
from __future__ import annotations
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
def _ensure_recommendation(f: Finding) -> Finding:
if not (f.recommendation or "").strip():
f.recommendation = (
"Align the PCB pad net with the schematic pin, then re-run PCB review."
)
return f
def check_pcb_net_match(
graph: DesignGraph,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.footprints:
return []
findings: list[Finding] = []
sch_refs = {
ref for ref in graph.components
if not ref.startswith("#")
}
pcb_refs = {
ref for ref in layout.footprints
if not ref.startswith("#")
}
for ref in sorted(sch_refs - pcb_refs):
comp = graph.components[ref]
findings.append(_ensure_recommendation(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="pcb_match",
finding=f"{ref} is in the schematic but has no footprint on the PCB.",
why="Layout review cannot check a part that is not placed.",
status="WARNING",
recommendation=f"Place {ref} on the board or remove it from the schematic/BOM.",
source="pcb_net_match",
rule_id="PE-LAY-002",
pins=[],
)))
for ref, fp in sorted(layout.footprints.items()):
if ref.startswith("#"):
continue
comp = graph.components.get(ref)
if comp is None:
continue
for pad in fp.pads:
if not pad.net or not pad.number:
continue
sch_net = graph.pin_net(ref, pad.number)
if not sch_net:
continue
if sch_net == pad.net:
continue
findings.append(_ensure_recommendation(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="pcb_match",
finding=(
f"{ref}.{pad.number} PCB net '{pad.net}' does not match "
f"schematic net '{sch_net}'."
),
why=(
"Datasheet and SI checks follow schematic net names. "
"A pad on the wrong net is a layout error, not a BOM typo."
),
status="ERROR",
recommendation=(
f"Reconnect pad {ref}.{pad.number} to '{sch_net}' "
f"(or fix the schematic if the PCB is authoritative)."
),
source="pcb_net_match",
rule_id="PE-LAY-001",
net=pad.net,
pins=[pad.number],
)))
return findings
+125
View File
@@ -0,0 +1,125 @@
"""PCB datasheet review prompt and layout neighborhood context (no auto-place)."""
from __future__ import annotations
import math
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.models import DesignGraph, LayoutGraph
from backend.periscopex.pcb_inventory import PcbInventoryReport
from backend.periscopex.si_check import net_length_mm
PCB_SYSTEM_PROMPT = """\
You are an electrical engineer reviewing a PCB layout against the IC \
datasheet. This is an EXAM of the existing board — do not propose a new \
floorplan, do not invent millimetres, and do not write Gerbers.
### Coverage checklist (layout)
- Domains (power-rail islands) and functional groups (IC + satellites: \
decoupling, bulk, filter, crystal, pullup).
- Placement: decoupling and crystal load caps vs datasheet layout notes \
and any numeric layout_rules (max_distance_mm, same_layer, thermal vias).
- Routing: net lengths, differential-pair skew, obvious stubs; length \
match only when the datasheet gives millimetres.
- Impedance: comment on Z0 only when stackup + width numbers are in the \
context. Never assume 50 Ω.
- Derating: flag a capacitor only when both operating and rated voltages \
are present and Vop exceeds Vrated.
- Filters: topology already on the schematic — check whether filter parts \
sit with the IC they serve when coordinates exist.
- Datasheet layout pages (typical application / PCB layout) vs this \
neighborhood.
### Findings
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
recommendation (what to change on the board). INFO still needs a next \
step (e.g. "Measure Z0 after stackup is filled in").
Call submit_review. Empty findings with checked_areas is valid when the \
layout matches the datasheet.
"""
def build_pcb_layout_context(
ic_ref: str,
graph: DesignGraph,
layout: LayoutGraph | None,
plan: FunctionalGroupsReport | None,
inventory: PcbInventoryReport | None = None,
) -> str:
"""Plain-text block appended to the schematic neighborhood for PCB review."""
lines = ["### PCB layout context (existing board — do not move parts)"]
domain_id = None
group = None
if plan is not None:
for g in plan.groups:
if g.ref == ic_ref:
group = g
break
for d in plan.domains:
if ic_ref in d.ic_refs:
domain_id = d.domain_id
lines.append(
f"Domain: {d.domain_id} (power nets: {', '.join(d.power_nets) or ''})"
)
break
if group is None:
lines.append("Functional group: (this IC is not in a classified group)")
else:
lines.append(
f"Functional group: {group.ref} subtype={group.component_subtype or ''} "
f"rank={group.rank}"
)
sats = group.satellites or []
if sats:
lines.append("Satellites:")
for s in sats:
xy = ""
if layout and s.ref in layout.footprints:
fp = layout.footprints[s.ref]
xy = f" @ ({fp.x:.2f},{fp.y:.2f}) {fp.layer}"
lines.append(
f" {s.ref} role={s.role_hint} nets={','.join(s.nets or [])}{xy}"
)
else:
lines.append("Satellites: none")
if layout and ic_ref in layout.footprints:
fp = layout.footprints[ic_ref]
lines.append(
f"Footprint {ic_ref}: ({fp.x:.2f},{fp.y:.2f}) layer={fp.layer} "
f"pads={len(fp.pads)}"
)
near: list[str] = []
for other, ofp in layout.footprints.items():
if other == ic_ref:
continue
dist = math.hypot(ofp.x - fp.x, ofp.y - fp.y)
if dist <= 15.0:
near.append(f"{other} {dist:.1f}mm {ofp.layer}")
if near:
lines.append("Within 15 mm: " + "; ".join(sorted(near)[:24]))
pin_nets = graph.components.get(ic_ref)
if pin_nets:
seen: set[str] = set()
lines.append("Connected net lengths (PCB copper):")
for net_name in pin_nets.pins.values():
if not net_name or net_name in seen:
continue
seen.add(net_name)
mm = net_length_mm(layout, net_name)
lines.append(f" {net_name}: {mm:.2f} mm")
elif layout is None:
lines.append("No LayoutGraph — skip millimetre claims.")
else:
lines.append(f"No footprint for {ic_ref} on the PCB.")
if inventory and inventory.nets:
sample = inventory.nets[:12]
lines.append("Inventory sample (name length_mm pair):")
for n in sample:
lines.append(
f" {n.name}: {n.length_mm:.2f} mm pair={n.pair or ''} "
f"Z0={n.z0_ohm if n.z0_ohm is not None else ''}"
)
if domain_id:
lines.append(f"(domain_id={domain_id})")
return "\n".join(lines)
+6 -1
View File
@@ -104,8 +104,13 @@ async def _run() -> None:
elif mode == "placement":
from backend.services import placement_pipeline as placement_svc
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
elif mode == "pcb":
from backend.services import pcb_pipeline as pcb_svc
await pcb_svc.run_pcb_pipeline(storage, user_id, project_id)
else:
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
raise SystemExit(
f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'"
)
def main() -> None:
+189 -2
View File
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
break
ev = msg["event"]
# Skip placement events in the shared log.
if ev.startswith("placement_"):
if ev.startswith("placement_") or ev.startswith("pcb_"):
continue
yield {
"event": ev,
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
"placement_status": meta.placement_status,
"placement_state": meta.placement_state,
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
"pcb_status": meta.pcb_status,
"pcb_state": meta.pcb_state,
"pcb_running": (meta.pcb_status or "draft") in ("queued", "running"),
"healed": healed is not None,
}
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
async def start_placement(project_id: str, request: Request):
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
from backend.services.placement_pipeline import analysis_busy, placement_busy
from backend.services.pcb_pipeline import pcb_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
@@ -557,6 +561,8 @@ async def start_placement(project_id: str, request: Request):
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
if placement_busy(meta):
raise HTTPException(409, "Placement pipeline already running or queued")
if pcb_busy(meta):
raise HTTPException(409, "PCB review is running; wait or cancel it first")
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
raise HTTPException(
409,
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
if not (
ev.startswith("placement_")
or ev == "heartbeat"
):
) or ev.startswith("pcb_"):
continue
yield {
"event": ev,
@@ -734,6 +740,187 @@ async def placement_events(project_id: str, request: Request):
)
# ---------------------------------------------------------------------------
# PCB review pipeline (parallel — exam, not auto-place)
# ---------------------------------------------------------------------------
_PCB_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
_PCB_SSE_TERMINAL = frozenset({
"pcb_complete",
"pcb_error",
"pcb_cancelled",
})
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
async def start_pcb(project_id: str, request: Request):
from backend.services.pcb_pipeline import analysis_busy, pcb_busy, placement_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not meta.has_pcb:
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
if not meta.has_bom or not meta.has_netlist:
raise HTTPException(400, "Upload BOM and netlist before starting PCB review")
if analysis_busy(meta):
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
if placement_busy(meta):
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
if pcb_busy(meta):
raise HTTPException(409, "PCB review already running or queued")
if (meta.pcb_status or "draft") not in _PCB_START_OK:
raise HTTPException(
409,
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_status="queued",
pcb_cancel_requested=False,
pcb_state=None,
pcb_execution_name=None,
)
try:
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
except Exception:
logger.exception("failed to clear events before PCB start for %s", project_id)
try:
execution_name = job_runner.enqueue_pcb_pipeline(
project_id, owner_user_id,
)
except Exception:
logger.exception("enqueue_pcb_pipeline failed for %s", project_id)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_status="error",
pcb_state={"error": "Failed to enqueue PCB worker"},
)
raise HTTPException(503, "Failed to enqueue PCB worker; please retry")
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_execution_name=execution_name,
)
return {"status": "started", "project_id": project_id}
@router.post("/pipeline/{project_id}/pcb/cancel")
async def cancel_pcb(project_id: str, request: Request):
from backend.services.pcb_pipeline import pcb_busy
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
if not pcb_busy(meta):
raise HTTPException(
409,
f"PCB review is not running (pcb_status={meta.pcb_status})",
)
proj_svc.update_project(
storage, owner_user_id, project_id,
pcb_cancel_requested=True,
)
return {"status": "cancel_requested", "project_id": project_id}
@router.get("/pipeline/{project_id}/pcb/inventory")
async def get_pcb_inventory(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/pcb_inventory.json"
if not storage.exists(key):
raise HTTPException(404, "PCB inventory not found — run PCB review first")
return storage.read_json(key)
@router.get("/pipeline/{project_id}/pcb/events")
async def pcb_events(project_id: str, request: Request):
owner_user_id, meta = await resolve_or_404(request, project_id)
storage = get_storage(request)
async def event_generator():
execution_name = meta.pcb_execution_name
crash_detected: dict[str, str | None] = {"reason": None}
async def watch_status() -> None:
poll_interval = 2.0
saw_active = (meta.pcb_status or "draft") in ("queued", "running")
while True:
await asyncio.sleep(poll_interval)
try:
cur = proj_svc.get_project(storage, owner_user_id, project_id)
except Exception:
continue
if cur is None:
continue
pst = cur.pcb_status or "draft"
if pst in ("queued", "running"):
saw_active = True
elif saw_active and pst in ("complete", "error", "cancelled"):
crash_detected["reason"] = f"pcb_status={pst} (terminal)"
return
if execution_name:
try:
state = job_runner.get_execution_state(execution_name)
except Exception:
state = "unknown"
if state in _EXEC_TERMINAL and (
saw_active or pst in ("queued", "running")
):
crash_detected["reason"] = f"execution state={state}"
return
watcher = asyncio.create_task(watch_status())
try:
async for msg in event_bridge.tail_events(
storage, owner_user_id, project_id,
terminal_events=_PCB_SSE_TERMINAL,
):
if crash_detected["reason"] is not None:
break
ev = msg["event"]
if not (ev.startswith("pcb_") or ev == "heartbeat"):
continue
yield {
"event": ev,
"data": json.dumps(msg.get("data", {})),
}
if ev in _PCB_SSE_TERMINAL:
return
if crash_detected["reason"] is not None:
cur = proj_svc.get_project(storage, owner_user_id, project_id)
err = None
if cur and cur.pcb_state:
err = cur.pcb_state.get("error")
yield {
"event": "pcb_error",
"data": json.dumps({
"error": err or crash_detected["reason"]
or "pcb worker terminated without a terminal event",
"synthetic": True,
}),
}
finally:
watcher.cancel()
try:
await watcher
except (asyncio.CancelledError, Exception):
pass
return EventSourceResponse(
event_generator(),
ping=15,
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+3
View File
@@ -148,6 +148,9 @@ async def get_project(project_id: str, request: Request):
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
if healed_pl is not None:
meta = healed_pl
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
if healed_pcb is not None:
meta = healed_pcb
return meta.model_dump()
+9 -3
View File
@@ -40,10 +40,16 @@ async def get_report(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
schema_key = f"{prefix}/report.json"
pcb_key = f"{prefix}/pcb_report.json"
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
merged = merge_schema_pcb_reports(schema, pcb)
if merged is None:
raise HTTPException(404, "Report not found — run the pipeline first")
return JSONResponse(storage.read_json(key))
return JSONResponse(merged)
@router.get("/report/{project_id}/cad-bridge")
+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)