Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""Regulator current margin and explicit series-R IR drop.
|
|
|
|
Iout_max is the rating, never the load. IQ/load are summed only when every
|
|
IC on the rail has a spec. Trace resistance is never estimated.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from backend.periscopex.led_current_check import _net_voltage, _parse_resistance
|
|
from backend.periscopex.models import (
|
|
Component,
|
|
ComponentConstraints,
|
|
ComponentType,
|
|
DesignGraph,
|
|
Finding,
|
|
InductorSpecs,
|
|
ResistorSpecs,
|
|
)
|
|
from backend.periscopex.passive_rail_check import _is_ground_net
|
|
from backend.periscopex.thermal_check import (
|
|
_IOUT_MAX_KEYS,
|
|
_LOAD_KEYS,
|
|
_VIN_PIN,
|
|
_VOUT_PIN,
|
|
_first,
|
|
_is_ldo,
|
|
_pin_net_by_role,
|
|
_specs_values,
|
|
)
|
|
from backend.periscopex.validate import _match_constraints
|
|
|
|
_IQ_KEYS = (
|
|
"iq_a", "quiescent_current_a", "supply_current_a", "idd_a", "icc_a",
|
|
)
|
|
_IR_FRAC = 0.05 # 5% of the rail — wide, not a datasheet number
|
|
|
|
|
|
def _two_nets(comp: Component) -> tuple[str, str] | None:
|
|
nets = list(dict.fromkeys(comp.pins.values()))
|
|
if len(nets) != 2:
|
|
return None
|
|
return nets[0], nets[1]
|
|
|
|
|
|
def _series_ohms(comp: Component) -> float | None:
|
|
if comp.component_type == ComponentType.RESISTOR:
|
|
if isinstance(comp.specs, ResistorSpecs) and comp.specs.value_ohms >= 0:
|
|
return float(comp.specs.value_ohms)
|
|
return _parse_resistance(comp.value)
|
|
if comp.component_type == ComponentType.INDUCTOR:
|
|
if isinstance(comp.specs, InductorSpecs) and comp.specs.dcr_ohms is not None:
|
|
return float(comp.specs.dcr_ohms)
|
|
return None
|
|
|
|
|
|
def _expand_rail(graph: DesignGraph, start: str) -> set[str]:
|
|
"""Follow series R/L between nets; do not walk through ICs (VIN/VOUT)."""
|
|
seen = {start}
|
|
stack = [start]
|
|
while stack:
|
|
n = stack.pop()
|
|
for ref in graph.components_on_net(n):
|
|
c = graph.components.get(ref)
|
|
if not c or c.component_type not in (
|
|
ComponentType.RESISTOR, ComponentType.INDUCTOR,
|
|
):
|
|
continue
|
|
pair = _two_nets(c)
|
|
if not pair:
|
|
continue
|
|
other = pair[1] if pair[0] == n else pair[0]
|
|
if other in seen or _is_ground_net(graph, other):
|
|
continue
|
|
seen.add(other)
|
|
stack.append(other)
|
|
return seen
|
|
|
|
|
|
def _regulators(graph: DesignGraph, cmap: dict[str, ComponentConstraints]):
|
|
for ref, comp in sorted(graph.components.items()):
|
|
if comp.component_type != ComponentType.IC:
|
|
continue
|
|
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
|
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
|
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
|
if not (vin and vout) and not _is_ldo(comp, cons):
|
|
continue
|
|
if not (vin and vout):
|
|
continue
|
|
yield ref, comp, cons, vin, vout
|
|
|
|
|
|
def check_power_margin(
|
|
graph: DesignGraph,
|
|
constraints_map: dict[str, ComponentConstraints] | None = None,
|
|
) -> list[Finding]:
|
|
cmap = constraints_map or {}
|
|
findings: list[Finding] = []
|
|
for ref, comp, cons, vin, vout in _regulators(graph, cmap):
|
|
values = _specs_values(comp)
|
|
iout_max = _first(values, _IOUT_MAX_KEYS)
|
|
i_load = _first(values, _LOAD_KEYS)
|
|
ics: list[Component] = []
|
|
missing_iq = False
|
|
iq_sum = 0.0
|
|
for net in _expand_rail(graph, vout):
|
|
for r in graph.components_on_net(net):
|
|
c = graph.components.get(r)
|
|
if not c or c.component_type != ComponentType.IC or r == ref:
|
|
continue
|
|
if c in ics:
|
|
continue
|
|
ics.append(c)
|
|
iq = _first(_specs_values(c), _IQ_KEYS)
|
|
if iq is None:
|
|
missing_iq = True
|
|
else:
|
|
iq_sum += iq
|
|
i_total = None
|
|
if i_load is not None and not missing_iq:
|
|
i_total = i_load + iq_sum
|
|
elif i_load is not None and not ics:
|
|
i_total = i_load
|
|
elif not missing_iq and ics and i_load is None:
|
|
i_total = iq_sum
|
|
if iout_max is not None and i_total is not None and i_total > iout_max:
|
|
findings.append(Finding(
|
|
designator=ref,
|
|
mpn=comp.mpn or "",
|
|
aspect="power",
|
|
source="power_margin_check",
|
|
status="WARNING",
|
|
finding=(
|
|
f"{ref} load ≈ {i_total:.3g} A exceeds Iout_max {iout_max:.3g} A "
|
|
f"on '{vout}'."
|
|
),
|
|
why="Sum of specified IQ on the rail plus I_load. Missing IQ was not guessed.",
|
|
recommendation="Raise the regulator rating or cut the load.",
|
|
reference="regulator Iout_max",
|
|
net=vout,
|
|
pins=[ref],
|
|
rule_id="PE-PWR-001",
|
|
))
|
|
|
|
# IR drop only through an explicit series R/ferrite on VIN or VOUT.
|
|
if i_load is None:
|
|
continue
|
|
for r in graph.components_on_net(vin):
|
|
c = graph.components.get(r)
|
|
if not c or c.component_type not in (
|
|
ComponentType.RESISTOR, ComponentType.INDUCTOR,
|
|
):
|
|
continue
|
|
pair = _two_nets(c)
|
|
if not pair:
|
|
continue
|
|
ohms = _series_ohms(c)
|
|
if ohms is None or ohms <= 0:
|
|
continue
|
|
drop = i_load * ohms
|
|
vrail = _net_voltage(graph, vin) or _net_voltage(graph, vout)
|
|
if vrail is None or vrail <= 0:
|
|
continue
|
|
if drop <= _IR_FRAC * vrail:
|
|
continue
|
|
findings.append(Finding(
|
|
designator=r,
|
|
mpn=c.mpn or "",
|
|
aspect="power",
|
|
source="power_margin_check",
|
|
status="WARNING",
|
|
finding=(
|
|
f"{r} series drop ≈ {drop:.3g} V at I_load={i_load:.3g} A "
|
|
f"into {ref} VIN '{vin}'."
|
|
),
|
|
why="IR from an explicit series R/ferrite DCR. Trace resistance was not estimated.",
|
|
recommendation="Lower DCR or the load, or accept the drop if it is intended.",
|
|
reference="netlist series R",
|
|
net=vin,
|
|
pins=[r],
|
|
rule_id="PE-PWR-001",
|
|
))
|
|
return findings
|