diff --git a/backend/periscopex/cad_bridge.py b/backend/periscopex/cad_bridge.py index 097a855..9d75347 100644 --- a/backend/periscopex/cad_bridge.py +++ b/backend/periscopex/cad_bridge.py @@ -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( diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py new file mode 100644 index 0000000..a273273 --- /dev/null +++ b/backend/periscopex/pcb_checks.py @@ -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 diff --git a/backend/periscopex/pcb_inventory.py b/backend/periscopex/pcb_inventory.py new file mode 100644 index 0000000..943bcae --- /dev/null +++ b/backend/periscopex/pcb_inventory.py @@ -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, + ) diff --git a/backend/periscopex/pcb_net_match.py b/backend/periscopex/pcb_net_match.py new file mode 100644 index 0000000..c6f26b7 --- /dev/null +++ b/backend/periscopex/pcb_net_match.py @@ -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 diff --git a/backend/periscopex/pcb_review.py b/backend/periscopex/pcb_review.py new file mode 100644 index 0000000..da4b62c --- /dev/null +++ b/backend/periscopex/pcb_review.py @@ -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) diff --git a/backend/pipeline_worker.py b/backend/pipeline_worker.py index a23837e..582981d 100644 --- a/backend/pipeline_worker.py +++ b/backend/pipeline_worker.py @@ -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: diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index 27c0470..1ab2a06 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 4408176..6dd0b80 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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() diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 4ec9908..d49bef0 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -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") diff --git a/backend/services/event_bridge.py b/backend/services/event_bridge.py index f3a99eb..4087b2d 100644 --- a/backend/services/event_bridge.py +++ b/backend/services/event_bridge.py @@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({ "placement_complete", "placement_error", "placement_cancelled", + "pcb_complete", + "pcb_error", + "pcb_cancelled", }) diff --git a/backend/services/job_runner.py b/backend/services/job_runner.py index 63e405c..42fccba 100644 --- a/backend/services/job_runner.py +++ b/backend/services/job_runner.py @@ -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}", + ) diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py new file mode 100644 index 0000000..4fa9042 --- /dev/null +++ b/backend/services/pcb_pipeline.py @@ -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 diff --git a/backend/services/pcb_validation.py b/backend/services/pcb_validation.py new file mode 100644 index 0000000..a7b1899 --- /dev/null +++ b/backend/services/pcb_validation.py @@ -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 diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index af554b9..52ca7a2 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -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") diff --git a/backend/services/projects.py b/backend/services/projects.py index 899609d..3ae301c 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -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 --- diff --git a/backend/services/validation.py b/backend/services/validation.py index 0866273..c7b9917 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 332ca04..20d0fe3 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,15 @@ 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 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. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 81b673a..309f3df 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -17,6 +17,7 @@ import { makeCollaboratorOwner, startPipeline, startPlacementPipeline, + startPcbPipeline, reprocessPipeline, resumePipeline, fetchPipelineEstimate, @@ -40,6 +41,7 @@ import { Check, Upload, LayoutGrid, + CircuitBoard, } from "lucide-react"; import { useOptionalUser } from "@/hooks/use-optional-auth"; import { ImpedancePanel } from "@/components/project/impedance-panel"; @@ -132,10 +134,20 @@ export default function ProjectDetailPage({ } }, [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 tab = searchParams.get("tab") ?? "bom"; const [starting, setStarting] = useState(false); const [startingPlacement, setStartingPlacement] = useState(false); + const [startingPcb, setStartingPcb] = useState(false); const [estimate, setEstimate] = useState(null); const [rerunProject, setRerunProject] = useState(null); @@ -215,8 +227,13 @@ export default function ProjectDetailPage({ const placementBusy = project?.placementStatus === "running" || project?.placementStatus === "queued"; + const pcbBusy = + project?.pcbStatus === "running" || + project?.pcbStatus === "queued"; const canStartPlacement = - Boolean(canRun) && !analysisBusy && !placementBusy; + Boolean(canRun) && !analysisBusy && !placementBusy && !pcbBusy; + const canStartPcb = + Boolean(project?.hasPcb && canRun) && !analysisBusy && !placementBusy && !pcbBusy; const handlePlacement = async () => { 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); return ( @@ -338,6 +367,26 @@ export default function ProjectDetailPage({ )} + + {project.pcbStatus === "complete" && ( + + + + )} ) : isPaused ? ( @@ -385,6 +434,19 @@ export default function ProjectDetailPage({ ? "Rebuild placement" : "Build placement plan"} + {!canRun && ( Upload BOM and netlist to enable diff --git a/frontend/src/app/(app)/project/[id]/pcb/page.tsx b/frontend/src/app/(app)/project/[id]/pcb/page.tsx new file mode 100644 index 0000000..0d89937 --- /dev/null +++ b/frontend/src/app/(app)/project/[id]/pcb/page.tsx @@ -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("draft"); + const [inventory, setInventory] = useState<{ + nets: Array>; + 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 ( +
+
+
+

PCB review

+

+ {projectName ? `${projectName} · ` : ""} + Exam of the existing board — not auto-placement +

+
+
+ + + + + + +
+
+ + {!statusLoaded && ( +
+ + Checking PCB review status… +
+ )} + + {isQueued && ( +
+ +

Queued — starting PCB worker…

+
+ )} + + {isRunning && !isQueued && ( + + + Progress + + + +
+ +
+
+
+ )} + + {error && ( +

{error}

+ )} + + {cancelled && ( +

PCB review cancelled.

+ )} + + {finished && !cancelled && !error && ( + + + + + Review complete + + + +

+ {summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "} + {summary?.groups ?? inventory?.group_count ?? "—"} groups +

+ {inventory && ( +

+ {inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists). +

+ )} +
+ + + + +
+
+
+ )} + + {statusLoaded && pcbStatus === "draft" && ( + + )} +
+ ); +} diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index f251d7f..65c5f6b 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -187,6 +187,7 @@ type NavItem = const PROJECT_NAV_ITEMS: NavItem[] = [ { 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: "domains", label: "Domains", icon: Boxes }, { type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard }, @@ -243,7 +244,7 @@ function ProjectNav({ const currentTab = searchParams.get("tab"); const isRunning = project?.status === "running"; 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. const onNestedRoute = pathname.startsWith(`${base}/`); diff --git a/frontend/src/components/report/finding-card.tsx b/frontend/src/components/report/finding-card.tsx index 4eb83ba..ff63848 100644 --- a/frontend/src/components/report/finding-card.tsx +++ b/frontend/src/components/report/finding-card.tsx @@ -176,6 +176,21 @@ export function FindingCard({ Automated check
)} + {(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")) && ( + + Layout + + )} + {finding.rule_id && ( + + {finding.rule_id} + + )} {open && ( diff --git a/frontend/src/components/report/findings-list.tsx b/frontend/src/components/report/findings-list.tsx index 7ef4562..8260802 100644 --- a/frontend/src/components/report/findings-list.tsx +++ b/frontend/src/components/report/findings-list.tsx @@ -42,6 +42,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe const componentParam = searchParams.get("component"); const reviewParam = searchParams.get("review"); const searchParam = searchParams.get("q") ?? ""; + const domainParam = searchParams.get("domain"); const statusFilters = useMemo(() => { if (!statusParam) return new Set(["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; 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; }, - [statusFilters, componentParam, searchParam, reviewParam, reviews] + [statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam] ); const filtered = useMemo(() => { @@ -131,6 +146,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe onToggleNeedsReview={() => updateParams({ review: reviewParam === "open" ? null : "open" }) } + domain={domainParam ?? "all"} + onDomainChange={(v) => updateParams({ domain: v })} designators={designators} />
diff --git a/frontend/src/components/report/report-filters.tsx b/frontend/src/components/report/report-filters.tsx index 95de677..fc66e24 100644 --- a/frontend/src/components/report/report-filters.tsx +++ b/frontend/src/components/report/report-filters.tsx @@ -23,6 +23,8 @@ interface ReportFiltersProps { designators: string[]; needsReview?: boolean; onToggleNeedsReview?: () => void; + domain?: string; + onDomainChange?: (value: string | null) => void; } const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [ @@ -41,6 +43,8 @@ export function ReportFilters({ designators, needsReview, onToggleNeedsReview, + domain, + onDomainChange, }: ReportFiltersProps) { return (
@@ -73,6 +77,24 @@ export function ReportFilters({ Needs review )} + {onDomainChange && ( +
+ {(["all", "schema", "layout"] as const).map((d) => ( + + ))} +
+ )}