FACT, REQUIREMENT, and INFERENCE are separate fields; datasheet provenance lives in the rule DB so Recommended cannot stay ERROR; LLM output is REVIEW. Designer decisions persist across rescans. Schematic and PCB share one library and the same finding object in the report UI.
157 lines
6.4 KiB
Python
157 lines
6.4 KiB
Python
"""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 ComponentConstraints, 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 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: comment on width/thickness vs I_load only when those numbers \
|
|
are in the context. Never assume 1 oz or 50 Ω.
|
|
- Thermal: copper pour / vias under the package vs θJA / layout notes in the extraction.
|
|
- 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.
|
|
|
|
### 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. Missing \
|
|
width/thickness/I_load → say insufficient evidence, do not assume 1 oz.
|
|
|
|
Every finding MUST have status ERROR, WARNING, or INFO and a non-empty \
|
|
recommendation. 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 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)
|