"""Turn S-parameters into VACASK subcircuits. Needs the ``models`` extra."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING
import numpy as np
from numpy.typing import ArrayLike
from qpdk.logger import logger
from .netlist import _templates
if TYPE_CHECKING:
from gdsfactory.typings import CrossSectionSpec
from skrf.vectorFitting import VectorFitting
MODELS_EXTRA_HINT = (
"Install it with `uv sync --extra models` or `pip install 'qpdk[models]'`."
)
[docs]
def cpw_tline_params(
length: float, cross_section: CrossSectionSpec = "cpw"
) -> tuple[float, float]:
"""Characteristic impedance and delay of a lossless CPW for ``tline_ideal``.
Uses :func:`~qpdk.models.cpw.cpw_parameters` with zero loss tangent, so the
result matches VACASK's lossless ``tline_ideal`` device. Needs the
``models`` extra.
Args:
length: Line length in µm.
cross_section: CPW cross-section.
Returns:
``(z0, td)``: characteristic impedance in Ω and propagation delay in s.
Raises:
ImportError: If the ``models`` extra is not installed.
"""
try:
from qpdk.models.constants import c_0 # ruff: ignore[import-outside-top-level]
from qpdk.models.cpw import ( # ruff: ignore[import-outside-top-level]
cpw_parameters,
get_cpw_dimensions,
)
except ImportError as error:
raise ImportError(
f"cpw_tline_params needs the qpdk CPW models. {MODELS_EXTRA_HINT}"
) from error
width, gap = get_cpw_dimensions(cross_section)
ep_eff, z0 = cpw_parameters(width, gap, tand=0.0)
td = length * 1e-6 * np.sqrt(float(np.real(ep_eff))) / c_0
return float(np.real(z0)), float(td)
[docs]
def vector_fit(
sdict: Mapping[tuple[str, str], ArrayLike],
f: ArrayLike,
*,
ports: Sequence[str] | None = None,
z0: float = 50.0,
n_poles_real: int = 1,
n_poles_cmplx: int = 10,
enforce_passivity: bool = True,
n_samples: int = 1000,
) -> VectorFitting:
"""Fit S-parameters with :class:`skrf.vectorFitting.VectorFitting`.
A thin wrapper that builds the :class:`skrf.Network` from an SDict and
checks that the result can be used as a circuit: every pole stable and the
model passive. Evaluate the fit with
:meth:`~skrf.vectorFitting.VectorFitting.get_model_response` and turn it
into a VACASK subcircuit with :func:`vector_fit_subckt`.
Args:
sdict: S-parameters on ``f``, referenced to ``z0``. Missing entries
of a sparse SDict are zero.
f: Frequencies in Hz. Cover every frequency the circuit will see,
e.g. up to ``nharm`` times the pump frequency for harmonic balance.
ports: Port order. Defaults to the order in which ``sdict`` names them.
Stored as the network's ``port_names``.
z0: Reference impedance in Ω.
n_poles_real: Number of initial real poles.
n_poles_cmplx: Number of initial complex-conjugate pole pairs.
enforce_passivity: Make the model passive if the fit is not.
n_samples: Frequency samples for the passivity enforcement.
Returns:
The fitted :class:`~skrf.vectorFitting.VectorFitting` object.
Raises:
ImportError: If the ``models`` extra is not installed.
ValueError: If a port is missing from ``sdict``, the fit has an
unstable pole, or it is still not passive after enforcement.
"""
try:
import jax.numpy as jnp # ruff: ignore[import-outside-top-level]
import sax # ruff: ignore[import-outside-top-level]
import skrf # ruff: ignore[import-outside-top-level]
from skrf.vectorFitting import ( # ruff: ignore[import-outside-top-level]
VectorFitting,
)
except ImportError as error:
raise ImportError(
f"vector_fit needs sax and scikit-rf. {MODELS_EXTRA_HINT}"
) from error
f = np.asarray(f, dtype=float)
dense, port_map = sax.sdense({
k: jnp.broadcast_to(jnp.asarray(v), f.shape) for k, v in sdict.items()
})
ports = tuple(ports) if ports is not None else tuple(port_map)
missing = set(ports) - set(port_map)
if missing:
raise ValueError(f"Ports {sorted(missing)} are not in the SDict")
order = [port_map[p] for p in ports]
s = np.asarray(dense)[:, order][:, :, order]
network = skrf.Network(frequency=skrf.Frequency.from_f(f, unit="Hz"), s=s, z0=z0)
network.port_names = list(ports)
active = np.flatnonzero(np.any(s != 0, axis=(0, 1)) | np.any(s != 0, axis=(0, 2)))
if not len(active):
# An exactly matched network has no dynamics for scikit-rf to identify.
vf = VectorFitting(network)
vf.poles = np.empty(0, dtype=complex)
vf.residues = np.empty((len(ports) ** 2, 0), dtype=complex)
vf.constant_coeff = np.zeros(len(ports) ** 2)
vf.proportional_coeff = np.zeros(len(ports) ** 2)
return vf
if 0 < len(active) < len(ports):
# Exactly matched, isolated ports destabilize scikit-rf's residue enforcement.
fitted = vector_fit(
{(ports[i], ports[j]): s[:, i, j] for i in active for j in active},
f,
ports=[ports[i] for i in active],
z0=z0,
n_poles_real=n_poles_real,
n_poles_cmplx=n_poles_cmplx,
enforce_passivity=enforce_passivity,
n_samples=n_samples,
)
vf = VectorFitting(network)
vf.poles = fitted.poles
indices = np.array([i * len(ports) + j for i in active for j in active])
for name in ("residues", "constant_coeff", "proportional_coeff"):
values = getattr(fitted, name)
padded = np.zeros((len(ports) ** 2, *values.shape[1:]), dtype=values.dtype)
padded[indices] = values
setattr(vf, name, padded)
return vf
vf = VectorFitting(network)
vf.vector_fit(n_poles_real=n_poles_real, n_poles_cmplx=n_poles_cmplx)
if enforce_passivity and not vf.is_passive():
vf.passivity_enforce(n_samples=n_samples, f_max=float(f.max()))
if not vf.is_passive():
raise ValueError(
"Vector fit is still not passive after passivity enforcement; "
"try fewer poles or a wider frequency range"
)
poles = vf.poles
if poles is None:
raise ValueError("Vector fit produced no poles")
if np.any(poles.real >= 0):
raise ValueError("Vector fit has unstable poles")
logger.info(
"Vector fit: {} poles, RMS error {:.2e}", len(poles), vf.get_rms_error()
)
return vf
[docs]
def vector_fit_subckt(vf: VectorFitting, name: str) -> str:
r"""Emit a vector fit as a VACASK subcircuit.
The fitted S-matrix is
.. math::
S(s) = D + \sum_k \frac{R_k}{s - p_k},
where each complex pole stands for itself and its conjugate.
Each port is a resistor :math:`Z_0` to ground in parallel with a current
source :math:`2 b / \sqrt{Z_0}`, which gives the port voltage
:math:`\sqrt{Z_0}(a + b)`. The incident waves :math:`a` drive one
first-order state per real pole and two per complex pair, and the
reflected waves :math:`b` are weighted sums of the states. Waves and
states are node voltages scaled by :math:`\sqrt{Z_0}`; each state
capacitor is :math:`1/|p_k|`, so all conductances and gains are of order
one.
The subcircuit uses the builtin ``vccs`` and the ``resistor`` and
``capacitor`` models, so the netlist must load ``resistor.osdi`` and
``capacitor.osdi`` and define models of those names.
Args:
vf: A fit from :func:`vector_fit`, with a constant term and no
proportional term, referenced to one real impedance.
name: Subcircuit name.
Returns:
The subcircuit text, with terminals ``p1``, ``p2``, ... in port order.
Raises:
ValueError: If the fit has a proportional term, an unstable pole, or
port-dependent or complex reference impedances.
"""
network = vf.network
n = network.nports
z0_all = np.asarray(network.z0)
z0 = float(z0_all.flat[0].real)
if not np.allclose(z0_all, z0):
raise ValueError("vector_fit_subckt needs one real reference impedance")
if np.any(np.asarray(vf.proportional_coeff) != 0):
raise ValueError("vector_fit_subckt does not support a proportional term")
poles = np.asarray(vf.poles)
if np.any(poles.real >= 0):
raise ValueError("Vector fit has unstable poles")
residues = np.asarray(vf.residues).reshape(n, n, -1)
constant = np.asarray(vf.constant_coeff).reshape(n, n)
terminals = [f"p{i + 1}" for i in range(n)]
elements: list[tuple[str, tuple[str, ...], float]] = []
for i in range(n):
# Port: V = Z0 I + 2 sqrt(Z0) b
elements += [
("r", (terminals[i],), z0),
("g", (terminals[i], f"b{i}"), 2 / z0),
# Incident wave: sqrt(Z0) a = V - sqrt(Z0) b
("r", (f"a{i}",), 1.0),
("g", (f"a{i}", terminals[i]), 1.0),
("g", (f"a{i}", f"b{i}"), -1.0),
("r", (f"b{i}",), 1.0),
]
for j in range(n):
for k, pole in enumerate(poles):
mag = abs(pole)
u, v = f"x{j}_{k}", f"y{j}_{k}"
# Scaled state |p| a / (s - p), split into real and imaginary parts
elements += [
("c", (u,), 1 / mag),
("r", (u,), mag / -pole.real),
("g", (u, f"a{j}"), 1.0),
]
if pole.imag != 0:
elements += [
("g", (u, v), -pole.imag / mag),
("c", (v,), 1 / mag),
("r", (v,), mag / -pole.real),
("g", (v, u), pole.imag / mag),
]
scale = 2 / mag if pole.imag != 0 else 1 / mag
for i in range(n):
residue = residues[i, j, k]
elements.append(("g", (f"b{i}", u), scale * residue.real))
if pole.imag != 0:
elements.append(("g", (f"b{i}", v), -scale * residue.imag))
for i in range(n):
elements.append(("g", (f"b{i}", f"a{j}"), float(constant[i, j].real)))
port_names = network.port_names or terminals
return (
_templates()
.get_template("subckt.sim.j2")
.render(
name=name,
ports=port_names,
terminals=terminals,
n_poles=len(poles),
elements=elements,
)
)