Files
periscope/backend/periscopex/led_current_check.py
T
micheleandCursor 8d2b85600f Rebrand Pinscope to Periscope across product and codebase.
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>
2026-09-13 20:02:04 +02:00

321 lines
11 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.
"""Deterministic LED forward-current check.
For each LED, compute the worst-case forward current per channel
``I = (V_rail - Vf) / R`` (0 V driver drop) and compare against the LED's
datasheet forward-current rating. Over-current is a hard ERROR; ambiguous cases
(unknown rail, no rating, no resistor found, possible constant-current driver)
are left alone or flagged WARNING rather than guessed. One finding per LED —
the worst offending channel.
All inputs come straight off the design graph — the LED's extracted specs
(``Component.specs.values``: per-colour ``forward_voltage_*_v``,
``forward_current_per_channel_a`` / ``forward_current_a``) and the series
resistor's ``value_ohms`` (or parsed ``value`` string). Nothing is re-fetched.
"""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding, NetType
from backend.periscopex.resolve_passives import _parse_spice_value
_COLOR_TOKENS = {
"R": "red", "RED": "red",
"G": "green", "GRN": "green", "GREEN": "green",
"B": "blue", "BLU": "blue", "BLUE": "blue",
}
# ---------------------------------------------------------------------------
# Value parsing
# ---------------------------------------------------------------------------
def _num(v: object) -> float | None:
"""Parse a free-form spec value ("13mA", "2.8V", "3.3V typ, 4V max", or a
bare float) to a float in base units, or None."""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip()
for cand in (s, *re.findall(r"[-+]?\d*\.?\d+\s*[a-zA-Zµ]*", s)):
cand = cand.strip()
if not cand:
continue
try:
return _parse_spice_value(cand)
except ValueError:
pass
m = re.match(r"^[-+]?\d*\.?\d+", cand)
if m:
try:
return float(m.group(0))
except ValueError:
pass
return None
def _parse_resistance(v: object) -> float | None:
"""Parse a resistance string to ohms: "5.6K"->5600, "5K6"->5600,
"150R"->150, "4R7"->4.7, "1M"->1e6, "0"->0."""
if v is None:
return None
if isinstance(v, (int, float)):
return float(v)
t = str(v).strip().upper().replace("OHMS", "").replace("OHM", "").replace("Ω", "").replace(" ", "")
if not t:
return None
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}
m = re.match(r"^(\d+)([RKMG])(\d+)$", t) # 5K6, 4R7, 1M5
if m:
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult[m.group(2)]
m = re.match(r"^(\d*\.?\d+)([RKMG])$", t) # 5.6K, 150R, 1M
if m:
return float(m.group(1)) * mult[m.group(2)]
try:
return float(t)
except ValueError:
return None
def _spec(values: dict, *keys: 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 _imax(values: dict) -> float | None:
"""LED forward-current rating in amps."""
i = _spec(values, "forward_current_per_channel_a", "forward_current_a",
"max_forward_current_a", "if_max_a")
if i is None:
return None
# A per-channel LED current >= 1 A is almost certainly mA written without a
# unit (e.g. "13" meaning 13 mA) — scale down.
if i >= 1.0:
i = i / 1000.0
return i
def _vf(values: dict, color: str | None) -> float | None:
vf = None
if color:
vf = _spec(values, f"forward_voltage_{color}_v")
if vf is None:
vf = _spec(values, "forward_voltage_v", "vf_v")
if vf is None:
cands = [_spec(values, f"forward_voltage_{c}_v") for c in ("red", "green", "blue")]
cands = [c for c in cands if c is not None]
vf = min(cands) if cands else None # lowest Vf = most conservative (highest I)
if vf is not None and vf > 20: # mV given without scaling
vf = vf / 1000.0
return vf
# ---------------------------------------------------------------------------
# Graph helpers
# ---------------------------------------------------------------------------
def _net_voltage(graph: DesignGraph, net_name: str | None) -> float | None:
if not net_name:
return None
net = graph.nets.get(net_name)
return net.voltage if net else None
def _is_rail_net(graph: DesignGraph, net_name: str) -> bool:
net = graph.nets.get(net_name)
if not net:
return False
return net.net_type in (NetType.POWER, NetType.GROUND) or net.voltage is not None
def _series_resistor(graph: DesignGraph, net_name: str, exclude_ref: str):
"""Return (resistor_ref, ohms, far_net) for a 2-terminal series resistor on a
private (degree-2) net, or None. Requiring degree 2 ensures the resistor is
truly in series with the LED leg, not merely sharing a bus/rail net."""
net = graph.nets.get(net_name)
if not net or len(net.pins) != 2:
return None
for pc in net.pins:
if pc.component_ref == exclude_ref:
continue
c = graph.components.get(pc.component_ref)
if not c or c.component_type != ComponentType.RESISTOR:
continue
rval = getattr(c.specs, "value_ohms", None) if c.specs else None
if rval is None:
rval = _parse_resistance(c.value)
if rval is None or rval <= 0:
continue
far = next((n for n in c.pins.values() if n != net_name), None)
return (pc.component_ref, float(rval), far)
return None
def _leg_to_ic(graph: DesignGraph, net_name: str, exclude_ref: str) -> bool:
"""True if an IC sits on this leg net (possible constant-current driver)."""
for r in graph.components_on_net(net_name):
if r == exclude_ref:
continue
c = graph.components.get(r)
if c and c.component_type == ComponentType.IC:
return True
return False
def _leg_color(pid: str, comp) -> str | None:
if pid.upper() in _COLOR_TOKENS:
return _COLOR_TOKENS[pid.upper()]
specs = comp.specs
pin = specs.pin_by_number(pid) if specs and hasattr(specs, "pin_by_number") else None
if pin:
for tok in re.split(r"[\s_/-]+", pin.name.upper()):
if tok in _COLOR_TOKENS:
return _COLOR_TOKENS[tok]
return None
# ---------------------------------------------------------------------------
# Per-LED check
# ---------------------------------------------------------------------------
def check_led_current(graph: DesignGraph) -> list[Finding]:
findings: list[Finding] = []
for ref in sorted(graph.components_by_subtype("discrete.led")):
comp = graph.components.get(ref)
if not comp or not comp.specs:
continue
values = getattr(comp.specs, "values", None)
if not values:
continue
imax = _imax(values)
if imax is None:
continue # no forward-current rating -> nothing to check against
finding = _check_led(graph, ref, comp, values, imax)
if finding is not None:
findings.append(finding)
return findings
def _check_led(graph, ref, comp, values, imax) -> Finding | None:
pins = comp.pins # pid -> net
pin_volts = [v for v in (_net_voltage(graph, n) for n in pins.values()) if v is not None]
# Channels carrying current sit on private (signal) nets; for a 2-pin LED the
# single channel is whichever pin actually has a series resistor.
if len(pins) <= 2:
leg = next(
((pid, net, _series_resistor(graph, net, ref))
for pid, net in pins.items()
if _series_resistor(graph, net, ref)),
None,
)
if leg is None:
cand = next(((pid, net) for pid, net in pins.items()
if not _is_rail_net(graph, net)), None)
legs_iter = [(cand[0], cand[1], None)] if cand else []
else:
legs_iter = [leg]
else:
legs_iter = [
(pid, net, _series_resistor(graph, net, ref))
for pid, net in pins.items()
if not _is_rail_net(graph, net)
]
worst = None # (i, color, net, vrail, vf, rval, rref)
no_res = None # (color, net, vrail, vf)
for pid, net, res in legs_iter:
color = _leg_color(pid, comp)
vf = _vf(values, color)
cand = list(pin_volts)
if res and res[2]:
fv = _net_voltage(graph, res[2])
if fv is not None:
cand.append(fv)
vrail = max(cand) if cand else None
if res is None:
if no_res is None and vrail is not None and vrail > 0 and not _leg_to_ic(graph, net, ref):
no_res = (color, net, vrail, vf)
continue
rref, rval, _far = res
if vrail is None or vf is None or vrail <= vf or rval <= 0:
continue
i = (vrail - vf) / rval
if i > imax and (worst is None or i > worst[0]):
worst = (i, color, net, vrail, vf, rval, rref)
if worst is not None:
i, color, net, vrail, vf, rval, rref = worst
return _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i)
if no_res is not None:
color, net, vrail, vf = no_res
return _no_resistor_finding(ref, comp, net, color, vrail, vf, imax)
return None
def _chan(color: str | None) -> str:
return f"{color} channel" if color else "LED"
def _over_current_finding(ref, comp, net, color, vrail, vf, rval, rref, imax, i) -> Finding:
rmin = (vrail - vf) / imax
return Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="led_current",
source="led_current_check",
source_page=None,
status="ERROR",
finding=(
f"{ref} {_chan(color)} forward current is ~{i * 1000:.0f} mA, "
f"exceeding its {imax * 1000:.0f} mA forward-current rating."
),
why=(
f"With the supply at {vrail:.1f} V and Vf≈{vf:.1f} V, series resistor "
f"{rref} ({rval:.0f} Ω) on net '{net}' passes "
f"~({vrail:.1f}{vf:.1f})/{rval:.0f} = {i * 1000:.0f} mA (worst case, "
f"0 V driver drop) — above the {imax * 1000:.0f} mA rating."
),
recommendation=(
f"Increase the series resistor to at least {rmin:.0f} Ω to keep the "
f"{_chan(color)} at or below {imax * 1000:.0f} mA."
),
reference=f"{comp.mpn or ref} LED specs",
)
def _no_resistor_finding(ref, comp, net, color, vrail, vf, imax) -> Finding:
rec = "Add a series current-limiting resistor, or confirm a constant-current driver."
if vf is not None and vrail > vf:
rec = (
f"Add a series resistor of at least {((vrail - vf) / imax):.0f} Ω "
f"(or confirm a constant-current driver)."
)
return Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="led_current",
source="led_current_check",
source_page=None,
status="WARNING",
finding=(
f"Unverified: {ref} {_chan(color)} has no series current-limiting "
f"resistor on net '{net}'."
),
why=(
f"The {_chan(color)} on net '{net}' has no series resistor between the "
f"LED and the {vrail:.1f} V supply. If it is not driven by a "
f"constant-current source, forward current can exceed the "
f"{imax * 1000:.0f} mA rating."
),
recommendation=rec,
reference=f"{comp.mpn or ref} LED specs",
)