ERROR stays only for RULE+MANDATORY. Hierarchy walks component→pin→net→block. Derating is PASS/MARGIN/RISK from Vop/Vrated. Timing and PI skip without numbers. ESD and HS return path are REVIEW, not RULE ERROR.
202 lines
7.9 KiB
Python
202 lines
7.9 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.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_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.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,
|
|
) -> 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)),
|
|
("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)),
|
|
):
|
|
try:
|
|
out.extend(fn())
|
|
except Exception:
|
|
log.exception("PCB check %s failed — skipping", name)
|
|
assign_pcb_finding_ids(out)
|
|
return out
|