# Brute-force electronic component selection space search

Yesterday I hacked up a simple component selection program. An example is shown at the bottom for the application, though this can be used generally for a lot of different simple circuits I come across.

It requires Python 3; I've only tested it on 3.8.

• It is self-contained
• The only really notable violation of PEP8 is capitalized local variables, but they're there for a reason - that's electronics notation
• There are no unit tests, but the code is probably correct given the output of the example, at least for non-corner-cases
• I know there are some algorithmic inefficiencies, in particular in dealing with minima/maxima
• The display of output values that are non-zero but below the noise level of floating-point math is awkward

Commentary of any kind welcome.

"""
Do a quick, sequential, numerical (not symbolic) exploration of some electronic
component values to propose solutions that use standard, inexpensive parts.
"""

from bisect import bisect_left
from functools import partial
from itertools import islice
from math import log10, floor
from typing import (
Iterable,
List,
Optional,
Protocol,
Sequence,
Set,
Tuple,
)

# See https://en.wikipedia.org/wiki/E_series_of_preferred_numbers
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 bisect_lower(a: Sequence[float], x: float) -> int:
"""
Run bisect, but use one index before the return value of bisect_left
:param a: The sorted haystack
:param x: The needle
:return: The index of the array element that equals or is lesser than x
"""
i = bisect_left(a, x)
if i < len(a) and a[i] > x:
i -= 1
return i

def approximate(x: float) -> (int, float):
"""
Approximate a value by using the E24 series.
:param x: Any positive value
:return: An integer index into E24 for the element lesser than or equal to
the value's mantissa, and the value's decade - a power of ten
"""
index = bisect_lower(E24, mantissa)
if index >= len(E24):

def fmt_eng(x: float, unit: str, sig: int = 2) -> str:
"""
Format a number in engineering (SI) notation
:param x: Any number
:param unit: The quantity unit (Hz, A, etc.)
:param sig: Number of significant digits to show
:return: The formatted string
"""
if x == 0:
p = 0
else:
p = floor(log10(abs(x)))
e = floor(p / 3)
digs = max(0, sig - p%3 - 1)
mantissa = x / 10**(3*e)

if e == 0:
prefix = ''
elif 0 < e < 9:
# See https://en.wikipedia.org/wiki/Metric_prefix
prefix = ' kMGTPEZY'[e]
elif 0 > e > -8:
prefix = 'mμnpfazy'[-e]
else:
raise IndexError(f'Number out of SI range: {x:.1e}')

fmt = '{:.%df} {:}{:}' % digs
return fmt.format(mantissa, prefix, unit)

class CalculateCall(Protocol):
"""
Protocol-notation to type-hint a callable with any number of floating-point
arguments, returning a float
"""
def __call__(self, *args: Iterable[float]) -> float: ...

class ComponentValue:
"""
A component value, without knowledge of the component it's from - to track
approximated values
"""

def __init__(
self,
index: Optional[int] = None,
exact: Optional[float] = None,
):
"""
Valid combinations:
exact - approximated value will be calculated
:param index: The integer index into E24 for the quantity's mantissa
:param exact: The exact quantity, if known
"""

if index is None:
assert exact is not None
self.exact = exact
else:

if index is not None:
if exact is None:
self.exact = self.approx
else:
self.exact = exact

@property
def error(self) -> float:
return self.approx / self.exact - 1

def get_other(self) -> Optional['ComponentValue']:
"""
:return: If this approximated value is below its exact value, then the
next-highest E24 value; otherwise None
"""
if self.approx >= self.exact:
return None

if index >= len(E24):
index = 0

def __str__(self):
e = floor(log10(self.exact) / 3) * 3
v = self.approx / 10**e
return f'{v:.3f}e{e} {self.error:.1%}'

class Component:
"""
A component, without knowledge of its value - only bounds and defining formula
"""

def __init__(
self,
prefix: str,
suffix: str,
unit: str,
calculate: Optional[CalculateCall] = None,
minimum: float = 0,
maximum: Optional[float] = None,
use_for_err: bool = True,
):
"""
:param prefix: i.e. R, C or L
:param suffix: Typically a number, i.e. the "2" in R2
:param unit: i.e. Hz, A, F, ...
:param calculate: A callable that will be given all values of previous
components in the calculation sequence. These values
are floats, and the return must be a float.
If this callable is None, the component will be
interpreted as a degree of freedom.
:param minimum: Min allowable value; the return of calculate will be
checked against this and failures will be silently
dropped.
Must be at least zero, or greater than zero if
calculate is not None.
:param maximum: Max allowable value; the return of calculate will be
checked against this and failures will be silently
dropped.
:param use_for_err: If True, error from this component's ideal to
approximated value will influence the solution rank.
"""
(
self.prefix, self.suffix, self.unit, self.calculate,
self.min, self.max, self.use_for_err,
) = prefix, suffix, unit, calculate, minimum, maximum, use_for_err

assert minimum >= 0
assert maximum is None or maximum >= minimum

if calculate:
self.values = self._calculate_values
else:
assert minimum > 0
self.values = self._iter_values

def __str__(self):
return self.name

@property
def name(self) -> str:
return f'{self.prefix}{self.suffix}'

def _calculate_values(self, prev: Sequence[ComponentValue]) -> Iterable[ComponentValue]:

def values():
# Get the value based on exact values first
from_exact_val = self.calculate(*(p.exact for p in prev))
if from_exact_val <= 0:
return

from_exact = ComponentValue(exact=from_exact_val)
yield from_exact
other = from_exact.get_other()
if other:
yield other

# See if there's a difference when calculating against approximated values
from_approx_val = self.calculate(*(p.approx for p in prev))
if from_approx_val > 0:
from_approx = ComponentValue(exact=from_approx_val)
if from_approx.exact != from_exact.exact:
yield from_approx
other = from_approx.get_other()
if other:
yield other

for v in values():
if self.min <= v.exact <= self.max:
yield v

def _all_values(self) -> Iterable[Tuple[int, float]]:
for index in range(self.start_index, len(E24)):
while True:
for index in range(len(E24)):

def _iter_values(self, prev: Sequence[ComponentValue]) -> Iterable[ComponentValue]:
if value.approx > self.max:
return
yield value

class Resistor(Component):
def __init__(
self,
suffix: str,
calculate: Optional[CalculateCall] = None,
minimum: float = 0,
maximum: Optional[float] = None,
use_for_err: bool = True,
):
super().__init__('R', suffix, 'Ω', calculate, minimum, maximum, use_for_err)

class Capacitor(Component):
def __init__(
self,
suffix: str,
calculate: Optional[CalculateCall] = None,
minimum: float = 0,
maximum: Optional[float] = None,
use_for_err: bool = True,
):
super().__init__('C', suffix, 'F', calculate, minimum, maximum, use_for_err)

class Output:
"""
A calculated parameter - potentially but not necessarily a circuit output -
to be calculated and checked for error in the solution ranking process.
"""

def __init__(self, name: str, unit: str, expected: float, calculate: CalculateCall):
"""
:param name: i.e. Vout
:param unit: i.e. V, A, Hz...
:param expected: The value that this parameter would assume under ideal
circumstances
:param calculate: A callable accepting a sequence of floats - one per
component, in the same order as they were passed to
the Solver constructor; returning a float.
"""
self.name, self.unit, self.expected, self.calculate = name, unit, expected, calculate

def error(self, value: float) -> float:
"""
:return: Absolute error, since the expected value might be 0
"""
return value - self.expected

def __str__(self):
return self.name

class Solver:
"""
Basic recursive solver class that does a brute-force search through some
component values.
"""

def __init__(
self,
components: Sequence[Component],
outputs: Sequence[Output],
threshold: float = 1e-3,
):
"""
:param components: A sequence of Component instances. The order of this
sequence determines the order of parameters passed to
Output.calculate and Component.calculate.
:param outputs: A sequence of Output instances - can be empty.
:param threshold: Maximum error above which solutions will be discarded
"""
self.components, self.outputs = components, outputs
self.candidates: List[Tuple[
float,                     # error
Sequence[float],           # output values
Sequence[ComponentValue],  # component values to get the above
]] = []
self.approx_seen: Set[Tuple[float, ...]] = set()
self.threshold = threshold

def _recurse(self, values: List[Optional[ComponentValue]], index: int = 0):
if index >= len(self.components):
self._evaluate(values)
else:
comp = self.components[index]
for v in comp.values(values[:index]):
values[index] = v
self._recurse(values, index+1)

def solve(self):
"""
Recurse through all of the components, doing a brute-force search.
Results are stored in self.candidates and sorted in order of increasing
error.
"""
values = [None]*len(self.components)
self._recurse(values)
self.candidates.sort(key=lambda v: v[0])

def _evaluate(self, values: Sequence[ComponentValue]):
approx = tuple(v.approx for v in values)
if approx in self.approx_seen:
return

outputs = tuple(
o.calculate(*approx)
for o in self.outputs
)
err = sum(
o.error(v)**2
for o, v in zip(self.outputs, outputs)
) + sum(
v.error**2
for c, v in zip(self.components, values)
if c.use_for_err
)
if err < self.threshold:
self.candidates.append((err, outputs, tuple(values)))

def print(self, top: int = 10):
"""
Print a table of all component values, output values and output error.
:param top: Row limit.
"""
print(' '.join(
f'{comp.name:>6}'
for comp in self.components
), end=' ')
print(' '.join(
f'{output.name:>10} {"Err":>8}'
for output in self.outputs
))

for err, outputs, values in islice(self.candidates, top):
print(' '.join(
f'{fmt_eng(value.approx, comp.unit):>6}'
for value, comp in zip(values, self.components)
), end=' ')
print(' '.join(
f'{fmt_eng(value, output.unit, 4):>10} '
f'{output.error(value):>8.1e}'
for value, output in zip(outputs, self.outputs)
))

def example():
"""
This represents a simple level shifter; see
https://electronics.stackexchange.com/a/491649/10008

Output:
R1     R2     R4     R3    Voutmin      Err    Voutmax      Err
120 Ω  30 kΩ 7.5 kΩ  16 kΩ    0.000 V  0.0e+00    3.300 V  0.0e+00
150 Ω  12 kΩ 3.0 kΩ  24 kΩ    0.000 V  0.0e+00    3.300 V  0.0e+00
33 Ω  30 kΩ 7.5 kΩ 1.5 kΩ   55.51 zV  5.6e-17    3.300 V  4.4e-16
75 Ω  12 kΩ 3.0 kΩ 2.0 kΩ  -55.51 zV -5.6e-17    3.300 V  4.4e-16
110 Ω  30 kΩ 7.5 kΩ  12 kΩ   166.5 zV  1.7e-16    3.300 V  4.4e-16
160 Ω  12 kΩ 3.0 kΩ  75 kΩ   600.0 nV  6.0e-04    3.303 V  3.0e-03
160 Ω  30 kΩ 7.5 kΩ 200 kΩ  -1.000 μV -1.0e-03    3.295 V -5.0e-03
160 Ω  33 kΩ 8.2 kΩ 220 kΩ   2.885 μV  2.9e-03    3.294 V -5.6e-03
160 Ω  27 kΩ 6.8 kΩ 180 kΩ  -5.748 μV -5.7e-03    3.296 V -4.3e-03
130 Ω  36 kΩ 9.1 kΩ  27 kΩ  -7.463 μV -7.5e-03    3.299 V -6.5e-04
"""

Vcc = 3.3
Imin, Imax = 4e-3, 20e-3

def Vout(Iin: float, R1: float, R2: float, R4: float, R3: float) -> float:
Vin = R1*Iin
I4 = (Vcc - Vin)/R2 - Vin/R3
return Vin - R4*I4

s = Solver(
(
Resistor(
'1', None, 30, Vcc/Imax, False
),
Resistor(
'2', None, 1e3, 100e3, False,
),
Resistor(
'4',
lambda R1, R2: R2/(Imax/Imin - 1),
1e3, 1e6, False
),
Resistor(
'3',
lambda R1, R2, R4: 1/((Vcc/R1/(Imax - Imin) - 1)/R4 - 1/R2),
1e3, 1e6, False
),
),
(
Output('Voutmin', 'V', 0, partial(Vout, Imin)),
Output('Voutmax', 'V', Vcc, partial(Vout, Imax)),
),
threshold=1e-4,
)

s.solve()
s.print(10)


First of all, I find your code very clean and I want to stress my Python proficiency is quite below yours. So I am learning more from you than you will from me. Nonetheless code review exercises are interesting because they force me to do research and learn more in the process.

My background in electronics is rather basic so I have only one remark at the moment. In the Solver class you have defined a print function. Probably I would have simply called the function print_table or similar. You are even using print in a function that is named the same. I find that slightly odd given that print is already a built-in function personally but I may be too conservative. I would be worried about potential downsides or risk of redefining an existing function.

I looked up the list of reserved keywords and built-in names in Python. For reference here is one topic on SO: Is the list of Python reserved words and builtins available in a library?

Demonstration code based on the above mentioned topic:

import builtins

# dump the whole list
dir(builtins)

# returns True
'print' in dir(builtins)


Not sure if this a real problem.

    R1     R2     R4     R3    Voutmin      Err    Voutmax      Err
120 Ω  30 kΩ 7.5 kΩ  16 kΩ    0.000 V  0.0e+00    3.300 V  0.0e+00
150 Ω  12 kΩ 3.0 kΩ  24 kΩ    0.000 V  0.0e+00    3.300 V  0.0e+00
33 Ω  30 kΩ 7.5 kΩ 1.5 kΩ   55.51 zV  5.6e-17    3.300 V  4.4e-16
75 Ω  12 kΩ 3.0 kΩ 2.0 kΩ  -55.51 zV -5.6e-17    3.300 V  4.4e-16
110 Ω  30 kΩ 7.5 kΩ  12 kΩ   166.5 zV  1.7e-16    3.300 V  4.4e-16
160 Ω  12 kΩ 3.0 kΩ  75 kΩ   600.0 nV  6.0e-04    3.303 V  3.0e-03
160 Ω  30 kΩ 7.5 kΩ 200 kΩ  -1.000 μV -1.0e-03    3.295 V -5.0e-03
160 Ω  33 kΩ 8.2 kΩ 220 kΩ   2.885 μV  2.9e-03    3.294 V -5.6e-03
160 Ω  27 kΩ 6.8 kΩ 180 kΩ  -5.748 μV -5.7e-03    3.296 V -4.3e-03
130 Ω  36 kΩ 9.1 kΩ  27 kΩ  -7.463 μV -7.5e-03    3.299 V -6.5e-04


Regarding tabular output I have found the tabulate module to be great and quite flexible so I often use it in conjunction with Pandas. I am not sure it supports merged cells which may come in handy, but it should not be difficult to get around this if needed. Of course I can easily understand that you don't want to import or install another dependency for something you can perform efficiently in 10 lines of code. And here you are gathering the data on the fly, it's not like you have an already populated dataframe just waiting to be printed out.

I might like to enhance this part of the code to customize the output of the results, for example to export the data to CSV instead of tabular format. This could be interesting if you want to use this procedure to automatically generate bills of materials for ordering electronic components. Since you are mentioning a "simple component selection program" I was thinking that could have been the original intention.

• Re. print - it's a valid point. It's less of a concern because a print function bound to an object will not shadow the built-in print function - unlike if I had defined a non-bound function or a variable, in which case it would shadow the built-in. Apr 8, 2020 at 18:42
• Re. bill of materials - also a valid point; however, the CSV would only be used as a starting point since it would be missing vendor, price and model information, etc. Apr 8, 2020 at 18:43