Pinscope open-source core

Agentic schematic validation: datasheet extraction via Claude Console
Skills, netlist/BOM design graph, per-IC direct datasheet review with
page citations, capacitor derating, Next.js report UI.

Extracted from the Pinscope cloud codebase. Auth and billing live in the
private gateway repo behind stable seams (billing_hook.py, adapter files
listed in CLAUDE.md).
This commit is contained in:
Siddharth Kothari
2026-07-16 21:29:45 -07:00
commit 6672d2be57
254 changed files with 56662 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
"""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.pinscopex.models import ComponentType, DesignGraph, Finding, NetType
from backend.pinscopex.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",
)