Add MODE=pcb layout exam job with AI and deterministic checks.

Parallel pcb_status pipeline: parse board, classify domains/groups,
inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review.
Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
2026-09-19 22:54:17 +02:00
parent a2011dad91
commit 16c606ae3d
28 changed files with 1906 additions and 32 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Deterministic PCB review checks (no LLM). Fail-soft at the caller."""
from __future__ import annotations
import logging
from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
log = logging.getLogger(__name__)
_FALLBACK_FIX = "Confirm the layout against the datasheet and adjust the board."
def assign_pcb_finding_ids(findings: list[Finding]) -> None:
"""IDs ``PCB-{designator}-{001}`` so they never collide with schema review."""
counter: Counter[str] = Counter()
for f in findings:
counter[f.designator] += 1
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
if not (f.recommendation or "").strip():
f.recommendation = _FALLBACK_FIX
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
"""ERROR only when both Vop and Vrated exist and Vop exceeds Vrated."""
out: list[Finding] = []
for row in build_derating_table(graph):
rated = row.get("rated_voltage_v")
op = row.get("operating_voltage_v")
if rated is None or op is None:
continue
if float(op) <= float(rated):
continue
ref = str(row.get("designator") or "")
out.append(Finding(
designator=ref,
mpn=str(row.get("mpn") or ""),
aspect="derating",
finding=(
f"{ref} operates at {op:g} V with a {rated:g} V rating."
),
why="Capacitor voltage rating must exceed the rail (no invented derating %).",
status="ERROR",
recommendation=(
f"Replace {ref} with a capacitor rated above {op:g} V, "
"or lower the rail."
),
source="pcb_derating",
rule_id="PE-DRT-001",
net=row.get("net_plus"),
pins=[],
))
return out
def merge_schema_pcb_reports(
schema: dict | None, pcb: dict | None,
) -> dict | None:
"""Combine schematic report.json with pcb_report.json for GET /report."""
if not schema and not pcb:
return None
if not pcb:
out = dict(schema or {})
out["findings"] = [
f for f in (out.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
return out
if not schema:
return dict(pcb)
findings = [
f for f in (schema.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
findings.extend(pcb.get("findings") or [])
out = dict(schema)
out["findings"] = findings
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
st = f.get("status")
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
return out
def run_pcb_checks(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Placement, SI, pad-net match, derating — skip individually on error."""
out: list[Finding] = []
for name, fn in (
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout)),
("pcb_derating", lambda: check_pcb_derating(graph)),
):
try:
out.extend(fn())
except Exception:
log.exception("PCB check %s failed — skipping", name)
assign_pcb_finding_ids(out)
return out