3
\$\begingroup\$

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)
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

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.


I ran your example:

    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.

\$\endgroup\$
2
  • \$\begingroup\$ 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. \$\endgroup\$
    – Reinderien
    Apr 8, 2020 at 18:42
  • \$\begingroup\$ 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. \$\endgroup\$
    – Reinderien
    Apr 8, 2020 at 18:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.