Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.
"""IEC 60063 preferred-number (E-) series + resistor combination search.
E1/E3/E6/E12/E24 are the **tabulated** historical series from IEC 60063:2015
(they do NOT follow the geometric formula — e.g. E24 has 2.7, 3.0, 3.3 where
the formula gives 2.61, 2.87, 3.16). E48/E96/E192 are formula series:
``round(10^(i/N), 3 significant digits)`` with the single standardized
exception 9.20 (calculated 9.19) in E192.
The combination search mirrors KiCad's resistor calculator behavior: build a
target resistance from up to four standard values (series/parallel), with the
exact requested value and up to two user-excluded values removed from the
pool (component-availability workaround).
References:
* IEC 60063:2015, *Preferred number series for resistors and capacitors*.
* Combination-search behavior cross-checked against KiCad ``pcb_calculator``
(independent reimplementation; no KiCad code used).
"""
from __future__ import annotations
import bisect
from typing import cast
from .registry import CalcError, Param, Result, register
from .si import fmt_eng
__all__ = ["SERIES", "series_values", "nearest", "snap"]
# --- IEC 60063:2015 tabulated series (must NOT be generated by formula) ---- #
_E1 = (1.0,)
_E3 = (1.0, 2.2, 4.7)
_E6 = (1.0, 1.5, 2.2, 3.3, 4.7, 6.8)
_E12 = (1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2)
_E24 = (1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0,
3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1)
def _formula(n: int) -> tuple[float, ...]:
"""E48/E96/E192 per IEC 60063: 10^(i/n) to 3 significant digits."""
vals = []
for i in range(n):
v = round(10 ** (i / n), 2)
# standardized exception: IEC 60063 lists 9.20 where 10^(185/192)
# rounds to 9.19
if n == 192 and abs(v - 9.19) < 1e-9:
v = 9.20
vals.append(v)
return tuple(vals)
SERIES: dict[str, tuple[float, ...]] = {
"E1": _E1, "E3": _E3, "E6": _E6, "E12": _E12, "E24": _E24,
"E48": _formula(48), "E96": _formula(96), "E192": _formula(192),
}
_SERIES_CHOICES = tuple(SERIES)
def series_values(series: str, lo: float, hi: float) -> list[float]:
"""All ``series`` values in ``[lo, hi]``, spanning decades."""
base = SERIES[series]
out = []
dec = 1e-12
while dec <= hi:
for m in base:
v = m * dec
if lo <= v <= hi:
out.append(round(v, 12))
dec *= 10
return out
def nearest(value: float, series: str) -> float:
"""Nearest ``series`` value to ``value`` (any decade)."""
if value <= 0:
raise CalcError("value must be positive")
base = SERIES[series]
best, err = base[0], float("inf")
import math
exp = math.floor(math.log10(value))
for d in (exp - 1, exp, exp + 1):
for m in base:
v = m * 10 ** d
e = abs(v - value)
if e < err:
best, err = v, e
return round(best, 12)
def snap(value: float, series: str) -> tuple[float, float]:
"""(nearest value, signed error %)."""
v = nearest(value, series)
return v, (v - value) / value * 100.0
# --------------------------------------------------------------------------- #
# calculators
# --------------------------------------------------------------------------- #
@register(
"eseries", "E-series preferred value (snap + neighbors)", "passives",
"IEC 60063:2015, Preferred number series for resistors and capacitors",
(Param("value", "Ω/F/H", "target value to snap"),
Param("series", "", "E series", default="E24", choices=_SERIES_CHOICES)),
notes="E1..E24 are the tabulated historical values; E48/E96/E192 follow "
"10^(i/N) rounded to 3 significant digits (E192 exception: 9.20).",
)
def _calc_eseries(value: float, series: str) -> list[Result]:
vals = series_values(series, value / 12.0, value * 12.0)
below = max((v for v in vals if v <= value), default=None)
above = min((v for v in vals if v >= value), default=None)
near, err = snap(value, series)
out = [
Result("nearest", near, "", f"{err:+.3f}% vs target"),
Result("nearest_str", fmt_eng(near)),
]
if below is not None:
out.append(Result("floor", below))
if above is not None:
out.append(Result("ceil", above))
return out
def _pairs(values: list[float]) -> list[tuple[float, str]]:
"""All 2-element series/parallel combos, sorted by value."""
combos: list[tuple[float, str]] = []
n = len(values)
for i in range(n):
a = values[i]
for j in range(i, n):
b = values[j]
combos.append((a + b, f"{fmt_eng(a)} + {fmt_eng(b)}"))
combos.append((a * b / (a + b), f"{fmt_eng(a)} ∥ {fmt_eng(b)}"))
combos.sort(key=lambda t: t[0])
return combos
def _best_near(sorted_combos: list[tuple[float, str]],
target: float) -> tuple[float, str] | None:
"""Best (value, expr) in a sorted combo list near ``target``."""
keys = [c[0] for c in sorted_combos]
i = bisect.bisect_left(keys, target)
best: tuple[float, str] | None = None
for k in (i - 1, i, i + 1):
if 0 <= k < len(sorted_combos):
v, expr = sorted_combos[k]
if best is None or abs(v - target) < abs(best[0] - target):
best = (v, expr)
return best
@register(
"rcombo", "Resistor combination search (2–4 resistors)", "passives",
"IEC 60063:2015 value pool; search behavior per KiCad pcb_calculator "
"resistor calculator (independent reimplementation)",
(Param("target", "Ω", "resistance to synthesize"),
Param("series", "", "E series pool", default="E24", choices=_SERIES_CHOICES),
Param("exclude1", "Ω", "excluded value (availability)", default=0.0),
Param("exclude2", "Ω", "second excluded value", default=0.0),
Param("rmin", "Ω", "pool lower bound", default=10.0),
Param("rmax", "Ω", "pool upper bound", default=1e6)),
notes="The exact target value is always excluded from the pool. Search "
"space: 2R exhaustive; 3R = single ⊕ pair; 4R = pair ⊕ pair "
"(series or parallel at each ⊕).",
)
def _calc_rcombo(target: float, series: str, exclude1: float, exclude2: float,
rmin: float, rmax: float) -> list[Result]:
if target <= 0:
raise CalcError("target must be positive")
excl = {round(v, 9) for v in (target, exclude1, exclude2) if v > 0}
pool = [v for v in series_values(series, rmin, rmax)
if round(v, 9) not in excl]
if not pool:
raise CalcError("empty value pool (check rmin/rmax/excludes)")
def sol(v: float, expr: str, count: int) -> dict[str, object]:
return {"expression": expr, "value": v, "n": count,
"error_pct": round((v - target) / target * 100.0, 6)}
# 1R
best_single: float | None = None
err = float("inf")
for v in pool:
if abs(v - target) < err:
best_single, err = v, abs(v - target)
assert best_single is not None # pool is non-empty (checked above)
one = sol(best_single, fmt_eng(best_single), 1)
# 2R exhaustive
pairs = _pairs(pool)
v2 = _best_near(pairs, target)
assert v2 is not None # pairs is non-empty (pool is non-empty)
two = sol(v2[0], v2[1], 2)
# 3R: single ⊕ pair
three = None
for a in pool:
# series: pair must hit target - a
if target - a > 0:
hit = _best_near(pairs, target - a)
if hit:
v = a + hit[0]
if three is None or abs(v - target) < abs(cast(float, three["value"]) - target):
three = sol(v, f"{fmt_eng(a)} + ({hit[1]})", 3)
# parallel: pair must hit a*target/(a-target)
if a > target:
want = a * target / (a - target)
hit = _best_near(pairs, want)
if hit:
v = a * hit[0] / (a + hit[0])
if three is None or abs(v - target) < abs(cast(float, three["value"]) - target):
three = sol(v, f"{fmt_eng(a)} ∥ ({hit[1]})", 3)
# 4R: pair ⊕ pair
four = None
for p1, e1 in pairs:
if target - p1 > 0:
hit = _best_near(pairs, target - p1)
if hit:
v = p1 + hit[0]
if four is None or abs(v - target) < abs(cast(float, four["value"]) - target):
four = sol(v, f"({e1}) + ({hit[1]})", 4)
if p1 > target:
want = p1 * target / (p1 - target)
hit = _best_near(pairs, want)
if hit:
v = p1 * hit[0] / (p1 + hit[0])
if four is None or abs(v - target) < abs(cast(float, four["value"]) - target):
four = sol(v, f"({e1}) ∥ ({hit[1]})", 4)
solutions = [s for s in (one, two, three, four) if s]
best = min(solutions, key=lambda s: abs(cast(float, s["error_pct"])))
return [Result("best", best["expression"], "",
f"= {fmt_eng(cast(float, best['value']), 'Ω')} "
f"({cast(float, best['error_pct']):+.4f}%)"),
Result("solutions", solutions)]