Files
periscope/backend/periscopex/pi_check.py
T
michele bee6369c1d Replace findings list with a tree; filter ESD/PI false positives.
PE-ESD-001 only on J* connector–IC nets (skip NC, unconnected, VSYS/GND/3V3).
PE-PI-001 treats any capacitor on the rail, including KiCad slash prefixes, as
decoupling. Sort findings ERROR then WARNING then INFO, then RULE/RISK/REVIEW/INFO.
Report sidebar is a collapsed expand-on-click tree instead of an all-open list.
GET /report and complete_findings fail-soft and sort the same way.
2026-09-20 10:03:50 +02:00

202 lines
7.8 KiB
Python

"""Power integrity: local vs bulk on IC supplies. Distance only with datasheet mm."""
from __future__ import annotations
import math
from backend.periscopex.functional_groups import _BULK_F
from backend.periscopex.models import (
CapacitorSpecs,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
)
from backend.periscopex.passive_rail_check import _is_ic_supply_pin
from backend.periscopex.pcb_net_match import (
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
from backend.periscopex.validate import _match_constraints
_LOAD_KEYS = ("i_load_a", "i_load", "iout", "i_out")
def _cap_farads(comp) -> float | None:
if comp.component_type != ComponentType.CAPACITOR:
return None
if isinstance(comp.specs, CapacitorSpecs) and comp.specs.value_farads > 0:
return float(comp.specs.value_farads)
return None
def _i_load(comp) -> float | None:
specs = getattr(comp, "specs", None)
vals = getattr(specs, "values", None) if specs else None
if not isinstance(vals, dict):
return None
for k in _LOAD_KEYS:
v = vals.get(k)
try:
n = float(v)
except (TypeError, ValueError):
continue
if n > 0:
return n
return None
def _max_distance_mm(cons: ComponentConstraints | None) -> float | None:
if not cons:
return None
best = None
for rule in cons.layout_rules or []:
if rule.get("kind") != "decoupling_proximity":
continue
mm = rule.get("max_distance_mm")
if isinstance(mm, (int, float)) and mm > 0:
best = float(mm) if best is None else min(best, float(mm))
return best
def _pad_xy(layout: LayoutGraph, ref: str) -> tuple[float, float] | None:
fp = layout.footprints.get(ref)
if not fp:
return None
return (fp.x, fp.y)
def check_power_integrity(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
layout: LayoutGraph | None = None,
) -> list[Finding]:
"""Local cap presence (RISK). Local vs bulk (REVIEW). Distance only with mm."""
cmap = constraints_map or {}
out: list[Finding] = []
seen: set[str] = set()
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
i_load = _i_load(comp)
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
if not net:
continue
net_key = normalize_kicad_hierarchy_net(net)
if net_key in seen:
continue
if not _is_ic_supply_pin(graph, cons, pin_num, net):
continue
seen.add(net_key)
locals_: list[tuple[str, float]] = []
bulks: list[tuple[str, float]] = []
unvalued = 0
for cref in refs_on_matched_net(graph, net):
ccomp = graph.components.get(cref)
if not ccomp:
continue
farads = _cap_farads(ccomp)
if farads is None:
if ccomp.component_type == ComponentType.CAPACITOR:
unvalued += 1
continue
if farads >= _BULK_F:
bulks.append((cref, farads))
else:
locals_.append((cref, farads))
# Any capacitor on the rail (local, bulk, or unvalued) is decoupling.
if not locals_ and not bulks and unvalued == 0:
rec = f"Add a local decoupling capacitor on {net} at {ref}."
facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}."
if i_load is not None:
facts = f"{facts} I_load={i_load:g} A."
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="power_integrity",
finding=f"{ref} supply net {net} has no local decoupling capacitor.",
facts=facts,
requirement="IC supply pins need a local bypass (recommended).",
inference="Not a mandatory abs-max; RISK not RULE.",
why="Local vs bulk: local is C < 1 µF (same split as functional groups).",
status="WARNING",
recommendation=rec,
action=rec,
source="pi_check",
rule_id="PE-PI-001",
evidence_status="SUFFICIENT",
net=net,
pins=[f"{ref}.{pin_num}"],
))
elif locals_ and not bulks and i_load is not None and i_load >= 0.5:
rec = (
f"Confirm bulk capacitance on {net} for I_load={i_load:g} A, "
"or document why local-only is enough."
)
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="power_integrity",
finding=(
f"{ref} {net} has local cap(s) {[c[0] for c in locals_]} "
f"and no bulk (≥1 µF) with I_load={i_load:g} A."
),
facts=(
f"local={[(a, b) for a, b in locals_]}; bulk=[]; "
f"I_load={i_load:g} A."
),
requirement="Bulk on a loaded rail is recommended, not abs-max.",
inference="REVIEW — no invented PSRR milliohms.",
why="Local vs bulk split uses valued capacitors only.",
status="INFO",
recommendation=rec,
action=rec,
source="pi_check",
rule_id="PE-PI-002",
evidence_status="SUFFICIENT",
net=net,
pins=[f"{ref}.{pin_num}"],
))
limit = _max_distance_mm(cons)
if layout is None or limit is None or not locals_:
continue
ic_xy = _pad_xy(layout, ref)
if ic_xy is None:
continue
nearest = None
for cref, _f in locals_:
xy = _pad_xy(layout, cref)
if xy is None:
continue
dist = math.hypot(xy[0] - ic_xy[0], xy[1] - ic_xy[1])
if nearest is None or dist < nearest[0]:
nearest = (dist, cref)
if nearest is None or nearest[0] <= limit:
continue
# Distance vs datasheet mm is PE-PLC-001's job; PI only records FACT if PLC didn't.
rec = f"Move {nearest[1]} within {limit:g} mm of {ref} (layout_rules)."
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="power_integrity",
finding=(
f"{nearest[1]} is {nearest[0]:.2f} mm from {ref} on {net} "
f"(max_distance_mm={limit:g})."
),
facts=f"euclidean {nearest[0]:.2f} mm; limit {limit:g} mm from layout_rules.",
requirement=f"layout_rules decoupling_proximity max_distance_mm={limit:g}.",
inference="Same millimetres as PE-PLC-001; PI records the supply view.",
why="Distance judged only with datasheet millimetres.",
status="WARNING",
recommendation=rec,
action=rec,
source="pi_check",
rule_id="PE-PI-001",
evidence_status="SUFFICIENT",
net=net,
pins=[f"{ref}.{pin_num}"],
))
return out