"""
circuit (:mod:`skrf.circuit`)
========================================
The Circuit class represents a circuit of arbitrary topology,
consisting of an arbitrary number of N-ports networks.
Like in an electronic circuit simulator, the circuit must have one or more ports
connected to the circuit. The Circuit object allows one retrieving the M-ports network,
where M is the number of ports defined.
The results are returned in :class:`~skrf.circuit.Circuit` object.
Building a Circuit
------------------
.. autosummary::
:toctree: generated/
Circuit
Circuit.Port
Circuit.SeriesImpedance
Circuit.ShuntAdmittance
Circuit.Ground
Circuit.Open
Circuit.update_networks
Representing a Circuit
----------------------
.. autosummary::
:toctree: generated/
Circuit.plot_graph
Network Representations
-----------------------
.. autosummary::
:toctree: generated/
Circuit.network
Circuit.s
Circuit.s_external
Circuit.s_active
Circuit.z_active
Circuit.y_active
Circuit.vswr_active
Circuit.port_z0
Voltages and Currents
---------------------
.. autosummary::
:toctree: generated/
Circuit.voltages
Circuit.voltages_external
Circuit.currents
Circuit.currents_external
Circuit internals
------------------
.. autosummary::
:toctree: generated/
Circuit.networks_dict
Circuit.networks_list
Circuit.connections_nb
Circuit.connections_list
Circuit.nodes_nb
Circuit.dim
Circuit.intersections_dict
Circuit.port_indexes
Circuit.C
Circuit.X
Circuit reduction
------------------
.. autosummary::
:toctree: generated/
reduce_circuit
Circuit._REDUCE_OPTIONS
Graph representation
--------------------
.. autosummary::
:toctree: generated/
Circuit.graph
Circuit.G
Circuit.edges
Circuit.edge_labels
"""
from __future__ import annotations
import warnings
from functools import cached_property
from itertools import chain
from typing import TYPE_CHECKING, Sequence, TypedDict
import numpy as np
from typing_extensions import NotRequired, Unpack
from .constants import S_DEF_DEFAULT, MemoryLayoutT, NumberLike
from .media import media
from .network import Network, connect, innerconnect, s2s
from .util import subplots
if TYPE_CHECKING:
from .frequency import Frequency
[docs]
class Circuit:
"""
Creates a circuit made of a set of N-ports networks.
For instructions on how to create Circuit see :func:`__init__`.
A Circuit object is representation a circuit assembly of an arbitrary
number of N-ports networks connected together via an arbitrary topology.
The algorithm used to calculate the resultant network can be found in [#]_.
References
----------
.. [#] P. Hallbjörner, Microw. Opt. Technol. Lett. 38, 99 (2003).
"""
@staticmethod
def _get_nx():
"""Returns networkx module if available.
Raises:
-------
ImportError: If networkx module is not installed
Returns:
--------
networkx module
"""
try:
import networkx as nx
return nx
except ImportError as err:
raise ImportError('networkx package as not been installed and is required.') from err
class _REDUCE_OPTIONS(TypedDict):
"""
Optional parameters passed to `reduce_circuit`.
Attributes
----------
check_duplication : bool, optional.
If True, check if the connections have duplicate names. Default is True.
split_ground : bool, optional.
If True, split the global ground connection to independent ground connections. Default is True.
split_multi : bool, optional.
If True, use a splitter to handle connections involving more than two components. This approach
increases the computational load for individual computations. However, it proves advantageous for
batch processing by enabling a more comprehensive reduction of circuits, leading to more efficiency
in batch computations. Default is False.
max_nports : int, optional.
The maximum number of ports of a Network that can be reduced in circuit. If a Network in the
circuit has a number of ports (nports), using the Network.connect() method to reduce the circuit's
dimensions becomes less efficient compared to directly calculating it with Circuit.s_external.
This value depends on the performance of the computer and the scale of the circuit. Default is 20.
dynamic_networks : Sequence[Network], optional.
A sequence of Networks to ignore in the reduction process. Default is an empty tuple.
"""
check_duplication: NotRequired[bool]
split_ground: NotRequired[bool]
split_multi: NotRequired[bool]
max_nports: NotRequired[int]
dynamic_networks: NotRequired[Sequence[Network]]
CACHEDPROPERTIES = ('s', 'X', 'X_F', 'C', 'C_F', 'T')
[docs]
def __init__(self,
connections: list[list[tuple[Network, int]]],
name: str | None = None,
*,
auto_reduce: bool = False, **kwargs: Unpack[_REDUCE_OPTIONS]) -> None:
"""
Circuit constructor. Creates a circuit made of a set of N-ports networks.
Parameters
----------
connections : list of list of tuples
Description of circuit connections.
Each connection is a described by a list of tuple.
Each tuple contains (network, network_port_nb).
Port number indexing starts from zero.
Network's port not explicitly listed will be treated as matched.
Network's port listed individually will be treated as open.
name : string, optional
Name assigned to the circuit (Network). Default is None.
auto_reduce : bool, optional
If True, the circuit will be automatically reduced using :func:`reduce_circuit`.
This will change the circuit connections description, affecting inner current and voltage distributions.
Suitable for cases where only the S-parameters of the final circuit ports are of interest. Default is False.
If `check_duplication`, `split_ground` or `max_nports` are provided as kwargs, `auto_reduce` will be
automatically set to True, as this indicates an intent to use the `reduce_circuit` method.
**kwargs :
keyword arguments passed to `reduce_circuit` method.
check_duplication : boolean. Default is True.
Controls whether to check the connections have duplicate names.
split_ground : boolean. Default is False.
Controls whether to split the global ground connection to independent.
split_multi : boolean, optional.
If True, use a splitter to handle connections involving more than two components. This approach
increases the computational load for individual computations. However, it proves advantageous for
batch processing by enabling a more comprehensive reduction of circuits, leading to more efficiency
in batch computations. Default is False.
max_nports : int. Default is 20.
Controls the maximum number of ports of a Network that can be reduced in circuit. If a
Network in the circuit has a number of ports (nports), using the Network.connect() method to reduce the
circuit's dimensions becomes less efficient compared to directly calculating it with Circuit.s_external.
This value depends on the performance of the computer and the scale of the circuit.
dynamic_networks : tuple. Default is an empty tuple.
Sequence of Networks to be skipped during the reduction process.
Examples
--------
Example of connections between two 1-port networks::
connections = [
[(network1, 0), (network2, 0)],
]
Example of a connection between three 1-port networks connected
to a single node::
connections = [
[(network1, 0), (network2, 0), (network3, 0)]
]
Example of a connection between two 1-port networks (port1 and port2)
and two 2-ports networks (ntw1 and ntw2)::
connections = [
[(port1, 0), (ntw1, 0)],
[(ntw1, 1), (ntw2, 0)],
[(ntw2, 1), (port2, 0)]
]
Example of a connection between three 1-port networks (port1, port2 and port3)
and a 3-ports network (ntw)::
connections = [
[(port1, 0), (ntw, 0)],
[(port2, 0), (ntw, 1)],
[(port3, 0), (ntw, 2)]
]
Example of a connection between three 1-port networks (port1, port2 and open)
and a 3-ports network (ntw)::
connections = [
[(port1, 0), (ntw, 0)],
[(open, 0), (ntw, 1)],
[(port2, 0), (ntw, 2)]
]
# or equivalently
connections = [
[(port1, 0), (ntw, 0)],
[(ntw, 1)],
[(port2, 0), (ntw, 2)]
]
Example of a connection between three 1-port networks (port1, port2 and match)
and a 3-ports network (ntw)::
connections = [
[(port1, 0), (ntw, 0)],
[(match, 0), (ntw, 1)],
[(port2, 0), (ntw, 2)]
]
# or equivalently
connections = [
[(port1, 0), (ntw, 0)],
[(port2, 0), (ntw, 2)]
]
NB1: Creating 1-port network to be used as a port should be made with :func:`Port`
NB2: The external ports indexing is defined by the order of appearance of
the ports in the connections list. Thus, the first network identified
as a port will be the 1st port of the resulting network (index 0),
the second network identified as a port will be the second port (index 1),
etc.
NB3: When a port of a network is listed individually in the connections, the circuit
will treat this port with a reflection coefficient of 1, equivalent to an Open.
Conversely, if a port is not explicitly included in the connections,the circuit will
treat it as matched.
"""
self._connections = connections
self.name = name
# check if all networks have a name
for cnx in self.connections:
for (ntw, _) in cnx:
if not self._is_named(ntw):
raise AttributeError('All Networks must have a name. Faulty network:', ntw)
# list of networks for initial checks
ntws = self.networks_list()
# check if all networks have same frequency
ref_freq = ntws[0].frequency
for ntw in ntws:
if ntw.frequency != ref_freq:
raise AttributeError('All Networks must have same frequencies')
# All frequencies are the same, Circuit frequency can be any of the ntw
self.frequency = ntws[0].frequency
# Check that a (ntwk, port) combination appears only once in the connection map
Circuit.check_duplicate_names(self.connections_list)
# Get the keyword arguments for the reduce_circuit method
kwargs_reduce = {k: kwargs[k] for k in self._REDUCE_OPTIONS.__annotations__.keys() if k in kwargs}
# Reduce the circuit if directly requested or any relevant kwargs are provided
if auto_reduce or any(kwargs_reduce):
self.connections = reduce_circuit(self.connections, **kwargs_reduce)
@property
def connections(self) -> list[list[tuple[Network, int]]]:
"""
Circuit connections.
Description of circuit connections. Each connection is a described by a
list of tuple. Each tuple contains (network, network_port_nb). Port number
indexing starts from zero.
Returns
-------
connections : :class:`list[list[tuple[Network, int]]]`
The circuit connections.
"""
return self._connections
@connections.setter
def connections(self, connections: list[list[tuple[Network, int]]]) -> None:
"""
Set the circuit connections.
Parameters
----------
connections : :class:`list[list[tuple[Network, int]]]`
The circuit connections.
"""
self._connections = connections
# Invalidate cached properties
for item in self.CACHEDPROPERTIES:
self.__dict__.pop(item, None)
[docs]
def update_networks(
self, networks: tuple[Network],
name: str | None = None,
*,
inplace: bool = False,
auto_reduce: bool = False, **kwargs: Unpack[_REDUCE_OPTIONS]) -> Circuit | None:
"""
Update the circuit connections with a new set of networks.
Parameters
----------
networks : tuple[Network]
A tuple of Networks to be updated in the circuit.
name : string, optional
Name assigned to the circuit (Network). Default is None.
inplace : bool, optional
If True, the circuit connections will be updated inplace. Default is False.
auto_reduce : bool, optional
If True, the circuit will be automatically reduced using :func:`reduce_circuit`.
This will change the circuit connections description, affecting inner current and voltage distributions.
Suitable for cases where only the S-parameters of the final circuit ports are of interest. Default is False.
If `check_duplication`, `split_ground` or `max_nports` are provided as kwargs, `auto_reduce` will be
automatically set to True, as this indicates an intent to use the `reduce_circuit` method.
**kwargs :
keyword arguments passed to `reduce_circuit` method.
`check_duplication` kwarg controls whether to check the connections have duplicate names. Default is True.
`split_ground` kwarg controls whether to split the global ground connection to independent.
Default is False.
`max_nports` kwarg controls the maximum number of ports of a Network that can be reduced in circuit. If a
Network in the circuit has a number of ports (nports), using the Network.connect() method to reduce the
circuit's dimensions becomes less efficient compared to directly calculating it with Circuit.s_external.
This value depends on the performance of the computer and the scale of the circuit. Default is 20.
`dynamic_networks` kwarg is a sequence of Networks to be skipped during the reduction process.
Default is an empty tuple.
Returns
-------
Circuit or None if inplace=True
The updated `Circuit` with the specified networks.
See Also
--------
Circuit.__init__ : Circuit constructor method.
Examples
--------
>>> import skrf as rf
>>> import numpy as np
>>>
>>> # Create a series of networks and build a circuit
>>> connections = [
... ...
... [(ntwk1, 0), (ntwk2, 0)],
... [(ntwk2, 1), (ntwk3, 0)],
... ...
... ]
>>> circuit = rf.Circuit(connections, dynamic_networks=[ntwk2])
>>>
>>> # Update the networks' S-parameters
>>> ntwk2.s = ntwk2.s @ ntwk2.s
>>>
>>> # Update the circuit in traditional way (ntwk2 has been updated)
>>> connections_updated = [
... ...
... [(ntwk1, 0), (ntwk2, 0)],
... [(ntwk2, 1), (ntwk3, 0)],
... ...
... ]
>>>
>>> circuit_updated_a = rf.Circuit(connections_updated)
>>>
>>> # Update the circuit by dynamic networks
>>> circuit_updated_b = circuit.update_networks(networks=[ntwk2])
>>>
>>> np.allclose(circuit_updated_b.s_external, circuit_updated_b.s_external)
True
"""
# Get current connection_dict
cnx_dict = self.networks_dict()
# Update the connection dict with the new networks
for ntw in networks:
if ntw.name in cnx_dict:
cnx_dict[ntw.name] = ntw
else:
raise ValueError(f"Network {ntw.name} not found in circuit.")
# Update the circuit connections with the new networks
connections = [
[(cnx_dict[n.name], p) for n, p in cnx] for cnx in self.connections
]
# Get the keyword arguments for the reduce_circuit method
kwargs_reduce = {k: kwargs[k] for k in self._REDUCE_OPTIONS.__annotations__.keys() if k in kwargs}
# Reduce the circuit if directly requested or any relevant kwargs are provided
if auto_reduce or any(kwargs_reduce):
connections = reduce_circuit(connections, **kwargs_reduce)
if inplace:
self.connections = connections
return None
return Circuit(connections=connections, name=name)
[docs]
@classmethod
def check_duplicate_names(cls, connections_list: list[tuple[int, tuple[Network, int]]]):
"""
Check that a (ntwk, port) combination appears only once in the connection map
"""
nodes = [(ntwk.name, port) for (_, (ntwk, port)) in connections_list]
if len(nodes) > len(set(nodes)):
duplicate_nodes = [node for node in nodes if nodes.count(node) > 1]
raise AttributeError(f'Nodes {duplicate_nodes} appears twice in the connection description.')
def _is_named(self, ntw: Network):
"""
Return True is the network has a name, False otherwise
"""
if not ntw.name or ntw.name == '':
return False
else:
return True
@classmethod
def _is_port(cls, ntw: Network):
"""
Return True is the network is a port, False otherwise
"""
return ntw._ext_attrs.get("_is_circuit_port", False)
@classmethod
def _is_ground(cls, ntw: Network):
"""
Return True is the network is a ground, False otherwise
"""
return ntw._ext_attrs.get("_is_circuit_ground", False)
@classmethod
def _is_open(cls, ntw: Network):
"""
Return True is the network is a open, False otherwise
"""
return ntw._ext_attrs.get("_is_circuit_open", False)
[docs]
@classmethod
def Port(cls, frequency: Frequency, name: str, z0: float = 50) -> Network:
"""
Return a 1-port Network to be used as a Circuit port.
Parameters
----------
frequency : :class:`~skrf.frequency.Frequency`
Frequency common to all other networks in the circuit
name : string
Name of the port.
z0 : real, optional
Characteristic impedance of the port. Default is 50 Ohm.
Returns
-------
port : :class:`~skrf.network.Network` object
1-port network
Examples
--------
.. ipython::
@suppress
In [16]: import skrf as rf
In [17]: freq = rf.Frequency(start=1, stop=2, npoints=101)
In [18]: port1 = rf.Circuit.Port(freq, name='Port1')
"""
_media = media.DefinedGammaZ0(frequency, z0=z0)
port = _media.match(name=name)
port._ext_attrs['_is_circuit_port'] = True
return port
[docs]
@classmethod
def SeriesImpedance(cls, frequency: Frequency, Z: NumberLike, name: str, z0: float = 50) -> Network:
"""
Return a 2-port network of a series impedance.
Passing the frequency and name is mandatory.
Parameters
----------
frequency : :class:`~skrf.frequency.Frequency`
Frequency common to all other networks in the circuit
Z : complex array of shape n_freqs or complex
Impedance
name : string
Name of the series impedance
z0 : real, optional
Characteristic impedance of the port. Default is 50 Ohm.
Returns
-------
serie_impedance : :class:`~skrf.network.Network` object
2-port network
Examples
--------
.. ipython::
@suppress
In [16]: import skrf as rf
In [17]: freq = rf.Frequency(start=1, stop=2, npoints=101)
In [18]: open = rf.Circuit.SeriesImpedance(freq, rf.INF, name='series_impedance')
"""
A = np.zeros(shape=(len(frequency), 2, 2), dtype=complex)
A[:, 0, 0] = 1
A[:, 0, 1] = Z
A[:, 1, 0] = 0
A[:, 1, 1] = 1
ntw = Network(a=A, frequency=frequency, z0=z0, name=name)
return ntw
[docs]
@classmethod
def ShuntAdmittance(cls, frequency: Frequency, Y: NumberLike, name: str, z0: float = 50) -> Network:
"""
Return a 2-port network of a shunt admittance.
Passing the frequency and name is mandatory.
Parameters
----------
frequency : :class:`~skrf.frequency.Frequency`
Frequency common to all other networks in the circuit
Y : complex array of shape n_freqs or complex
Admittance
name : string
Name of the shunt admittance
z0 : real, optional
Characteristic impedance of the port. Default is 50 Ohm.
Returns
-------
shunt_admittance : :class:`~skrf.network.Network` object
2-port network
Examples
--------
.. ipython::
@suppress
In [16]: import skrf as rf
In [17]: freq = rf.Frequency(start=1, stop=2, npoints=101)
In [18]: short = rf.Circuit.ShuntAdmittance(freq, rf.INF, name='shunt_admittance')
"""
A = np.zeros(shape=(len(frequency), 2, 2), dtype=complex)
A[:, 0, 0] = 1
A[:, 0, 1] = 0
A[:, 1, 0] = Y
A[:, 1, 1] = 1
ntw = Network(a=A, frequency=frequency, z0=z0, name=name)
return ntw
[docs]
@classmethod
def Ground(cls, frequency: Frequency, name: str, z0: float = 50) -> Network:
"""
Return a 1-port network of a grounded link.
Passing the frequency and a name is mandatory.
The ground link is implemented by media.short object.
Parameters
----------
frequency : :class:`~skrf.frequency.Frequency`
Frequency common to all other networks in the circuit
name : string
Name of the ground.
z0 : real, optional
Characteristic impedance of the port. Default is 50 Ohm.
Returns
-------
ground : :class:`~skrf.network.Network` object
1-port network
Examples
--------
.. ipython::
@suppress
In [16]: import skrf as rf
In [17]: freq = rf.Frequency(start=1, stop=2, npoints=101)
In [18]: ground = rf.Circuit.Ground(freq, name='GND')
"""
_media = media.DefinedGammaZ0(frequency, z0=z0)
ground = _media.short(name=name)
ground._ext_attrs['_is_circuit_ground'] = True
return ground
[docs]
@classmethod
def Open(cls, frequency: Frequency, name: str, z0: float = 50) -> Network:
"""
Return a 1-port network of an open link.
Passing the frequency and name is mandatory.
The open link is implemented by media.open object.
Parameters
----------
frequency : :class:`~skrf.frequency.Frequency`
Frequency common to all other networks in the circuit
name : string
Name of the open.
z0 : real, optional
Characteristic impedance of the port. Default is 50 Ohm.
Returns
-------
open : :class:`~skrf.network.Network` object
1-port network
Examples
--------
.. ipython::
@suppress
In [16]: import skrf as rf
In [17]: freq = rf.Frequency(start=1, stop=2, npoints=101)
In [18]: open = rf.Circuit.Open(freq, name='open')
"""
_media = media.DefinedGammaZ0(frequency, z0=z0)
Open = _media.open(name=name)
Open._ext_attrs['_is_circuit_open'] = True
return Open
[docs]
def networks_dict(self,
connections: list[list[tuple[Network, int]]] | None = None,
min_nports: int = 1) -> dict[str, Network]:
"""
Return the dictionary of Networks from the connection setup X.
Parameters
----------
connections : List, optional
connections list, by default None (then uses the `self.connections`)
min_nports : int, optional
min number of ports, by default 1
Returns
-------
dict
Dictionary of Networks
"""
if not connections:
connections = self.connections
ntws: list[Network] = []
for cnx in connections:
for (ntw, _port) in cnx:
ntws.append(ntw)
return {ntw.name: ntw for ntw in ntws if ntw.nports >= min_nports}
[docs]
def networks_list(self,
connections: list[list[tuple[Network, int]]] | None = None,
min_nports: int = 1) -> list[Network]:
"""
Return a list of unique networks (sorted by appearing order in connections).
Parameters
----------
connections : List, optional
connections list, by default None (then uses the `self.connections`)
min_nports : int, optional
min number of ports, by default 1
Returns
-------
list
List of unique networks
"""
if not connections:
connections = self.connections
ntw_dict = self.networks_dict(connections)
return [ntw for ntw in ntw_dict.values() if ntw.nports >= min_nports]
@property
def connections_nb(self) -> int:
"""
Return the number of intersections in the circuit.
"""
return len(self.connections)
@property
def connections_list(self) -> list[tuple[int, tuple[Network, int]]]:
"""
Return the full list of connections, including intersections.
The resulting list if of the form::
[
[connexion_number, connexion],
[connexion_number, connexion],
...
]
"""
return list(enumerate(chain.from_iterable(self.connections)))
@property
def networks_nb(self) -> int:
"""
Return the number of connected networks (port excluded).
"""
return len(self.networks_list(self.connections))
@property
def nodes_nb(self) -> int:
"""
Return the number of nodes in the circuit.
"""
return self.connections_nb + self.networks_nb
@property
def dim(self) -> int:
"""
Return the dimension of the C, X and global S matrices.
It correspond to the sum of all connections.
"""
return np.sum([len(cnx) for cnx in self.connections])
@property
def G(self):
"""
Generate the graph of the circuit. Convenience shortname for :func:`graph`.
"""
return self.graph()
[docs]
def graph(self):
"""
Generate the graph of the circuit.
Returns
-------
G: :class:`networkx.Graph`
graph object [#]_ .
References
----------
.. [#] https://networkx.github.io/
"""
nx = self._get_nx()
G = nx.Graph()
# Adding network nodes
G.add_nodes_from([it for it in self.networks_dict(self.connections)])
# Adding edges in the graph between connections and networks
for (idx, cnx) in enumerate(self.connections):
cnx_name = 'X'+str(idx)
# Adding connection nodes and edges
G.add_node(cnx_name)
for (ntw, _ntw_port) in cnx:
ntw_name = ntw.name
G.add_edge(cnx_name, ntw_name)
return G
[docs]
def is_connected(self) -> bool:
"""
Check if the circuit's graph is connected.
Check if every pair of vertices in the graph is connected.
"""
nx = self._get_nx()
return nx.algorithms.components.is_connected(self.G)
@property
def intersections_dict(self) -> dict:
"""
Return a dictionary of all intersections with associated ports and z0:
::
{ k: [(ntw1_name, ntw1_port), (ntw1_z0, ntw2_name, ntw2_port), ntw2_z0], ... }
"""
inter_dict = {}
# for k in range(self.connections_nb):
# # get all edges connected to intersection Xk
# inter_dict[k] = list(nx.algorithms.boundary.edge_boundary(self.G, ('X'+str(k),) ))
for (k, cnx) in enumerate(self.connections):
inter_dict[k] = [(ntw, ntw_port, ntw.z0[0, ntw_port]) \
for (ntw, ntw_port) in cnx]
return inter_dict
@property
def edges(self) -> list:
"""
Return the list of all circuit connections
"""
return list(self.G.edges)
@property
def edge_labels(self) -> dict:
"""
Return a dictionary describing the port and z0 of all graph edges.
Dictionary is in the form::
{('ntw1_name', 'X0'): '3 (50+0j)',
('ntw2_name', 'X0'): '0 (50+0j)',
('ntw2_name', 'X1'): '2 (50+0j)', ... }
which can be used in `networkx.draw_networkx_edge_labels`
"""
# for all intersections,
# get the N interconnected networks and associated ports and z0
# and forge the edge label dictionary containing labels between
# two nodes
edge_labels = {}
for it in self.intersections_dict.items():
k, cnx = it
for idx in range(len(cnx)):
ntw, ntw_port, ntw_z0 = cnx[idx]
#ntw_z0 = ntw.z0[0,ntw_port]
edge_labels[(ntw.name, 'X'+str(k))] = str(ntw_port)+'\n'+\
str(np.round(ntw_z0, decimals=1))
return edge_labels
def _Xk(self,
cnx_k: list[tuple[Network, int]],
order: MemoryLayoutT = 'C',
inverse: bool = False) -> np.ndarray:
"""
Return the scattering matrices [X]_k of the individual intersections k.
The results in [#]_ do not agree due to an error in the formula (3)
for mismatched intersections.
Due to the ideal power splitter is unitary, that is [X]_k^H * [X]_k = I, where [X]_k^H is
the conjugate transpose of [X]_k. And considers the reciprocity, the inverse of [X]_k could
be simplified as the conjugate of [X]_k, that is [X]_k^-1 = [X]_k^*.
Parameters
----------
cnx_k : list of tuples
each tuple contains (network, port)
order: str, optional
'C' or 'F' for row-major or column-major order of the output array.
Default is 'C'.
inverse: bool, optional
If True, return the inverse of the scattering matrix [X]_k.
Default is False.
Returns
-------
Xs : :class:`numpy.ndarray`
shape `f x n x n`
References
----------
.. [#] P. Hallbjörner, Microw. Opt. Technol. Lett. 38, 99 (2003).
"""
y0s = np.array([1/ntw.z0[:,ntw_port] for (ntw, ntw_port) in cnx_k]).T
y_k = y0s.sum(axis=1)
Xs = np.zeros((len(self.frequency), len(cnx_k), len(cnx_k)), dtype='complex', order=order)
Xs = 2 *np.sqrt(np.einsum('ij,ik->ijk', y0s, y0s)) / y_k[:, None, None]
np.einsum('kii->ki', Xs)[:] -= 1 # Sii
return np.conjugate(Xs) if inverse else Xs
def _X(self, order: MemoryLayoutT = 'C', inverse: bool = False) -> np.ndarray:
"""
Return the concatenated intersection matrix [X] of the circuit.
It is composed of the individual intersection matrices [X]_k assembled
by block diagonal.
Due to the concatenated intersection matrix [X] is a block diagonal matrix,
the inverse of [X] could be simplified as the block diagonal matrix of the
inverses of [X]_k.
Parameters
----------
order : str, optional
'C' or 'F' for row-major or column-major order of the output array.
Default is 'C'.
inverse : bool, optional
If True, return the inverse of the concatenated intersection matrix [X].
Default is False.
Returns
-------
X : :class:`numpy.ndarray`
Note
----
The block diagonal matrix [X] has a numerical bottleneck that depends on the
order specified:
- When 'order' is 'C', the creation of the block diagonal matrix [X] in row-major
order is a bottleneck. This is because the creation process is port-by-port,
which does not align well with the row-major memory layout, leading to
suboptimal performance.
- When 'order' is 'F', the computation of the block diagonal matrix [X] in column-major
order is a bottleneck. Numpy generally optimizes operators for 'C' order, which
lead to performance issues when using 'F' order.
"""
Xks = [self._Xk(cnx, order, inverse) for cnx in self.connections]
Xf = np.zeros((len(self.frequency), self.dim, self.dim), dtype='complex', order=order)
off = np.array([0, 0])
for Xk in Xks:
Xf[:, off[0]:off[0] + Xk.shape[1], off[1]:off[1]+Xk.shape[2]] = Xk
off += Xk.shape[1:]
return Xf
@cached_property
def X(self) -> np.ndarray:
"""
Return the concatenated intersection matrix [X] of the circuit in C-order.
It is composed of the individual intersection matrices [X]_k assembled
by block diagonal.
Returns
-------
X : :class:`numpy.ndarray`
Note
----
There is a numerical bottleneck in this function,
when creating the block diagonal matrice [X] from the [X]_k matrices.
"""
# Check if X_F is already computed, if so, convert it to C-order
if self.__dict__.get('X_F', None) is not None:
return np.ascontiguousarray(self.X_F)
return self._X()
@cached_property
def X_F(self) -> np.ndarray:
"""
Return the concatenated intersection matrix [X] of the circuit in F-order.
It is composed of the individual intersection matrices [X]_k assembled
by block diagonal. The results of this function are the same as :func:`X`
but in F-order.
Returns
-------
X : :class:`numpy.ndarray`
Note
----
F-order has a numerical bottleneck in matrix operations, but the assignment
is more efficient.
"""
# Check if X is already computed, if so, convert it to F-order
if self.__dict__.get('X', None) is not None:
return np.asfortranarray(self.X)
return self._X('F')
@cached_property
def C(self) -> np.ndarray:
"""
Return the global scattering matrix [C] of the networks in C-order.
Returns
-------
S : :class:`numpy.ndarray`
Global scattering matrix of the networks.
Shape `f x (nb_inter*nb_n) x (nb_inter*nb_n)`
"""
# Check if C_F is already computed, if so, convert it to C-order
if self.__dict__.get('C_F', None) is not None:
return np.ascontiguousarray(self.C_F)
return self._C()
@cached_property
def C_F(self) -> np.ndarray:
"""
Return the global scattering matrix [C] of the networks in F-order. The results
of this function are the same as :func:`C` but in F-order.
Returns
-------
S : :class:`numpy.ndarray`
Global scattering matrix of the networks.
Shape `f x (nb_inter*nb_n) x (nb_inter*nb_n)`
Note
----
F-order has a numerical bottleneck in matrix operations, but the assignment
is more efficient.
"""
# Check if C is already computed, if so, convert it to F-order
if self.__dict__.get('C', None) is not None:
return np.asfortranarray(self.C)
return self._C('F')
def _C(self, order: MemoryLayoutT = 'C') -> np.ndarray:
"""
Return the global scattering matrix [C] of the networks.
Args:
order : str, optional
'C' or 'F' for row-major or column-major order of the output array.
Default is 'C'.
Returns
-------
S : :class:`numpy.ndarray`
Global scattering matrix of the networks.
Shape `f x (nb_inter*nb_n) x (nb_inter*nb_n)`
"""
# list all networks which are not considered as "ports",
ntws = {k:v for k,v in self.networks_dict().items() if not Circuit._is_port(v)}
# generate the port reordering indexes from each connections
ntws_ports_reordering = {ntw:[] for ntw in ntws}
for (idx_cnx, (ntw, ntw_port)) in self.connections_list:
if ntw.name in ntws.keys():
ntws_ports_reordering[ntw.name].append([ntw_port, idx_cnx])
# re-ordering scattering parameters
S = np.zeros((len(self.frequency), self.dim, self.dim), dtype='complex', order=order)
for (ntw_name, ntw_ports) in ntws_ports_reordering.items():
# get the port re-ordering indexes (from -> to)
ntw_ports = np.array(ntw_ports)
# port permutations
from_port = ntw_ports[:,0]
to_port = ntw_ports[:,1]
for (_from, _to) in zip(from_port, to_port):
S[:, _to, to_port] = ntws[ntw_name].s_traveling[:, _from, from_port]
return S # shape (nb_frequency, nb_inter*nb_n, nb_inter*nb_n)
@cached_property
def T(self) -> np.ndarray:
"""
Return the matrix of multiplication of the global scattering matrix [C] and concatenated
intersection matrix [X] of the networks, that is [T] = - [C] @ [X].
Returns
-------
T : :class:`numpy.ndarray`
Multiplication of the global scattering matrix [C] and concatenated intersection matrix
[X] of the networks. In practice, F-contiguous [C_F] and [X_F] are used.
Shape `f x (nb_inter*nb_n) x (nb_inter*nb_n)`
Note
----
This is an auxiliary matrix used to break the numerical bottleneck of [C_F] @ [X_F] using the
mathematical feature of block diagonal matrice [X].
"""
X, C = self.X_F, self.C_F
# T will be fully updated, so `empty_like` is safe and faster
T = np.empty_like(X, dtype="complex", order='F')
# Precompute the sizes of connections and slices for each intersection
cnx_size = [len(cnx) for cnx in self.connections]
slice_ = np.cumsum([0] + cnx_size)
slices = [slice(slice_[i], slice_[i+1]) for i in range(len(cnx_size))]
# Perform the multiplication
for j_slice in slices:
# Get the Block diagonal part of X and corresponding C matrix buffer
X_jj = - X[:, j_slice, j_slice]
C_j = C[:, :, j_slice]
# Perform the multiplication
for i_slice in slices:
T[:, i_slice, j_slice] = C_j[:, i_slice, :] @ X_jj
return T
@cached_property
def s(self) -> np.ndarray:
"""
Return the global scattering parameters of the circuit.
Return the scattering parameters of both "inner" and "outer" ports.
Note
----
The original implementation of this method calculated the S-parameters [S] using the global
scattering matrix [C] and the concatenated intersection matrix [X] by solving the equation:
[S] = [X] @ ([E] - [C] @ [X])^-1
where [E] is the identity matrix. However, this approach is computationally inefficient for
large networks due to its numerical bottlenecks.
The current implementation optimizes the calculation by leveraging matrix algebra to simplify
the relationship between the incident {a} and reflected {b} power waves. The key steps are as
follows:
1. Define the relationship between {b} and {a} using [X] and [C]:
{b} = [X] @ ([E] - [C] @ [X])^-1 @ {a}
2. Simplify the relationship between {a} and {b}:
{a} = ([X] @ ([E] - [C] @ [X])^-1)^-1 @ {b}
= ([E] - [C] @ [X]) @ [X]^-1 @ {b}
= ([X]^-1 - [C] @ [X] @ [X]^-1) @ {b}
= ([X]^-1 - [C]) @ {b}
3. Derive the S-parameters matrix [S] from the simplified relationship:
[S] = ([X]^-1 - [C])^-1
4. Leverage the property of the concatenated intersection matrix [X]:
- [X] is a block diagonal matrix composed of individual intersection matrices [X]_k:
[X] = diag([X_1], [X_2], ..., [X_n]).
- Each [X]_k represents an ideal lossless power splitter, which is unitary:
[X]_k^H @ [X]_k = I,
where [X]_k^H is the conjugate transpose of [X]_k.
- Due to reciprocity, the inverse of [X]_k is its conjugate:
[X]_k^-1 = [X]_k^*.
5. Substitute the property into the S-parameters equation:
[S] = ([X^T]^* - [C])^-1
This approach avoids the explicit computation of [X]^-1, significantly improving computational
efficiency for large-scale networks.
Returns
-------
S : :class:`numpy.ndarray`
global scattering parameters of the circuit.
"""
return np.linalg.inv(self._X(inverse=True) - self.C)
@property
def port_indexes(self) -> list[int]:
"""
Return the indexes of the "external" ports.
Returns
-------
port_indexes : list
"""
port_indexes = []
for (idx_cnx, (ntw, _)) in enumerate(chain.from_iterable(self.connections)):
if Circuit._is_port(ntw):
port_indexes.append(idx_cnx)
return port_indexes
def _cnx_z0(self, cnx_k: list[tuple]) -> np.ndarray:
"""
Return the characteristic impedances of a specific connections.
Parameters
----------
cnx_k : list of tuples
each tuple contains (network, port)
Returns
-------
z0s : :class:`numpy.ndarray`
shape `f x nb_ports_at_cnx`
"""
z0s = []
for (ntw, ntw_port) in cnx_k:
z0s.append(ntw.z0[:,ntw_port])
return np.array(z0s).T # shape (nb_freq, nb_ports_at_cnx)
@property
def port_z0(self) -> np.ndarray:
"""
Return the external port impedances.
Returns
-------
z0s : :class:`numpy.ndarray`
shape `f x nb_ports`
"""
z0s = []
for cnx in self.connections:
for (ntw, ntw_port) in cnx:
z0s.append(ntw.z0[:,ntw_port])
return np.array(z0s)[self.port_indexes, :].T # shape (nb_freq, nb_ports)
@property
def s_external(self) -> np.ndarray:
"""
Return the scattering parameters for the external ports.
Returns
-------
S : :class:`numpy.ndarray`
Scattering parameters of the circuit for the external ports.
Shape `f x nb_ports x nb_ports`
"""
# The external S-matrix is the submatrix corresponding to external ports:
# port_indexes = self.port_indexes
# a, b = np.meshgrid(port_indexes, port_indexes, indexing='ij')
# S_ext = self.s[:, a, b]
# Instead of calculating all S-parameters and taking a submatrix,
# the following faster approach only calculates external the S-parameters
# from block-matrix operations.
# generate index lists of internal and external ports
port_indexes = self.port_indexes
in_idxs = [(i,) for i in range(self.dim) if i not in port_indexes]
ext_idxs = [(i,) for i in port_indexes]
ext_l, in_l = len(ext_idxs), len(in_idxs)
# generate index slices for each sub-matrices
idx_a, idx_b, idx_c, idx_d = (
np.repeat(i, l, axis=1)
for i, l in (
(ext_idxs, ext_l),
(ext_idxs, in_l),
(in_idxs, ext_l),
(in_idxs, in_l),
)
)
# sub-matrices index, Matrix = [[A, B], [C, D]]]
A_idx = (slice(None), idx_a, idx_a.T)
B_idx = (slice(None), idx_b, idx_c.T)
C_idx = (slice(None), idx_c, idx_b.T)
D_idx = (slice(None), idx_d, idx_d.T)
# Get the buffer of global matrix in f-order [X_T] and intermediate temporary matrix [T]
# [T] = - [C] @ [X]
x, t = self.X_F, np.array(self.T)
np.einsum('...ii->...i', t)[:] += 1
# Get the sub-matrices of inverse of intermediate temporary matrix t
# The method np.linalg.solve(A, B) is equivalent to np.inv(A) @ B, but more efficient
try:
tmp_mat = np.linalg.solve(t[D_idx], t[C_idx])
except np.linalg.LinAlgError:
warnings.warn('Singular matrix detected, using numpy.linalg.lstsq instead.', RuntimeWarning, stacklevel=2)
# numpy.linalg.lstsq only works for 2D arrays, so we need to loop over frequencies
tmp_mat = np.zeros((self.frequency.npoints, len(in_idxs), len(ext_idxs)), dtype='complex')
for i in range(self.frequency.npoints):
tmp_mat[i, :, :] = np.linalg.lstsq(t[i, D_idx[1], D_idx[2]], t[i, C_idx[1], C_idx[2]], rcond=None)[0]
# Get the external S-parameters for the external ports
# Calculated by multiplying the sub-matrices of x and t
S_ext = (x[A_idx] - x[B_idx] @ tmp_mat) @ np.linalg.inv(
t[A_idx] - t[B_idx] @ tmp_mat
)
S_ext = s2s(S_ext, self.port_z0, S_DEF_DEFAULT, 'traveling')
return S_ext # shape (nb_frequency, nb_ports, nb_ports)
@property
def network(self) -> Network:
"""
Return the Network associated to external ports.
Returns
-------
ntw : :class:`~skrf.network.Network`
Network associated to external ports
"""
return Network(frequency = self.frequency, z0 = self.port_z0,
s = self.s_external, name = self.name)
[docs]
def s_active(self, a: NumberLike) -> np.ndarray:
r"""
Return "active" s-parameters of the circuit's network for a defined wave excitation `a`.
The "active" s-parameter at a port is the reflection coefficients
when other ports are excited. It is an important quantity for active
phased array antennas.
Active s-parameters are defined by [#]_:
.. math::
\mathrm{active}(s)_{mn} = \sum_i s_{mi} \frac{a_i}{a_n}
Parameters
----------
a : complex array of shape (n_ports)
forward wave complex amplitude (power-wave formulation [#]_)
Returns
-------
s_act : complex array of shape (n_freqs, n_ports)
active s-parameters for the excitation a
References
----------
.. [#] D. M. Pozar, IEEE Trans. Antennas Propag. 42, 1176 (1994).
.. [#] D. Williams, IEEE Microw. Mag. 14, 38 (2013).
"""
return self.network.s_active(a)
[docs]
def z_active(self, a: NumberLike) -> np.ndarray:
r"""
Return the "active" Z-parameters of the circuit's network for a defined wave excitation a.
The "active" Z-parameters are defined by:
.. math::
\mathrm{active}(z)_{m} = z_{0,m} \frac{1 + \mathrm{active}(s)_m}{1 - \mathrm{active}(s)_m}
where :math:`z_{0,m}` is the characteristic impedance and
:math:`\mathrm{active}(s)_m` the active S-parameter of port :math:`m`.
Parameters
----------
a : complex array of shape (n_ports)
forward wave complex amplitude
Returns
-------
z_act : complex array of shape (nfreqs, nports)
active Z-parameters for the excitation a
See Also
--------
s_active : active S-parameters
y_active : active Y-parameters
vswr_active : active VSWR
"""
return self.network.z_active(a)
[docs]
def y_active(self, a: NumberLike) -> np.ndarray:
r"""
Return the "active" Y-parameters of the circuit's network for a defined wave excitation a.
The "active" Y-parameters are defined by:
.. math::
\mathrm{active}(y)_{m} = y_{0,m} \frac{1 - \mathrm{active}(s)_m}{1 + \mathrm{active}(s)_m}
where :math:`y_{0,m}` is the characteristic admittance and
:math:`\mathrm{active}(s)_m` the active S-parameter of port :math:`m`.
Parameters
----------
a : complex array of shape (n_ports)
forward wave complex amplitude
Returns
-------
y_act : complex array of shape (nfreqs, nports)
active Y-parameters for the excitation a
See Also
--------
s_active : active S-parameters
z_active : active Z-parameters
vswr_active : active VSWR
"""
return self.network.y_active(a)
[docs]
def vswr_active(self, a: NumberLike) -> np.ndarray:
r"""
Return the "active" VSWR of the circuit's network for a defined wave excitation a.
The "active" VSWR is defined by :
.. math::
\mathrm{active}(vswr)_{m} = \frac{1 + |\mathrm{active}(s)_m|}{1 - |\mathrm{active}(s)_m|}
where :math:`\mathrm{active}(s)_m` the active S-parameter of port :math:`m`.
Parameters
----------
a : complex array of shape (n_ports)
forward wave complex amplitude
Returns
-------
vswr_act : complex array of shape (nfreqs, nports)
active VSWR for the excitation a
See Also
--------
s_active : active S-parameters
z_active : active Z-parameters
y_active : active Y-parameters
"""
return self.network.vswr_active(a)
@property
def z0(self) -> np.ndarray:
"""
Characteristic impedances of "internal" ports.
Returns
-------
z0 : complex array of shape (nfreqs, nports)
Characteristic impedances of both "inner" and "outer" ports
"""
z0s = []
for _cnx_idx, (ntw, ntw_port) in self.connections_list:
z0s.append(ntw.z0[:,ntw_port])
return np.array(z0s).T
@property
def connections_pair(self) -> list:
"""
List the connections by pair.
Each connection in the circuit is between a specific pair of two
(networks, port, z0).
Returns
-------
connections_pair : list
list of pair of connections
"""
return [self.connections_list[i:i+2] for i in range(0, len(self.connections_list), 2)]
@property
def _currents_directions(self) -> np.ndarray:
"""
Create a array of indices to define the sign of the current.
The currents are defined positive when entering an internal network.
Returns
-------
directions : array of int (nports, 2)
Note
----
This function is used in internal currents and voltages calculations.
"""
directions = np.zeros((self.dim,2), dtype='int')
for cnx_pair in self.connections_pair:
(cnx_idx_A, cnx_A), (cnx_idx_B, cnx_B) = cnx_pair
directions[cnx_idx_A,:] = cnx_idx_A, cnx_idx_B
directions[cnx_idx_B,:] = cnx_idx_B, cnx_idx_A
return directions
def _a(self, a_external: NumberLike) -> np.ndarray:
"""
Wave input array at "internal" ports.
Parameters
----------
a_external : array
power-wave input vector at ports
Returns
-------
a_internal : array
Wave input array at internal ports
"""
# create a zero array and fill the values corresponding to ports
a_internal = np.zeros(self.dim, dtype='complex')
a_internal[self.port_indexes] = a_external
return a_internal
def _a_external(self, power: NumberLike, phase: NumberLike) -> np.ndarray:
r"""
Wave input array at Circuit's ports ("external" ports).
The array is defined from power and phase by:
.. math::
a = \sqrt(2 P_{in} ) e^{j \phi}
The factor 2 is in order to deal with peak values.
Parameters
----------
power : list or array
Input power at external ports in Watts [W]
phase : list or array
Input phase at external ports in radian [rad]
NB: the size of the power and phase array should match the number of ports
Returns
-------
a_external: array
Wave input array at Circuit's ports
"""
if len(power) != len(self.port_indexes):
raise ValueError('Length of power array does not match the number of ports of the circuit.')
if len(phase) != len(self.port_indexes):
raise ValueError('Length of phase array does not match the number of ports of the circuit.')
return np.sqrt(2*np.array(power))*np.exp(1j*np.array(phase))
def _b(self, a_internal: NumberLike) -> np.ndarray:
"""
Wave output array at "internal" ports
Parameters
----------
a_internal : array
Wave input array at internal ports
Returns
-------
b_internal : array
Wave output array at internal ports
Note
----
Wave input array at internal ports can be derived from power and
phase excitation at "external" ports using `_a(power, phase)` method.
"""
return self.s @ a_internal
[docs]
def currents(self, power: NumberLike, phase: NumberLike) -> np.ndarray:
"""
Currents at internal ports.
NB: current direction is defined as positive when entering a node.
NB: external current sign are opposite than corresponding internal ones,
as the internal currents are actually flowing into the "port" networks
Parameters
----------
power : list or array
Input power at external ports in Watts [W]
phase : list or array
Input phase at external ports in radian [rad]
Returns
-------
I : complex array of shape (nfreqs, nports)
Currents in Amperes [A] (peak) at internal ports.
"""
a = self._a(self._a_external(power, phase))
b = self._b(a)
z0s = self.z0
i, Is = 0, np.zeros_like(z0s)
for cnx in self.connections:
cnx_len = len(cnx)
z0_segment = z0s[:, i : i + cnx_len]
tot_shunt_z0 = (1 / z0_segment).sum(axis=1)
Ij = np.zeros_like(z0_segment)
# Calculate the ports' output current through the output wave
for j in range(cnx_len):
in_z0 = z0_segment[:, j]
out_z0 = 1 / (tot_shunt_z0 - 1 / in_z0)
tau = (2 * out_z0) / (out_z0 + in_z0)
Ij[:, j] = (b[:, i + j] / np.sqrt(in_z0)) * tau
# The current of each port is different in the same node
# The ports' current should take into account the output current of each port in the node
for j in range(cnx_len):
in_z0 = z0_segment[:, j]
out_z0 = 1 / (tot_shunt_z0 - 1 / in_z0)
Itmp = np.zeros_like(Is[:, i + j])
for k in range(cnx_len):
tmp_z0 = z0_segment[:, k]
if j == k:
Itmp += Ij[:, k] * (tmp_z0 / out_z0)
else:
Itmp -= Ij[:, k] * (tmp_z0 / in_z0)
Is[:, i + j] = Itmp
i += cnx_len
return Is
[docs]
def voltages(self, power: NumberLike, phase: NumberLike) -> np.ndarray:
"""
Voltages at internal ports.
Parameters
----------
power : list or array
Input power at external ports in Watts [W]
phase : list or array
Input phase at external ports in radian [rad]
Returns
-------
V : complex array of shape (nfreqs, nports)
Voltages in Volt [V] (peak) at internal ports.
"""
a = self._a(self._a_external(power, phase))
b = self._b(a)
z0s = self.z0
i, Vs = 0, np.zeros_like(z0s)
for cnx in self.connections:
cnx_len = len(cnx)
z0_segment = z0s[:, i : i + cnx_len]
tot_shunt_z0 = (1 / z0_segment).sum(axis=1)
Vk = np.zeros(shape=z0s.shape[0], dtype="complex128")
# Node voltage is the summation of each ports' outwave voltage
# The voltage of each port in the same node is consistent
for j in range(cnx_len):
in_z0 = z0_segment[:, j]
out_z0 = 1 / (tot_shunt_z0 - 1 / in_z0)
tau = (2 * out_z0) / (out_z0 + in_z0)
Vk += (b[:, i + j] * np.sqrt(in_z0)) * tau
Vs[:, i : i + cnx_len] = Vk[:, None]
i += cnx_len
return Vs
[docs]
def currents_external(self, power: NumberLike, phase: NumberLike) -> np.ndarray:
"""
Currents at external ports.
NB: current direction is defined positive when "entering" into port.
Parameters
----------
power : list or array
Input power at external ports in Watts [W]
phase : list or array
Input phase at external ports in radian [rad]
Returns
-------
I : complex array of shape (nfreqs, nports)
Currents in Amperes [A] (peak) at external ports.
"""
a = self._a(self._a_external(power, phase))
b = self._b(a)
z0s = self.z0
Is = []
for port_idx in self.port_indexes:
Is.append((a[port_idx] - b[:,port_idx])/np.sqrt(z0s[:,port_idx]))
return np.array(Is).T
[docs]
def voltages_external(self, power: NumberLike, phase: NumberLike) -> np.ndarray:
"""
Voltages at external ports
Parameters
----------
power : list or array
Input power at external ports in Watts [W]
phase : list or array
Input phase at external ports in radian [rad]
Returns
-------
V : complex array of shape (nfreqs, nports)
Voltages in Volt [V] (peak) at ports
"""
a = self._a(self._a_external(power, phase))
b = self._b(a)
z0s = self.z0
Vs = []
for port_idx in self.port_indexes:
Vs.append((a[port_idx] + b[:,port_idx])*np.sqrt(z0s[:,port_idx]))
return np.array(Vs).T
[docs]
def plot_graph(self, **kwargs):
"""
Plot the graph of the circuit using networkx drawing capabilities.
Customisation options with default values::
'network_shape': 's'
'network_color': 'gray'
'network_size', 300
'network_fontsize': 7
'inter_shape': 'o'
'inter_color': 'lightblue'
'inter_size', 300
'port_shape': '>'
'port_color': 'red'
'port_size', 300
'port_fontsize': 7
'edges_fontsize': 5
'network_labels': False
'edge_labels': False
'inter_labels': False
'port_labels': False
'label_shift_x': 0
'label_shift_y': 0
"""
nx = self._get_nx()
G = self.G
# default values
network_labels = kwargs.pop('network_labels', False)
network_shape = kwargs.pop('network_shape', 's')
network_color = kwargs.pop('network_color', 'gray')
network_fontsize = kwargs.pop('network_fontsize', 7)
network_size = kwargs.pop('network_size', 300)
inter_labels = kwargs.pop('inter_labels', False)
inter_shape = kwargs.pop('inter_shape', 'o')
inter_color = kwargs.pop('inter_color', 'lightblue')
inter_size = kwargs.pop('inter_size', 300)
port_labels = kwargs.pop('port_labels', False)
port_shape = kwargs.pop('port_shape', '>')
port_color = kwargs.pop('port_color', 'red')
port_size = kwargs.pop('port_size', 300)
port_fontsize = kwargs.pop('port_fontsize', 7)
edge_labels = kwargs.pop('edge_labels', False)
edge_fontsize = kwargs.pop('edge_fontsize', 5)
label_shift_x = kwargs.pop('label_shift_x', 0)
label_shift_y = kwargs.pop('label_shift_y', 0)
# sort between network nodes and port nodes
all_ntw_names = [ntw.name for ntw in self.networks_list()]
port_names = [ntw_name for ntw_name in all_ntw_names if 'port' in ntw_name]
ntw_names = [ntw_name for ntw_name in all_ntw_names if 'port' not in ntw_name]
# generate connecting nodes names
int_names = ['X'+str(k) for k in range(self.connections_nb)]
fig, ax = subplots(figsize=(10,8))
pos = nx.spring_layout(G)
# draw Networks
nx.draw_networkx_nodes(G, pos, port_names, ax=ax,
node_size=port_size,
node_color=port_color, node_shape=port_shape)
nx.draw_networkx_nodes(G, pos, ntw_names, ax=ax,
node_size=network_size,
node_color=network_color, node_shape=network_shape)
# draw intersections
nx.draw_networkx_nodes(G, pos, int_names, ax=ax,
node_size=inter_size,
node_color=inter_color, node_shape=inter_shape)
# labels shifts
pos_labels = {}
for node, coords in pos.items():
pos_labels[node] = (coords[0] + label_shift_x,
coords[1] + label_shift_y)
# network labels
if network_labels:
network_labels = {lab:lab for lab in ntw_names}
nx.draw_networkx_labels(G, pos_labels, labels=network_labels,
font_size=network_fontsize, ax=ax)
# intersection labels
if inter_labels:
inter_labels = {'X'+str(k):'X'+str(k) for k in range(self.connections_nb)}
nx.draw_networkx_labels(G, pos_labels, labels=inter_labels,
font_size=network_fontsize, ax=ax)
# port labels
if port_labels:
port_labels = {lab:lab for lab in port_names}
nx.draw_networkx_labels(G, pos_labels, labels=port_labels,
font_size=port_fontsize, ax=ax)
# draw edges
nx.draw_networkx_edges(G, pos, ax=ax)
if edge_labels:
edge_labels = self.edge_labels
nx.draw_networkx_edge_labels(G, pos,
edge_labels=edge_labels, label_pos=0.5,
font_size=edge_fontsize, ax=ax)
# remove x and y axis and labels
ax.axis('off')
fig.tight_layout()
## Functions operating on Circuit
[docs]
def reduce_circuit(connections: list[list[tuple[Network, int]]],
check_duplication: bool = True,
split_ground: bool = True,
split_multi: bool = False,
max_nports: int = 20,
dynamic_networks: Sequence[Network] = tuple()) -> list[list[tuple[Network, int]]]:
"""
Return a reduced equivalent circuit connections with fewer components.
The reduced equivalent circuit connections allows faster calculation of the circuit network.
Parameters
----------
connections : list[list[tuple[Network, int]]].
The connection list to reduce.
check_duplication : bool, optional.
If True, check if the connections have duplicate names. Default is True.
split_ground : bool, optional.
If True, split the global ground connection to independent ground connections. Default is True.
split_multi : bool, optional.
If True, use a splitter to handle connections involving more than two components. This approach
increases the computational load for individual computations. However, it proves advantageous for
batch processing by enabling a more comprehensive reduction of circuits, leading to more efficiency
in batch computations. Default is False.
max_nports : int, optional.
The maximum number of ports of a Network that can be reduced in circuit. If a Network in the
circuit has a number of ports (nports), using the Network.connect() method to reduce the circuit's
dimensions becomes less efficient compared to directly calculating it with Circuit.s_external.
This value depends on the performance of the computer and the scale of the circuit. Default is 20.
dynamic_networks : Sequence[Network], optional.
A sequence of Networks to ignore in the reduction process. Default is an empty tuple.
Returns
-------
reduced_cnxs : list.
The reduced connections.
Examples
--------
>>> import skrf as rf
>>> import numpy as np
>>> circuit = rf.Circuit(connections)
>>> reduced_cnxs = rf.reduce_circuit(connections)
>>> reduced_circuit = rf.Circuit(reduced_cnxs)
>>> ntwkA = circuit.network
>>> ntwkB = reduced_circuit.network
>>> np.allclose(ntwkA.s, ntwkB.s)
True
"""
# Pre-processing the dynamic_networks tuple
ignore_ntwk_names: set[str] = set(ntw.name for ntw in dynamic_networks)
def invalide_to_reduce(cnx: list[tuple[Network, int]]) -> bool:
return (
any(
(
Circuit._is_port(ntwk)
or ntwk.nports > max_nports
or ntwk.name in ignore_ntwk_names
)
for ntwk, _ in cnx
)
or len(cnx) != 2
)
if split_ground:
tmp_cnxs = []
for cnx in connections:
ground_ntwk = next((ntwk for ntwk, _ in cnx if Circuit._is_ground(ntwk)), None)
# If there is no ground network or if the connection has exactly 2 elements, append it as is
if not ground_ntwk or len(cnx) == 2:
tmp_cnxs.append(cnx)
continue
# Otherwise, create new ground connections
for ntwk, port in cnx:
if Circuit._is_ground(ntwk):
continue
tmp_gnd = Circuit.Ground(frequency=ground_ntwk.frequency,
name=f'G_{ntwk.name}_{port}')
tmp_gnd.z0 = ground_ntwk.z0
tmp_cnxs.append([(ntwk, port), (tmp_gnd, 0)])
connections = tmp_cnxs
if split_multi:
tmp_cnxs = []
for cnx in connections:
# Check if the connection has more than 2 components
if len(cnx) <= 2:
tmp_cnxs.append(cnx)
continue
# Create a splitter for the connection
_media = media.DefinedGammaZ0(cnx[0][0].frequency)
splitter = _media.splitter(
name=f"Splt_{'&'.join(ntwk.name for ntwk, _ in cnx)}",
nports=len(cnx),
z0=np.array([ntwk.z0[:, p] for ntwk, p in cnx]).T
)
# Connect the splitter to the connection
for (idx, (ntwk, port)) in enumerate(cnx):
tmp_cnxs.append([(splitter, idx), (ntwk, port)])
connections = tmp_cnxs
# Cache the connections that have been processed to avoid loop
processed_network_names: set[str] = set()
# Calculate the total number of Network ports in the specified connection
def calculate_ports(cnx: list[tuple[Network, int]]) -> int:
if invalide_to_reduce(cnx):
return -1
name_list = sorted(ntwk.name for ntwk, _ in cnx)
ntwks_str: str = ''.join(name_list)
unique_networks = len(set(name_list))
total_ports = sum(ntwk.nports for ntwk, _ in cnx) - 2
# Return the number of ports if the connections performed
ports = total_ports if unique_networks == 2 else total_ports // 2 - 1
# If tuples of Networks in 'connections' have the same Networks, they form a loop.
# Prioritize processing loops in the circuit by reducing the number of ports by 1.
# This reduces the computational load during the circuit reduction process.
if ntwks_str in processed_network_names:
ports -= 1
else:
processed_network_names.add(ntwks_str)
return ports
# List of tuples containing connection indices and their calculated ports
cnx_ports_list = [(idx, calculate_ports(cnx)) for idx, cnx in enumerate(connections)]
reorder_indices = [idx for idx, _ in sorted(cnx_ports_list, key=lambda x: x[1])]
# Reorder connections
connections = [connections[i] for i in reorder_indices]
# check if the connections are valid
if check_duplication:
connections_list = [conn for conn in enumerate(chain.from_iterable(connections))]
Circuit.check_duplicate_names(connections_list)
# Use list comprehension to find the connection need to be reduced
gen = (
(idx, cnx)
for idx, cnx in enumerate(connections)
if not invalide_to_reduce(cnx)
)
# Get the first connection need to be reduced
skip_idx, cnx_to_reduce = next(gen, (-1, [(Network(), -1)] * 2))
# If there is no connection need to reduce, return the original circuit
if skip_idx == -1:
return connections
# Connect the connections that need to be reduced
(ntwkA, k), (ntwkB, l) = cnx_to_reduce
ntwks_name = (ntwkA.name, ntwkB.name)
# Get the Networks' names
name_cnt, name_a, name_b = "", str(ntwkA.name), str(ntwkB.name)
# Generate the connected network and the original port index
if ntwkA.name == ntwkB.name:
ntwk_cnt = innerconnect(ntwkA=ntwkA, k=k, l=l)
name_cnt = f"<{name_a}>"
else:
ntwk_cnt = connect(ntwkA=ntwkA, k=k, ntwkB=ntwkB, l=l)
name_cnt = f"({name_a}**{name_b})"
# Update the name of the connected network
ntwk_cnt.name = name_cnt
# Generate the port index, the index is the original port index
# and the value is the new port index, -1 means the port is removed.
port_idx = tuple()
if ntwkA.name == ntwkB.name:
port_cnt = list(range(ntwk_cnt.nports))
port_cnt.insert(min(k, l), -1)
port_cnt.insert(max(k, l), -1)
port_idx = (tuple(port_cnt), tuple(port_cnt))
elif ntwkB.nports == 2 and ntwkA.nports > 2:
# if ntwkB is a 2port, then keep port indices where you expect.
port_idx = (
tuple([(i if i != k else -1) for i in range(ntwkA.nports)]),
((-1, k) if l == 0 else (k, -1)),
)
else:
portA = list(range(ntwkA.nports - 1))
portA.insert(k, -1)
portB = [i + ntwkA.nports - 1 for i in range(ntwkB.nports - 1)]
portB.insert(l, -1)
port_idx = (tuple(portA), tuple(portB))
# Perform the reduction to get the reduced circuit connections
# Skip the connection that connected and replace the network and port index
reduced_cnxs = []
for idx, cnx in enumerate(connections):
# Skip the connection reduced
if idx == skip_idx:
continue
tmp_cnx = []
for ntwk, port in cnx:
name = ntwk.name
ntwk_changed = name in ntwks_name
ntwk_tmp = ntwk
port_tmp = port
# Update the connected network and port index
if ntwk_changed:
ntwk_tmp = ntwk_cnt
port_tmp = port_idx[ntwks_name.index(name)][port]
tmp_cnx.append((ntwk_tmp, port_tmp))
reduced_cnxs.append(tmp_cnx)
return reduce_circuit(
connections=reduced_cnxs,
check_duplication=False,
split_ground=False,
split_multi=False,
max_nports=max_nports,
dynamic_networks=dynamic_networks,
)