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.
202 lines
7.8 KiB
Python
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
|