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

Parallel pcb_status pipeline: parse board, classify domains/groups,
inventory traces, PE-LAY/PLC/SI/DRT checks, per-IC datasheet review.
Findings merge into the report UI. No auto-place or pcbnew write-back.
This commit is contained in:
2026-09-19 22:54:17 +02:00
parent a2011dad91
commit 16c606ae3d
28 changed files with 1906 additions and 32 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
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(
+110
View File
@@ -0,0 +1,110 @@
"""Deterministic PCB review checks (no LLM). Fail-soft at the caller."""
from __future__ import annotations
import logging
from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
log = logging.getLogger(__name__)
_FALLBACK_FIX = "Confirm the layout against the datasheet and adjust the board."
def assign_pcb_finding_ids(findings: list[Finding]) -> None:
"""IDs ``PCB-{designator}-{001}`` so they never collide with schema review."""
counter: Counter[str] = Counter()
for f in findings:
counter[f.designator] += 1
f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}"
if not (f.recommendation or "").strip():
f.recommendation = _FALLBACK_FIX
def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
"""ERROR only when both Vop and Vrated exist and Vop exceeds Vrated."""
out: list[Finding] = []
for row in build_derating_table(graph):
rated = row.get("rated_voltage_v")
op = row.get("operating_voltage_v")
if rated is None or op is None:
continue
if float(op) <= float(rated):
continue
ref = str(row.get("designator") or "")
out.append(Finding(
designator=ref,
mpn=str(row.get("mpn") or ""),
aspect="derating",
finding=(
f"{ref} operates at {op:g} V with a {rated:g} V rating."
),
why="Capacitor voltage rating must exceed the rail (no invented derating %).",
status="ERROR",
recommendation=(
f"Replace {ref} with a capacitor rated above {op:g} V, "
"or lower the rail."
),
source="pcb_derating",
rule_id="PE-DRT-001",
net=row.get("net_plus"),
pins=[],
))
return out
def merge_schema_pcb_reports(
schema: dict | None, pcb: dict | None,
) -> dict | None:
"""Combine schematic report.json with pcb_report.json for GET /report."""
if not schema and not pcb:
return None
if not pcb:
out = dict(schema or {})
out["findings"] = [
f for f in (out.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
return out
if not schema:
return dict(pcb)
findings = [
f for f in (schema.get("findings") or [])
if not str(f.get("finding_id") or "").startswith("PCB-")
]
findings.extend(pcb.get("findings") or [])
out = dict(schema)
out["findings"] = findings
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
st = f.get("status")
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
return out
def run_pcb_checks(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
"""Placement, SI, pad-net match, derating — skip individually on error."""
out: list[Finding] = []
for name, fn in (
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout)),
("pcb_derating", lambda: check_pcb_derating(graph)),
):
try:
out.extend(fn())
except Exception:
log.exception("PCB check %s failed — skipping", name)
assign_pcb_finding_ids(out)
return out
+97
View File
@@ -0,0 +1,97 @@
"""Trace / bus inventory from a LayoutGraph. Numbers only, no findings."""
from __future__ import annotations
import re
from collections import defaultdict
from pydantic import BaseModel
from backend.periscopex.models import DesignGraph, LayoutGraph, NetType
from backend.periscopex.si_check import net_length_mm, partner_net
_BUS_RE = re.compile(r"^(.*?)(\d+)$")
class PcbNetInventory(BaseModel):
name: str
length_mm: float = 0.0
via_count: int = 0
layers: list[str] = []
width_min_mm: float | None = None
width_max_mm: float | None = None
pair: str | None = None
bus: str | None = None
net_type: str | None = None
z0_ohm: float | None = None
class PcbInventoryReport(BaseModel):
nets: list[PcbNetInventory] = []
domains: list[str] = []
group_count: int = 0
def _bus_name(net: str) -> str | None:
m = _BUS_RE.match(net.split("/")[-1].replace(".", "_"))
if not m:
return None
prefix, digits = m.group(1), m.group(2)
if not prefix or len(digits) > 3:
return None
return prefix.rstrip("_") or None
def build_pcb_inventory(
layout: LayoutGraph,
graph: DesignGraph | None = None,
*,
domain_ids: list[str] | None = None,
group_count: int = 0,
z0_by_net: dict[str, float] | None = None,
) -> PcbInventoryReport:
names: set[str] = set()
layers: dict[str, set[str]] = defaultdict(set)
widths: dict[str, list[float]] = defaultdict(list)
vias: dict[str, int] = defaultdict(int)
for s in layout.segments:
if not s.net:
continue
names.add(s.net)
if s.layer:
layers[s.net].add(s.layer)
if s.width > 0:
widths[s.net].append(s.width)
for v in layout.vias:
if v.net:
names.add(v.net)
vias[v.net] += 1
z0_by_net = z0_by_net or {}
rows: list[PcbNetInventory] = []
for name in sorted(names):
w = widths.get(name) or []
sch = graph.nets.get(name) if graph else None
nt = sch.net_type.value if sch and isinstance(sch.net_type, NetType) else (
str(sch.net_type) if sch and sch.net_type else None
)
partner = partner_net(name)
if partner and partner not in names:
partner = None
rows.append(PcbNetInventory(
name=name,
length_mm=round(net_length_mm(layout, name), 3),
via_count=vias.get(name, 0),
layers=sorted(layers.get(name, ())),
width_min_mm=min(w) if w else None,
width_max_mm=max(w) if w else None,
pair=partner,
bus=_bus_name(name),
net_type=nt,
z0_ohm=z0_by_net.get(name),
))
return PcbInventoryReport(
nets=rows,
domains=list(domain_ids or []),
group_count=group_count,
)
+86
View File
@@ -0,0 +1,86 @@
"""Pad net on the PCB vs pin net on the schematic graph.
Silent when either side has no name. Power-flag footprints (#PWR) skipped.
"""
from __future__ import annotations
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
def _ensure_recommendation(f: Finding) -> Finding:
if not (f.recommendation or "").strip():
f.recommendation = (
"Align the PCB pad net with the schematic pin, then re-run PCB review."
)
return f
def check_pcb_net_match(
graph: DesignGraph,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.footprints:
return []
findings: list[Finding] = []
sch_refs = {
ref for ref in graph.components
if not ref.startswith("#")
}
pcb_refs = {
ref for ref in layout.footprints
if not ref.startswith("#")
}
for ref in sorted(sch_refs - pcb_refs):
comp = graph.components[ref]
findings.append(_ensure_recommendation(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="pcb_match",
finding=f"{ref} is in the schematic but has no footprint on the PCB.",
why="Layout review cannot check a part that is not placed.",
status="WARNING",
recommendation=f"Place {ref} on the board or remove it from the schematic/BOM.",
source="pcb_net_match",
rule_id="PE-LAY-002",
pins=[],
)))
for ref, fp in sorted(layout.footprints.items()):
if ref.startswith("#"):
continue
comp = graph.components.get(ref)
if comp is None:
continue
for pad in fp.pads:
if not pad.net or not pad.number:
continue
sch_net = graph.pin_net(ref, pad.number)
if not sch_net:
continue
if sch_net == pad.net:
continue
findings.append(_ensure_recommendation(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="pcb_match",
finding=(
f"{ref}.{pad.number} PCB net '{pad.net}' does not match "
f"schematic net '{sch_net}'."
),
why=(
"Datasheet and SI checks follow schematic net names. "
"A pad on the wrong net is a layout error, not a BOM typo."
),
status="ERROR",
recommendation=(
f"Reconnect pad {ref}.{pad.number} to '{sch_net}' "
f"(or fix the schematic if the PCB is authoritative)."
),
source="pcb_net_match",
rule_id="PE-LAY-001",
net=pad.net,
pins=[pad.number],
)))
return findings
+125
View File
@@ -0,0 +1,125 @@
"""PCB datasheet review prompt and layout neighborhood context (no auto-place)."""
from __future__ import annotations
import math
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.models import DesignGraph, LayoutGraph
from backend.periscopex.pcb_inventory import PcbInventoryReport
from backend.periscopex.si_check import net_length_mm
PCB_SYSTEM_PROMPT = """\
You are an electrical engineer reviewing a PCB layout against the IC \
datasheet. This is an EXAM of the existing board — do not propose a new \
floorplan, do not invent millimetres, and do not write Gerbers.
### Coverage checklist (layout)
- Domains (power-rail islands) and functional groups (IC + satellites: \
decoupling, bulk, filter, crystal, pullup).
- Placement: decoupling and crystal load caps vs datasheet layout notes \
and any numeric layout_rules (max_distance_mm, same_layer, thermal vias).
- Routing: net lengths, differential-pair skew, obvious stubs; length \
match only when the datasheet gives millimetres.
- Impedance: comment on Z0 only when stackup + width numbers are in the \
context. Never assume 50 Ω.
- Derating: flag a capacitor only when both operating and rated voltages \
are present and Vop exceeds Vrated.
- Filters: topology already on the schematic — check whether filter parts \
sit with the IC they serve when coordinates exist.
- Datasheet layout pages (typical application / PCB layout) vs this \
neighborhood.
### Findings
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
recommendation (what to change on the board). INFO still needs a next \
step (e.g. "Measure Z0 after stackup is filled in").
Call submit_review. Empty findings with checked_areas is valid when the \
layout matches the datasheet.
"""
def build_pcb_layout_context(
ic_ref: str,
graph: DesignGraph,
layout: LayoutGraph | None,
plan: FunctionalGroupsReport | None,
inventory: PcbInventoryReport | None = None,
) -> str:
"""Plain-text block appended to the schematic neighborhood for PCB review."""
lines = ["### PCB layout context (existing board — do not move parts)"]
domain_id = None
group = None
if plan is not None:
for g in plan.groups:
if g.ref == ic_ref:
group = g
break
for d in plan.domains:
if ic_ref in d.ic_refs:
domain_id = d.domain_id
lines.append(
f"Domain: {d.domain_id} (power nets: {', '.join(d.power_nets) or ''})"
)
break
if group is None:
lines.append("Functional group: (this IC is not in a classified group)")
else:
lines.append(
f"Functional group: {group.ref} subtype={group.component_subtype or ''} "
f"rank={group.rank}"
)
sats = group.satellites or []
if sats:
lines.append("Satellites:")
for s in sats:
xy = ""
if layout and s.ref in layout.footprints:
fp = layout.footprints[s.ref]
xy = f" @ ({fp.x:.2f},{fp.y:.2f}) {fp.layer}"
lines.append(
f" {s.ref} role={s.role_hint} nets={','.join(s.nets or [])}{xy}"
)
else:
lines.append("Satellites: none")
if layout and ic_ref in layout.footprints:
fp = layout.footprints[ic_ref]
lines.append(
f"Footprint {ic_ref}: ({fp.x:.2f},{fp.y:.2f}) layer={fp.layer} "
f"pads={len(fp.pads)}"
)
near: list[str] = []
for other, ofp in layout.footprints.items():
if other == ic_ref:
continue
dist = math.hypot(ofp.x - fp.x, ofp.y - fp.y)
if dist <= 15.0:
near.append(f"{other} {dist:.1f}mm {ofp.layer}")
if near:
lines.append("Within 15 mm: " + "; ".join(sorted(near)[:24]))
pin_nets = graph.components.get(ic_ref)
if pin_nets:
seen: set[str] = set()
lines.append("Connected net lengths (PCB copper):")
for net_name in pin_nets.pins.values():
if not net_name or net_name in seen:
continue
seen.add(net_name)
mm = net_length_mm(layout, net_name)
lines.append(f" {net_name}: {mm:.2f} mm")
elif layout is None:
lines.append("No LayoutGraph — skip millimetre claims.")
else:
lines.append(f"No footprint for {ic_ref} on the PCB.")
if inventory and inventory.nets:
sample = inventory.nets[:12]
lines.append("Inventory sample (name length_mm pair):")
for n in sample:
lines.append(
f" {n.name}: {n.length_mm:.2f} mm pair={n.pair or ''} "
f"Z0={n.z0_ohm if n.z0_ohm is not None else ''}"
)
if domain_id:
lines.append(f"(domain_id={domain_id})")
return "\n".join(lines)