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
|
from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
|
||||||
|
|
||||||
CAD_BRIDGE_VERSION = 1
|
CAD_BRIDGE_VERSION = 1
|
||||||
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR")
|
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR", "PE-DRT", "PE-Z0")
|
||||||
|
|
||||||
|
|
||||||
def annotate_findings_cad(
|
def annotate_findings_cad(
|
||||||
|
|||||||
@@ -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":
|
elif mode == "placement":
|
||||||
from backend.services import placement_pipeline as placement_svc
|
from backend.services import placement_pipeline as placement_svc
|
||||||
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
||||||
|
elif mode == "pcb":
|
||||||
|
from backend.services import pcb_pipeline as pcb_svc
|
||||||
|
await pcb_svc.run_pcb_pipeline(storage, user_id, project_id)
|
||||||
else:
|
else:
|
||||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
|
raise SystemExit(
|
||||||
|
f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
+189
-2
@@ -463,7 +463,7 @@ async def events(project_id: str, request: Request):
|
|||||||
break
|
break
|
||||||
ev = msg["event"]
|
ev = msg["event"]
|
||||||
# Skip placement events in the shared log.
|
# Skip placement events in the shared log.
|
||||||
if ev.startswith("placement_"):
|
if ev.startswith("placement_") or ev.startswith("pcb_"):
|
||||||
continue
|
continue
|
||||||
yield {
|
yield {
|
||||||
"event": ev,
|
"event": ev,
|
||||||
@@ -527,6 +527,9 @@ async def status(project_id: str, request: Request):
|
|||||||
"placement_status": meta.placement_status,
|
"placement_status": meta.placement_status,
|
||||||
"placement_state": meta.placement_state,
|
"placement_state": meta.placement_state,
|
||||||
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
||||||
|
"pcb_status": meta.pcb_status,
|
||||||
|
"pcb_state": meta.pcb_state,
|
||||||
|
"pcb_running": (meta.pcb_status or "draft") in ("queued", "running"),
|
||||||
"healed": healed is not None,
|
"healed": healed is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +551,7 @@ _PLACEMENT_SSE_TERMINAL = frozenset({
|
|||||||
async def start_placement(project_id: str, request: Request):
|
async def start_placement(project_id: str, request: Request):
|
||||||
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
|
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
|
||||||
from backend.services.placement_pipeline import analysis_busy, placement_busy
|
from backend.services.placement_pipeline import analysis_busy, placement_busy
|
||||||
|
from backend.services.pcb_pipeline import pcb_busy
|
||||||
|
|
||||||
storage = get_storage(request)
|
storage = get_storage(request)
|
||||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||||
@@ -557,6 +561,8 @@ async def start_placement(project_id: str, request: Request):
|
|||||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||||
if placement_busy(meta):
|
if placement_busy(meta):
|
||||||
raise HTTPException(409, "Placement pipeline already running or queued")
|
raise HTTPException(409, "Placement pipeline already running or queued")
|
||||||
|
if pcb_busy(meta):
|
||||||
|
raise HTTPException(409, "PCB review is running; wait or cancel it first")
|
||||||
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
409,
|
409,
|
||||||
@@ -694,7 +700,7 @@ async def placement_events(project_id: str, request: Request):
|
|||||||
if not (
|
if not (
|
||||||
ev.startswith("placement_")
|
ev.startswith("placement_")
|
||||||
or ev == "heartbeat"
|
or ev == "heartbeat"
|
||||||
):
|
) or ev.startswith("pcb_"):
|
||||||
continue
|
continue
|
||||||
yield {
|
yield {
|
||||||
"event": ev,
|
"event": ev,
|
||||||
@@ -734,6 +740,187 @@ async def placement_events(project_id: str, request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PCB review pipeline (parallel — exam, not auto-place)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_PCB_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||||
|
_PCB_SSE_TERMINAL = frozenset({
|
||||||
|
"pcb_complete",
|
||||||
|
"pcb_error",
|
||||||
|
"pcb_cancelled",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/pcb/start", status_code=202)
|
||||||
|
async def start_pcb(project_id: str, request: Request):
|
||||||
|
from backend.services.pcb_pipeline import analysis_busy, pcb_busy, placement_busy
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not meta.has_pcb:
|
||||||
|
raise HTTPException(400, "Upload a .kicad_pcb before starting PCB review")
|
||||||
|
if not meta.has_bom or not meta.has_netlist:
|
||||||
|
raise HTTPException(400, "Upload BOM and netlist before starting PCB review")
|
||||||
|
if analysis_busy(meta):
|
||||||
|
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||||
|
if placement_busy(meta):
|
||||||
|
raise HTTPException(409, "Placement pipeline is running; wait or cancel it first")
|
||||||
|
if pcb_busy(meta):
|
||||||
|
raise HTTPException(409, "PCB review already running or queued")
|
||||||
|
if (meta.pcb_status or "draft") not in _PCB_START_OK:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Cannot start PCB review from pcb_status={meta.pcb_status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_user_id, project_id,
|
||||||
|
pcb_status="queued",
|
||||||
|
pcb_cancel_requested=False,
|
||||||
|
pcb_state=None,
|
||||||
|
pcb_execution_name=None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to clear events before PCB start for %s", project_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
execution_name = job_runner.enqueue_pcb_pipeline(
|
||||||
|
project_id, owner_user_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("enqueue_pcb_pipeline failed for %s", project_id)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_user_id, project_id,
|
||||||
|
pcb_status="error",
|
||||||
|
pcb_state={"error": "Failed to enqueue PCB worker"},
|
||||||
|
)
|
||||||
|
raise HTTPException(503, "Failed to enqueue PCB worker; please retry")
|
||||||
|
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_user_id, project_id,
|
||||||
|
pcb_execution_name=execution_name,
|
||||||
|
)
|
||||||
|
return {"status": "started", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pipeline/{project_id}/pcb/cancel")
|
||||||
|
async def cancel_pcb(project_id: str, request: Request):
|
||||||
|
from backend.services.pcb_pipeline import pcb_busy
|
||||||
|
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||||
|
if not pcb_busy(meta):
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"PCB review is not running (pcb_status={meta.pcb_status})",
|
||||||
|
)
|
||||||
|
proj_svc.update_project(
|
||||||
|
storage, owner_user_id, project_id,
|
||||||
|
pcb_cancel_requested=True,
|
||||||
|
)
|
||||||
|
return {"status": "cancel_requested", "project_id": project_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/pcb/inventory")
|
||||||
|
async def get_pcb_inventory(project_id: str, request: Request):
|
||||||
|
storage = get_storage(request)
|
||||||
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||||
|
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/pcb_inventory.json"
|
||||||
|
if not storage.exists(key):
|
||||||
|
raise HTTPException(404, "PCB inventory not found — run PCB review first")
|
||||||
|
return storage.read_json(key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pipeline/{project_id}/pcb/events")
|
||||||
|
async def pcb_events(project_id: str, request: Request):
|
||||||
|
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||||
|
storage = get_storage(request)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
execution_name = meta.pcb_execution_name
|
||||||
|
crash_detected: dict[str, str | None] = {"reason": None}
|
||||||
|
|
||||||
|
async def watch_status() -> None:
|
||||||
|
poll_interval = 2.0
|
||||||
|
saw_active = (meta.pcb_status or "draft") in ("queued", "running")
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
try:
|
||||||
|
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if cur is None:
|
||||||
|
continue
|
||||||
|
pst = cur.pcb_status or "draft"
|
||||||
|
if pst in ("queued", "running"):
|
||||||
|
saw_active = True
|
||||||
|
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||||
|
crash_detected["reason"] = f"pcb_status={pst} (terminal)"
|
||||||
|
return
|
||||||
|
if execution_name:
|
||||||
|
try:
|
||||||
|
state = job_runner.get_execution_state(execution_name)
|
||||||
|
except Exception:
|
||||||
|
state = "unknown"
|
||||||
|
if state in _EXEC_TERMINAL and (
|
||||||
|
saw_active or pst in ("queued", "running")
|
||||||
|
):
|
||||||
|
crash_detected["reason"] = f"execution state={state}"
|
||||||
|
return
|
||||||
|
|
||||||
|
watcher = asyncio.create_task(watch_status())
|
||||||
|
try:
|
||||||
|
async for msg in event_bridge.tail_events(
|
||||||
|
storage, owner_user_id, project_id,
|
||||||
|
terminal_events=_PCB_SSE_TERMINAL,
|
||||||
|
):
|
||||||
|
if crash_detected["reason"] is not None:
|
||||||
|
break
|
||||||
|
ev = msg["event"]
|
||||||
|
if not (ev.startswith("pcb_") or ev == "heartbeat"):
|
||||||
|
continue
|
||||||
|
yield {
|
||||||
|
"event": ev,
|
||||||
|
"data": json.dumps(msg.get("data", {})),
|
||||||
|
}
|
||||||
|
if ev in _PCB_SSE_TERMINAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
if crash_detected["reason"] is not None:
|
||||||
|
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||||
|
err = None
|
||||||
|
if cur and cur.pcb_state:
|
||||||
|
err = cur.pcb_state.get("error")
|
||||||
|
yield {
|
||||||
|
"event": "pcb_error",
|
||||||
|
"data": json.dumps({
|
||||||
|
"error": err or crash_detected["reason"]
|
||||||
|
or "pcb worker terminated without a terminal event",
|
||||||
|
"synthetic": True,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
watcher.cancel()
|
||||||
|
try:
|
||||||
|
await watcher
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return EventSourceResponse(
|
||||||
|
event_generator(),
|
||||||
|
ping=15,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ async def get_project(project_id: str, request: Request):
|
|||||||
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
||||||
if healed_pl is not None:
|
if healed_pl is not None:
|
||||||
meta = healed_pl
|
meta = healed_pl
|
||||||
|
healed_pcb = proj_svc.heal_if_pcb_stuck(storage, owner_user_id, project_id)
|
||||||
|
if healed_pcb is not None:
|
||||||
|
meta = healed_pcb
|
||||||
return meta.model_dump()
|
return meta.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ async def get_report(project_id: str, request: Request):
|
|||||||
storage = get_storage(request)
|
storage = get_storage(request)
|
||||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||||
key = f"{prefix}/report.json"
|
schema_key = f"{prefix}/report.json"
|
||||||
if not storage.exists(key):
|
pcb_key = f"{prefix}/pcb_report.json"
|
||||||
|
schema = storage.read_json(schema_key) if storage.exists(schema_key) else None
|
||||||
|
pcb = storage.read_json(pcb_key) if storage.exists(pcb_key) else None
|
||||||
|
from backend.periscopex.pcb_checks import merge_schema_pcb_reports
|
||||||
|
|
||||||
|
merged = merge_schema_pcb_reports(schema, pcb)
|
||||||
|
if merged is None:
|
||||||
raise HTTPException(404, "Report not found — run the pipeline first")
|
raise HTTPException(404, "Report not found — run the pipeline first")
|
||||||
return JSONResponse(storage.read_json(key))
|
return JSONResponse(merged)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/report/{project_id}/cad-bridge")
|
@router.get("/report/{project_id}/cad-bridge")
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ TERMINAL_EVENTS = frozenset({
|
|||||||
"placement_complete",
|
"placement_complete",
|
||||||
"placement_error",
|
"placement_error",
|
||||||
"placement_cancelled",
|
"placement_cancelled",
|
||||||
|
"pcb_complete",
|
||||||
|
"pcb_error",
|
||||||
|
"pcb_cancelled",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
|||||||
if execution_name.startswith("local/placement/"):
|
if execution_name.startswith("local/placement/"):
|
||||||
project_id = execution_name.split("/", 2)[-1]
|
project_id = execution_name.split("/", 2)[-1]
|
||||||
return _local_state(f"placement:{project_id}")
|
return _local_state(f"placement:{project_id}")
|
||||||
|
if execution_name.startswith("local/pcb/"):
|
||||||
|
project_id = execution_name.split("/", 2)[-1]
|
||||||
|
return _local_state(f"pcb:{project_id}")
|
||||||
return _cloud_run_state(execution_name)
|
return _cloud_run_state(execution_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -369,6 +372,10 @@ def cancel_execution(execution_name: str | None) -> None:
|
|||||||
project_id = execution_name.split("/", 2)[-1]
|
project_id = execution_name.split("/", 2)[-1]
|
||||||
_local_cancel(f"placement:{project_id}")
|
_local_cancel(f"placement:{project_id}")
|
||||||
return
|
return
|
||||||
|
if execution_name.startswith("local/pcb/"):
|
||||||
|
project_id = execution_name.split("/", 2)[-1]
|
||||||
|
_local_cancel(f"pcb:{project_id}")
|
||||||
|
return
|
||||||
_cloud_run_cancel(execution_name)
|
_cloud_run_cancel(execution_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,3 +390,16 @@ def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
|||||||
proc_key=f"placement:{project_id}",
|
proc_key=f"placement:{project_id}",
|
||||||
execution_name=f"local/placement/{project_id}",
|
execution_name=f"local/placement/{project_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_pcb_pipeline(project_id: str, user_id: str) -> str:
|
||||||
|
"""Dispatch the parallel PCB review pipeline (deterministic + AI exam)."""
|
||||||
|
if use_cloud_run_jobs():
|
||||||
|
return _enqueue_cloud_run_job(
|
||||||
|
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||||
|
)
|
||||||
|
return _spawn_local_subprocess(
|
||||||
|
project_id, user_id, resume=False, free=False, mode="pcb",
|
||||||
|
proc_key=f"pcb:{project_id}",
|
||||||
|
execution_name=f"local/pcb/{project_id}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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("bom_summary.json")
|
||||||
self._upload_file("derating.json")
|
self._upload_file("derating.json")
|
||||||
self._upload_file("report.json")
|
self._upload_file("report.json")
|
||||||
|
self._upload_file("pcb_report.json")
|
||||||
|
self._upload_file("pcb_inventory.json")
|
||||||
self._upload_file("periscope-findings.json")
|
self._upload_file("periscope-findings.json")
|
||||||
self._upload_file("review_fingerprints.json")
|
self._upload_file("review_fingerprints.json")
|
||||||
self._upload_file("api_logs.jsonl")
|
self._upload_file("api_logs.jsonl")
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ class ProjectMeta(BaseModel):
|
|||||||
placement_execution_name: str | None = None
|
placement_execution_name: str | None = None
|
||||||
placement_cancel_requested: bool = False
|
placement_cancel_requested: bool = False
|
||||||
|
|
||||||
|
# PCB review pipeline (parallel exam — does not overwrite analysis status).
|
||||||
|
pcb_status: str = "draft"
|
||||||
|
pcb_state: dict[str, Any] | None = None
|
||||||
|
pcb_execution_name: str | None = None
|
||||||
|
pcb_cancel_requested: bool = False
|
||||||
|
|
||||||
|
|
||||||
def completed_review_refs_for_retry(
|
def completed_review_refs_for_retry(
|
||||||
storage: StorageBackend, user_id: str, project_id: str,
|
storage: StorageBackend, user_id: str, project_id: str,
|
||||||
@@ -401,6 +407,70 @@ def heal_if_placement_stuck(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def heal_if_pcb_stuck(
|
||||||
|
storage: StorageBackend, user_id: str, project_id: str,
|
||||||
|
) -> ProjectMeta | None:
|
||||||
|
"""Unstick pcb_status queued/running when the worker is gone."""
|
||||||
|
meta = get_project(storage, user_id, project_id)
|
||||||
|
if meta is None:
|
||||||
|
return None
|
||||||
|
pst = meta.pcb_status or "draft"
|
||||||
|
if pst not in ("queued", "running"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
prefix = _project_prefix(user_id, project_id)
|
||||||
|
events_prefix = f"{prefix}/events/"
|
||||||
|
last_event = None
|
||||||
|
try:
|
||||||
|
keys = sorted(
|
||||||
|
k for k in storage.list_prefix(events_prefix)
|
||||||
|
if k.endswith(".json") and "/events/" in k
|
||||||
|
)
|
||||||
|
if keys:
|
||||||
|
last_event = storage.read_json(keys[-1])
|
||||||
|
except Exception:
|
||||||
|
last_event = None
|
||||||
|
|
||||||
|
if (last_event or {}).get("event") == "pcb_complete":
|
||||||
|
data = (last_event or {}).get("data") or {}
|
||||||
|
return update_project(
|
||||||
|
storage, user_id, project_id,
|
||||||
|
pcb_status="complete",
|
||||||
|
pcb_cancel_requested=False,
|
||||||
|
pcb_state={
|
||||||
|
"findings": data.get("findings"),
|
||||||
|
"domains": data.get("domains"),
|
||||||
|
"groups": data.get("groups"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
from backend.services import job_runner
|
||||||
|
|
||||||
|
exec_name = meta.pcb_execution_name or f"local/pcb/{project_id}"
|
||||||
|
try:
|
||||||
|
state = job_runner.get_execution_state(exec_name)
|
||||||
|
except Exception:
|
||||||
|
state = "unknown"
|
||||||
|
|
||||||
|
if state in ("pending", "running"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
has_report = storage.exists(f"{prefix}/pcb_report.json")
|
||||||
|
if has_report:
|
||||||
|
return update_project(
|
||||||
|
storage, user_id, project_id,
|
||||||
|
pcb_status="complete",
|
||||||
|
pcb_cancel_requested=False,
|
||||||
|
pcb_state=meta.pcb_state,
|
||||||
|
)
|
||||||
|
return update_project(
|
||||||
|
storage, user_id, project_id,
|
||||||
|
pcb_status="error",
|
||||||
|
pcb_cancel_requested=False,
|
||||||
|
pcb_state={"error": f"PCB worker terminated ({state})"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- CRUD ---
|
# --- CRUD ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from backend.periscopex.models import (
|
|||||||
ComponentType,
|
ComponentType,
|
||||||
DesignGraph,
|
DesignGraph,
|
||||||
Finding,
|
Finding,
|
||||||
LayoutGraph,
|
|
||||||
NetType,
|
NetType,
|
||||||
ValidationReport,
|
ValidationReport,
|
||||||
)
|
)
|
||||||
@@ -61,8 +60,6 @@ from backend.periscopex.dnp_check import check_dnp_enables
|
|||||||
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||||
from backend.periscopex.errata_check import check_errata
|
from backend.periscopex.errata_check import check_errata
|
||||||
from backend.periscopex.internal_features_check import check_internal_features
|
from backend.periscopex.internal_features_check import check_internal_features
|
||||||
from backend.periscopex.placement_check import check_placement
|
|
||||||
from backend.periscopex.si_check import check_si
|
|
||||||
from backend.periscopex.crystal_cl_check import check_crystal_cl
|
from backend.periscopex.crystal_cl_check import check_crystal_cl
|
||||||
from backend.periscopex.nc_pin_check import check_nc_pins
|
from backend.periscopex.nc_pin_check import check_nc_pins
|
||||||
|
|
||||||
@@ -77,7 +74,6 @@ def _is_deterministic(f: Finding) -> bool:
|
|||||||
def _run_deterministic_checks(
|
def _run_deterministic_checks(
|
||||||
graph: DesignGraph, constraints_map: dict,
|
graph: DesignGraph, constraints_map: dict,
|
||||||
lifecycle_map: dict | None = None,
|
lifecycle_map: dict | None = None,
|
||||||
layout: LayoutGraph | None = None,
|
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
||||||
can never break the review or the report."""
|
can never break the review or the report."""
|
||||||
@@ -100,8 +96,6 @@ def _run_deterministic_checks(
|
|||||||
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
||||||
("errata_check", lambda: check_errata(graph, constraints_map)),
|
("errata_check", lambda: check_errata(graph, constraints_map)),
|
||||||
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
||||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
|
||||||
("si_check", lambda: check_si(graph, constraints_map, layout)),
|
|
||||||
("crystal_cl_check", lambda: check_crystal_cl(graph)),
|
("crystal_cl_check", lambda: check_crystal_cl(graph)),
|
||||||
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
|
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
|
||||||
):
|
):
|
||||||
@@ -112,17 +106,6 @@ def _run_deterministic_checks(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _load_layout_graph(graph_path: str) -> LayoutGraph | None:
|
|
||||||
path = Path(graph_path).with_name("layout_graph.json")
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return LayoutGraph.model_validate_json(path.read_text())
|
|
||||||
except Exception:
|
|
||||||
log.exception("layout_graph.json invalid — skipping placement_check")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _assistant_text(blocks) -> str:
|
def _assistant_text(blocks) -> str:
|
||||||
"""Best-effort extraction of text content from a completion's raw
|
"""Best-effort extraction of text content from a completion's raw
|
||||||
assistant blocks. Provider-agnostic and never raises."""
|
assistant blocks. Provider-agnostic and never raises."""
|
||||||
@@ -312,6 +295,9 @@ async def review_ic_async(
|
|||||||
pdf_dir: Path | None = None,
|
pdf_dir: Path | None = None,
|
||||||
storage=None,
|
storage=None,
|
||||||
excerpt_cache: dict | None = None,
|
excerpt_cache: dict | None = None,
|
||||||
|
extra_context: str = "",
|
||||||
|
system_prompt: str | None = None,
|
||||||
|
log_stage: str = "review",
|
||||||
) -> tuple[ReviewResult, dict]:
|
) -> tuple[ReviewResult, dict]:
|
||||||
"""Review one IC against its datasheet. Async, multi-turn.
|
"""Review one IC against its datasheet. Async, multi-turn.
|
||||||
|
|
||||||
@@ -373,7 +359,7 @@ async def review_ic_async(
|
|||||||
|
|
||||||
session = await provider.create_session(
|
session = await provider.create_session(
|
||||||
model=model,
|
model=model,
|
||||||
system=SYSTEM_PROMPT,
|
system=system_prompt or SYSTEM_PROMPT,
|
||||||
# Gemini 2.5/3 thinking models count thoughts against this cap.
|
# Gemini 2.5/3 thinking models count thoughts against this cap.
|
||||||
# 4096 was too tight: U3 (largest IC) burned the entire budget
|
# 4096 was too tight: U3 (largest IC) burned the entire budget
|
||||||
# on thinking and emitted zero visible output, dropping its
|
# on thinking and emitted zero visible output, dropping its
|
||||||
@@ -387,13 +373,16 @@ async def review_ic_async(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
context = build_component_context(graph, constraints_map, ic_ref)
|
context = build_component_context(graph, constraints_map, ic_ref)
|
||||||
|
user_text = f"Review this component's usage:\n\n{context}"
|
||||||
|
if extra_context.strip():
|
||||||
|
user_text += "\n\n" + extra_context.strip()
|
||||||
|
|
||||||
initial_msg = Message(
|
initial_msg = Message(
|
||||||
role="user",
|
role="user",
|
||||||
content=[
|
content=[
|
||||||
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
|
PdfBlock(path=Path(trimmed_pdf), cacheable=True),
|
||||||
TextBlock(
|
TextBlock(
|
||||||
text=f"Review this component's usage:\n\n{context}",
|
text=user_text,
|
||||||
cacheable=True,
|
cacheable=True,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -503,7 +492,7 @@ async def review_ic_async(
|
|||||||
)
|
)
|
||||||
if api_logger:
|
if api_logger:
|
||||||
api_logger.log(
|
api_logger.log(
|
||||||
stage="review", identifier=ic_ref,
|
stage=log_stage, identifier=ic_ref,
|
||||||
model=model, provider=provider.name,
|
model=model, provider=provider.name,
|
||||||
input_tokens=total_input, output_tokens=total_output,
|
input_tokens=total_input, output_tokens=total_output,
|
||||||
cache_creation_input_tokens=total_cache_creation,
|
cache_creation_input_tokens=total_cache_creation,
|
||||||
@@ -607,7 +596,7 @@ async def review_ic_async(
|
|||||||
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
|
trace["duration_ms"] = int((time.monotonic() - t0) * 1000)
|
||||||
if api_logger:
|
if api_logger:
|
||||||
api_logger.log(
|
api_logger.log(
|
||||||
stage="review", identifier=ic_ref,
|
stage=log_stage, identifier=ic_ref,
|
||||||
model=model, provider=provider.name,
|
model=model, provider=provider.name,
|
||||||
input_tokens=total_input, output_tokens=total_output,
|
input_tokens=total_input, output_tokens=total_output,
|
||||||
cache_creation_input_tokens=total_cache_creation,
|
cache_creation_input_tokens=total_cache_creation,
|
||||||
@@ -730,7 +719,7 @@ async def validate_design_async(
|
|||||||
if loaded:
|
if loaded:
|
||||||
lifecycle_map.update(loaded)
|
lifecycle_map.update(loaded)
|
||||||
deterministic_findings = _run_deterministic_checks(
|
deterministic_findings = _run_deterministic_checks(
|
||||||
graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path),
|
graph, constraints_map, lifecycle_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
pdf_dir_path = Path(pdf_dir)
|
pdf_dir_path = Path(pdf_dir)
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.30.0 — 2026-09-19 — PCB review pipeline (exam, not auto-place)
|
||||||
|
|
||||||
|
Parallel `MODE=pcb` job examines an uploaded `.kicad_pcb`: domains/groups, trace inventory, deterministic layout checks, and per-IC AI datasheet review. Findings merge into the report with a Layout filter. No packing or pcbnew write-back.
|
||||||
|
|
||||||
|
- [New] `POST /api/pipeline/{id}/pcb/start` — PCB exam job (`pcb_status`).
|
||||||
|
- [New] Deterministic `PE-LAY-*`, `PE-PLC-*`, `PE-SI-001`, `PE-DRT-001` with required recommendations.
|
||||||
|
- [New] AI PCB review (same loop as schematic) with layout/domain context.
|
||||||
|
- [Changed] Schematic review no longer seeds placement/SI checks.
|
||||||
|
|
||||||
## 2.29.1 — 2026-09-13 — Fix sign-in /login 404 after rebrand
|
## 2.29.1 — 2026-09-13 — Fix sign-in /login 404 after rebrand
|
||||||
|
|
||||||
Landing CTAs pointed at `/login`, which did not exist (page is `/sign-in`). Added redirects, accepted legacy JWT issuer `pinscope-local`, and CORS for both Periscope and Pinscope hosts.
|
Landing CTAs pointed at `/login`, which did not exist (page is `/sign-in`). Added redirects, accepted legacy JWT issuer `pinscope-local`, and CORS for both Periscope and Pinscope hosts.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
makeCollaboratorOwner,
|
makeCollaboratorOwner,
|
||||||
startPipeline,
|
startPipeline,
|
||||||
startPlacementPipeline,
|
startPlacementPipeline,
|
||||||
|
startPcbPipeline,
|
||||||
reprocessPipeline,
|
reprocessPipeline,
|
||||||
resumePipeline,
|
resumePipeline,
|
||||||
fetchPipelineEstimate,
|
fetchPipelineEstimate,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Upload,
|
Upload,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
CircuitBoard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
import { useOptionalUser } from "@/hooks/use-optional-auth";
|
||||||
import { ImpedancePanel } from "@/components/project/impedance-panel";
|
import { ImpedancePanel } from "@/components/project/impedance-panel";
|
||||||
@@ -132,10 +134,20 @@ export default function ProjectDetailPage({
|
|||||||
}
|
}
|
||||||
}, [project?.placementStatus, id, router]);
|
}, [project?.placementStatus, id, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
project?.pcbStatus === "running" ||
|
||||||
|
project?.pcbStatus === "queued"
|
||||||
|
) {
|
||||||
|
router.replace(`/project/${id}/pcb`);
|
||||||
|
}
|
||||||
|
}, [project?.pcbStatus, id, router]);
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tab = searchParams.get("tab") ?? "bom";
|
const tab = searchParams.get("tab") ?? "bom";
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [startingPlacement, setStartingPlacement] = useState(false);
|
const [startingPlacement, setStartingPlacement] = useState(false);
|
||||||
|
const [startingPcb, setStartingPcb] = useState(false);
|
||||||
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
|
||||||
const [rerunProject, setRerunProject] = useState<Project | null>(null);
|
const [rerunProject, setRerunProject] = useState<Project | null>(null);
|
||||||
|
|
||||||
@@ -215,8 +227,13 @@ export default function ProjectDetailPage({
|
|||||||
const placementBusy =
|
const placementBusy =
|
||||||
project?.placementStatus === "running" ||
|
project?.placementStatus === "running" ||
|
||||||
project?.placementStatus === "queued";
|
project?.placementStatus === "queued";
|
||||||
|
const pcbBusy =
|
||||||
|
project?.pcbStatus === "running" ||
|
||||||
|
project?.pcbStatus === "queued";
|
||||||
const canStartPlacement =
|
const canStartPlacement =
|
||||||
Boolean(canRun) && !analysisBusy && !placementBusy;
|
Boolean(canRun) && !analysisBusy && !placementBusy && !pcbBusy;
|
||||||
|
const canStartPcb =
|
||||||
|
Boolean(project?.hasPcb && canRun) && !analysisBusy && !placementBusy && !pcbBusy;
|
||||||
|
|
||||||
const handlePlacement = async () => {
|
const handlePlacement = async () => {
|
||||||
if (!canStartPlacement) return;
|
if (!canStartPlacement) return;
|
||||||
@@ -230,6 +247,18 @@ export default function ProjectDetailPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePcbReview = async () => {
|
||||||
|
if (!canStartPcb) return;
|
||||||
|
setStartingPcb(true);
|
||||||
|
try {
|
||||||
|
await startPcbPipeline(id);
|
||||||
|
router.push(`/project/${id}/pcb`);
|
||||||
|
} catch (e) {
|
||||||
|
setStartingPcb(false);
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to start PCB review");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hasFailedReviews = Boolean(hasSkipped);
|
const hasFailedReviews = Boolean(hasSkipped);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +367,26 @@ export default function ProjectDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canStartPcb || startingPcb}
|
||||||
|
onClick={handlePcbReview}
|
||||||
|
>
|
||||||
|
<CircuitBoard className="h-4 w-4 mr-1" />
|
||||||
|
{startingPcb
|
||||||
|
? "Starting…"
|
||||||
|
: project.pcbStatus === "complete"
|
||||||
|
? "Re-run PCB review"
|
||||||
|
: "Run PCB review"}
|
||||||
|
</Button>
|
||||||
|
{project.pcbStatus === "complete" && (
|
||||||
|
<Link href={`/project/${id}/pcb`}>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
View PCB review
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isPaused ? (
|
) : isPaused ? (
|
||||||
@@ -385,6 +434,19 @@ export default function ProjectDetailPage({
|
|||||||
? "Rebuild placement"
|
? "Rebuild placement"
|
||||||
: "Build placement plan"}
|
: "Build placement plan"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!canStartPcb || startingPcb}
|
||||||
|
onClick={handlePcbReview}
|
||||||
|
>
|
||||||
|
<CircuitBoard className="h-4 w-4 mr-1" />
|
||||||
|
{startingPcb
|
||||||
|
? "Starting…"
|
||||||
|
: project.pcbStatus === "complete"
|
||||||
|
? "Re-run PCB review"
|
||||||
|
: "Run PCB review"}
|
||||||
|
</Button>
|
||||||
{!canRun && (
|
{!canRun && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Upload BOM and netlist to enable
|
Upload BOM and netlist to enable
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { PipelineStepper } from "@/components/progress/pipeline-stepper";
|
||||||
|
import { usePcbProgress } from "@/hooks/use-pcb-progress";
|
||||||
|
import {
|
||||||
|
cancelPcbPipeline,
|
||||||
|
fetchPcbInventory,
|
||||||
|
fetchProject,
|
||||||
|
startPcbPipeline,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
OctagonX,
|
||||||
|
CircuitBoard,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export default function PcbReviewPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [pcbStatus, setPcbStatus] = useState<string>("draft");
|
||||||
|
const [inventory, setInventory] = useState<{
|
||||||
|
nets: Array<Record<string, unknown>>;
|
||||||
|
domains: string[];
|
||||||
|
group_count: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [statusLoaded, setStatusLoaded] = useState(false);
|
||||||
|
|
||||||
|
const active = pcbStatus === "queued" || pcbStatus === "running";
|
||||||
|
const alreadyDone = pcbStatus === "complete";
|
||||||
|
const { steps, done, cancelled, error, summary, started } = usePcbProgress(
|
||||||
|
id,
|
||||||
|
statusLoaded && active,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProject(id)
|
||||||
|
.then((p) => {
|
||||||
|
setProjectName(p.name);
|
||||||
|
setPcbStatus(p.pcbStatus ?? "draft");
|
||||||
|
setStatusLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => setStatusLoaded(true));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (done) setPcbStatus("complete");
|
||||||
|
}, [done]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!statusLoaded) return;
|
||||||
|
if (!alreadyDone && !done) return;
|
||||||
|
fetchPcbInventory(id)
|
||||||
|
.then(setInventory)
|
||||||
|
.catch(() => setInventory(null));
|
||||||
|
}, [id, alreadyDone, done, statusLoaded]);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await cancelPcbPipeline(id);
|
||||||
|
} catch {
|
||||||
|
// may already be finished
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
await startPcbPipeline(id);
|
||||||
|
setPcbStatus("queued");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
setStarting(false);
|
||||||
|
alert(e instanceof Error ? e.message : "Failed to start PCB review");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finished = alreadyDone || done;
|
||||||
|
const isRunning = statusLoaded && active && !done && !cancelled && !error;
|
||||||
|
const isQueued = isRunning && !started;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">PCB review</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{projectName ? `${projectName} · ` : ""}
|
||||||
|
Exam of the existing board — not auto-placement
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href={`/project/${id}/report?domain=layout`}>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/project/${id}`}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Project
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!statusLoaded && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Checking PCB review status…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isQueued && (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
||||||
|
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
|
||||||
|
<p className="text-sm">Queued — starting PCB worker…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && !isQueued && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Progress</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PipelineStepper steps={steps} />
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={cancelling}
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<OctagonX className="h-4 w-4 mr-1" />
|
||||||
|
{cancelling ? "Cancelling…" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cancelled && (
|
||||||
|
<p className="text-sm text-muted-foreground">PCB review cancelled.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finished && !cancelled && !error && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||||
|
Review complete
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{summary?.findings ?? "—"} findings · {summary?.domains ?? inventory?.domains.length ?? "—"} domains ·{" "}
|
||||||
|
{summary?.groups ?? inventory?.group_count ?? "—"} groups
|
||||||
|
</p>
|
||||||
|
{inventory && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{inventory.nets.length} routed nets inventoried (lengths / pairs / Z0 when stackup exists).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href={`/project/${id}/report?domain=layout`}>
|
||||||
|
<Button size="sm">View layout findings</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={starting}
|
||||||
|
onClick={handleStart}
|
||||||
|
>
|
||||||
|
<CircuitBoard className="h-4 w-4 mr-1" />
|
||||||
|
{starting ? "Starting…" : "Re-run PCB review"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{statusLoaded && pcbStatus === "draft" && (
|
||||||
|
<Button size="sm" disabled={starting} onClick={handleStart}>
|
||||||
|
<CircuitBoard className="h-4 w-4 mr-1" />
|
||||||
|
{starting ? "Starting…" : "Run PCB review"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -187,6 +187,7 @@ type NavItem =
|
|||||||
|
|
||||||
const PROJECT_NAV_ITEMS: NavItem[] = [
|
const PROJECT_NAV_ITEMS: NavItem[] = [
|
||||||
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
|
||||||
|
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
|
||||||
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
|
||||||
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
|
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
|
||||||
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
|
||||||
@@ -243,7 +244,7 @@ function ProjectNav({
|
|||||||
const currentTab = searchParams.get("tab");
|
const currentTab = searchParams.get("tab");
|
||||||
const isRunning = project?.status === "running";
|
const isRunning = project?.status === "running";
|
||||||
const isOnProgress = pathname === `${base}/progress`;
|
const isOnProgress = pathname === `${base}/progress`;
|
||||||
// Nested routes (report / progress / placement): soft-nav to `?tab=` can
|
// Nested routes (report / progress / placement / pcb): soft-nav to `?tab=` can
|
||||||
// leave the report page mounted — use a full document navigation instead.
|
// leave the report page mounted — use a full document navigation instead.
|
||||||
const onNestedRoute = pathname.startsWith(`${base}/`);
|
const onNestedRoute = pathname.startsWith(`${base}/`);
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ export function FindingCard({
|
|||||||
Automated check
|
Automated check
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{(finding.finding_id?.startsWith("PCB-") ||
|
||||||
|
finding.source === "pcb_review" ||
|
||||||
|
(finding.rule_id || "").startsWith("PE-PLC") ||
|
||||||
|
(finding.rule_id || "").startsWith("PE-LAY") ||
|
||||||
|
(finding.rule_id || "").startsWith("PE-SI") ||
|
||||||
|
(finding.rule_id || "").startsWith("PE-DRT")) && (
|
||||||
|
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
|
||||||
|
Layout
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{finding.rule_id && (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{finding.rule_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
const componentParam = searchParams.get("component");
|
const componentParam = searchParams.get("component");
|
||||||
const reviewParam = searchParams.get("review");
|
const reviewParam = searchParams.get("review");
|
||||||
const searchParam = searchParams.get("q") ?? "";
|
const searchParam = searchParams.get("q") ?? "";
|
||||||
|
const domainParam = searchParams.get("domain");
|
||||||
|
|
||||||
const statusFilters = useMemo(() => {
|
const statusFilters = useMemo(() => {
|
||||||
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
|
if (!statusParam) return new Set<FindingStatus>(["ERROR", "WARNING", "INFO"]);
|
||||||
@@ -84,9 +85,23 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
|
||||||
if (st && st !== "open") return false;
|
if (st && st !== "open") return false;
|
||||||
}
|
}
|
||||||
|
if (domainParam === "layout") {
|
||||||
|
const layout =
|
||||||
|
(f.finding_id || "").startsWith("PCB-") ||
|
||||||
|
f.source === "pcb_review" ||
|
||||||
|
(f.rule_id || "").startsWith("PE-PLC") ||
|
||||||
|
(f.rule_id || "").startsWith("PE-LAY") ||
|
||||||
|
(f.rule_id || "").startsWith("PE-SI") ||
|
||||||
|
(f.rule_id || "").startsWith("PE-DRT");
|
||||||
|
if (!layout) return false;
|
||||||
|
} else if (domainParam === "schema") {
|
||||||
|
const layout =
|
||||||
|
(f.finding_id || "").startsWith("PCB-");
|
||||||
|
if (layout) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[statusFilters, componentParam, searchParam, reviewParam, reviews]
|
[statusFilters, componentParam, searchParam, reviewParam, reviews, domainParam]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -131,6 +146,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
|
|||||||
onToggleNeedsReview={() =>
|
onToggleNeedsReview={() =>
|
||||||
updateParams({ review: reviewParam === "open" ? null : "open" })
|
updateParams({ review: reviewParam === "open" ? null : "open" })
|
||||||
}
|
}
|
||||||
|
domain={domainParam ?? "all"}
|
||||||
|
onDomainChange={(v) => updateParams({ domain: v })}
|
||||||
designators={designators}
|
designators={designators}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface ReportFiltersProps {
|
|||||||
designators: string[];
|
designators: string[];
|
||||||
needsReview?: boolean;
|
needsReview?: boolean;
|
||||||
onToggleNeedsReview?: () => void;
|
onToggleNeedsReview?: () => void;
|
||||||
|
domain?: string;
|
||||||
|
onDomainChange?: (value: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
|
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
|
||||||
@@ -41,6 +43,8 @@ export function ReportFilters({
|
|||||||
designators,
|
designators,
|
||||||
needsReview,
|
needsReview,
|
||||||
onToggleNeedsReview,
|
onToggleNeedsReview,
|
||||||
|
domain,
|
||||||
|
onDomainChange,
|
||||||
}: ReportFiltersProps) {
|
}: ReportFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -73,6 +77,24 @@ export function ReportFilters({
|
|||||||
Needs review
|
Needs review
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDomainChange && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{(["all", "schema", "layout"] as const).map((d) => (
|
||||||
|
<Button
|
||||||
|
key={d}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 text-xs capitalize",
|
||||||
|
(domain || "all") === d && "bg-emerald-500/15 border-emerald-500/40",
|
||||||
|
)}
|
||||||
|
onClick={() => onDomainChange(d === "all" ? null : d)}
|
||||||
|
>
|
||||||
|
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
|
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
|
||||||
<SelectTrigger className="w-[140px] h-8 text-xs">
|
<SelectTrigger className="w-[140px] h-8 text-xs">
|
||||||
|
|||||||
@@ -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,
|
netlistSubdesigns: (p.netlist_subdesigns as string[] | null) ?? null,
|
||||||
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
|
placementStatus: (p.placement_status as Project["placementStatus"]) ?? "draft",
|
||||||
placementState: (p.placement_state as Record<string, unknown> | null) ?? null,
|
placementState: (p.placement_state as Record<string, unknown> | null) ?? null,
|
||||||
|
pcbStatus: (p.pcb_status as Project["pcbStatus"]) ?? "draft",
|
||||||
|
pcbState: (p.pcb_state as Record<string, unknown> | null) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +616,47 @@ export function placementEventsUrl(projectId: string): string {
|
|||||||
return `${BASE}/api/pipeline/${projectId}/placement/events`;
|
return `${BASE}/api/pipeline/${projectId}/placement/events`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function startPcbPipeline(projectId: string) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE}/api/pipeline/${projectId}/pcb/start`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "Failed to start PCB review" }));
|
||||||
|
throw new Error(err.detail || "Failed to start PCB review");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelPcbPipeline(projectId: string) {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${BASE}/api/pipeline/${projectId}/pcb/cancel`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "Failed to cancel PCB review" }));
|
||||||
|
throw new Error(err.detail || "Failed to cancel PCB review");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pcbEventsUrl(projectId: string): string {
|
||||||
|
return `${BASE}/api/pipeline/${projectId}/pcb/events`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPcbInventory(projectId: string): Promise<{
|
||||||
|
nets: Array<Record<string, unknown>>;
|
||||||
|
domains: string[];
|
||||||
|
group_count: number;
|
||||||
|
}> {
|
||||||
|
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/pcb/inventory`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "PCB inventory not found" }));
|
||||||
|
throw new Error(err.detail || "PCB inventory not found");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> {
|
export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> {
|
||||||
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
|
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ export interface Project {
|
|||||||
// Placement pipeline (parallel to analysis — topology only).
|
// Placement pipeline (parallel to analysis — topology only).
|
||||||
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
placementStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||||
placementState?: Record<string, unknown> | null;
|
placementState?: Record<string, unknown> | null;
|
||||||
|
pcbStatus?: "draft" | "queued" | "running" | "complete" | "error" | "cancelled";
|
||||||
|
pcbState?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoleHint =
|
export type RoleHint =
|
||||||
|
|||||||
@@ -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(),
|
_xtal_keepout_cons(),
|
||||||
_x1_keepout_layout(net="GND", courtyard=False),
|
_x1_keepout_layout(net="GND", courtyard=False),
|
||||||
) == []
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_deterministic_runner_omits_layout_checks():
|
||||||
|
"""PCB placement/SI belong to MODE=pcb, not the schematic review seed."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from backend.services.validation import _run_deterministic_checks
|
||||||
|
|
||||||
|
src = inspect.getsource(_run_deterministic_checks)
|
||||||
|
assert "check_placement" not in src
|
||||||
|
assert "check_si" not in src
|
||||||
|
|||||||
Reference in New Issue
Block a user