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.
About the code:
- 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
"""
decade = 10**floor(log10(x))
mantissa = x / decade
index = bisect_lower(E24, mantissa)
if index >= len(E24):
return 0, decade * 10
return index, decade
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,
decade: Optional[float] = None,
index: Optional[int] = None,
exact: Optional[float] = None,
):
"""
Valid combinations:
exact - approximated value will be calculated
exact, index, decade - approximated value = E24[index]*decade
index, decade - approximated value = E24[index]*decade; exact=approximate
:param decade: The quantity's power-of-ten
:param index: The integer index into E24 for the quantity's mantissa
:param exact: The exact quantity, if known
"""
if index is None:
assert decade is None
assert exact is not None
self.exact = exact
self.index, self.decade = approximate(exact)
else:
assert decade is not None
self.index, self.decade = index, decade
self.approx = E24[self.index] * self.decade
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
index, decade = self.index + 1, self.decade
if index >= len(E24):
index = 0
decade *= 10
return ComponentValue(exact=self.exact, index=index, decade=decade)
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.start_index, self.start_decade = approximate(minimum)
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]]:
decade = self.start_decade
for index in range(self.start_index, len(E24)):
yield index, decade
while True:
decade *= 10
for index in range(len(E24)):
yield index, decade
def _iter_values(self, prev: Sequence[ComponentValue]) -> Iterable[ComponentValue]:
for index, decade in self._all_values():
value = ComponentValue(index=index, decade=decade)
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)))
self.approx_seen.add(approx)
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)