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
Parentheses, ( ), represent parallel connections of elements/connections, \(Z_i\), that have a total impedance of
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:
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).
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)))
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"
Alternatively, there is a plot_circuit()
function that can be used without generating the intermediate DataSet
object.