Equivalent circuits

Equivalent circuits are one way of analyzing impedance spectra and they are used to extract quantitative information by fitting the circuit (i.e., the model) to the experimental data.

Circuit description code (CDC)

CDCs are a convenient way of representing a circuit in terms of its components and connections. There are a few different syntaxes that various software have implemented. The syntax that is used by pyimpspec is based on the one described in “The circuit description code explained” by Bernard Boukamp (PDF available here).

Note

CDCs cannot be used to describe all types of circuits (see the PDF that was mentioned earlier for more information). However, user-defined elements can be used to implement circuits that can not be defined using a CDC and the various elements included with pyimpspec. See User-defined elements for more information.

The basic elements of the syntax are:

  • Square brackets, [ ], represent series connections of elements/connections, \(Z_i\), that have a total impedance of

\[Z_{total} = Z_1 + Z_2 + \ldots + Z_n\]
  • Parentheses, ( ), represent parallel connections of elements/connections, \(Z_i\), that have a total impedance of

\[Z_{total} = (Z_1^{-1} + Z_2^{-1} + \ldots + Z_n^{-1})^{-1}\]
  • Elements such as resistors and capacitors are represented by some character (or group of characters) like R and C, respectively.

For example, the Randles circuit can be defined with the following CDC: R(C[RW]).

Creating circuits

Using CDCs and the parse_cdc() function is a straightforward way of (re)creating circuits. Below is an example of some code that creates a Circuit object based on the CDC that corresponds to the Randles circuit.

>>> from pyimpspec import Circuit, parse_cdc
>>>
>>> circuit: Circuit = parse_cdc("R(C[RW])")
>>> circuit.to_string()  # Generate the CDC based on the Circuit object.
'[R(C[RW])]'
>>> str(circuit)  # Equivalent to the line above.
'[R(C[RW])]'

As can be seen from the example above, a Circuit object can be used to generate a CDC that corresponds to the circuit. The Circuit class also has methods for generating circuit diagrams using either SchemDraw (to_drawing()) or CircuiTikZ (to_circuitikz()). Below is an example of the output generated by using SchemDraw:

(Source code)

_images/guide_circuit-1.svg

It is also possible to obtain a SymPy expression for the impedance of a circuit:

>>> from pyimpspec import Circuit, parse_cdc
>>>
>>> circuit: Circuit = parse_cdc("R(C[RW])")
>>> circuit.to_sympy()
R_0 + 1/(2*I*pi*C_1*f + 1/(R_2 + 1/(Y_3*(2*I*pi*f)**n_3)))

Note

The lower indices of the various parameters in SymPy expressions are by default based on a running count starting from 0. This is to avoid conflicting/overlapping variable names for parameters from two or more different circuit elements with, e.g., parameters called Y. In most other contexts (e.g., circuit diagrams and tables of fitted circuit parameters) the circuit elements have lower indices that reflect that the element is the nth instance of that type of element in that circuit starting from 1 (see the previous circuit diagram for an example). It is possible to, e.g., draw circuit diagrams using the same running count by calling the relevant function with running=True as an argument (see the example below).

(Source code)

_images/guide_circuit-2.svg

Extended CDC syntax

Pyimpspec also supports an extended CDC syntax that allows for defining element parameters (i.e., their initial values and limits) and for assigning labels to elements. Curly braces, { }, enclose parameter and/or label definitions. For example, let’s redefine the Randles circuit using the extended syntax to set the initial values (and some limits) of the parameters, and also assign labels to the elements:

circuit = parse_cdc("R{R=20f:sol}(C{C=25e-6//1e-3:dl}[R{R=100/50/200:ct}W{Y=2.357e-3/inf/150%:diff}])")

The new circuit now has:

  • A 20 \(\Omega\) resistor with a fixed value and the label “sol” (solution resistance).

  • A 25 microfarad capacitor with no lower limit and a 1 millifarad upper limit, and the label “dl” (double-layer capacitance).

  • A 100 \(\Omega\) resistor with a 50 \(\Omega\) lower limit and 200 \(\Omega\) upper limit, and the label “ct” (charge-transfer resistance).

  • A Warburg impedance with an “admittance” of 2.357 millisiemens*seconds^(1/2), no lower limit, and an upper limit of 1.5 times the initial value, and the label “diff” (diffusion).

Below are the updated SymPy expression and circuit diagram:

>>> from pyimpspec import Circuit, parse_cdc
>>>
>>> circuit: Circuit = parse_cdc("R{R=20f:sol}(C{C=25e-6//1e-3:dl}[R{R=100/50/100:ct}W{Y=2.357e-3/inf/150%:diff}])")
>>> circuit.to_sympy()
R_sol + 1/(2*I*pi*C_dl*f + 1/(R_ct + 1/(Y_diff*(2*I*pi*f)**n_diff)))

(Source code)

_images/guide_circuit-3.svg

The extended CDC syntax is also useful for sharing/storing circuits in text form since one can use it to define how precise the representation of parameter values should be:

>>> from pyimpspec import Circuit, parse_cdc
>>>
>>> circuit: Circuit = parse_cdc("R{R=20f:sol}(C{C=25e-6//1e-3:dl}[R{R=100/50/200:ct}W{Y=2.357e-3/inf/150%:diff}])")
>>> circuit.to_string(decimals=3)  # Use scientific notation and three decimal places for values.
'[R{R=2.000E+01F/0.000E+00/inf:sol}(C{C=2.500E-05/1.000E-24/1.000E-03:dl}[R{R=1.000E+02/5.000E+01/2.000E+02:ct}W{Y=2.357E-03/inf/3.536E-03,n=5.000E-01F/0.000E+00/1.000E+00:diff}])]'

There is also a dedicated method for generating a CDC with some metadata that should help ensure compatibility when parsing a CDC generated before some breaking changes were made in a newer version of the API:

>>> from pyimpspec import Circuit, parse_cdc
>>>
>>> circuit: Circuit = parse_cdc("R{R=20f:sol}(C{C=25e-6//1e-3:dl}[R{R=100/50/100:ct}W{Y=2.357e-3/inf/150%:diff}])")
>>> circuit.serialize()  # Includes metadata (e.g., version number) and uses 12 decimal places by default.
'!V=1![R{R=2.000000000000E+01F/0.000000000000E+00/inf:sol}(C{C=2.500000000000E-05/1.000000000000E-24/1.000000000000E-03:dl}[R{R=1.000000000000E+02/5.000000000000E+01/1.000000000000E+02:ct}W{Y=2.357000000000E-03/inf/3.535500000000E-03,n=5.000000000000E-01F/0.000000000000E+00/1.000000000000E+00:diff}])]'

Other ways of creating circuits

It is also possible to create circuits by creating Element objects (e.g., Resistor) that are placed inside Connection objects (e.g., Series). Ultimately the outermost Connection object is placed within a Circuit object.

>>> from pyimpspec import Circuit, Series, Parallel, Resistor, Capacitor, Warburg
>>> from numpy import inf, sqrt
>>>
>>> R_sol: Resistor = (
...   Resistor(R=20)
...   .set_fixed(R=True)
...   .set_label("sol")
... )
>>> C_dl: Capacitor = (
...   Capacitor()
...   .set_label("dl")
...   .set_values(C=25e-6)
...   .set_lower_limits(C=-inf)
...   .set_upper_limits(C=1e-3)
... )
>>> R_ct: Resistor = (
...   Resistor(R=100)
...   .set_label("ct")
...   .set_lower_limits(R=50)
...   .set_upper_limits(R=200)
... )
>>> W_diff: Warburg = (
...   Warburg(Y=1/(sqrt(2)*300))
...   .set_label("diff")
...   .set_lower_limits(Y=-inf)
...   .set_upper_limits(Y=1.5*1/(sqrt(2)*300))
... )
>>> inner_series: Series = Series([R_ct, W_diff])
>>> parallel: Parallel = Parallel([C_dl, inner_series])
>>> outer_series: Series = Series([R_sol, parallel])
>>> circuit: Circuit = Circuit(outer_series)
>>> circuit.to_string(3)
'[R{R=2.000E+01F/0.000E+00/inf:sol}(C{C=2.500E-05/inf/1.000E-03:dl}[R{R=1.000E+02/5.000E+01/2.000E+02:ct}W{Y=2.357E-03/inf/3.536E-03,n=5.000E-01F/0.000E+00/1.000E+00:diff}])]'

However, using the CircuitBuilder context manager class may be more convenient:

>>> from pyimpspec import Circuit, CircuitBuilder, Resistor, Capacitor, Warburg
>>> from numpy import inf, sqrt
>>>
>>> with CircuitBuilder() as outer_series:
...   outer_series.add(
...     Resistor(R=20)
...     .set_label("sol")
...     .set_fixed(R=True)
...   )
...   with outer_series.parallel() as parallel:
...     parallel.add(
...       Capacitor(C=25e-6)
...       .set_label("dl")
...       .set_lower_limits(C=-inf)
...       .set_upper_limits(C=1e-3)
...     )
...     with parallel.series() as inner_series:
...       inner_series.add(
...         Resistor(R=100)
...         .set_label("ct")
...         .set_lower_limits(R=50)
...         .set_upper_limits(R=200)
...       )
...       inner_series.add(
...         Warburg(Y=1/(sqrt(2)*300))
...         .set_label("diff")
...         .set_lower_limits(Y=-inf)
...         .set_upper_limits(Y=1.5*1/(sqrt(2)*300))
...       )
>>> circuit: Circuit = outer_series.to_circuit()
>>> circuit.to_string(3)
'[R{R=2.000E+01F/0.000E+00/inf:sol}(C{C=2.500E-05/inf/1.000E-03:dl}[R{R=1.000E+02/5.000E+01/2.000E+02:ct}W{Y=2.357E-03/inf/3.536E-03,n=5.000E-01F/0.000E+00/1.000E+00:diff}])]'

User-defined elements

If pyimpspec lacks support for a specific element, then one can be added at runtime using the same system that is used to implement the elements included in pyimpspec. The register_element() function accepts either an ElementDefinition or a ContainerDefinition object, which in turn contains a reference to the new Element or Container subclass and some metadata (e.g., ParameterDefinition and/or SubcircuitDefinition objects). For example, a new element could be implemented as follows:

>>> from pyimpspec import (
...     ComplexImpedances,    # Alias for a NumPy array of complex128 values
...     Frequencies,          # Alias for a NumPy array of float64 values
...     Circuit,
...     Element,              # The base class for all circuit elements
...     ElementDefinition,    # A class that contains information regarding a new circuit element
...     ParameterDefinition,  # A class that contains information regarding a circuit element's parameter
...     register_element,     # A function that processes the new element class
...     parse_cdc,
... )
>>> from numpy import pi, inf
>>>
>>>
>>> class SomeNewElement(Element):
...     def _impedance(self, f: Frequencies, X: float, a: float) -> ComplexImpedances:
...         return 1 / (X * (1j * 2 * pi * f)**a)
>>>
>>>
>>> register_element(
...     ElementDefinition(
...         Class=SomeNewElement,
...         symbol="Ude",
...         name="Some new element",
...         description="User-defined element of some type.",
...         equation="1/(X*(2*pi*f*I)^a)",
...         parameters=[
...             ParameterDefinition(
...                 symbol="X",
...                 unit="Some unit",
...                 description="Description of this parameter",
...                 value=1e-6,
...                 lower_limit=1e-24,
...                 upper_limit=inf,
...                 fixed=False,
...             ),
...             ParameterDefinition(
...                 symbol="a",
...                 unit="",
...                 description="Some exponent",
...                 value=0.5,
...                 lower_limit=0.0,
...                 upper_limit=1.0,
...                 fixed=False,
...             ),
...         ],
...     ),
... )
>>>
>>> # The new element should now be available for use like any of the
>>> # elements that are bundled with pyimpspec.
>>> circuit: Circuit = parse_cdc("Ude")

The various metadata included in the register_element() call are used to automatically generate a docstring for the user-defined element (hence the lack of an explicit docstring in the class in the example above). The _impedance method is used internally by pyimpspec and, e.g., the Element class’ get_impedances() method acts as a wrapper that handles validation of the frequencies given as input before passing them along to the _impedance method together with the element’s parameters.

Note that the equation/expression for the impedance of the element (assume “Z = “ on the left-hand side) is also provided as a string when the element registered. This string must be compatible with SymPy’s sympify function. This string is used primarily to validate the output of the element’s _impedance method when the element is registered. However, the string is also used for calculating the element’s impedance when the excitation frequency is zero or infinity by making use of SymPy’s limit function. The _impedance method is used for most calculations due to the greater performance that it offers compared to using SymPy.

Elements that are subclasses of Container can, in addition to numeric parameters, contain subcircuits in the form of Connection instances such as a Series instance containing various Element (or Container) instances.

Note

The default implementations of the Element._sympy and Container._sympy methods simply call sympify on the element’s equation string. It may be necessary in some cases for elements to also override the _sympy method if there are special cases that need to be handled (e.g., in the case where an element based on Container has a subcircuit that can be a short circuit or an open circuit). See the source code (e.g., on GitHub) for pyimpspec’s implementation of the TransmissionLineModel element for an example of this.

Simulating spectra

The Circuit objects can be used to simulate impedance spectra and there is a convenient simulate_spectrum() function that returns a DataSet object.

>>> from pyimpspec import Circuit, DataSet, parse_cdc, simulate_spectrum
>>> from numpy import logspace
>>>
>>> cdc: str = "R{R=20f:sol}(C{C=25e-6//1e-3:dl}[R{R=100/50/100:ct}W{Y=2.357e-3/inf/150%:diff}])"
>>> circuit: Circuit = parse_cdc(cdc)
>>> data: DataSet = simulate_spectrum(circuit, frequencies=logspace(3, 0, num=16), label="Randles")
>>> assert data.get_label() == "Randles"

(Source code)

_images/guide_circuit-4.svg

Alternatively, there is a plot_circuit() function that can be used without generating the intermediate DataSet object.

(Source code)

_images/guide_circuit-5.svg