"""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.bom_pcb_check import check_bom_pcb_datasheet from backend.periscopex.emi_check import check_emi from backend.periscopex.esd_return_check import check_esd, check_return_path from backend.periscopex.finding_engine import complete_findings from backend.periscopex.functional_groups import FunctionalGroupsReport from backend.periscopex.hierarchy import check_hierarchy 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_junction_temp, check_pcb_kelvin, check_pcb_power_traces, check_pcb_thermal_copper, check_pcb_via_current, ) from backend.periscopex.pi_check import check_power_integrity from backend.periscopex.placement_check import check_placement from backend.periscopex.si_check import check_si from backend.periscopex.spof_check import check_spof from backend.periscopex.timing_check import check_timing 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}" rec = (f.recommendation or "").strip() act = (f.action or "").strip() if not rec: rec = act or _FALLBACK_FIX f.recommendation = rec if not act: f.action = rec complete_findings(findings) def check_pcb_derating(graph: DesignGraph) -> list[Finding]: """PASS / MARGIN / RISK (exceed) when both Vop and Vrated exist.""" out: list[Finding] = [] n_pass = 0 for row in build_derating_table(graph): rated = row.get("rated_voltage_v") op = row.get("operating_voltage_v") stress = row.get("stress") or "UNKNOWN" if rated is None or op is None: continue ref = str(row.get("designator") or "") mpn = str(row.get("mpn") or "") net = row.get("net_plus") ratio = float(op) / float(rated) if stress == "RISK": rec = ( f"Replace {ref} with a capacitor rated above {op:g} V, " "or lower the rail." ) out.append(Finding( designator=ref, mpn=mpn, aspect="derating", finding=( f"{ref} operates at {op:g} V with a {rated:g} V rating " f"(Vop/Vrated={ratio:.2f})." ), facts=f"Vop={op:g} V, Vrated={rated:g} V, ratio={ratio:.3f}.", requirement="Operating voltage must not exceed the capacitor rating.", inference="Exceeding Vr is a measured RULE, not a dielectric %.", why="Capacitor voltage rating must exceed the rail (no invented derating %).", status="ERROR", recommendation=rec, action=rec, source="pcb_derating", rule_id="PE-DRT-001", evidence_status="SUFFICIENT", net=net, pins=[], )) elif stress == "MARGIN": rec = ( f"Confirm {ref} voltage margin ({op:g}/{rated:g} V) or pick a " "higher Vr. This is utilization, not a 50% ceramic rule." ) out.append(Finding( designator=ref, mpn=mpn, aspect="derating", finding=( f"{ref} Vop/Vrated={ratio:.2f} ({op:g}/{rated:g} V) — MARGIN." ), facts=f"Vop={op:g} V, Vrated={rated:g} V, ratio={ratio:.3f}.", requirement="Utilization > 0.8 of Vr is flagged as MARGIN (RISK).", inference="Not an invented IPC/ceramic derating percentage.", why="PASS/MARGIN/RISK from Vop and Vrated only.", status="WARNING", recommendation=rec, action=rec, source="pcb_derating", rule_id="PE-DRT-002", evidence_status="SUFFICIENT", net=net, pins=[], )) elif stress == "PASS": n_pass += 1 if n_pass: rec = "No voltage-rating change required for PASS capacitors." out.append(Finding( designator="C", mpn="", aspect="derating", finding=f"{n_pass} capacitor(s) PASS (Vop/Vrated ≤ 0.8).", facts=f"PASS count={n_pass} (both Vop and Vrated present).", requirement="Rated voltage exists and Vop is ≤ 80% of Vr.", inference="Summary INFO — per-cap PASS lives on the derating table.", why="Avoid one INFO card per capacitor.", status="INFO", recommendation=rec, action=rec, source="pcb_derating", rule_id="PE-DRT-003", evidence_status="SUFFICIENT", 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 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 def run_pcb_checks( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, plan: FunctionalGroupsReport | None = None, impedance_nets: list[dict] | dict | None = None, ) -> list[Finding]: """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" 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, impedance_nets)), ("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)), ("hierarchy_check", lambda: check_hierarchy(graph, plan)), ("timing_check", lambda: check_timing(graph, constraints_map)), ("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)), ("esd_check", lambda: check_esd(graph, constraints_map)), ("return_path", lambda: check_return_path(graph, layout)), ("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)), ("spof_check", lambda: check_spof(graph, constraints_map)), ("emi_check", lambda: check_emi(graph, constraints_map, layout)), ("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)), ): try: out.extend(fn()) except Exception: log.exception("PCB check %s failed — skipping", name) assign_pcb_finding_ids(out) return out