.. include:: ./substitutions.rst
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, :math:`Z_i`, that have a total impedance of
.. math::
Z_{total} = Z_1 + Z_2 + \ldots + Z_n
* Parentheses, **( )**, represent parallel connections of elements/connections, :math:`Z_i`, that have a total impedance of
.. math::
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 :func:`~pyimpspec.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.
.. doctest::
>>> 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_ (|Circuit.to_drawing|) or CircuiTikZ_ (|Circuit.to_circuitikz|).
Below is an example of the output generated by using SchemDraw:
.. plot::
from pyimpspec import Circuit, parse_cdc
circuit: Circuit = parse_cdc("R(C[RW])")
drawing = circuit.to_drawing()
drawing.draw()
It is also possible to obtain a SymPy_ expression for the impedance of a circuit:
.. doctest::
>>> 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).
.. plot::
from pyimpspec import Circuit, parse_cdc
circuit: Circuit = parse_cdc("R(C[RW])")
drawing = circuit.to_drawing(running=True)
drawing.draw()
.. raw:: latex
\clearpage
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:
.. code::
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 |ohm| 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 |ohm| resistor with a 50 |ohm| lower limit and 200 |ohm| 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:
.. doctest::
>>> 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)))
.. plot::
from pyimpspec import parse_cdc
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}])")
drawing = circuit.to_drawing()
drawing.draw()
.. raw:: latex
\clearpage
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:
.. doctest::
>>> 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:
.. doctest::
>>> 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.
.. doctest::
>>> 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:
.. doctest::
>>> 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:
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:
.. doctest::
>>> 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' |Element.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.
.. doctest::
>>> 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"
.. plot::
from pyimpspec import Circuit, DataSet, parse_cdc, simulate_spectrum
from pyimpspec import mpl
from numpy import logspace
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}])")
data: DataSet = simulate_spectrum(circuit, frequencies=logspace(3, 0, num=16), label="Randles")
figure, axes = mpl.plot_nyquist(data)
figure.tight_layout()
.. raw:: latex
\clearpage
Alternatively, there is a |plot_circuit| function that can be used without generating the intermediate |DataSet| object.
.. plot::
from pyimpspec import Circuit, parse_cdc
from pyimpspec import mpl
from numpy import logspace
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}])")
figure, axes = mpl.plot_circuit(circuit, frequencies=logspace(3, 0, num=16), label="Randles", title="")
figure.tight_layout()
.. raw:: latex
\clearpage