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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -104,8 +104,13 @@ async def _run() -> None:
|
||||
elif mode == "placement":
|
||||
from backend.services import placement_pipeline as placement_svc
|
||||
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
||||
elif mode == "pcb":
|
||||
from backend.services import pcb_pipeline as pcb_svc
|
||||
await pcb_svc.run_pcb_pipeline(storage, user_id, project_id)
|
||||
else:
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
|
||||
raise SystemExit(
|
||||
f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
+189
-2
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
"pcb_complete",
|
||||
"pcb_error",
|
||||
"pcb_cancelled",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<CostEstimate | null>(null);
|
||||
const [rerunProject, setRerunProject] = useState<Project | null>(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({
|
||||
</Button>
|
||||
</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>
|
||||
) : isPaused ? (
|
||||
@@ -385,6 +434,19 @@ export default function ProjectDetailPage({
|
||||
? "Rebuild placement"
|
||||
: "Build placement plan"}
|
||||
</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 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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}/`);
|
||||
|
||||
|
||||
@@ -176,6 +176,21 @@ export function FindingCard({
|
||||
Automated check
|
||||
</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>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -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<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;
|
||||
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}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -73,6 +77,24 @@ export function ReportFilters({
|
||||
Needs review
|
||||
</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")}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -110,6 +110,8 @@ function mapProject(p: Record<string, unknown>): Project {
|
||||
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
||||
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
|
||||
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`;
|
||||
}
|
||||
|
||||
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> {
|
||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -283,6 +283,8 @@ export interface Project {
|
||||
// Placement pipeline (parallel to analysis — topology only).
|
||||
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||
placementState?: Record<string, unknown> | null;
|
||||
pcbStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||
pcbState?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type RoleHint =
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""PCB review checks, inventory, report merge — no invented millimetres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementDomain, PlacementIcGroup
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
Net,
|
||||
NetType,
|
||||
)
|
||||
from backend.periscopex.pcb_checks import (
|
||||
assign_pcb_finding_ids,
|
||||
merge_schema_pcb_reports,
|
||||
run_pcb_checks,
|
||||
)
|
||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||
from backend.periscopex.pcb_review import build_pcb_layout_context
|
||||
from backend.services.projects import ProjectMeta, STATUS_QUEUED, STATUS_RUNNING
|
||||
|
||||
SIMPLE = Path(__file__).resolve().parents[1] / "simple_project"
|
||||
|
||||
|
||||
def _graph() -> DesignGraph:
|
||||
return DesignGraph.model_validate_json(
|
||||
(SIMPLE / "design_graph.json").read_text()
|
||||
)
|
||||
|
||||
|
||||
def test_pad_net_mismatch_is_pe_lay_001():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="X",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
|
||||
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", x=0, y=0, layer="F.Cu",
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PE-LAY-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
assert findings[0].recommendation
|
||||
|
||||
|
||||
def test_missing_footprint_is_pe_lay_002():
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"R1": Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="",
|
||||
pins={"1": "GND"},
|
||||
),
|
||||
},
|
||||
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
|
||||
)
|
||||
layout = LayoutGraph(footprints={
|
||||
"U9": LayoutFootprint(reference="U9", x=0, y=0, layer="F.Cu"),
|
||||
})
|
||||
findings = check_pcb_net_match(graph, layout)
|
||||
assert any(f.rule_id == "PE-LAY-002" for f in findings)
|
||||
assert all(f.recommendation for f in findings)
|
||||
|
||||
|
||||
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
|
||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=10.0),
|
||||
)
|
||||
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
|
||||
assert plc
|
||||
assert plc[0].finding_id.startswith("PCB-")
|
||||
assert plc[0].recommendation
|
||||
|
||||
|
||||
def test_close_decoupling_has_no_plc_001():
|
||||
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
|
||||
|
||||
findings = run_pcb_checks(
|
||||
_graph(),
|
||||
_xtal_cons(max_distance_mm=2.0),
|
||||
_x1_c9_layout(cap_x=0.5),
|
||||
)
|
||||
assert all(f.rule_id != "PE-PLC-001" for f in findings)
|
||||
|
||||
|
||||
def test_inventory_lists_net_length_and_pair():
|
||||
layout = LayoutGraph(
|
||||
nets={"USB_DP": 1, "USB_DM": 2},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_DP"),
|
||||
LayoutSegment(start=(0, 1), end=(8, 1), width=0.2, layer="F.Cu", net="USB_DM"),
|
||||
],
|
||||
)
|
||||
inv = build_pcb_inventory(layout)
|
||||
names = {n.name: n for n in inv.nets}
|
||||
assert names["USB_DP"].length_mm == 10.0
|
||||
assert names["USB_DP"].pair == "USB_DM"
|
||||
|
||||
|
||||
def test_layout_context_includes_domain_and_group():
|
||||
graph = _graph()
|
||||
plan = FunctionalGroupsReport(
|
||||
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
|
||||
groups=[PlacementIcGroup(ref="U3", satellites=[])],
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={"U3": LayoutFootprint(reference="U3", x=1, y=2, layer="F.Cu")},
|
||||
)
|
||||
text = build_pcb_layout_context("U3", graph, layout, plan)
|
||||
assert "Domain: 3v3" in text
|
||||
assert "Functional group: U3" in text
|
||||
assert "Footprint U3" in text
|
||||
|
||||
|
||||
def test_merge_reports_prefixes_do_not_collide():
|
||||
schema = {"findings": [{"finding_id": "U3-001", "status": "WARNING"}]}
|
||||
pcb = {"findings": [{"finding_id": "PCB-U3-001", "status": "ERROR"}]}
|
||||
merged = merge_schema_pcb_reports(schema, pcb)
|
||||
ids = [f["finding_id"] for f in merged["findings"]]
|
||||
assert ids == ["U3-001", "PCB-U3-001"]
|
||||
assert merged["summary"]["ERROR"] == 1
|
||||
assert merged["summary"]["WARNING"] == 1
|
||||
|
||||
|
||||
def test_assign_fills_empty_recommendation():
|
||||
from backend.periscopex.models import Finding
|
||||
|
||||
f = Finding(
|
||||
designator="U1", mpn="", finding="x", why="y", status="INFO",
|
||||
recommendation="",
|
||||
)
|
||||
assign_pcb_finding_ids([f])
|
||||
assert f.finding_id == "PCB-U1-001"
|
||||
assert f.recommendation
|
||||
|
||||
|
||||
def test_pcb_busy_helpers():
|
||||
active = frozenset({"queued", "running"})
|
||||
analysis = frozenset({STATUS_QUEUED, STATUS_RUNNING})
|
||||
draft = ProjectMeta(id="p", name="t", created="2026-01-01", user_id="u")
|
||||
assert (draft.pcb_status or "draft") not in active
|
||||
draft.pcb_status = "queued"
|
||||
assert (draft.pcb_status or "draft") in active
|
||||
draft.status = STATUS_RUNNING
|
||||
assert draft.status in analysis
|
||||
|
||||
|
||||
def test_pcb_start_requires_board(tmp_path: Path):
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
client = TestClient(app)
|
||||
pid = client.post("/api/projects", json={"name": "noboard"}).json()["id"]
|
||||
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
|
||||
assert resp.status_code == 400
|
||||
assert "kicad_pcb" in resp.json()["detail"]
|
||||
@@ -278,3 +278,14 @@ def test_keepout_without_courtyard_is_silent():
|
||||
_xtal_keepout_cons(),
|
||||
_x1_keepout_layout(net="GND", courtyard=False),
|
||||
) == []
|
||||
|
||||
|
||||
def test_schema_deterministic_runner_omits_layout_checks():
|
||||
"""PCB placement/SI belong to MODE=pcb, not the schematic review seed."""
|
||||
import inspect
|
||||
|
||||
from backend.services.validation import _run_deterministic_checks
|
||||
|
||||
src = inspect.getsource(_run_deterministic_checks)
|
||||
assert "check_placement" not in src
|
||||
assert "check_si" not in src
|
||||
|
||||
Reference in New Issue
Block a user