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) == [] +