"""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)