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>
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""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",
|
||
)
|