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