Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a datasheet quote, and Tj from copper+vias+θJA when every parameter exists. Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
211 lines
8.5 KiB
Python
211 lines
8.5 KiB
Python
"""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
|