Source code for qpdk.models.inductor

"""Inductor and lumped-element resonator models."""

from functools import cache

import jax
import jax.numpy as jnp
import sax
from gdsfactory.typings import CrossSectionSpec
from jax.typing import ArrayLike

from qpdk.models.capacitor import interdigital_capacitor_capacitance_analytical
from qpdk.models.constants import DEFAULT_FREQUENCY, μ_0, π
from qpdk.models.cpw import (
    cpw_ep_r_from_cross_section,
    get_cpw_dimensions,
    get_cpw_substrate_params,
)
from qpdk.models.datasets import Dataset, GridInterpolator, s_parameters_model
from qpdk.models.generic import inductor, lc_resonator
from qpdk.models.jit import jit
from qpdk.tech import get_meander_wire_gap


[docs] def meander_inductor_fullwave_model(n_turns: int = 5) -> sax.Model: """Build a two-port model from the stored Palace frequency sweeps. Select 3, 5 or 7 turns when creating the model. The returned JAX model accepts ``f`` in Hz from 0.5 to 20 GHz, ``turn_length`` from 100 to 300 um, ``wire_width`` from 1 to 2 um and ``wire_gap`` from 2 to 8 um. Omitted geometry arguments use the middle grid coordinates. Values outside the stored ranges return NaN. The ``o1`` and ``o2`` reference planes are the winding ends, normalized to 50 ohms. A finite ground frame is 100 um wide and 20 um from the winding. Both winding and frame have a fixed sheet inductance of 0.4 pH per square. The silicon coupon omits material loss and allows radiation through its outer boundary. Args: n_turns: Number of horizontal runs, selected before JIT compilation. Returns: Jittable SAX model interpolating the complex scattering matrix. """ return s_parameters_model("meander_sparameters_palace", n_turns=str(n_turns))
@cache def _meander_ground_lookup(n_turns: int) -> GridInterpolator: """Load one turn-count variant outside numerical evaluation.""" return GridInterpolator( Dataset("meander_ground_capacitance_palace").grid( "maxwell_capacitance", n_turns=str(n_turns), allow_missing=False ) )
[docs] def meander_inductor_ground_capacitance( *, turn_length: float = 200.0, wire_width: float = 2.0, wire_gap: float = 4.0, n_turns: int = 3, ) -> jax.Array: """Look up the capacitance of an equipotential winding to its ground frame. Dimensions are in micrometres. The Palace coupon uses a silicon substrate and a 100 um wide coplanar frame, 20 um from the winding bounding box. Both winding ends share one potential. This common-mode capacitance does not describe a voltage varying along the winding, its inductance or its self-resonance. Use the analytical inductance model separately. Args: turn_length: Horizontal run length, between 100 and 300 um. wire_width: Winding width, between 1 and 2 um. wire_gap: Gap between runs, between 2 and 8 um. n_turns: Discrete variant: 3, 5 or 7; static when using JIT. Returns: Common-mode winding-to-ground capacitance in F. Dimensions outside the stored grid return NaN. """ with jax.ensure_compile_time_eval(): lookup = _meander_ground_lookup(n_turns) return lookup(turn_length=turn_length, wire_width=wire_width, wire_gap=wire_gap)[ ..., 0, 0 ]
@jit(inline=True) def self_inductance_strip(length: float, width: float, thickness: float) -> jax.Array: r"""Analytical formula for the self-inductance of a rectangular metal strip. Uses the formula from :cite:`chenCompactInductorcapacitorResonators2023`: .. math:: L_\text{s} = \frac{\mu_0 l}{2\pi} \left[ \ln\left(\frac{2l}{w+t}\right) + 0.5 + \frac{w+t}{3l} \right] Args: length: Length of the strip in m. width: Width of the strip in m. thickness: Thickness of the strip in m. Returns: Self-inductance in Henries. """ return (μ_0 * length / (2 * π)) * ( jnp.log(2 * length / (width + thickness)) + 0.5 + (width + thickness) / (3 * length) ) @jit(inline=True) def mutual_inductance_parallel_strips(length: ArrayLike, d: ArrayLike) -> jax.Array: r"""Analytical formula for the mutual inductance between two parallel metal strips. Uses the formula from :cite:`chenCompactInductorcapacitorResonators2023`: .. math:: L_\text{m}(d) = \frac{\mu_0 l}{2\pi} \left[ \ln \left( \frac{l}{d} + \sqrt{1 + \frac{l^2}{d^2}} \right) - \sqrt{1 + \frac{d^2}{l^2}} + \frac{d}{l} \right] Args: length: Length of the strips in m. d: Center-to-center distance between the strips in m. Returns: Mutual inductance in Henries. """ length, d = jnp.asarray(length), jnp.asarray(d) return (μ_0 * length / (2 * π)) * ( jnp.log(length / d + jnp.sqrt(1 + (length / d) ** 2)) - jnp.sqrt(1 + (d / length) ** 2) + d / length )
[docs] @jit(inline=True) def meander_inductor_inductance_analytical( n_turns: int, turn_length: float, wire_width: float, wire_gap: float, sheet_inductance: float, thickness: float | None = None, ) -> jax.Array: r"""Analytical formula for meander inductor inductance. The total inductance is the sum of geometric and kinetic contributions: .. math:: L_{\text{total}} = L_\text{g} + L_\text{k} The geometric inductance :math:`L_\text{g}` is calculated by summing the self-inductances of all horizontal segments and the mutual inductances between all pairs of parallel segments, following :cite:`chenCompactInductorcapacitorResonators2023`: .. math:: L_\text{g} = N L_\text{s} + 2 \sum_{k=1}^{N-1} (N-k) (-1)^k L_\text{m}(k p) where :math:`N` is the number of turns and :math:`p` is the pitch. The kinetic inductance :math:`L_\text{k}` is calculated from the sheet inductance :math:`L_\square`: .. math:: L_\text{k} = L_\square \cdot \frac{\ell_{\text{total}}}{w} Args: n_turns: Number of horizontal meander runs. turn_length: Length of each horizontal run in µm. wire_width: Width of the meander wire in µm. wire_gap: Gap between adjacent meander runs in µm. sheet_inductance: Sheet inductance per square in H/□. thickness: Thickness of the metal film in µm. If None, it is fetched from the PDK technology parameters. Returns: Total inductance in Henries. """ if thickness is None: _h, thickness, _ep_r, _tand = get_cpw_substrate_params() # Convert to SI (meters) l_m = turn_length * 1e-6 w_m = wire_width * 1e-6 g_m = wire_gap * 1e-6 t_m = thickness * 1e-6 p_m = w_m + g_m # Pitch (center-to-center) # 1. Geometric Inductance # Self-inductance of horizontal segments (turns) L_s_horiz = self_inductance_strip(l_m, w_m, t_m) # Self-inductance of vertical connection segments L_s_vert = self_inductance_strip(p_m, w_m, t_m) # Mutual inductance sum between horizontal segments # Formula: L_g = sum(L_s_i) + sum_{i!=j} M_ij # Current directions alternate: sign(i, j) = (-1)**(i-j) # L_g_horiz = N*L_s_horiz + 2 * sum_{i=0 to N-2} sum_{j=i+1 to N-1} (-1)**(j-i) * L_m(abs(j-i)*p) # This simplifies to the sum used in Chen et al. (2023): # L_g_horiz = N*L_s_horiz + 2 * sum_{k=1 to N-1} (N-k) * (-1)**k * L_m(k*p) offsets = jnp.arange(1, 501) mask = offsets < n_turns L_m_sum = jnp.sum( jnp.where( mask, (n_turns - offsets) * ((-1.0) ** offsets) * mutual_inductance_parallel_strips(l_m, offsets * p_m), 0.0, ) ) # Ensure L_g_horiz calculation is accurate. The negative mutual inductance # should be outweighed by the self-inductance for physically valid meanders. L_g_horiz = n_turns * L_s_horiz + 2 * L_m_sum L_g = L_g_horiz + (n_turns - 1) * L_s_vert # 2. Kinetic Inductance # Total wire length in µm (horizontal runs + vertical connections) total_length_um = n_turns * turn_length + jnp.maximum(0, n_turns - 1) * wire_gap n_squares = total_length_um / wire_width L_k = sheet_inductance * n_squares return L_g + L_k
def _get_wire_width_and_gap( cross_section: CrossSectionSpec, wire_gap: float | None, ) -> tuple[float, float]: """Resolve the wire width and the run-to-run gap from a cross-section. Args: cross_section: Cross-section specification for the meander wire. wire_gap: Explicit gap between adjacent meander runs in µm, if given. Returns: tuple[float, float]: Wire width and run-to-run gap in µm. """ # TODO(Python 3.15): `lazy from qpdk import PDK` (PEP 810) at module scope breaks the cycle. # Local import: qpdk/__init__.py imports the models before defining PDK. from qpdk import PDK # ruff: ignore[import-outside-top-level] # Scoped lookup: activating the PDK would repoint gf.get_cross_section # for every later name-based lookup in the process. xs = PDK.get_cross_section(cross_section) return xs.width, get_meander_wire_gap(xs, wire_gap)
[docs] def meander_inductor( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, n_turns: int = 5, turn_length: float = 200.0, cross_section: CrossSectionSpec = "meander_inductor_cross_section", wire_gap: float | None = None, sheet_inductance: float = 0.4e-12, ) -> sax.SDict: r"""Meander inductor SAX model. Computes the inductance from the meander geometry and returns S-parameters of an equivalent lumped inductor. The model extracts the center conductor width and gap from the provided cross-section. The vertical pitch is calculated as: .. math:: p = w + \text{wire gap} where :math:`w` is the center conductor width. Without an explicit :code:`wire_gap`, the gap defaults to twice the etch-section width so that the etched regions of adjacent meander runs do not overlap and interfere with the characteristic impedance of each other, or to :math:`w` for cross-sections without an etch section such as a plain microstrip. Args: f: Array of frequency points in Hz. n_turns: Number of horizontal meander runs. turn_length: Length of each horizontal run in µm. cross_section: Cross-section specification for the meander wire. Used to determine the wire width and the gap between runs. wire_gap: Optional explicit gap between adjacent meander runs in µm. If None (default), it is inferred from the cross-section the same way the meander inductor cell does it. An explicit gap is used as is, whether or not the cross-section defines an etch section, and must be positive. sheet_inductance: Sheet inductance per square in H/□. Returns: sax.SDict: S-parameters dictionary. """ f_arr = jnp.asarray(f) wire_width, wire_gap = _get_wire_width_and_gap(cross_section, wire_gap) inductance = meander_inductor_inductance_analytical( n_turns=n_turns, turn_length=turn_length, wire_width=wire_width, wire_gap=wire_gap, sheet_inductance=sheet_inductance, ) return inductor(f=f_arr, inductance=inductance)
[docs] def lumped_element_resonator( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, fingers: int = 20, finger_length: float = 20.0, finger_gap: float = 2.0, finger_thickness: float = 5.0, n_turns: int = 15, sheet_inductance: float = 0.4e-12, cross_section: CrossSectionSpec = "meander_inductor_cross_section", grounded: bool = False, ) -> sax.SDict: r"""Lumped-element LC resonator SAX model. Combines an interdigital capacitor and a meander inductor in parallel to form an LC resonator. The resonance frequency is: .. math:: f_\text{r} = \frac{1}{2\pi\sqrt{LC}} where :math:`C` is computed from the interdigital capacitor geometry using :func:`~qpdk.models.capacitor.interdigital_capacitor_capacitance_analytical` and :math:`L` is computed from the meander inductor geometry using :func:`meander_inductor_inductance_analytical`. The inductor section uses the width and gap derived from the `cross_section` to ensure consistent RF behavior across the meander. The vertical spacing between meander runs is set to twice the etch gap to prevent overlap of the etched regions. See :cite:`kimThinfilmSuperconductingResonator2011,chenCompactInductorcapacitorResonators2023`. Args: f: Array of frequency points in Hz. fingers: Number of interdigital capacitor fingers. finger_length: Length of each capacitor finger in µm. finger_gap: Gap between adjacent capacitor fingers in µm. finger_thickness: Width of each capacitor finger in µm. n_turns: Number of horizontal meander inductor runs (must be odd to match the cell geometry where the path spans left-to-right bus bars). sheet_inductance: Sheet inductance per square in H/□. cross_section: Cross-section specification. Used for substrate permittivity and to determine inductor wire width and gap. grounded: If True, one port of the resonator is grounded. Returns: sax.SDict: S-parameters dictionary with ports o1 and o2. """ f_arr = jnp.asarray(f) ep_r = cpw_ep_r_from_cross_section(cross_section) capacitance = interdigital_capacitor_capacitance_analytical( fingers=fingers, finger_length=finger_length, finger_gap=finger_gap, thickness=finger_thickness, ep_r=ep_r, ) wire_width, wire_gap_half = get_cpw_dimensions(cross_section) wire_gap = 2 * wire_gap_half cap_width = 2 * finger_thickness + finger_length + finger_gap meander_turn_length = cap_width - 4 * wire_width inductance = meander_inductor_inductance_analytical( n_turns=n_turns, turn_length=meander_turn_length, wire_width=wire_width, wire_gap=wire_gap, sheet_inductance=sheet_inductance, ) return lc_resonator( f=f_arr, capacitance=capacitance, inductance=inductance, grounded=grounded, )