From 8f4ebc645c3f8611478509ed075929cd610cda22 Mon Sep 17 00:00:00 2001
From: Michele Bigi
Date: Sun, 20 Sep 2026 08:32:23 +0200
Subject: [PATCH] Share library/extracted for schematic and PCB review.
PCB AI skips a second PDF exam (pintable cache, pdf_path=None) and the report
gains a PCB exam section plus power-trace, thermal, via, Kelvin, and GND-stitch
checks grounded in board geometry and extracted specs.
---
backend/periscopex/pcb_checks.py | 16 +
backend/periscopex/pcb_power_thermal.py | 455 ++++++++++++++++++
backend/periscopex/pcb_review.py | 72 ++-
backend/services/pcb_pipeline.py | 44 +-
backend/services/pcb_validation.py | 40 +-
backend/services/validation.py | 53 +-
docs/piano-pcb-review.md | 41 +-
frontend/content/changelog.md | 10 +
.../app/(app)/project/[id]/report/page.tsx | 29 +-
.../src/components/report/finding-card.tsx | 8 +-
.../src/components/report/findings-list.tsx | 17 +-
.../components/report/pcb-exam-section.tsx | 72 +++
frontend/src/hooks/use-pcb-progress.ts | 4 +-
frontend/src/lib/layout-finding.ts | 40 ++
tests/test_pcb_review.py | 220 +++++++++
15 files changed, 1014 insertions(+), 107 deletions(-)
create mode 100644 backend/periscopex/pcb_power_thermal.py
create mode 100644 frontend/src/components/report/pcb-exam-section.tsx
create mode 100644 frontend/src/lib/layout-finding.ts
diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py
index a273273..9fe4c76 100644
--- a/backend/periscopex/pcb_checks.py
+++ b/backend/periscopex/pcb_checks.py
@@ -8,6 +8,13 @@ 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.pcb_power_thermal import (
+ check_pcb_gnd_stitch,
+ check_pcb_kelvin,
+ check_pcb_power_traces,
+ check_pcb_thermal_copper,
+ check_pcb_via_current,
+)
from backend.periscopex.placement_check import check_placement
from backend.periscopex.si_check import check_si
@@ -86,6 +93,10 @@ def merge_schema_pcb_reports(
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
+ schema_nr = list((schema or {}).get("not_reviewed") or [])
+ pcb_nr = list((pcb or {}).get("not_reviewed") or [])
+ if schema_nr or pcb_nr:
+ out["not_reviewed"] = schema_nr + pcb_nr
return out
@@ -101,6 +112,11 @@ def run_pcb_checks(
("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)),
+ ("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
+ ("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
+ ("pcb_thermal_copper", lambda: check_pcb_thermal_copper(graph, constraints_map, layout)),
+ ("pcb_kelvin", lambda: check_pcb_kelvin(graph, constraints_map)),
+ ("pcb_gnd_stitch", lambda: check_pcb_gnd_stitch(graph, layout)),
):
try:
out.extend(fn())
diff --git a/backend/periscopex/pcb_power_thermal.py b/backend/periscopex/pcb_power_thermal.py
new file mode 100644
index 0000000..7f59aac
--- /dev/null
+++ b/backend/periscopex/pcb_power_thermal.py
@@ -0,0 +1,455 @@
+"""Power-trace, via, thermal-copper, and Kelvin checks on a LayoutGraph.
+
+Skip when current, width, copper thickness, or datasheet numbers are missing.
+IPC-2221 is cited only when ΔT can be taken from datasheet Tjmax − 25 °C.
+"""
+
+from __future__ import annotations
+
+import re
+
+from backend.periscopex.models import (
+ ComponentType,
+ DesignGraph,
+ Finding,
+ LayoutGraph,
+ NetType,
+)
+from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
+from backend.periscopex.placement_check import _in_poly
+from backend.periscopex.thermal_check import (
+ _LOAD_KEYS,
+ _TA_C,
+ _THETA_KEYS,
+ _VIN_PIN,
+ _VOUT_PIN,
+ _first,
+ _net_voltage,
+ _pin_net_by_role,
+ _specs_values,
+)
+from backend.periscopex.validate import _match_constraints
+
+# IPC-2221 §6.2 (empirical): I = k · ΔT^0.44 · A^0.725, A in mil², I in A.
+_IPC_B = 0.44
+_IPC_C = 0.725
+_IPC_K_EXT = 0.048
+_IPC_K_INT = 0.024
+_TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax")
+_SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I)
+
+
+def _mm_to_mil(mm: float) -> float:
+ return mm / 0.0254
+
+
+def _ipc2221_amps(area_mil2: float, dt_c: float, *, external: bool) -> float:
+ k = _IPC_K_EXT if external else _IPC_K_INT
+ return k * (dt_c ** _IPC_B) * (area_mil2 ** _IPC_C)
+
+
+def _min_width_layers(layout: LayoutGraph, sch_net: str) -> tuple[float | None, list[str]]:
+ widths: list[float] = []
+ layers: set[str] = set()
+ for s in layout.segments:
+ if not s.net or s.width <= 0:
+ continue
+ if kicad_nets_match(s.net, sch_net):
+ widths.append(s.width)
+ if s.layer:
+ layers.add(s.layer)
+ if not widths:
+ return None, sorted(layers)
+ return min(widths), sorted(layers)
+
+
+def _via_stats(layout: LayoutGraph, sch_net: str) -> tuple[int, float | None]:
+ n = 0
+ drills: list[float] = []
+ for v in layout.vias:
+ if not v.net or not kicad_nets_match(v.net, sch_net):
+ continue
+ n += 1
+ if v.drill and v.drill > 0:
+ drills.append(v.drill)
+ return n, (min(drills) if drills else None)
+
+
+def _external_layers(layers: list[str]) -> bool:
+ if not layers:
+ return True
+ return all(
+ ly.upper().startswith("F.") or ly.upper().startswith("B.")
+ for ly in layers
+ )
+
+
+def _load_on_output(
+ graph: DesignGraph, cmap: dict, ref: str,
+) -> tuple[float, str, str] | None:
+ """I_load (not Iout_max) on an IC output net, plus that net name."""
+ comp = graph.components.get(ref)
+ if not comp or comp.component_type != ComponentType.IC:
+ return None
+ cons = _match_constraints(comp.mpn or comp.value, cmap)
+ values = _specs_values(comp)
+ i_load = _first(values, _LOAD_KEYS)
+ if i_load is None:
+ return None
+ vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
+ if not vout:
+ return None
+ return i_load, vout, ref
+
+
+def check_pcb_power_traces(
+ graph: DesignGraph,
+ constraints_map: dict,
+ layout: LayoutGraph | None,
+) -> list[Finding]:
+ """Trace width × copper thickness vs datasheet I_load (IPC-2221 when ΔT known)."""
+ if layout is None:
+ return []
+ t_mm = layout.stackup.copper_thickness_mm if layout.stackup else None
+ out: list[Finding] = []
+ seen_nets: set[str] = set()
+ for ref, comp in sorted(graph.components.items()):
+ got = _load_on_output(graph, constraints_map, ref)
+ if not got:
+ continue
+ i_load, net, _ = got
+ key = normalize_kicad_hierarchy_net(net)
+ if key in seen_nets:
+ continue
+ seen_nets.add(key)
+ w_mm, layers = _min_width_layers(layout, net)
+ if w_mm is None:
+ continue
+ if t_mm is None or t_mm <= 0:
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_power",
+ finding=(
+ f"{net} carries I_load={i_load:.3g} A with min trace "
+ f"{w_mm:g} mm; copper thickness is not in the PCB stackup."
+ ),
+ why="IPC-2221 ampacity needs both width and copper thickness from the board.",
+ status="INFO",
+ recommendation=(
+ "Fill the KiCad stackup copper thickness (or export it) and re-run PCB review."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-PWR-001",
+ net=net,
+ pins=[],
+ ))
+ continue
+ area_mil2 = _mm_to_mil(w_mm) * _mm_to_mil(t_mm)
+ values = _specs_values(comp)
+ tjmax = _first(values, _TJMAX_KEYS)
+ if tjmax is None or tjmax <= _TA_C:
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_power",
+ finding=(
+ f"{net} I_load={i_load:.3g} A on {w_mm:g} mm × {t_mm * 1000:g} µm copper "
+ f"(A≈{area_mil2:.3g} mil²). Tjmax missing — IPC-2221 not applied."
+ ),
+ why="ΔT for IPC-2221 is Tjmax − 25 °C from the datasheet, never a default 10 °C.",
+ status="INFO",
+ recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
+ source="pcb_power_thermal",
+ rule_id="PE-PWR-001",
+ net=net,
+ pins=[],
+ ))
+ continue
+ dt = tjmax - _TA_C
+ ext = _external_layers(layers)
+ i_allow = _ipc2221_amps(area_mil2, dt, external=ext)
+ if i_load <= i_allow:
+ continue
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_power",
+ finding=(
+ f"{net} I_load={i_load:.3g} A exceeds IPC-2221 capacity "
+ f"{i_allow:.3g} A ({'external' if ext else 'internal'} k="
+ f"{_IPC_K_EXT if ext else _IPC_K_INT}, ΔT={dt:.0f} °C, "
+ f"w={w_mm:g} mm, t={t_mm * 1000:g} µm)."
+ ),
+ why="IPC-2221 §6.2 I = k·ΔT^0.44·A^0.725. I_load is the datasheet/typical load, not Iout_max.",
+ status="ERROR",
+ recommendation=(
+ f"Widen {net} (and/or add copper/vias/planes) so ampacity ≥ {i_load:.3g} A, "
+ "or reduce load current."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-PWR-001",
+ net=net,
+ pins=[],
+ ))
+ return out
+
+
+def check_pcb_via_current(
+ graph: DesignGraph,
+ constraints_map: dict,
+ layout: LayoutGraph | None,
+) -> list[Finding]:
+ """Share I_load across vias; no invented via-ampacity table."""
+ if layout is None:
+ return []
+ out: list[Finding] = []
+ seen: set[str] = set()
+ for ref, comp in sorted(graph.components.items()):
+ got = _load_on_output(graph, constraints_map, ref)
+ if not got:
+ continue
+ i_load, net, _ = got
+ key = normalize_kicad_hierarchy_net(net)
+ if key in seen:
+ continue
+ seen.add(key)
+ n, drill = _via_stats(layout, net)
+ if n <= 0:
+ continue
+ per = i_load / n
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_power",
+ finding=(
+ f"{net} shares I_load={i_load:.3g} A across {n} via(s) "
+ f"(≈{per:.3g} A each"
+ + (f", min drill {drill:g} mm" if drill else "")
+ + "). No via current rating in the library extraction."
+ ),
+ why="Via current is reported from board geometry + datasheet I_load only.",
+ status="INFO",
+ recommendation=(
+ "If the datasheet or stackup vendor quotes via current, add more/larger vias "
+ f"so each via stays under that rating at {i_load:.3g} A."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-VIA-001",
+ net=net,
+ pins=[],
+ ))
+ return out
+
+
+def check_pcb_thermal_copper(
+ graph: DesignGraph,
+ constraints_map: dict,
+ layout: LayoutGraph | None,
+) -> list[Finding]:
+ """Dissipating IC with P from I_load×drop and no pour/vias under courtyard."""
+ if layout is None:
+ return []
+ out: list[Finding] = []
+ for ref, comp in sorted(graph.components.items()):
+ if comp.component_type != ComponentType.IC:
+ continue
+ cons = _match_constraints(comp.mpn or comp.value, constraints_map)
+ values = _specs_values(comp)
+ i_load = _first(values, _LOAD_KEYS)
+ if i_load is None:
+ continue
+ vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
+ vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
+ vin = _net_voltage(graph, vin_n) if vin_n else None
+ vout = _net_voltage(graph, vout_n) if vout_n else None
+ if vin is None or vout is None or vin <= vout:
+ continue
+ p = i_load * (vin - vout)
+ fp = layout.footprints.get(ref)
+ if not fp or len(fp.courtyard) < 3:
+ continue
+ theta = _first(values, _THETA_KEYS)
+ via_n = 0
+ for v in layout.vias:
+ if _in_poly(v.x, v.y, fp.courtyard):
+ via_n += 1
+ pour = False
+ for z in layout.zones:
+ if not z.net:
+ continue
+ if vout_n and kicad_nets_match(z.net, vout_n):
+ pass
+ elif vin_n and kicad_nets_match(z.net, vin_n):
+ pass
+ elif graph.nets.get(normalize_kicad_hierarchy_net(z.net)) and (
+ graph.nets.get(z.net) and graph.nets[z.net].net_type == NetType.GROUND
+ ):
+ pass
+ else:
+ # GND by name
+ leaf = normalize_kicad_hierarchy_net(z.net).upper()
+ if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf):
+ continue
+ for outline in z.outlines:
+ if len(outline) >= 3 and _in_poly(fp.x, fp.y, outline):
+ pour = True
+ break
+ if via_n or pour:
+ continue
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_thermal",
+ finding=(
+ f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the courtyard "
+ "has no thermal vias and no overlapping copper pour."
+ ),
+ why=(
+ "P = I_load×(Vin−Vout) from the shared extraction/specs. "
+ + (f"θJA={theta:.3g} °C/W. " if theta else "θJA missing. ")
+ + "No millimetres invented."
+ ),
+ status="WARNING",
+ recommendation=(
+ "Add a copper pour and/or thermal vias under the package as the datasheet "
+ "layout page specifies, then re-run PCB review."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-THM-001",
+ net=vout_n,
+ pins=[],
+ ))
+ return out
+
+
+def _is_gnd_name(name: str) -> bool:
+ leaf = normalize_kicad_hierarchy_net(name).upper()
+ return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
+
+
+def check_pcb_gnd_stitch(
+ graph: DesignGraph,
+ layout: LayoutGraph | None,
+) -> list[Finding]:
+ """INFO when a signal span has a GND pour but no GND via in its bbox."""
+ if layout is None:
+ return []
+ gnd_zones = [
+ z for z in layout.zones
+ if z.net and _is_gnd_name(z.net) and z.outlines
+ ]
+ if not gnd_zones:
+ return []
+ gnd_vias = [
+ v for v in layout.vias
+ if v.net and _is_gnd_name(v.net)
+ ]
+ out: list[Finding] = []
+ seen: set[str] = set()
+ for net_name, net in sorted(graph.nets.items()):
+ if net.net_type != NetType.SIGNAL:
+ continue
+ key = normalize_kicad_hierarchy_net(net_name)
+ if key in seen:
+ continue
+ segs = [
+ s for s in layout.segments
+ if s.net and kicad_nets_match(s.net, net_name)
+ ]
+ if len(segs) < 1:
+ continue
+ xs: list[float] = []
+ ys: list[float] = []
+ for s in segs:
+ xs.extend([s.start[0], s.end[0]])
+ ys.extend([s.start[1], s.end[1]])
+ xmin, xmax = min(xs), max(xs)
+ ymin, ymax = min(ys), max(ys)
+ if xmax - xmin < 2.0 and ymax - ymin < 2.0:
+ continue
+ if any(xmin <= v.x <= xmax and ymin <= v.y <= ymax for v in gnd_vias):
+ continue
+ seen.add(key)
+ refs = [p.component_ref for p in net.pins[:1]]
+ ref = refs[0] if refs else net_name
+ out.append(Finding(
+ designator=ref,
+ mpn=(graph.components[ref].mpn if ref in graph.components else "") or "",
+ aspect="layout_return",
+ finding=(
+ f"Signal '{net_name}' spans {xmax - xmin:.1f}×{ymax - ymin:.1f} mm "
+ "over a GND pour with no GND via in that bounding box."
+ ),
+ why=(
+ "Return path / stitch vias are inferred from board zones and vias only "
+ "(no IEC clearance invented)."
+ ),
+ status="INFO",
+ recommendation=(
+ f"Add GND stitch vias along '{net_name}' so the return current "
+ "has a nearby via to the ground plane."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-STCH-001",
+ net=net_name,
+ pins=[],
+ ))
+ return out
+
+
+def check_pcb_kelvin(
+ graph: DesignGraph,
+ constraints_map: dict,
+) -> list[Finding]:
+ """Sense/Kelvin pin on a net that also carries the load (not a 4-wire tap)."""
+ out: list[Finding] = []
+ for ref, comp in sorted(graph.components.items()):
+ if comp.component_type != ComponentType.IC:
+ continue
+ cons = _match_constraints(comp.mpn or comp.value, constraints_map)
+ if not cons:
+ continue
+ for pin in cons.pintable:
+ blob = " ".join(
+ x for x in (str(pin.number), pin.name, pin.description or "")
+ if x
+ )
+ if not _SENSE_RE.search(blob):
+ continue
+ net = graph.pin_net(ref, str(pin.number))
+ if not net:
+ continue
+ others = [
+ r for r in graph.components_on_net(net)
+ if r != ref
+ ]
+ ics = [
+ r for r in others
+ if graph.components.get(r)
+ and graph.components[r].component_type == ComponentType.IC
+ ]
+ if len(others) <= 1 and not ics:
+ continue
+ if len(others) < 2:
+ continue
+ out.append(Finding(
+ designator=ref,
+ mpn=comp.mpn or "",
+ aspect="layout_kelvin",
+ finding=(
+ f"{ref} pin {pin.number} ({pin.name}) looks like a current-sense/Kelvin "
+ f"pin on '{net}' with {len(others)} other parts on the same net."
+ ),
+ why="A Kelvin/sense pin should tap the shunt, not share the high-current path.",
+ status="WARNING",
+ recommendation=(
+ f"Route {ref}.{pin.number} as a dedicated Kelvin pair to the sense resistor; "
+ "do not share that net with the load current."
+ ),
+ source="pcb_power_thermal",
+ rule_id="PE-KEL-001",
+ net=net,
+ pins=[str(pin.number)],
+ ))
+ return out
diff --git a/backend/periscopex/pcb_review.py b/backend/periscopex/pcb_review.py
index da4b62c..50d1cf7 100644
--- a/backend/periscopex/pcb_review.py
+++ b/backend/periscopex/pcb_review.py
@@ -5,41 +5,67 @@ from __future__ import annotations
import math
from backend.periscopex.functional_groups import FunctionalGroupsReport
-from backend.periscopex.models import DesignGraph, LayoutGraph
+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. This is an EXAM of the existing board — do not propose a new \
-floorplan, do not invent millimetres, and do not write Gerbers.
+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.
-### 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.
+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
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.
+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,
diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py
index 4fa9042..0d73fa0 100644
--- a/backend/services/pcb_pipeline.py
+++ b/backend/services/pcb_pipeline.py
@@ -36,19 +36,39 @@ _ANALYSIS_BUSY = frozenset({
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
-def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
+def _load_constraints_map(
+ extracted_dir: Path,
+ storage: StorageBackend | None = None,
+) -> dict[str, ComponentConstraints]:
+ """Project extracted/ plus shared ``library/extracted`` (one store for schema+PCB)."""
result: dict[str, ComponentConstraints] = {}
- if not extracted_dir.is_dir():
+ if extracted_dir.is_dir():
+ for f in extracted_dir.glob("*.json"):
+ try:
+ c = ComponentConstraints.model_validate_json(
+ f.read_text(encoding="utf-8"),
+ )
+ except Exception:
+ logger.exception("skipping bad extraction %s", f)
+ continue
+ result[c.mpn] = c
+ if storage is None:
return result
- for f in extracted_dir.glob("*.json"):
- try:
- c = ComponentConstraints.model_validate_json(
- f.read_text(encoding="utf-8"),
- )
- except Exception:
- logger.exception("skipping bad extraction %s", f)
+ try:
+ keys = storage.list_recursive("library/extracted/")
+ except Exception:
+ logger.exception("listing library/extracted failed")
+ return result
+ for key in keys:
+ if not key.endswith(".json"):
continue
- result[c.mpn] = c
+ try:
+ data = storage.read_json(key)
+ c = ComponentConstraints.model_validate(data)
+ except Exception:
+ logger.exception("skipping bad library extraction %s", key)
+ continue
+ result.setdefault(c.mpn, c)
return result
@@ -110,7 +130,7 @@ async def run_pcb_pipeline(
return
_step(project_id, "classify", "running", "domains and groups")
- cmap = _load_constraints_map(ws.local_path("extracted"))
+ cmap = _load_constraints_map(ws.local_path("extracted"), storage)
plan = build_placement_plan(graph, cmap)
fg_path = ws.local_path("functional_groups.json")
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
@@ -168,7 +188,7 @@ async def run_pcb_pipeline(
_finish_cancelled(storage, user_id, project_id)
return
- _step(project_id, "ai_review", "running", "per-IC datasheet vs layout")
+ _step(project_id, "ai_review", "running", "layout vs shared library extraction")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
try:
diff --git a/backend/services/pcb_validation.py b/backend/services/pcb_validation.py
index a7b1899..9dcd02b 100644
--- a/backend/services/pcb_validation.py
+++ b/backend/services/pcb_validation.py
@@ -1,4 +1,4 @@
-"""Per-IC PCB datasheet review — same agentic loop as schematic, layout context."""
+"""Per-IC PCB layout review — consumes shared library extraction, no second PDF exam."""
from __future__ import annotations
@@ -7,12 +7,16 @@ from pathlib import Path
from backend.periscopex.cad_bridge import annotate_findings_cad, cad_index_from_graph
from backend.periscopex.functional_groups import FunctionalGroupsReport
-from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph, ValidationReport
+from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_inventory import PcbInventoryReport
-from backend.periscopex.pcb_review import PCB_SYSTEM_PROMPT, build_pcb_layout_context
-from backend.periscopex.validate import ReviewResult
+from backend.periscopex.pcb_review import (
+ PCB_SYSTEM_PROMPT,
+ build_pcb_layout_context,
+ format_library_extraction,
+)
+from backend.periscopex.validate import ReviewResult, _match_constraints
from backend.services.api_logs import ApiLogger
-from backend.services.validation import _find_pdf, review_ic_async
+from backend.services.validation import review_ic_async
log = logging.getLogger(__name__)
@@ -27,6 +31,10 @@ def _ensure_recs(findings: list[Finding]) -> None:
f.recommendation = _FIX
+def _has_library_extraction(cons) -> bool:
+ return cons is not None and bool(cons.pintable)
+
+
async def review_pcb_ics(
graph: DesignGraph,
constraints_map: dict,
@@ -38,7 +46,11 @@ async def review_pcb_ics(
api_logger: ApiLogger | None = None,
on_progress=None,
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
- """Review each IC with a datasheet. Fail-soft per IC. No auto-place."""
+ """Layout-only AI exam using ``library/extracted`` (or project extracted/).
+
+ Does not attach a datasheet PDF. ICs without a library pintable are skipped
+ with a reason to run schematic review first.
+ """
findings: list[Finding] = []
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
@@ -50,14 +62,20 @@ async def review_pcb_ics(
if not mpn:
skipped.append({"designator": ref, "reason": "no MPN in BOM"})
continue
- pdf = _find_pdf(mpn, pdf_dir, storage=storage)
- if pdf is None:
- skipped.append({"designator": ref, "reason": "no datasheet PDF"})
+ cons = _match_constraints(mpn, constraints_map)
+ if not _has_library_extraction(cons):
+ skipped.append({
+ "designator": ref,
+ "reason": "no library extraction — run schematic review first",
+ })
continue
- extra = build_pcb_layout_context(ref, graph, layout, plan, inventory)
+ extra = "\n\n".join([
+ format_library_extraction(cons),
+ build_pcb_layout_context(ref, graph, layout, plan, inventory),
+ ])
try:
result, _trace = await review_ic_async(
- graph, constraints_map, ref, str(pdf),
+ graph, constraints_map, ref, None,
on_progress=on_progress,
api_logger=api_logger,
pdf_dir=pdf_dir,
diff --git a/backend/services/validation.py b/backend/services/validation.py
index c7b9917..8279d7f 100644
--- a/backend/services/validation.py
+++ b/backend/services/validation.py
@@ -288,7 +288,7 @@ async def review_ic_async(
graph: DesignGraph,
constraints_map: ConstraintsMap,
ic_ref: str,
- pdf_path: str,
+ pdf_path: str | None,
on_progress: ProgressCallback | None = None,
api_logger: ApiLogger | None = None,
trace_git_commit: str = "unknown",
@@ -301,21 +301,21 @@ async def review_ic_async(
) -> tuple[ReviewResult, dict]:
"""Review one IC against its datasheet. Async, multi-turn.
- Returns ``(ReviewResult, trace)`` — ``trace`` is a transcript dict of the
- full agentic loop (turns, tool calls + outputs, final submission) for
- offline inspection. Trace assembly is best-effort and never affects the
- review result.
+ ``pdf_path`` may be ``None`` when the caller already has library
+ extraction (PCB layout-only exam) — no PDF is attached and citations
+ are not re-verified against a PDF.
"""
comp = graph.components[ic_ref]
mpn = comp.mpn or comp.value
# Datasheet identity for the trace — hash the original PDF, not the
# trimmed copy, so the reference is stable across trim-heuristic changes.
- try:
- ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
- except Exception:
- log.exception("trace: datasheet md5 failed for %s", ic_ref)
- ds_md5 = None
+ ds_md5 = None
+ if pdf_path:
+ try:
+ ds_md5 = hashlib.md5(Path(pdf_path).read_bytes()).hexdigest()
+ except Exception:
+ log.exception("trace: datasheet md5 failed for %s", ic_ref)
# Pre-compute which designators the excerpt tool will accept for this
# review (neighbors via signal nets only — power/GND fan-out filtered).
@@ -338,7 +338,7 @@ async def review_ic_async(
current_ic=ic_ref,
connected_designators=connected_designators,
graph=graph,
- pdf_dir=pdf_dir or Path(pdf_path).parent,
+ pdf_dir=pdf_dir or (Path(pdf_path).parent if pdf_path else Path(".")),
storage=storage,
cache=excerpt_cache if excerpt_cache is not None else {},
fetch_budget=_PER_REVIEW_FETCH_BUDGET,
@@ -347,7 +347,7 @@ async def review_ic_async(
)
# Trim PDF up-front — both primary and fallback attempts share it.
- trimmed_pdf = _select_review_pages(pdf_path)
+ trimmed_pdf = _select_review_pages(pdf_path) if pdf_path else None
try:
async def _run(provider, model) -> tuple[ReviewResult, dict]:
t0 = time.monotonic()
@@ -377,15 +377,13 @@ async def review_ic_async(
if extra_context.strip():
user_text += "\n\n" + extra_context.strip()
+ user_blocks: list = []
+ if trimmed_pdf:
+ user_blocks.append(PdfBlock(path=Path(trimmed_pdf), cacheable=True))
+ user_blocks.append(TextBlock(text=user_text, cacheable=True))
initial_msg = Message(
role="user",
- content=[
- PdfBlock(path=Path(trimmed_pdf), cacheable=True),
- TextBlock(
- text=user_text,
- cacheable=True,
- ),
- ],
+ content=user_blocks,
)
messages: list[Message] = [initial_msg]
@@ -465,13 +463,14 @@ async def review_ic_async(
mpn_by_designator=mpn_by_designator,
connected=connected_designators,
)
- verify_finding_citations(
- result.findings,
- default_pdf=Path(pdf_path),
- default_mpn=mpn,
- pdf_dir=excerpt_state.pdf_dir,
- mpn_by_designator=mpn_by_designator,
- )
+ if pdf_path:
+ verify_finding_citations(
+ result.findings,
+ default_pdf=Path(pdf_path),
+ default_mpn=mpn,
+ pdf_dir=excerpt_state.pdf_dir,
+ mpn_by_designator=mpn_by_designator,
+ )
turn_record["tool_calls"].append({
"name": "submit_review",
"input": tc.input,
@@ -610,7 +609,7 @@ async def review_ic_async(
return await call_with_fallback("validation", _run)
finally:
- if trimmed_pdf != pdf_path:
+ if trimmed_pdf and pdf_path and trimmed_pdf != pdf_path:
Path(trimmed_pdf).unlink(missing_ok=True)
diff --git a/docs/piano-pcb-review.md b/docs/piano-pcb-review.md
index a286675..bf70639 100644
--- a/docs/piano-pcb-review.md
+++ b/docs/piano-pcb-review.md
@@ -113,8 +113,9 @@ Normalization resta **downgrade-only**.
| `.kicad_pcb` (`has_pcb`) | Sì |
| `design_graph.json` | Sì |
| `extracted/` + `layout_rules` / pintable | Per check numerici e AI |
-| PDF datasheet | Per AI (skip IC senza PDF, come schema) |
-| Stackup nel PCB | Per Z0; skip se assente |
+| **Shared library** `library/extracted/{safe_mpn}.json` | **Unico** store schema+PCB. Deep exam PDF una volta (pipeline schema). |
+| PDF datasheet | Solo per estrazione / review **schema**. PCB **non** riattacca il PDF. |
+| Stackup nel PCB | Per Z0 e ampacity; skip se assente |
---
@@ -134,22 +135,32 @@ Net con rame: lunghezza, layer, width min/max, via, coppia, bus, Z0 se stackup.
| `PE-SI-001` | skew coppia | `length_match` mm |
| `PE-DRT-001` | Vop > Vrated sul cap (derating.py) | entrambe le tensioni |
| `PE-Z0-001` | Z0 fuori target | target in regola o netclass; mai 50 Ω default |
+| `PE-PWR-001` | Larghezza × spessore rame vs I_load | I_load + width; IPC-2221 solo con `copper_thickness_mm` e Tjmax (ΔT=Tjmax−25 °C) |
+| `PE-VIA-001` | I_load / N via | geometria via + I_load; INFO (niente tabella via inventata) |
+| `PE-THM-001` | P=I_load×(Vin−Vout) senza pour/via courtyard | I_load, Vin/Vout, courtyard |
+| `PE-KEL-001` | Pin sense/Kelvin su net di carico | nome pintable + ≥2 altri sul net |
+| `PE-STCH-001` | Segnale sopra pour GND senza via GND nel bbox | zone GND + segmenti; INFO |
+| Creepage | — | **skip** senza numero datasheet/IEC |
+| Crystal keepout | `PE-PLC-004` | `layout_rules` kind=keepout |
Filtri: `check_filters` **non** duplicato nel report PCB (resta schema). L’AI e i groups usano `role_hint=filter` per proximity se c’è mm.
-### 3.3 AI exam (schema-like)
+### 3.3 AI exam (layout-only, shared library)
-Per ogni IC con PDF:
+Per ogni IC con **pintable in `library/extracted` o extracted/ di progetto** (stesso JSON):
-- System prompt **PCB** (placement, routing, decoupling, filtri, lunghezze, Z0, compliance layout).
-- User: `build_component_context` + blocco layout (xy, satelliti del group, lunghezze net, domains).
-- Stessi graph tools + `submit_review`.
-- Quote verify. Recommendation obbligatoria su ogni finding.
-- Isolamento per-IC; skip senza PDF.
+- **Non** si ri-legge il PDF. L’esame datasheet profondo è quello della pipeline schema.
+- System prompt **PCB**: layout vs JSON di libreria.
+- User: `format_library_extraction` + `build_pcb_layout_context`.
+- `review_ic_async(..., pdf_path=None)` — niente attach, niente quote-verify PDF.
+- Skip: `no library extraction — run schematic review first`.
+- Recommendation obbligatoria; isolation per-IC.
+
+UI: sezione report **PCB exam** (`pcb-exam-section.tsx`) — findings `source=pcb_review` e PE-PWR/THM/VIA/KEL/STCH. Nascosta con `?domain=schema`.
### 3.4 Skip (no folklore)
-3W, creepage IEC, CPWG, HV isolation, length-match USB spec, confronto foto TI vs gerber.
+3W, creepage IEC senza numero, CPWG, HV isolation, length-match USB spec, confronto foto TI vs gerber, 1 oz / 10 °C / 50 Ω di default.
---
@@ -160,7 +171,7 @@ Per ogni IC con PDF:
| Hub | **Run PCB review** se `hasPcb` |
| `/project/[id]/pcb` | Stepper SSE (include `ai_review`) |
| Sidebar | **Layout** → `/pcb` |
-| Report | Merge `report.json` + `pcb_report.json`; `?domain=layout`; badge Layout + `rule_id`; recommendation sempre visibile |
+| Report | Merge + sezione **PCB exam**; `?domain=layout`; badge Layout; recommendation sempre visibile |
| Inventory | Traces/buses/Z0 + domains/groups |
---
@@ -185,12 +196,18 @@ Meta `pcb_*`, worker, router, `run_pcb_checks`, inventory, report merge, UI star
`classify` + `ai_review` per-IC, prompt PCB, recommendation obbligatoria, changelog Layout.
-**Done when:** con API key, IC con PDF producono finding `source=pcb_review` o coverage vuota; senza key, skip loggato. Deploy.
+**Done when:** con API key, IC con libreria producono finding `source=pcb_review` o coverage vuota; senza key, skip loggato. Deploy.
### Fase 3 — Plugin + eval PCB
Cad-bridge pcbnew; golden fixture. Packing resta fuori.
+### Fase 4 — Libreria unica + sezione PCB exam (macro operativa)
+
+Un store `library/extracted` (più extracted/ di progetto). PCB non duplica l’esame PDF. Sezione report PCB exam + PE-PWR/THM/VIA/KEL/STCH.
+
+**Done when:** IC senza pintable skipped; con libreria, AI layout-only; check IPC solo con stackup+Tjmax. Deploy.
+
---
## 6. Fuori scope
diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md
index 20d0fe3..8804ea0 100644
--- a/frontend/content/changelog.md
+++ b/frontend/content/changelog.md
@@ -2,6 +2,16 @@
What's new in Periscope.
+## 2.31.0 — 2026-09-20 — Shared library PCB exam (no second PDF pass)
+
+Schematic and PCB share `library/extracted`. PCB AI consumes that cache (pintable required; no PDF attach). Report **PCB exam** section plus power-trace/thermal checks.
+
+- [New] Report section **PCB exam** (`source=pcb_review` and PE-PWR/THM/VIA/KEL/STCH).
+- [New] `PE-PWR-001` width × copper vs I_load (IPC-2221 only with stackup + Tjmax).
+- [New] `PE-THM-001` dissipation vs courtyard vias/pour; `PE-VIA-001` via share of I_load (INFO).
+- [New] `PE-KEL-001` Kelvin/sense on a shared load net; `PE-STCH-001` GND stitch in a signal bbox over a pour.
+- [Changed] PCB review skips ICs without library pintable (“run schematic review first”).
+
## 2.30.0 — 2026-09-19 — PCB review pipeline (exam, not auto-place)
Parallel `MODE=pcb` job examines an uploaded `.kicad_pcb`: domains/groups, trace inventory, deterministic layout checks, and per-IC AI datasheet review. Findings merge into the report with a Layout filter. No packing or pcbnew write-back.
diff --git a/frontend/src/app/(app)/project/[id]/report/page.tsx b/frontend/src/app/(app)/project/[id]/report/page.tsx
index 3a17003..7488c8e 100644
--- a/frontend/src/app/(app)/project/[id]/report/page.tsx
+++ b/frontend/src/app/(app)/project/[id]/report/page.tsx
@@ -2,12 +2,13 @@
import { use, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef, Suspense } from "react";
import { Download, RotateCcw } from "lucide-react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { useReport } from "@/hooks/use-report";
import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
+import { PcbExamSection } from "@/components/report/pcb-exam-section";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -28,6 +29,8 @@ interface FocusState {
function ReportContent({ projectId }: { projectId: string }) {
const router = useRouter();
+ const searchParams = useSearchParams();
+ const domainParam = searchParams.get("domain");
const { report, graph, loading, error } = useReport(projectId);
const { user } = useOptionalUser();
const [focus, setFocus] = useState(null);
@@ -349,7 +352,28 @@ function ReportContent({ projectId }: { projectId: string }) {
) : (
-
+ {domainParam !== "schema" && (
+ keyByFinding.get(f) ?? getFindingKey(f, 0)}
+ comments={comments}
+ collaborators={collaborators}
+ currentUserId={user?.id}
+ currentUserName={user?.name ?? user?.email ?? "User"}
+ onCommentAdded={handleCommentAdded}
+ onCommentDeleted={handleCommentDeleted}
+ onReportFinding={handleReportFinding}
+ reportedFindingIds={reportedFindingIds}
+ reviews={reviews}
+ onReviewSaved={handleReviewSaved}
+ />
+ )}
+
+ >
)}
{focus && (
diff --git a/frontend/src/components/report/finding-card.tsx b/frontend/src/components/report/finding-card.tsx
index ff63848..30f0c73 100644
--- a/frontend/src/components/report/finding-card.tsx
+++ b/frontend/src/components/report/finding-card.tsx
@@ -10,6 +10,7 @@ import { FindingComments } from "./finding-comments";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
+import { isLayoutFinding } from "@/lib/layout-finding";
const BORDER_COLOR: Record = {
ERROR: "border-l-rose-500",
@@ -176,12 +177,7 @@ export function FindingCard({
Automated check
)}
- {(finding.finding_id?.startsWith("PCB-") ||
- finding.source === "pcb_review" ||
- (finding.rule_id || "").startsWith("PE-PLC") ||
- (finding.rule_id || "").startsWith("PE-LAY") ||
- (finding.rule_id || "").startsWith("PE-SI") ||
- (finding.rule_id || "").startsWith("PE-DRT")) && (
+ {isLayoutFinding(finding) && (
Layout
diff --git a/frontend/src/components/report/findings-list.tsx b/frontend/src/components/report/findings-list.tsx
index 8260802..7cc621f 100644
--- a/frontend/src/components/report/findings-list.tsx
+++ b/frontend/src/components/report/findings-list.tsx
@@ -7,6 +7,7 @@ import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
+import { isLayoutFinding, isPcbExamFinding } from "@/lib/layout-finding";
interface FindingsListProps {
findings: Finding[];
@@ -85,19 +86,11 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
+ if (isPcbExamFinding(f)) return false;
if (domainParam === "layout") {
- const layout =
- (f.finding_id || "").startsWith("PCB-") ||
- f.source === "pcb_review" ||
- (f.rule_id || "").startsWith("PE-PLC") ||
- (f.rule_id || "").startsWith("PE-LAY") ||
- (f.rule_id || "").startsWith("PE-SI") ||
- (f.rule_id || "").startsWith("PE-DRT");
- if (!layout) return false;
+ if (!isLayoutFinding(f)) return false;
} else if (domainParam === "schema") {
- const layout =
- (f.finding_id || "").startsWith("PCB-");
- if (layout) return false;
+ if (isLayoutFinding(f)) return false;
}
return true;
},
@@ -176,7 +169,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onReviewSaved={onReviewSaved}
/>
))}
- {filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
+ {filtered.length === 0 && reviewedFindings.length === 0 && findings.some((f) => !isPcbExamFinding(f)) && (
No findings match your filters.
diff --git a/frontend/src/components/report/pcb-exam-section.tsx b/frontend/src/components/report/pcb-exam-section.tsx
new file mode 100644
index 0000000..88fc48b
--- /dev/null
+++ b/frontend/src/components/report/pcb-exam-section.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { CircuitBoard } from "lucide-react";
+import { FindingCard } from "@/components/report/finding-card";
+import { isPcbExamFinding } from "@/lib/layout-finding";
+import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
+
+interface PcbExamSectionProps {
+ findings: Finding[];
+ onViewReference: (finding: Finding) => void;
+ projectId: string;
+ isReviewed: (key: string) => boolean;
+ toggleReviewed: (key: string) => void;
+ findingKey: (f: Finding) => string;
+ comments?: Record;
+ collaborators?: Collaborator[];
+ currentUserId?: string;
+ currentUserName?: string;
+ onCommentAdded?: (comment: FindingComment) => void;
+ onCommentDeleted?: (commentId: string, findingId: string) => void;
+ onReportFinding?: (finding: Finding) => void;
+ reportedFindingIds?: Set;
+ reviews?: Record;
+ onReviewSaved?: (findingId: string, review: FindingReview) => void;
+}
+
+export function PcbExamSection(props: PcbExamSectionProps) {
+ const exam = props.findings.filter(isPcbExamFinding);
+ if (exam.length === 0) return null;
+ return (
+
+
+
+
+ PCB exam
+
+
+ Layout-only AI using the shared datasheet library (no second PDF pass),
+ plus power-trace / copper / thermal / via / Kelvin checks grounded in
+ board geometry and extracted specs.
+
+
+
+ {exam.map((f) => {
+ const key = props.findingKey(f);
+ return (
+ props.toggleReviewed(key)}
+ comments={f.finding_id ? props.comments?.[f.finding_id] : undefined}
+ projectId={props.projectId}
+ collaborators={props.collaborators}
+ currentUserId={props.currentUserId}
+ currentUserName={props.currentUserName}
+ onCommentAdded={props.onCommentAdded}
+ onCommentDeleted={props.onCommentDeleted}
+ onReportFinding={props.onReportFinding}
+ isReported={
+ !!f.finding_id && props.reportedFindingIds?.has(f.finding_id)
+ }
+ review={f.finding_id ? props.reviews?.[f.finding_id] : undefined}
+ onReviewSaved={props.onReviewSaved}
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/hooks/use-pcb-progress.ts b/frontend/src/hooks/use-pcb-progress.ts
index 533f875..a5c80cb 100644
--- a/frontend/src/hooks/use-pcb-progress.ts
+++ b/frontend/src/hooks/use-pcb-progress.ts
@@ -10,8 +10,8 @@ const PCB_STAGES = [
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
- { id: "checks", title: "Deterministic checks", description: "Placement, SI, pad nets, derating" },
- { id: "ai_review", title: "AI datasheet exam", description: "Per-IC layout vs datasheet" },
+ { id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
+ { id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
] as const;
diff --git a/frontend/src/lib/layout-finding.ts b/frontend/src/lib/layout-finding.ts
new file mode 100644
index 0000000..4e82a48
--- /dev/null
+++ b/frontend/src/lib/layout-finding.ts
@@ -0,0 +1,40 @@
+/** Layout / PCB-exam findings (merged report). */
+
+export function isLayoutFinding(f: {
+ finding_id?: string | null;
+ source?: string | null;
+ rule_id?: string | null;
+}): boolean {
+ const id = f.finding_id || "";
+ const rid = f.rule_id || "";
+ return (
+ id.startsWith("PCB-") ||
+ f.source === "pcb_review" ||
+ f.source === "pcb_power_thermal" ||
+ rid.startsWith("PE-PLC") ||
+ rid.startsWith("PE-LAY") ||
+ rid.startsWith("PE-SI") ||
+ rid.startsWith("PE-DRT") ||
+ rid.startsWith("PE-PWR") ||
+ rid.startsWith("PE-THM") ||
+ rid.startsWith("PE-VIA") ||
+ rid.startsWith("PE-KEL") ||
+ rid.startsWith("PE-STCH")
+ );
+}
+
+export function isPcbExamFinding(f: {
+ source?: string | null;
+ rule_id?: string | null;
+}): boolean {
+ const rid = f.rule_id || "";
+ return (
+ f.source === "pcb_review" ||
+ f.source === "pcb_power_thermal" ||
+ rid.startsWith("PE-PWR") ||
+ rid.startsWith("PE-THM") ||
+ rid.startsWith("PE-VIA") ||
+ rid.startsWith("PE-KEL") ||
+ rid.startsWith("PE-STCH")
+ );
+}
diff --git a/tests/test_pcb_review.py b/tests/test_pcb_review.py
index 2aff83c..00dad76 100644
--- a/tests/test_pcb_review.py
+++ b/tests/test_pcb_review.py
@@ -317,3 +317,223 @@ def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
assert jwt_meta is not None
assert jwt_meta.pcb_status == "queued"
assert proj_svc.get_project(storage, "local", pid) is None
+
+
+def _ldo_graph_layout(*, i_load=10.0, tj_max=150.0, width=0.15, thickness=0.035):
+ from backend.periscopex.models import (
+ ComponentConstraints,
+ LayoutStackup,
+ Pin,
+ PinConnection,
+ SimpleComponentSpecs,
+ )
+
+ pins = {"1": "VIN", "2": "VOUT"}
+ cons = ComponentConstraints(
+ mpn="LDO1",
+ pintable=[
+ Pin(number="1", name="VIN"),
+ Pin(number="2", name="VOUT"),
+ ],
+ absolute_maximum_ratings=[],
+ rules=[],
+ )
+ graph = DesignGraph(
+ components={
+ "U1": Component(
+ reference="U1", value="LDO", footprint="",
+ component_type=ComponentType.IC, mpn="LDO1",
+ component_subtype="ic.power.ldo",
+ pins=pins,
+ specs=SimpleComponentSpecs(
+ specs_type="discrete",
+ values={"i_load_a": i_load, "tj_max": tj_max, "theta_ja": 50},
+ ),
+ ),
+ },
+ nets={
+ "VIN": Net(
+ name="VIN", net_type=NetType.POWER, voltage=5.0,
+ pins=[PinConnection(component_ref="U1", pin_number="1")],
+ ),
+ "VOUT": Net(
+ name="VOUT", net_type=NetType.POWER, voltage=3.3,
+ pins=[PinConnection(component_ref="U1", pin_number="2")],
+ ),
+ },
+ )
+ layout = LayoutGraph(
+ stackup=LayoutStackup(
+ copper_layers=["F.Cu", "B.Cu"],
+ dielectrics=[],
+ copper_thickness_mm=thickness,
+ ),
+ footprints={
+ "U1": LayoutFootprint(
+ reference="U1", x=0, y=0, layer="F.Cu",
+ courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
+ pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
+ ),
+ },
+ segments=[
+ LayoutSegment(start=(0, 0), end=(20, 0), width=width, layer="F.Cu", net="VOUT"),
+ ],
+ )
+ return graph, {"LDO1": cons}, layout
+
+
+def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity():
+ from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
+
+ graph, cmap, layout = _ldo_graph_layout()
+ findings = check_pcb_power_traces(graph, cmap, layout)
+ assert findings
+ assert findings[0].rule_id == "PE-PWR-001"
+ assert findings[0].status == "ERROR"
+ assert "IPC-2221" in findings[0].finding
+ assert findings[0].recommendation
+
+
+def test_power_trace_skips_without_i_load():
+ from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
+
+ graph, cmap, layout = _ldo_graph_layout(i_load=10)
+ graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
+ # drop i_load
+ graph.components["U1"].specs = None
+ assert check_pcb_power_traces(graph, cmap, layout) == []
+
+
+def test_kelvin_sense_pin_on_shared_net():
+ from backend.periscopex.models import ComponentConstraints, Pin, PinConnection
+ from backend.periscopex.pcb_power_thermal import check_pcb_kelvin
+
+ cons = ComponentConstraints(
+ mpn="AMP",
+ pintable=[Pin(number="4", name="ISNS", description="current sense")],
+ absolute_maximum_ratings=[],
+ rules=[],
+ )
+ graph = DesignGraph(
+ components={
+ "U2": Component(
+ reference="U2", value="", footprint="",
+ component_type=ComponentType.IC, mpn="AMP",
+ pins={"4": "ISNS_NET"},
+ ),
+ "R1": Component(
+ reference="R1", value="10m", footprint="",
+ component_type=ComponentType.RESISTOR, mpn="",
+ pins={"1": "ISNS_NET", "2": "GND"},
+ ),
+ "U9": Component(
+ reference="U9", value="", footprint="",
+ component_type=ComponentType.IC, mpn="LOAD",
+ pins={"1": "ISNS_NET"},
+ ),
+ },
+ nets={
+ "ISNS_NET": Net(
+ name="ISNS_NET", net_type=NetType.SIGNAL,
+ pins=[
+ PinConnection(component_ref="U2", pin_number="4"),
+ PinConnection(component_ref="R1", pin_number="1"),
+ PinConnection(component_ref="U9", pin_number="1"),
+ ],
+ ),
+ },
+ )
+ findings = check_pcb_kelvin(graph, {"AMP": cons})
+ assert findings and findings[0].rule_id == "PE-KEL-001"
+ assert findings[0].status == "WARNING"
+
+
+def test_pcb_ai_skips_without_library_extraction():
+ import asyncio
+ from backend.services.pcb_validation import review_pcb_ics
+
+ graph = DesignGraph(
+ components={
+ "U1": Component(
+ reference="U1", value="X", footprint="",
+ component_type=ComponentType.IC, mpn="NOEXT",
+ pins={"1": "GND"},
+ ),
+ },
+ nets={},
+ )
+
+ async def _run():
+ return await review_pcb_ics(
+ graph, {}, None, None, None, Path("/tmp"),
+ )
+
+ _f, _c, skipped = asyncio.run(_run())
+ assert skipped
+ assert "library extraction" in skipped[0]["reason"]
+
+
+def test_library_constraints_fill_from_storage(tmp_path: Path):
+ from backend.periscopex.models import ComponentConstraints, Pin
+ from backend.services.pcb_pipeline import _load_constraints_map
+ from backend.services.storage import LocalStorageBackend
+
+ storage = LocalStorageBackend(tmp_path)
+ cons = ComponentConstraints(
+ mpn="LIBIC",
+ pintable=[Pin(number="1", name="VCC")],
+ absolute_maximum_ratings=[],
+ rules=[],
+ )
+ storage.write_json("library/extracted/LIBIC.json", cons.model_dump())
+ cmap = _load_constraints_map(tmp_path / "missing", storage)
+ assert "LIBIC" in cmap
+ assert cmap["LIBIC"].pintable[0].name == "VCC"
+
+
+def test_thermal_copper_warns_without_pour_or_vias():
+ from backend.periscopex.pcb_power_thermal import check_pcb_thermal_copper
+
+ graph, cmap, layout = _ldo_graph_layout(i_load=0.5, width=1.0)
+ findings = check_pcb_thermal_copper(graph, cmap, layout)
+ assert findings
+ assert findings[0].rule_id == "PE-THM-001"
+ assert findings[0].status == "WARNING"
+ assert findings[0].recommendation
+
+
+def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
+ from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
+ from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
+
+ graph = DesignGraph(
+ components={
+ "U1": Component(
+ reference="U1", value="", footprint="",
+ component_type=ComponentType.IC, mpn="X",
+ pins={"1": "SDA"},
+ ),
+ },
+ nets={
+ "SDA": Net(
+ name="SDA", net_type=NetType.SIGNAL,
+ pins=[PinConnection(component_ref="U1", pin_number="1")],
+ ),
+ },
+ )
+ layout = LayoutGraph(
+ footprints={},
+ segments=[
+ LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="SDA"),
+ ],
+ zones=[
+ LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
+ ],
+ vias=[],
+ )
+ findings = check_pcb_gnd_stitch(graph, layout)
+ assert findings and findings[0].rule_id == "PE-STCH-001"
+ assert findings[0].status == "INFO"
+ layout.vias = [LayoutVia(x=10, y=0, net="GND", drill=0.3)]
+ assert check_pcb_gnd_stitch(graph, layout) == []
+