Files
periscope/backend/periscopex/pcb_review.py
T
michele facbaa2305 Show Action on finding cards and inject PCB geometry into AI context.
Layout and AI findings always get action (fallback recommendation). The card
renders that sentence in the body. PCB review context now includes vias under
the footprint, copper thickness, nearby widths, courtyard, and keepout polygons.
2026-09-20 09:13:51 +02:00

319 lines
12 KiB
Python

"""PCB datasheet review prompt and layout neighborhood context (no auto-place)."""
from __future__ import annotations
import math
from collections import defaultdict
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph
from backend.periscopex.pcb_inventory import PcbInventoryReport
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.pcb_power_thermal import _footprint_region
from backend.periscopex.placement_check import _in_poly
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 knowledge already stored in the shared library extraction \
(library/extracted). Do NOT re-read or request the PDF — schematic review \
already did the deep datasheet exam once.
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-only)
- Use pintable, layout_rules, and abs-max from the library JSON in context.
- Domains and functional groups (IC + satellites).
- Placement vs numeric layout_rules (max_distance_mm, same_layer, thermal vias).
- Routing: lengths, skew, stubs — length match only with datasheet millimetres.
- Power copper: use the parsed copper thickness and nearby trace widths in \
the "Parsed board geometry" block. Never assume 1 oz or 50 Ω.
- Thermal: use vias-under-footprint counts and drill sizes vs PowerPAD/EPAD \
notes. Do not claim the via count or size is missing if that block lists them.
- Keepout / courtyard: use parsed courtyard vertices and keepout polygons. \
Do not claim antenna keepout geometry is missing if keepout zones are listed.
- Kelvin/sense pins, crystal keepout — only if named in the pintable/layout_rules.
- Creepage/clearance only if the extraction or IEC number is in context.
### Evidence
Cite numbers from "Parsed board geometry". Say insufficient evidence ONLY \
when that extract is empty or explicitly "(none)". If vias, copper thickness, \
trace widths, or keepout polygons are listed, you MUST use them.
### Findings
This is a **design review** of geometry vs the library JSON, not a second \
datasheet exam. Put board measurements in `finding` (FACT), library text in \
`why` (REQUIREMENT). Recommended layout notes are never ERROR.
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
`recommendation` (the Action the designer should take) even for INFO. \
Call submit_review. Empty findings with checked_areas is valid when the \
layout matches the extraction.
"""
def format_library_extraction(cons: ComponentConstraints) -> str:
"""Compact shared-library dump so PCB review does not re-ingest the PDF."""
lines = [
"### Shared library extraction (library/extracted — do not re-read PDF)",
f"MPN: {cons.mpn}",
f"subtype: {cons.component_subtype or '—'}",
f"model_version: {cons.model_version}",
]
if cons.package_info:
lines.append(
f"package: {cons.package_info.package} "
f"({cons.package_info.pin_count} pins)"
)
if cons.layout_rules:
lines.append("layout_rules:")
for rule in cons.layout_rules[:40]:
lines.append(f" {rule}")
if cons.absolute_maximum_ratings:
lines.append("absolute_maximum_ratings:")
for r in cons.absolute_maximum_ratings[:30]:
lines.append(
f" {r.parameter}: min={r.min} max={r.max} {r.unit} (p.{r.source_page})"
)
if cons.pintable:
lines.append("pintable (number name):")
for p in cons.pintable[:80]:
lines.append(f" {p.number} {p.name}")
return "\n".join(lines)
def _outline_overlaps_region(
outlines: list[list[tuple[float, float]]],
region: list[tuple[float, float]],
) -> bool:
if len(region) < 3:
return False
rx = [p[0] for p in region]
ry = [p[1] for p in region]
cx = (min(rx) + max(rx)) / 2
cy = (min(ry) + max(ry)) / 2
for outline in outlines:
if len(outline) < 3:
continue
for x, y in outline:
if _in_poly(x, y, region):
return True
if _in_poly(cx, cy, outline):
return True
ox = [p[0] for p in outline]
oy = [p[1] for p in outline]
if _in_poly((min(ox) + max(ox)) / 2, (min(oy) + max(oy)) / 2, region):
return True
return False
def format_pcb_geometry(
ic_ref: str,
graph: DesignGraph,
layout: LayoutGraph,
) -> str:
"""Vias under footprint, copper thickness, nearby widths, courtyard/keepout."""
lines = ["### Parsed board geometry (from .kicad_pcb — cite these numbers)"]
fp = layout.footprints.get(ic_ref)
if fp is None:
lines.append(f"Footprint {ic_ref}: (none)")
lines.append("Vias under footprint: (none)")
return "\n".join(lines)
region = _footprint_region(fp)
if fp.courtyard and len(fp.courtyard) >= 3:
verts = "; ".join(f"({x:.2f},{y:.2f})" for x, y in fp.courtyard[:16])
lines.append(f"Courtyard vertices: {verts}")
else:
lines.append(
"Courtyard: (none in .kicad_pcb; using pad bbox + 1.5 mm for via/trace queries)"
)
by_net: dict[str, list] = defaultdict(list)
for v in layout.vias:
if _in_poly(v.x, v.y, region):
by_net[v.net or "(unnamed)"].append(v)
total = sum(len(vs) for vs in by_net.values())
if total == 0:
lines.append(
f"Vias under footprint: 0 (board vias parsed: {len(layout.vias)})"
)
else:
lines.append(
f"Vias under footprint: {total} (board vias parsed: {len(layout.vias)})"
)
for net, vs in sorted(by_net.items(), key=lambda kv: (-len(kv[1]), kv[0])):
drills = sorted({d for d in (v.drill for v in vs) if d})
drill_s = (
", ".join(f"{d:g} mm" for d in drills[:6]) if drills else "(drill not in file)"
)
lines.append(f" {net}: count={len(vs)} drill={drill_s}")
t = layout.stackup.copper_thickness_mm if layout.stackup else None
if t and t > 0:
lines.append(f"Copper thickness: {t * 1000:g} µm ({t:g} mm) — parsed stackup, not 1 oz")
if layout.stackup.copper_layers:
lines.append("Copper layers: " + ", ".join(layout.stackup.copper_layers[:12]))
else:
lines.append("Copper thickness: (none in parsed stackup)")
pin_nets: set[str] = set()
comp = graph.components.get(ic_ref)
if comp:
pin_nets.update(n for n in comp.pins.values() if n)
pin_nets.update(p.net for p in fp.pads if p.net)
widths: list[str] = []
seen_w: set[tuple[str, float, str]] = set()
for s in layout.segments:
if s.width <= 0:
continue
near = (
_in_poly(s.start[0], s.start[1], region)
or _in_poly(s.end[0], s.end[1], region)
or _in_poly(
(s.start[0] + s.end[0]) / 2,
(s.start[1] + s.end[1]) / 2,
region,
)
)
on_pin = bool(s.net) and any(kicad_nets_match(s.net, n) for n in pin_nets)
if not near and not on_pin:
continue
key = (s.net or "", round(s.width, 4), s.layer or "")
if key in seen_w:
continue
seen_w.add(key)
where = "courtyard" if near else "pin-net"
widths.append(
f" {s.net or '(unnamed)'} width={s.width:g} mm layer={s.layer or '—'} ({where})"
)
if len(widths) >= 24:
break
if widths:
lines.append("Nearby / pin-net trace widths:")
lines.extend(widths)
else:
lines.append("Nearby / pin-net trace widths: (none)")
keep_hits: list[str] = []
keep_board: list[str] = []
for z in layout.zones:
bbox = ""
if z.outlines and z.outlines[0]:
xs = [p[0] for p in z.outlines[0]]
ys = [p[1] for p in z.outlines[0]]
bbox = f" bbox=({min(xs):.1f},{min(ys):.1f})-({max(xs):.1f},{max(ys):.1f})"
label = z.name or z.net or "(unnamed)"
kind = "keepout" if z.keepout else "zone"
entry = f" {kind} {label} layer={z.layer or '—'} net={z.net or '—'}{bbox}"
if z.keepout:
keep_board.append(entry)
if _outline_overlaps_region(z.outlines, region):
keep_hits.append(entry)
if keep_hits:
lines.append("Zones overlapping courtyard:")
lines.extend(keep_hits[:16])
else:
lines.append("Zones overlapping courtyard: (none)")
if keep_board:
lines.append("Board keepout polygons (antenna / RF):")
lines.extend(keep_board[:16])
else:
lines.append("Board keepout polygons: (none)")
return "\n".join(lines)
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")
lines.append(format_pcb_geometry(ic_ref, graph, layout))
elif layout is None:
lines.append("No LayoutGraph — skip millimetre claims.")
lines.append("### Parsed board geometry (from .kicad_pcb — cite these numbers)")
lines.append("Vias under footprint: (none)")
lines.append("Copper thickness: (none in parsed stackup)")
lines.append("Nearby / pin-net trace widths: (none)")
lines.append("Board keepout polygons: (none)")
else:
lines.append(f"No footprint for {ic_ref} on the PCB.")
lines.append(format_pcb_geometry(ic_ref, graph, layout))
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)