"""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.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 or net in seen: continue if not _is_ic_supply_pin(graph, cons, pin_num, net): continue seen.add(net) locals_: list[tuple[str, float]] = [] bulks: list[tuple[str, float]] = [] unvalued = 0 for cref in graph.components_on_net(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)) if not locals_ 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