from typing import Sequence
import jax.numpy as jnp
from pmrf.frequency import Frequency
from pmrf.models.model import Model
from pmrf.models.misc import Port
from pmrf.functions.connections import connect_one, connect_many
from pmrf._util import field
[docs]
class Circuit(Model):
"""
Represents an arbitrary circuit defined by component connections.
This model allows for the definition of a circuit by specifying how the ports
of various sub-models are connected together.
Attributes
----------
models : list[Model]
The list of unique models involved in the circuit.
indexed_connections : list[list[tuple[int, int]]]
Internal representation of connections using model indices instead of objects.
port_idxs : list[int]
Indices of the models that act as external ports for the circuit.
"""
models: list[Model]
indexed_connections: list[list[tuple[int, int]]] = field(static=True)
port_idxs: list[int] = field(static=True)
[docs]
def __init__(self, connections: list[list[tuple[Model, int]]]):
"""
Initialize the Circuit.
Parameters
----------
connections : list[list[tuple[Model, int]]]
A list of connections (nodes). Each connection is a list of
`(model_instance, port_index)` tuples that are electrically connected.
"""
super().__init__()
self.models = []
self.indexed_connections = []
self.port_idxs = []
id_to_index: dict[Model, int] = {}
for connection in connections:
indexed_connection = []
for model, value in connection:
if id(model) not in id_to_index:
id_to_index[id(model)] = len(self.models)
self.models.append(model)
model_idx = id_to_index[id(model)]
indexed_connection.append((model_idx, value))
self.indexed_connections.append(indexed_connection)
for model in self.models:
if isinstance(model, Port):
self.port_idxs.append(id_to_index[id(model)])
[docs]
def s(self, freq: Frequency) -> jnp.array:
Smats = [model.s(freq) for model in self.models]
z0s = [model.z0 for model in self.models]
return connect_many(Smats, z0s, self.indexed_connections, self.port_idxs)
[docs]
class Connected(Model):
"""
Represents a connection of multiple models at a single intersection.
The algorithm in :func:`pmrf.functions.connections.connect_one` is used.
Attributes
----------
models : Sequence[Model] | Model
The models to connect.
ports : Sequence[int | Sequence[int]]
The port indices on each model that are connected to the common node.
"""
models: Sequence[Model] | Model
ports: Sequence[int | Sequence[int]]
def __post_init__(self):
self.name = 'connected'
# Do some validation
[docs]
def s(self, freq: Frequency) -> jnp.array:
models, ports = self.models, self.ports
if isinstance(models, Model):
models = [models]
Smats = [model.s(freq) for model in models]
z0s = [model.z0 for model in models]
Sout, _ = connect_one(Smats, z0s, ports)
return Sout
[docs]
class Cascade(Model):
"""
Represents a cascade, or series connection, of two or more `Model` objects.
This container connects multiple models end-to-end. The output port of
one model is connected to the input port of the next. This is mathematically
equivalent to chain-multiplying the ABCD-parameter matrices of the
constituent models.
The `Cascade` model automatically flattens any nested `Cascade` instances
to maintain a simple, linear chain of models. The number of ports of the
resulting `Cascade` network depends on the port count of the final model
in the chain.
Attributes
----------
models : tuple[Model]
The sequence of models in the cascade.
Examples
--------
Cascading models is most easily done using the `**` operator, which is
an alias for creating a `Cascade` model.
>>> import pmrf as prf
>>> from pmrf.models import Resistor, Capacitor, Inductor
# Create individual component models
>>> res = Resistor(50)
>>> cap = Capacitor(1e-12)
>>> ind = Inductor(1e-9)
# Cascade them together in a series R-L-C configuration
# This is equivalent to Cascade(models=(res, ind, cap))
>>> rlc_series = res ** ind ** cap
# Define a frequency axis
>>> freq = prf.Frequency(start=1, stop=10, npoints=101, unit='ghz')
# Calculate the S-parameters of the cascaded network
>>> s_params = rlc_series.s(freq)
>>> print(f"Cascaded model has {rlc_series.nports} ports.")
>>> print(f"S11 at first frequency point: {s_params[0,0,0]:.2f}")
"""
models: tuple[Model]
def __post_init__(self):
self.name = 'cascade'
models = self.models
# First check all the port conditions
if models[0].nports != 2:
raise Exception('First network must be a two port when cascade')
for model in models[1:-1]:
if model.nports != 2:
raise Exception('Inner networks must be two ports when cascade')
if models[-1].nports not in (1, 2):
raise Exception('Last network must either be a one port or a two port when cascade')
# Next check if any models themselves are of type Cascade. We don't nest these - we chain them to avoid very deep, nested models
model_reduced = []
for model in models:
if isinstance(model, Cascade):
model_reduced.extend(model.models)
else:
model_reduced.append(model)
self.models = tuple(model_reduced)
@property
def first_model(self) -> Model:
"""Model: The first model in the cascade chain."""
return self.models[0]
@property
def inner_models(self) -> tuple['Model']:
"""tuple[Model]: A tuple of the inner models in the cascade chain."""
return tuple(self.models[1:-1])
@property
def last_model(self) -> Model:
"""Model: The last model in the cascade chain."""
return self.models[-1]
[docs]
def a(self, freq: Frequency) -> jnp.ndarray:
a = self.first_model.a(freq)
for model in self.models[1:]:
a = a @ model.a(freq)
if self.last_model.nports == 1:
raise Exception('Cannot get abcd-matrix for a cascade of models terminated in a one-port')
return a
[docs]
def s(self, freq: Frequency) -> jnp.ndarray:
# We only implement s when we are terminating in a one-port.
# Otherwise, we call the parent s, which will ultimatlely call the 'a' implementation above
if self.last_model.nports != 1:
return Model.s(self, freq)
# Get abcd matrix of inners
a = self.first_model.a(freq)
for model in self.inner_models:
a = a @ model.a(freq)
# Terminated last in s11
s11 = self.last_model.s(freq)[:,0,0]
z0 = self.first_model._z0
A, B, C, D = a[:,0,0], a[:,0,1], a[:,1,0], a[:,1,1]
num = z0 * (1 + s11) * (A - z0*C) + (B - D*z0)*(1-s11)
den = z0 * (1 + s11) * (A + z0*C) + (B + D*z0)*(1-s11)
s11_out = num / den
return s11_out.reshape(-1, 1, 1)
[docs]
class Renumbered(Model):
"""
A container that re-numbers the ports of a given `Model`.
This is useful for creating complex network topologies by explicitly
re-mapping the port indices of a sub-network.
Attributes
----------
model : Model
The underlying model to renumber.
to_ports : tuple[int]
The new port indices.
from_ports : tuple[int]
The original port indices that map to `to_ports`.
"""
model: Model
to_ports: tuple[int]
from_ports: tuple[int]
def __post_init__(self):
self.name = 'renumbered'
model = self.model
to_ports, from_ports = self.to_ports, self.from_ports
if model.primary_property == 'a' and len(from_ports) != 2 and len(to_ports) != 2:
raise ValueError("(from_ports, to_ports) must be either (0, 1) or (1, 0) for 'a' primary networks")
# TODO upgrade for matrix z0
# new_z0 = jnp.copy(self.z0)
# new_z0 = new_z0.at[:, to_ports].set(new_z0[:, from_ports])
object.__setattr__(self, '_z0', self.z0)
[docs]
def renumber(self, p: jnp.ndarray) -> jnp.ndarray:
"""
Applies the port renumbering to a parameter matrix.
Parameters
----------
p : jnp.ndarray
The parameter matrix to renumber (e.g., S-parameters).
Returns
-------
jnp.ndarray
The renumbered parameter matrix.
"""
p_new = p.copy()
p_new = p_new.at[:, self.to_ports, :].set(p[:, self.from_ports, :])
p_new = p_new.at[:, :, self.to_ports].set(p_new[:, :, self.from_ports])
return p_new
[docs]
def a(self, freq: Frequency) -> jnp.ndarray:
return self.renumber(self.model.a(freq))
[docs]
def s(self, freq: Frequency) -> jnp.ndarray:
return self.renumber(self.model.s(freq))
[docs]
class Flipped(Renumbered):
"""
A model container that flips the ports of a multi-port network.
For a 2-port network, this is equivalent to swapping port 1 and port 2.
For a 4-port network, ports (1,2) are swapped with (3,4), and so on.
This is a convenient specialization of the `Renumbered` model.
"""
to_ports: tuple[int] = field(init=False)
from_ports: tuple[int] = field(init=False)
def __post_init__(self):
if self.model.nports % 2 != 0:
raise ValueError("You can only flip multiple-of-two-port Networks")
n = int(self.model.nports / 2)
self.to_ports = tuple(range(0, 2 * n))
self.from_ports = tuple(range(n, 2 * n)) + tuple(range(0, n))
super().__post_init__()
self.name = 'flipped'
[docs]
class Stacked(Model):
"""
A container that stacks multiple models in a block-diagonal fashion.
This combines several `Model` objects into a single, larger model where
the individual S-parameter matrices are placed along the diagonal of the
combined S-parameter matrix. This represents a set of unconnected
networks treated as a single component.
Attributes
----------
models : tuple[Model, ...]
The models to stack.
"""
models: tuple[Model, ...]
def __post_init__(self):
self.name = 'stacked'
[docs]
def s(self, freq: Frequency) -> jnp.ndarray:
num_ports = sum(model.nports for model in self.models)
s = jnp.zeros((freq.npoints, num_ports, num_ports), dtype=jnp.complex128)
i = 0
for submodel in self.models:
s_sub = submodel.s(freq)
n_sub = submodel.nports
s = s.at[:,i:i+n_sub,i:i+n_sub].set(s_sub)
i += n_sub
return s