"""Deterministic PCB review checks (no LLM). Fail-soft at the caller.""" from __future__ import annotations import logging 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.placement_check import check_placement from backend.periscopex.si_check import check_si log = logging.getLogger(__name__) _FALLBACK_FIX = "Confirm the layout against the datasheet and adjust the board." def assign_pcb_finding_ids(findings: list[Finding]) -> None: """IDs ``PCB-{designator}-{001}`` so they never collide with schema review.""" counter: Counter[str] = Counter() for f in findings: counter[f.designator] += 1 f.finding_id = f"PCB-{f.designator}-{counter[f.designator]:03d}" if not (f.recommendation or "").strip(): f.recommendation = _FALLBACK_FIX def check_pcb_derating(graph: DesignGraph) -> list[Finding]: """ERROR only when both Vop and Vrated exist and Vop exceeds Vrated.""" out: list[Finding] = [] for row in build_derating_table(graph): rated = row.get("rated_voltage_v") op = row.get("operating_voltage_v") if rated is None or op is None: continue if float(op) <= float(rated): continue ref = str(row.get("designator") or "") out.append(Finding( designator=ref, mpn=str(row.get("mpn") or ""), aspect="derating", finding=( f"{ref} operates at {op:g} V with a {rated:g} V rating." ), why="Capacitor voltage rating must exceed the rail (no invented derating %).", status="ERROR", recommendation=( f"Replace {ref} with a capacitor rated above {op:g} V, " "or lower the rail." ), source="pcb_derating", rule_id="PE-DRT-001", net=row.get("net_plus"), pins=[], )) return out def merge_schema_pcb_reports( schema: dict | None, pcb: dict | None, ) -> dict | None: """Combine schematic report.json with pcb_report.json for GET /report.""" if not schema and not pcb: return None if not pcb: out = dict(schema or {}) out["findings"] = [ f for f in (out.get("findings") or []) if not str(f.get("finding_id") or "").startswith("PCB-") ] return out if not schema: return dict(pcb) findings = [ f for f in (schema.get("findings") or []) if not str(f.get("finding_id") or "").startswith("PCB-") ] findings.extend(pcb.get("findings") or []) out = dict(schema) out["findings"] = findings summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0} for f in findings: st = f.get("status") if st in summary: summary[st] = summary.get(st, 0) + 1 out["summary"] = summary return out def run_pcb_checks( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, ) -> list[Finding]: """Placement, SI, pad-net match, derating — skip individually on error.""" out: list[Finding] = [] for name, fn in ( ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), ("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)), ): try: out.extend(fn()) except Exception: log.exception("PCB check %s failed — skipping", name) assign_pcb_finding_ids(out) return out