Files
periscope/backend/periscopex/pcb_checks.py
T
michele 342792df2d Ship Fase B slices: hierarchy, derating bands, timing, PI, ESD/return.
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.
2026-09-20 09:35:04 +02:00

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