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 from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
CAD_BRIDGE_VERSION = 1 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( 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": elif mode == "placement":
from backend.services import placement_pipeline as placement_svc from backend.services import placement_pipeline as placement_svc
await placement_svc.run_placement_pipeline(storage, user_id, project_id) 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: 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: def main() -> None:
+189 -2
View File
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
break break
ev = msg["event"] ev = msg["event"]
# Skip placement events in the shared log. # Skip placement events in the shared log.
if ev.startswith("placement_"): if ev.startswith("placement_") or ev.startswith("pcb_"):
continue continue
yield { yield {
"event": ev, "event": ev,
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
"placement_status": meta.placement_status, "placement_status": meta.placement_status,
"placement_state": meta.placement_state, "placement_state": meta.placement_state,
"placement_running": (meta.placement_status or "draft") in ("queued", "running"), "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, "healed": healed is not None,
} }
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
async def start_placement(project_id: str, request: Request): async def start_placement(project_id: str, request: Request):
"""Enqueue the Placement topology pipeline (free, no analysis status change).""" """Enqueue the Placement topology pipeline (free, no analysis status change)."""
from backend.services.placement_pipeline import analysis_busy, placement_busy from backend.services.placement_pipeline import analysis_busy, placement_busy
from backend.services.pcb_pipeline import pcb_busy
storage = get_storage(request) storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id) 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") raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
if placement_busy(meta): if placement_busy(meta):
raise HTTPException(409, "Placement pipeline already running or queued") 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: if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
raise HTTPException( raise HTTPException(
409, 409,
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
if not ( if not (
ev.startswith("placement_") ev.startswith("placement_")
or ev == "heartbeat" or ev == "heartbeat"
): ) or ev.startswith("pcb_"):
continue continue
yield { yield {
"event": ev, "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 # 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) healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
if healed_pl is not None: if healed_pl is not None:
meta = healed_pl 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() 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) storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id) owner_user_id, _ = await resolve_or_404(request, project_id)
prefix = proj_svc.project_prefix(owner_user_id, project_id) prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json" schema_key = f"{prefix}/report.json"
if not storage.exists(key): 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") 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") @router.get("/report/{project_id}/cad-bridge")
+3
View File
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
"placement_complete", "placement_complete",
"placement_error", "placement_error",
"placement_cancelled", "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/"): if execution_name.startswith("local/placement/"):
project_id = execution_name.split("/", 2)[-1] project_id = execution_name.split("/", 2)[-1]
return _local_state(f"placement:{project_id}") 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) 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] project_id = execution_name.split("/", 2)[-1]
_local_cancel(f"placement:{project_id}") _local_cancel(f"placement:{project_id}")
return 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) _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}", proc_key=f"placement:{project_id}",
execution_name=f"local/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("bom_summary.json")
self._upload_file("derating.json") self._upload_file("derating.json")
self._upload_file("report.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("periscope-findings.json")
self._upload_file("review_fingerprints.json") self._upload_file("review_fingerprints.json")
self._upload_file("api_logs.jsonl") self._upload_file("api_logs.jsonl")
+70
View File
@@ -143,6 +143,12 @@ class ProjectMeta(BaseModel):
placement_execution_name: str | None = None placement_execution_name: str | None = None
placement_cancel_requested: bool = False 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( def completed_review_refs_for_retry(
storage: StorageBackend, user_id: str, project_id: str, 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 --- # --- CRUD ---
+11 -22
View File
@@ -26,7 +26,6 @@ from backend.periscopex.models import (
ComponentType, ComponentType,
DesignGraph, DesignGraph,
Finding, Finding,
LayoutGraph,
NetType, NetType,
ValidationReport, 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.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.errata_check import check_errata from backend.periscopex.errata_check import check_errata
from backend.periscopex.internal_features_check import check_internal_features 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.crystal_cl_check import check_crystal_cl
from backend.periscopex.nc_pin_check import check_nc_pins from backend.periscopex.nc_pin_check import check_nc_pins
@@ -77,7 +74,6 @@ def _is_deterministic(f: Finding) -> bool:
def _run_deterministic_checks( def _run_deterministic_checks(
graph: DesignGraph, constraints_map: dict, graph: DesignGraph, constraints_map: dict,
lifecycle_map: dict | None = None, lifecycle_map: dict | None = None,
layout: LayoutGraph | None = None,
) -> list[Finding]: ) -> list[Finding]:
"""Run the deterministic graph checks, fail-soft per check — a check bug """Run the deterministic graph checks, fail-soft per check — a check bug
can never break the review or the report.""" can never break the review or the report."""
@@ -100,8 +96,6 @@ def _run_deterministic_checks(
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)), ("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
("errata_check", lambda: check_errata(graph, constraints_map)), ("errata_check", lambda: check_errata(graph, constraints_map)),
("internal_features_check", lambda: check_internal_features(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)), ("crystal_cl_check", lambda: check_crystal_cl(graph)),
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)), ("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
): ):
@@ -112,17 +106,6 @@ def _run_deterministic_checks(
return out 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: def _assistant_text(blocks) -> str:
"""Best-effort extraction of text content from a completion's raw """Best-effort extraction of text content from a completion's raw
assistant blocks. Provider-agnostic and never raises.""" assistant blocks. Provider-agnostic and never raises."""
@@ -312,6 +295,9 @@ async def review_ic_async(
pdf_dir: Path | None = None, pdf_dir: Path | None = None,
storage=None, storage=None,
excerpt_cache: dict | None = None, excerpt_cache: dict | None = None,
extra_context: str = "",
system_prompt: str | None = None,
log_stage: str = "review",
) -> tuple[ReviewResult, dict]: ) -> tuple[ReviewResult, dict]:
"""Review one IC against its datasheet. Async, multi-turn. """Review one IC against its datasheet. Async, multi-turn.
@@ -373,7 +359,7 @@ async def review_ic_async(
session = await provider.create_session( session = await provider.create_session(
model=model, model=model,
system=SYSTEM_PROMPT, system=system_prompt or SYSTEM_PROMPT,
# Gemini 2.5/3 thinking models count thoughts against this cap. # Gemini 2.5/3 thinking models count thoughts against this cap.
# 4096 was too tight: U3 (largest IC) burned the entire budget # 4096 was too tight: U3 (largest IC) burned the entire budget
# on thinking and emitted zero visible output, dropping its # on thinking and emitted zero visible output, dropping its
@@ -387,13 +373,16 @@ async def review_ic_async(
) )
try: try:
context = build_component_context(graph, constraints_map, ic_ref) 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( initial_msg = Message(
role="user", role="user",
content=[ content=[
PdfBlock(path=Path(trimmed_pdf), cacheable=True), PdfBlock(path=Path(trimmed_pdf), cacheable=True),
TextBlock( TextBlock(
text=f"Review this component's usage:\n\n{context}", text=user_text,
cacheable=True, cacheable=True,
), ),
], ],
@@ -503,7 +492,7 @@ async def review_ic_async(
) )
if api_logger: if api_logger:
api_logger.log( api_logger.log(
stage="review", identifier=ic_ref, stage=log_stage, identifier=ic_ref,
model=model, provider=provider.name, model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output, input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation, cache_creation_input_tokens=total_cache_creation,
@@ -607,7 +596,7 @@ async def review_ic_async(
trace["duration_ms"] = int((time.monotonic() - t0) * 1000) trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
if api_logger: if api_logger:
api_logger.log( api_logger.log(
stage="review", identifier=ic_ref, stage=log_stage, identifier=ic_ref,
model=model, provider=provider.name, model=model, provider=provider.name,
input_tokens=total_input, output_tokens=total_output, input_tokens=total_input, output_tokens=total_output,
cache_creation_input_tokens=total_cache_creation, cache_creation_input_tokens=total_cache_creation,
@@ -730,7 +719,7 @@ async def validate_design_async(
if loaded: if loaded:
lifecycle_map.update(loaded) lifecycle_map.update(loaded)
deterministic_findings = _run_deterministic_checks( 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) pdf_dir_path = Path(pdf_dir)
+9
View File
@@ -2,6 +2,15 @@
What's new in Periscope. What's new in Periscope.
## 2.30.0 — 2026-09-19 — PCB review pipeline (exam, not auto-place)
Parallel `MODE=pcb` job examines an uploaded `.kicad_pcb`: domains/groups, trace inventory, deterministic layout checks, and per-IC AI datasheet review. Findings merge into the report with a Layout filter. No packing or pcbnew write-back.
- [New] `POST /api/pipeline/{id}/pcb/start` — PCB exam job (`pcb_status`).
- [New] Deterministic `PE-LAY-*`, `PE-PLC-*`, `PE-SI-001`, `PE-DRT-001` with required recommendations.
- [New] AI PCB review (same loop as schematic) with layout/domain context.
- [Changed] Schematic review no longer seeds placement/SI checks.
## 2.29.1 — 2026-09-13 — Fix sign-in /login 404 after rebrand ## 2.29.1 — 2026-09-13 — Fix sign-in /login 404 after rebrand
Landing CTAs pointed at `/login`, which did not exist (page is `/sign-in`). Added redirects, accepted legacy JWT issuer `pinscope-local`, and CORS for both Periscope and Pinscope hosts. Landing CTAs pointed at `/login`, which did not exist (page is `/sign-in`). Added redirects, accepted legacy JWT issuer `pinscope-local`, and CORS for both Periscope and Pinscope hosts.
+63 -1
View File
@@ -17,6 +17,7 @@ import {
makeCollaboratorOwner, makeCollaboratorOwner,
startPipeline, startPipeline,
startPlacementPipeline, startPlacementPipeline,
startPcbPipeline,
reprocessPipeline, reprocessPipeline,
resumePipeline, resumePipeline,
fetchPipelineEstimate, fetchPipelineEstimate,
@@ -40,6 +41,7 @@ import {
Check, Check,
Upload, Upload,
LayoutGrid, LayoutGrid,
CircuitBoard,
} from "lucide-react"; } from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth"; import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel"; import { ImpedancePanel } from "@/components/project/impedance-panel";
@@ -132,10 +134,20 @@ export default function ProjectDetailPage({
} }
}, [project?.placementStatus, id, router]); }, [project?.placementStatus, id, router]);
useEffect(() => {
if (
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued"
) {
router.replace(`/project/${id}/pcb`);
}
}, [project?.pcbStatus, id, router]);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tab = searchParams.get("tab") ?? "bom"; const tab = searchParams.get("tab") ?? "bom";
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [startingPlacement, setStartingPlacement] = useState(false); const [startingPlacement, setStartingPlacement] = useState(false);
const [startingPcb, setStartingPcb] = useState(false);
const [estimate, setEstimate] = useState<CostEstimate | null>(null); const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [rerunProject, setRerunProject] = useState<Project | null>(null); const [rerunProject, setRerunProject] = useState<Project | null>(null);
@@ -215,8 +227,13 @@ export default function ProjectDetailPage({
const placementBusy = const placementBusy =
project?.placementStatus === "running" || project?.placementStatus === "running" ||
project?.placementStatus === "queued"; project?.placementStatus === "queued";
const pcbBusy =
project?.pcbStatus === "running" ||
project?.pcbStatus === "queued";
const canStartPlacement = const canStartPlacement =
Boolean(canRun) && !analysisBusy && !placementBusy; Boolean(canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const canStartPcb =
Boolean(project?.hasPcb && canRun) && !analysisBusy && !placementBusy && !pcbBusy;
const handlePlacement = async () => { const handlePlacement = async () => {
if (!canStartPlacement) return; if (!canStartPlacement) return;
@@ -230,6 +247,18 @@ export default function ProjectDetailPage({
} }
}; };
const handlePcbReview = async () => {
if (!canStartPcb) return;
setStartingPcb(true);
try {
await startPcbPipeline(id);
router.push(`/project/${id}/pcb`);
} catch (e) {
setStartingPcb(false);
alert(e instanceof Error ? e.message : "Failed to start PCB review");
}
};
const hasFailedReviews = Boolean(hasSkipped); const hasFailedReviews = Boolean(hasSkipped);
return ( return (
@@ -338,6 +367,26 @@ export default function ProjectDetailPage({
</Button> </Button>
</Link> </Link>
)} )}
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{project.pcbStatus === "complete" && (
<Link href={`/project/${id}/pcb`}>
<Button size="sm" variant="ghost">
View PCB review
</Button>
</Link>
)}
</div> </div>
</div> </div>
) : isPaused ? ( ) : isPaused ? (
@@ -385,6 +434,19 @@ export default function ProjectDetailPage({
? "Rebuild placement" ? "Rebuild placement"
: "Build placement plan"} : "Build placement plan"}
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={!canStartPcb || startingPcb}
onClick={handlePcbReview}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{startingPcb
? "Starting…"
: project.pcbStatus === "complete"
? "Re-run PCB review"
: "Run PCB review"}
</Button>
{!canRun && ( {!canRun && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Upload BOM and netlist to enable Upload BOM and netlist to enable
@@ -0,0 +1,209 @@
"use client";
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
import { usePcbProgress } from "@/hooks/use-pcb-progress";
import {
cancelPcbPipeline,
fetchPcbInventory,
fetchProject,
startPcbPipeline,
} from "@/lib/api";
import {
ArrowLeft,
CheckCircle2,
Loader2,
OctagonX,
CircuitBoard,
} from "lucide-react";
export default function PcbReviewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [projectName, setProjectName] = useState("");
const [pcbStatus, setPcbStatus] = useState<string>("draft");
const [inventory, setInventory] = useState<{
nets: Array<Record<string, unknown>>;
domains: string[];
group_count: number;
} | null>(null);
const [cancelling, setCancelling] = useState(false);
const [starting, setStarting] = useState(false);
const [statusLoaded, setStatusLoaded] = useState(false);
const active = pcbStatus === "queued" || pcbStatus === "running";
const alreadyDone = pcbStatus === "complete";
const { steps, done, cancelled, error, summary, started } = usePcbProgress(
id,
statusLoaded && active,
);
useEffect(() => {
fetchProject(id)
.then((p) => {
setProjectName(p.name);
setPcbStatus(p.pcbStatus ?? "draft");
setStatusLoaded(true);
})
.catch(() => setStatusLoaded(true));
}, [id]);
useEffect(() => {
if (done) setPcbStatus("complete");
}, [done]);
useEffect(() => {
if (!statusLoaded) return;
if (!alreadyDone && !done) return;
fetchPcbInventory(id)
.then(setInventory)
.catch(() => setInventory(null));
}, [id, alreadyDone, done, statusLoaded]);
const handleCancel = async () => {
setCancelling(true);
try {
await cancelPcbPipeline(id);
} catch {
// may already be finished
} finally {
setCancelling(false);
}
};
const handleStart = async () => {
setStarting(true);
try {
await startPcbPipeline(id);
setPcbStatus("queued");
window.location.reload();
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to start PCB review");
}
};
const finished = alreadyDone || done;
const isRunning = statusLoaded && active && !done && !cancelled && !error;
const isQueued = isRunning && !started;
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">PCB review</h1>
<p className="text-sm text-muted-foreground">
{projectName ? `${projectName} · ` : ""}
Exam of the existing board not auto-placement
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/project/${id}/report?domain=layout`}>
<Button size="sm" variant="ghost">
Report
</Button>
</Link>
<Link href={`/project/${id}`}>
<Button size="sm" variant="outline">
<ArrowLeft className="h-4 w-4 mr-1" />
Project
</Button>
</Link>
</div>
</div>
{!statusLoaded && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking PCB review status
</div>
)}
{isQueued && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
<p className="text-sm">Queued starting PCB worker</p>
</div>
)}
{isRunning && !isQueued && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Progress</CardTitle>
</CardHeader>
<CardContent>
<PipelineStepper steps={steps} />
<div className="mt-4">
<Button
size="sm"
variant="outline"
disabled={cancelling}
onClick={handleCancel}
>
<OctagonX className="h-4 w-4 mr-1" />
{cancelling ? "Cancelling…" : "Cancel"}
</Button>
</div>
</CardContent>
</Card>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{cancelled && (
<p className="text-sm text-muted-foreground">PCB review cancelled.</p>
)}
{finished && !cancelled && !error && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Review complete
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
{summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "}
{summary?.groups ?? inventory?.group_count ?? "—"} groups
</p>
{inventory && (
<p className="text-muted-foreground">
{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists).
</p>
)}
<div className="flex flex-wrap gap-2">
<Link href={`/project/${id}/report?domain=layout`}>
<Button size="sm">View layout findings</Button>
</Link>
<Button
size="sm"
variant="outline"
disabled={starting}
onClick={handleStart}
>
<CircuitBoard className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Re-run PCB review"}
</Button>
</div>
</CardContent>
</Card>
)}
{statusLoaded && pcbStatus === "draft" && (
<Button size="sm" disabled={starting} onClick={handleStart}>
<CircuitBoard className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Run PCB review"}
</Button>
)}
</div>
);
}
+2 -1
View File
@@ -187,6 +187,7 @@ type NavItem =
const PROJECT_NAV_ITEMS: NavItem[] = [ const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "route", path: "/report", label: "Report", icon: ClipboardList }, { type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties }, { type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes }, { type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard }, { type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
@@ -243,7 +244,7 @@ function ProjectNav({
const currentTab = searchParams.get("tab"); const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running"; const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`; const isOnProgress = pathname === `${base}/progress`;
// Nested routes (report / progress / placement): soft-nav to `?tab=` can // Nested routes (report / progress / placement / pcb): soft-nav to `?tab=` can
// leave the report page mounted — use a full document navigation instead. // leave the report page mounted — use a full document navigation instead.
const onNestedRoute = pathname.startsWith(`${base}/`); const onNestedRoute = pathname.startsWith(`${base}/`);
@@ -176,6 +176,21 @@ export function FindingCard({
Automated check Automated check
</span> </span>
)} )}
{(finding.finding_id?.startsWith("PCB-") ||
finding.source === "pcb_review" ||
(finding.rule_id || "").startsWith("PE-PLC") ||
(finding.rule_id || "").startsWith("PE-LAY") ||
(finding.rule_id || "").startsWith("PE-SI") ||
(finding.rule_id || "").startsWith("PE-DRT")) && (
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
Layout
</span>
)}
{finding.rule_id && (
<span className="font-mono text-[11px] text-muted-foreground">
{finding.rule_id}
</span>
)}
</div> </div>
{open && ( {open && (
@@ -42,6 +42,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const componentParam = searchParams.get("component"); const componentParam = searchParams.get("component");
const reviewParam = searchParams.get("review"); const reviewParam = searchParams.get("review");
const searchParam = searchParams.get("q") ?? ""; const searchParam = searchParams.get("q") ?? "";
const domainParam = searchParams.get("domain");
const statusFilters = useMemo(() => { const statusFilters = useMemo(() => {
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]); if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
@@ -84,9 +85,23 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined; const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false; if (st && st !== "open") return false;
} }
if (domainParam === "layout") {
const layout =
(f.finding_id || "").startsWith("PCB-") ||
f.source === "pcb_review" ||
(f.rule_id || "").startsWith("PE-PLC") ||
(f.rule_id || "").startsWith("PE-LAY") ||
(f.rule_id || "").startsWith("PE-SI") ||
(f.rule_id || "").startsWith("PE-DRT");
if (!layout) return false;
} else if (domainParam === "schema") {
const layout =
(f.finding_id || "").startsWith("PCB-");
if (layout) return false;
}
return true; return true;
}, },
[statusFilters, componentParam, searchParam, reviewParam, reviews] [statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam]
); );
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -131,6 +146,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onToggleNeedsReview={() => onToggleNeedsReview={() =>
updateParams({ review: reviewParam === "open" ? null : "open" }) updateParams({ review: reviewParam === "open" ? null : "open" })
} }
domain={domainParam ?? "all"}
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators} designators={designators}
/> />
<div className="space-y-1"> <div className="space-y-1">
@@ -23,6 +23,8 @@ interface ReportFiltersProps {
designators: string[]; designators: string[];
needsReview?: boolean; needsReview?: boolean;
onToggleNeedsReview?: () => void; onToggleNeedsReview?: () => void;
domain?: string;
onDomainChange?: (value: string | null) => void;
} }
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [ const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
@@ -41,6 +43,8 @@ export function ReportFilters({
designators, designators,
needsReview, needsReview,
onToggleNeedsReview, onToggleNeedsReview,
domain,
onDomainChange,
}: ReportFiltersProps) { }: ReportFiltersProps) {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -73,6 +77,24 @@ export function ReportFilters({
Needs review Needs review
</Button> </Button>
)} )}
{onDomainChange && (
<div className="flex items-center gap-1.5">
{(["all", "schema", "layout"] as const).map((d) => (
<Button
key={d}
variant="outline"
size="sm"
className={cn(
"h-8 text-xs capitalize",
(domain || "all") === d && "bg-emerald-500/15 border-emerald-500/40",
)}
onClick={() => onDomainChange(d === "all" ? null : d)}
>
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
</Button>
))}
</div>
)}
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}> <Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
<SelectTrigger className="w-[140px] h-8 text-xs"> <SelectTrigger className="w-[140px] h-8 text-xs">
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import type { PipelineStep } from "@/lib/types";
import { pcbEventsUrl } from "@/lib/api";
import { useOptionalAuth } from "@/hooks/use-optional-auth";
const PCB_STAGES = [
{ id: "ensure_graph", title: "Ensure design graph", description: "Reuse or build design_graph.json" },
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, pad nets, derating" },
{ id: "ai_review", title: "AI datasheet exam", description: "Per-IC layout vs datasheet" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
] as const;
const STAGE_INDEX: Record<string, number> = Object.fromEntries(
PCB_STAGES.map((s, i) => [s.id, i]),
);
function createInitialSteps(): PipelineStep[] {
return PCB_STAGES.map((s) => ({
title: s.title,
description: s.description,
status: "pending" as const,
substeps: [],
}));
}
export function usePcbProgress(projectId: string | null, enabled = true) {
const [steps, setSteps] = useState<PipelineStep[]>(createInitialSteps);
const [done, setDone] = useState(false);
const [cancelled, setCancelled] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<{
findings?: number;
domains?: number;
groups?: number;
} | null>(null);
const [started, setStarted] = useState(false);
const esRef = useRef<EventSource | null>(null);
const terminalRef = useRef(false);
const { getToken } = useOptionalAuth();
const handleEvent = useCallback((event: MessageEvent) => {
const eventType = event.type || "message";
if (eventType === "heartbeat") return;
let data: Record<string, unknown>;
try {
data = JSON.parse(event.data);
} catch {
return;
}
if (eventType === "pcb_complete") {
setSummary({
findings: Number(data.findings) || 0,
domains: Number(data.domains) || 0,
groups: Number(data.groups) || 0,
});
setDone(true);
terminalRef.current = true;
esRef.current?.close();
return;
}
if (eventType === "pcb_cancelled") {
setCancelled(true);
setDone(true);
terminalRef.current = true;
esRef.current?.close();
return;
}
if (eventType === "pcb_error") {
setError((data.error as string) || "PCB review failed");
setDone(true);
terminalRef.current = true;
esRef.current?.close();
return;
}
if (eventType !== "pcb_step_update") return;
setStarted(true);
const stage = data.stage as string;
const status = data.status as "pending" | "running" | "complete" | "failed";
const detail = data.detail as string | undefined;
setSteps((prev) => {
const next = prev.map((s) => ({ ...s, substeps: [...s.substeps] }));
const idx = STAGE_INDEX[stage];
if (idx === undefined) return next;
const step = next[idx];
if (status === "running") {
step.status = "running";
if (detail) step.description = detail;
} else if (status === "complete") {
step.status = "complete";
if (detail) step.description = detail;
}
return next;
});
}, []);
useEffect(() => {
if (!projectId || !enabled) return;
let es: EventSource | null = null;
let retries = 0;
const MAX_RETRIES = 50;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let closed = false;
async function connect() {
if (closed) return;
if (es) {
es.close();
es = null;
}
const token = await getToken();
const baseUrl = pcbEventsUrl(projectId!);
const url = token ? `${baseUrl}?token=${token}` : baseUrl;
es = new EventSource(url);
esRef.current = es;
for (const eventName of [
"pcb_step_update",
"pcb_complete",
"pcb_error",
"pcb_cancelled",
"heartbeat",
]) {
es.addEventListener(eventName, (event: MessageEvent) => {
retries = 0;
handleEvent(event);
});
}
es.onerror = () => {
if (closed || terminalRef.current) return;
es?.close();
es = null;
esRef.current = null;
if (retries >= MAX_RETRIES) {
setError("Lost connection to PCB review. Refresh to reconnect.");
setDone(true);
return;
}
retries++;
reconnectTimer = setTimeout(connect, 30_000);
};
}
connect();
return () => {
closed = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
es?.close();
esRef.current = null;
};
}, [projectId, enabled, getToken, handleEvent]);
return { steps, done, cancelled, error, summary, started };
}
+43
View File
@@ -110,6 +110,8 @@ function mapProject(p: Record<string, unknown>): Project {
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null, netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft", placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
placementState: (p.placement_state as Record<string, unknown> | null) ?? null, placementState: (p.placement_state as Record<string, unknown> | null) ?? null,
pcbStatus: (p.pcb_status as Project["pcbStatus"]) ?? "draft",
pcbState: (p.pcb_state as Record<string, unknown> | null) ?? null,
}; };
} }
@@ -614,6 +616,47 @@ export function placementEventsUrl(projectId: string): string {
return `${BASE}/api/pipeline/${projectId}/placement/events`; return `${BASE}/api/pipeline/${projectId}/placement/events`;
} }
export async function startPcbPipeline(projectId: string) {
const res = await authFetch(
`${BASE}/api/pipeline/${projectId}/pcb/start`,
{ method: "POST" },
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Failed to start PCB review" }));
throw new Error(err.detail || "Failed to start PCB review");
}
return res.json();
}
export async function cancelPcbPipeline(projectId: string) {
const res = await authFetch(
`${BASE}/api/pipeline/${projectId}/pcb/cancel`,
{ method: "POST" },
);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Failed to cancel PCB review" }));
throw new Error(err.detail || "Failed to cancel PCB review");
}
return res.json();
}
export function pcbEventsUrl(projectId: string): string {
return `${BASE}/api/pipeline/${projectId}/pcb/events`;
}
export async function fetchPcbInventory(projectId: string): Promise<{
nets: Array<Record<string, unknown>>;
domains: string[];
group_count: number;
}> {
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/pcb/inventory`);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "PCB inventory not found" }));
throw new Error(err.detail || "PCB inventory not found");
}
return res.json();
}
export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> { export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> {
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`); const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
if (!res.ok) { if (!res.ok) {
+2
View File
@@ -283,6 +283,8 @@ export interface Project {
// Placement pipeline (parallel to analysis — topology only). // Placement pipeline (parallel to analysis — topology only).
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled"; placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
placementState?: Record<string, unknown> | null; placementState?: Record<string, unknown> | null;
pcbStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
pcbState?: Record<string, unknown> | null;
} }
export type RoleHint = export type RoleHint =
+183
View File
@@ -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"]
+11
View File
@@ -278,3 +278,14 @@ def test_keepout_without_courtyard_is_silent():
_xtal_keepout_cons(), _xtal_keepout_cons(),
_x1_keepout_layout(net="GND", courtyard=False), _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