FACT, REQUIREMENT, and INFERENCE are separate fields; datasheet provenance lives in the rule DB so Recommended cannot stay ERROR; LLM output is REVIEW. Designer decisions persist across rescans. Schematic and PCB share one library and the same finding object in the report UI.
128 lines
4.6 KiB
Python
128 lines
4.6 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.finding_engine import complete_findings
|
|
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.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
|
|
complete_findings(findings)
|
|
|
|
|
|
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
|
|
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,
|
|
) -> 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)),
|
|
("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)),
|
|
):
|
|
try:
|
|
out.extend(fn())
|
|
except Exception:
|
|
log.exception("PCB check %s failed — skipping", name)
|
|
assign_pcb_finding_ids(out)
|
|
return out
|