Files
periscope/backend/pinscopex/thermal_check.py
T
micheleandCursor 78013bd404 Add power-margin, PG-to-EN sequencing, and DNP enable checks.
Compare only specified IQ plus I_load to Iout_max and series-R drop, flag sequencing only when power_sequence is in specs, and treat DNP as a fitted-variant graph so a missing enable pull is ERROR only when the BOM actually marks DNP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:34:06 +02:00

299 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Schematic thermal estimates for LDOs and dissipating resistors.
I_load is never inferred from Iout_max. θJA is never invented: missing
theta_ja after a known P is INFO only. Ta defaults to 25 °C.
"""
from __future__ import annotations
import re
from backend.pinscopex.led_current_check import (
_leg_color,
_net_voltage,
_parse_resistance,
_series_resistor,
_vf,
)
from backend.pinscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
ResistorSpecs,
)
from backend.pinscopex.passive_rail_check import _pin_name_tokens
from backend.pinscopex.resolve_passives import _parse_spice_value
from backend.pinscopex.validate import _match_constraints
_TA_C = 25.0
_TJ_WARN_C = 125.0
_LOAD_KEYS = (
"i_load", "i_load_a", "load_current_a", "typical_load_a",
"iout_typical_a", "typical_output_current_a",
)
_IOUT_MAX_KEYS = (
"iout_max", "iout_max_a", "i_out_max", "max_output_current_a",
"output_current_max_a",
)
_THETA_KEYS = ("theta_ja", "theta_ja_c_per_w", "thermal_resistance_ja", "rth_ja")
_VIN_PIN = re.compile(r"(?:^|[_/])(VIN|IN)(?:$|[_/\d])", re.I)
_VOUT_PIN = re.compile(r"(?:^|[_/])(VOUT|V_OUT|VO|OUT)(?:$|[_/\d])", re.I)
_NOT_OUT = re.compile(r"\b(EN|FB|NC|GND|PG)\b", re.I)
def _num(v: object) -> float | None:
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
try:
return _parse_spice_value(s)
except ValueError:
m = re.match(r"^[-+]?\d*\.?\d+", s)
if m:
try:
return float(m.group(0))
except ValueError:
return None
return None
def _specs_values(comp: Component) -> dict:
specs = comp.specs
values = getattr(specs, "values", None) if specs else None
return values if isinstance(values, dict) else {}
def _first(values: dict, keys: tuple[str, ...]) -> float | None:
for k in keys:
if k in values:
n = _num(values[k])
if n is not None:
return n
return None
def _power_rating_w(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.power_rating_w:
raw = specs.power_rating_w
s = str(raw).strip().upper().replace("W", "")
if "/" in s:
try:
a, b = s.split("/", 1)
return float(a) / float(b)
except (TypeError, ValueError):
pass
return _num(raw) or _num(s)
return None
def _is_ldo(comp: Component, cons: ComponentConstraints | None) -> bool:
sub = (comp.component_subtype or "") + " " + ((cons.component_subtype if cons else "") or "")
if "ldo" in sub.lower() or "linear_regulator" in sub.lower():
return True
return False
def _pin_net_by_role(
graph: DesignGraph,
comp: Component,
cons: ComponentConstraints | None,
role_re: re.Pattern,
exclude_re: re.Pattern | None = _NOT_OUT,
) -> str | None:
for pin_num, net in comp.pins.items():
tokens = _pin_name_tokens(cons, pin_num) or [pin_num]
if any(
role_re.search(t) and not (exclude_re and exclude_re.search(t))
for t in tokens
):
return net
if role_re.search(net or "") and not (exclude_re and exclude_re.search(net or "")):
return net
return None
def check_thermal(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
findings.extend(_ldo_thermal(graph, cmap))
findings.extend(_resistor_thermal(graph))
return findings
def _ldo_thermal(
graph: DesignGraph,
cmap: dict[str, ComponentConstraints],
) -> list[Finding]:
out: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
if not _is_ldo(comp, cons):
# VIN+VOUT names still count as a regulator for this check.
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin_n and vout_n):
continue
else:
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
values = _specs_values(comp)
i_load = _first(values, _LOAD_KEYS)
if i_load is None:
# Explicitly ignore Iout_max — that is not a load.
continue
vin = _net_voltage(graph, vin_n) if vin_n else None
vout = _net_voltage(graph, vout_n) if vout_n else None
if vin is None or vout is None or vin <= vout:
continue
p = i_load * (vin - vout)
theta = _first(values, _THETA_KEYS)
net = vout_n or vin_n
if theta is None:
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status="INFO",
finding=(
f"{ref} dissipation ≈ {p:.3g} W "
f"(I_load={i_load:.3g} A, Vin-Vout={vin - vout:.3g} V); "
f"manca theta_ja."
),
why="θJA is not in the IC specs; Tj is not estimated.",
recommendation="Add theta_ja (or θJA) from the datasheet package table.",
reference="thermal estimate",
net=net,
pins=[ref],
rule_id="PS-TH-001",
))
continue
tj = _TA_C + p * theta
status = "WARNING" if tj >= _TJ_WARN_C else "INFO"
rule = "PS-TH-002" if status == "WARNING" else "PS-TH-001"
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status=status,
finding=(
f"{ref} Tj ≈ {tj:.0f} °C at Ta={_TA_C:.0f} °C "
f"(P≈{p:.3g} W, θJA={theta:.3g} °C/W)."
),
why="P = I_load × (VinVout); Tj = Ta + P·θJA. Iout_max was not used as load.",
recommendation="Lower I_load, drop, or θJA (better copper / package) if Tj is high.",
reference="thermal estimate",
net=net,
pins=[ref],
rule_id=rule,
))
return out
def _resistor_thermal(graph: DesignGraph) -> list[Finding]:
out: list[Finding] = []
seen: set[str] = set()
for ref in sorted(graph.components_by_subtype("discrete.led")):
led = graph.components.get(ref)
if not led or not led.specs:
continue
values = getattr(led.specs, "values", None) or {}
for pid, net in led.pins.items():
res = _series_resistor(graph, net, ref)
if not res:
continue
rref, rval, far = res
if rref in seen:
continue
rcomp = graph.components.get(rref)
rating = _power_rating_w(rcomp) if rcomp else None
if rating is None:
continue
color = _leg_color(pid, led)
vf = _vf(values, color)
vrail = _net_voltage(graph, far)
if vrail is None:
vrail = max(
(v for v in (_net_voltage(graph, n) for n in led.pins.values()) if v is not None),
default=None,
)
if vrail is None or vf is None or vrail <= vf or rval <= 0:
continue
i = (vrail - vf) / rval
p = i * i * rval
if p <= rating:
continue
seen.add(rref)
out.append(Finding(
designator=rref,
mpn=(rcomp.mpn if rcomp else "") or "",
aspect="thermal",
source="thermal_check",
status="WARNING",
finding=(
f"{rref} dissipates ≈ {p:.3g} W on the LED path, "
f"above its {rating:.3g} W rating."
),
why="P = I²R with I from (VrailVf)/R. Rating comes from power_rating_w.",
recommendation="Use a higher-wattage resistor or raise R to cut current.",
reference="resistor power rating",
net=net,
pins=[rref],
rule_id="PS-TH-003",
))
for ref, comp in sorted(graph.components.items()):
if ref in seen or comp.component_type != ComponentType.RESISTOR:
continue
rating = _power_rating_w(comp)
ohms = None
if isinstance(comp.specs, ResistorSpecs):
ohms = float(comp.specs.value_ohms)
if ohms is None:
ohms = _parse_resistance(comp.value)
if rating is None or ohms is None or ohms <= 0:
continue
nets = list(dict.fromkeys(comp.pins.values()))
if len(nets) != 2:
continue
v1, v2 = _net_voltage(graph, nets[0]), _net_voltage(graph, nets[1])
if v1 is None or v2 is None:
continue
dv = abs(v1 - v2)
if dv <= 0:
continue
i = dv / ohms
p = i * i * ohms
if p <= rating:
continue
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="thermal",
source="thermal_check",
status="WARNING",
finding=(
f"{ref} shunt dissipates ≈ {p:.3g} W "
f"(ΔV={dv:.3g} V / {ohms:.3g} Ω), above its {rating:.3g} W rating."
),
why="P = I²R with I = ΔV/R from known net voltages. No guessed current.",
recommendation="Raise the wattage rating or the resistance.",
reference="resistor power rating",
net=nets[0],
pins=[ref],
rule_id="PS-TH-003",
))
return out