Source code for qpdk.models.hyperbolic

r"""SAX models for hyperbolic resonator lattices.

Implements the edge-resonator scheme of Kollár et al.
:cite:`kollarHyperbolicLatticesCircuit2019`: identical CPW resonators sit
on the edges of a {p,q} tiling and meet at ``q``-pin capacitive couplers on
its vertices. Near the full-wave (second) mode of the resonators, whose
ends oscillate in phase, the lattice obeys

.. math::

    H = \omega_0 \sum_i a_i^\dagger a_i - t \sum_{\langle ij \rangle}
        \left(a_i^\dagger a_j + \text{h.c.}\right), \qquad t < 0,

on the line graph of the tiling, so the spectrum is
:math:`\omega_0 - t\,\lambda(A_\text{L})` with a flat band at
:math:`\omega_0 - 2|t|` (see :func:`tight_binding_hamiltonian`). This needs
every pair of resonators at a coupler to be coupled equally, which the
disk coupler does exactly only for :math:`q \le 3`.

:func:`hyperbolic_lattice` does not use the tight-binding approximation: it
solves the full circuit of transmission lines, couplers and boundary stubs.
"""

import math

import jax
import jax.numpy as jnp
import numpy as np
import sax
from gdsfactory.typings import CrossSectionSpec

from qpdk.hyperbolic import hyperbolic_tiling
from qpdk.models.capacitor import plate_capacitor_capacitance_analytical
from qpdk.models.constants import DEFAULT_FREQUENCY
from qpdk.models.cpw import cpw_ep_r_from_cross_section, cpw_z0_from_cross_section
from qpdk.models.waveguides import straight, straight_shorted

_BATCH_SIZE = 32  # frequencies solved at once by the lattice model

__all__ = [
    "hyperbolic_cpw_path",
    "hyperbolic_edge_resonator",
    "hyperbolic_lattice",
    "hyperbolic_vertex_coupler",
    "hyperbolic_vertex_coupler_capacitances",
    "tight_binding_hamiltonian",
]


def tight_binding_hamiltonian(
    adjacency: jax.typing.ArrayLike,
    t: jax.typing.ArrayLike,
    onsite: jax.typing.ArrayLike = 0.0,
) -> jax.Array:
    r"""Tight-binding Hamiltonian :math:`H = \omega_0 I - t A`.

    Follows the sign convention of Kollár et al.
    :cite:`kollarHyperbolicLatticesCircuit2019`, Eq. (1). Capacitively
    coupled CPW resonators have :math:`t < 0`, so on a line graph (whose
    adjacency spectrum is bounded below by :math:`-2`) the flat band sits at
    the bottom, :math:`\omega_0 + 2t = \omega_0 - 2|t|`.

    Args:
        adjacency: Symmetric (weighted) adjacency matrix, e.g.
            :attr:`qpdk.hyperbolic.HyperbolicTiling.line_graph_adjacency`.
        t: Hopping rate.
        onsite: On-site energy :math:`\omega_0`.

    Returns:
        The Hamiltonian matrix, real symmetric and in the units of ``t`` and
        ``onsite``.

    Notes:
        The matrix is a plain JAX array, so it goes straight into QuTiP as a
        single-excitation operator on ``N`` sites, with the optional JAX
        backend if installed. Install ``qpdk[models,qutip]``.
        ``isherm=True`` keeps the Hermiticity check static under JIT.

    Example:
        Spectrum of the one-shell {7,3} edge-resonator lattice in QuTiP:

        .. code-block:: python

            import qutip as qt
            import qutip_jax  # optional; registers dtype="jax"

            from qpdk.hyperbolic import hyperbolic_tiling

            tiling = hyperbolic_tiling(7, 3, shells=1)
            h = tight_binding_hamiltonian(
                tiling.line_graph_adjacency, t=-0.1, onsite=7.5
            )
            H = qt.Qobj(h, dtype="jax", isherm=True)  # or qt.Qobj(np.asarray(h))
            energies, states = H.eigenstates()
            # The flat band sits at onsite + 2 t = 7.3, E - V = 7 times.

        With ``t`` and ``onsite`` in GHz, use ``2 * jnp.pi * H`` with times
        in ns for :func:`qutip.sesolve` or :func:`qutip.mesolve`.
    """
    a = jnp.asarray(adjacency, dtype=float)
    return jnp.asarray(onsite) * jnp.eye(a.shape[-1]) - jnp.asarray(t) * a


def _cayley(x: jax.Array) -> jax.Array:
    r"""Cayley transform :math:`(I + X)^{-1}(I - X)` over the last two axes.

    It is its own inverse and converts between S-parameters and normalised
    admittance: :math:`Z_0 Y = \mathcal{C}(S)` and :math:`S = \mathcal{C}(Z_0 Y)`
    for ports of equal reference impedance :math:`Z_0`. This is the matrix
    form of the S-to-Z/Y conversion in :cite:`m.pozarMicrowaveEngineering2012`,
    Sec. 4.3, written for :math:`Y = Z^{-1}`; :math:`I + X` and :math:`I - X`
    commute, so the order of the product does not matter.

    Returns:
        The transformed matrices.
    """
    eye = jnp.eye(x.shape[-1])
    return jnp.linalg.solve(eye + x, eye - x)


[docs] def hyperbolic_vertex_coupler_capacitances( q: int = 3, pin_radius: float = 40.0, pin_gap: float = 4.0, ground_gap: float = 6.0, cross_section: CrossSectionSpec = "cpw", ) -> tuple[jax.Array, jax.Array]: r"""Pin-to-pin and pin-to-ground capacitance of a ``q``-pin vertex coupler. The coupler is a metal disk of radius ``pin_radius`` cut into ``q`` sectors by radial gaps of width ``pin_gap`` and surrounded by a ground clearance of width ``ground_gap`` (see :func:`qpdk.cells.hyperbolic.hyperbolic_vertex_coupler`). Each gap is treated as a pair of coplanar pads of length ``pin_radius`` and width ``pin_radius / 2`` with :func:`~qpdk.models.capacitor.plate_capacitor_capacitance_analytical`, and each sector's arc as a pad of the same width facing the ground plane. Only neighbouring sectors are coupled; for :math:`q > 3` the weaker coupling across the disk is neglected, so the hopping between edges is not uniform and the line-graph picture holds only approximately. Returns: ``(C_c, C_g)`` in farads: coupling between neighbouring pins and capacitance of each pin to ground. """ ep_r = cpw_ep_r_from_cross_section(cross_section) width = pin_radius / 2 c_c = plate_capacitor_capacitance_analytical( length=pin_radius, width=width, gap=pin_gap, ep_r=ep_r ) c_g = plate_capacitor_capacitance_analytical( length=2 * math.pi * pin_radius / q, width=width, gap=ground_gap, ep_r=ep_r ) return c_c, c_g
def _coupler_capacitance_matrix( q: int, c_c: jax.Array | float, c_g: jax.Array | float ) -> jax.Array: """Maxwell capacitance matrix of ``q`` pins on a ring.""" # For q = 2 both gaps lie between the same two pins, so they add up. neighbours = jnp.roll(jnp.eye(q), 1, axis=1) + jnp.roll(jnp.eye(q), -1, axis=1) return (c_g + c_c * neighbours.sum(axis=1)) * jnp.eye(q) - c_c * neighbours
[docs] def hyperbolic_vertex_coupler( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, q: int = 3, pin_radius: float = 40.0, pin_gap: float = 4.0, ground_gap: float = 6.0, lead_length: float = 20.0, cross_section: CrossSectionSpec = "cpw", ) -> sax.SDict: r"""S-parameters of a ``q``-pin capacitive vertex coupler. The pins form a lumped capacitance network with Maxwell matrix :math:`C` (:func:`hyperbolic_vertex_coupler_capacitances`). Its nodal admittance :math:`Y = j\omega C` gives .. math:: S = (I - Z_0 Y)(I + Z_0 Y)^{-1}, referenced to the CPW impedance :math:`Z_0` of ``cross_section`` so the pins connect directly to :func:`~qpdk.models.waveguides.straight` lines. The matched leads then multiply :math:`S_{ij}` by their transmission :math:`t^2`, moving the reference planes to the layout ports. Args: f: Frequency in Hz. q: Number of pins. pin_radius: Radius of the metal disk in µm. pin_gap: Width of the radial gaps between pins in µm. ground_gap: Clearance between the disk and the ground plane in µm. lead_length: Length of the CPW lead on each pin in µm. cross_section: Cross-section of the leads. Returns: S-parameters with ports ``o1`` … ``oq``. """ q = int(q) # pyrefly: ignore[unnecessary-type-conversion] f_arr = jnp.asarray(f) c_c, c_g = hyperbolic_vertex_coupler_capacitances( q, pin_radius, pin_gap, ground_gap, cross_section ) capacitance = _coupler_capacitance_matrix(q, c_c, c_g) z0 = cpw_z0_from_cross_section(cross_section, f_arr)[..., None, None] z0_y = 1j * 2 * jnp.pi * f_arr[..., None, None] * capacitance * z0 t = straight(f=f_arr, length=lead_length, cross_section=cross_section)["o2", "o1"] s = _cayley(z0_y) * (t**2)[..., None, None] names = [f"o{k + 1}" for k in range(q)] return {(names[i], names[j]): s[..., i, j] for i in range(q) for j in range(q)}
[docs] def hyperbolic_cpw_path( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, points: tuple[tuple[float, float], ...] = ((0.0, 0.0), (100.0, 0.0)), radius: float = 100.0, shorted: bool = False, cross_section: CrossSectionSpec = "cpw", ) -> sax.SDict: r"""S-parameters of :func:`qpdk.cells.hyperbolic.hyperbolic_cpw_path`. A straight CPW of the centreline length: the polyline length minus :math:`2r\tan(\theta/2) - r\theta` at every corner turning by :math:`\theta`. Args: f: Frequency in Hz. points: Waypoints in µm. radius: Corner radius in µm. shorted: Short the far end, leaving only port ``o1``. cross_section: CPW cross-section. Returns: S-parameters with ports ``o1`` and, unless ``shorted``, ``o2``. """ pts = np.asarray(points, dtype=float) seg = np.diff(pts, axis=0) heading = np.arctan2(seg[:, 1], seg[:, 0]) turns = np.abs((np.diff(heading) + np.pi) % (2 * np.pi) - np.pi) length = float( np.linalg.norm(seg, axis=1).sum() - (2 * radius * np.tan(turns / 2) - radius * turns).sum() ) model = straight_shorted if shorted else straight return model(f=f, length=length, cross_section=cross_section)
[docs] def hyperbolic_edge_resonator( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, length: float = 4000.0, cross_section: CrossSectionSpec = "cpw", ) -> sax.SDict: """S-parameters of a meandered hyperbolic-lattice edge resonator. The meander of :func:`qpdk.cells.hyperbolic.hyperbolic_edge_resonator` is modelled as a straight CPW of the same centreline length; bend corrections are neglected. Args: f: Frequency in Hz. length: Centreline length in µm. cross_section: CPW cross-section. Returns: S-parameters with ports ``o1`` and ``o2``. """ return straight(f=f, length=length, cross_section=cross_section)
[docs] def hyperbolic_lattice( *, f: sax.FloatArrayLike = DEFAULT_FREQUENCY, p: int = 7, q: int = 3, shells: int = 1, resonator_length: sax.FloatArrayLike = 7500.0, stub_length: float = 400.0, probe_pins: tuple[int, ...] = (11, 0), pin_radius: float = 40.0, pin_gap: float = 4.0, ground_gap: float = 6.0, lead_length: float = 20.0, cross_section: CrossSectionSpec = "cpw", ) -> sax.SDict: r"""Circuit model of a Kollár-type hyperbolic lattice of edge resonators. Builds the circuit of :func:`qpdk.cells.hyperbolic.hyperbolic_lattice` from the tiling rather than from the layout: - one :func:`~qpdk.models.waveguides.straight` of ``resonator_length`` per tiling edge, open at both ends through the couplers; - one :func:`hyperbolic_vertex_coupler` per vertex, wired by :attr:`qpdk.hyperbolic.HyperbolicTiling.pins`; - a shorted CPW stub of ``stub_length`` on every free boundary pin (a :math:`\lambda/4` resonator far above the band) so boundary resonators see the same end capacitance as interior ones; - probe ports in place of the stubs on the free pins listed in ``probe_pins``. Their reference plane is the coupler pin, so the feed lines and launchers of the layout are not included. One vertex of the {7,3} circuit, with three edge resonators meeting at a coupler; resonator ends at the flake boundary end on a stub or a probe: .. mermaid:: flowchart LR R1["edge resonator<br/>straight, resonator_length"] --- P1(("pin 1")) R2["edge resonator"] --- P2(("pin 2")) P3(("pin 3")) --- B["boundary: shorted stub,<br/>or probe port o1, o2, ..."] subgraph V["vertex coupler, Maxwell matrix C"] P1 ---|"C_c"| P2 P2 ---|"C_c"| P3 P3 ---|"C_c"| P1 end V ---|"C_g from every pin"| G["ground"] The circuit is solved exactly by nodal analysis rather than with :func:`sax.evaluate_circuit_fg`, which handles the circuit too but takes far longer to compile and needs far more memory at this size: .. mermaid:: flowchart TD T["hyperbolic_tiling(p, q, shells)"] --> L["lines: S to Y with Cayley,<br/>Z0 Y = C(S)"] T --> C["couplers: Y = jωC"] T --> B["stubs: S to Y"] L --> Y["nodal admittance matrix Y,<br/>one node per coupler pin"] C --> Y B --> Y Y --> K["Kron reduction onto probe nodes,<br/>Yp = Ypp - Ypi Yii⁻¹ Yip"] K --> S["S = C(Z0 Yp), times lead transmission t²"] Each element's S-matrix is converted to an admittance matrix referenced to the CPW impedance :math:`Z_0` (:func:`_cayley`), the elements are stamped into the nodal admittance matrix :math:`Y` with one node per coupler pin, the nodes without a probe are eliminated by a Schur complement (Kron reduction :cite:`dorflerKronReductionGraphs2013`) and the result is converted back to :math:`S = (I - Z_0 Y_\text{p})(I + Z_0 Y_\text{p})^{-1}`. This is the same S-matrix SAX produces for the netlist. Frequencies are solved in batches with :func:`jax.lax.map`, so memory stays :math:`O(N^2)` in the number of coupler pins. The model is a leaf model: gdsfactoryplus calls it directly instead of expanding the layout into resonators and couplers. It is also :func:`jax.jit`-compatible with the structural arguments below held static. Args: f: Frequency in Hz. p: Polygon side count of the tiling. q: Polygons per vertex, i.e. coupler pins. shells: Polygon rings around the central polygon. resonator_length: Length of every edge resonator in µm. stub_length: Length of the shorted boundary stubs in µm. probe_pins: Indices into :attr:`~qpdk.hyperbolic.HyperbolicTiling.free_pins` that become ports ``o1``, ``o2``, … in this order. pin_radius: Coupler disk radius in µm. pin_gap: Coupler radial gap in µm. ground_gap: Coupler ground clearance in µm. lead_length: Coupler lead length in µm. It is part of ``resonator_length`` and ``stub_length``, and moves the probe reference planes to the end of the probe pins' leads, like :func:`hyperbolic_vertex_coupler`. cross_section: CPW cross-section of resonators, stubs and probes. Returns: S-parameters between the probe ports. The structural arguments ``p``, ``q``, ``shells`` and ``probe_pins`` fix the circuit topology, so they are converted to Python integers and must be concrete; all other arguments may be traced by :func:`jax.jit`. Raises: ValueError: If ``probe_pins`` is empty, repeats a pin or is out of range. """ # SAX passes settings as JAX arrays, which are unhashable. p, q, shells = int(p), int(q), int(shells) # pyrefly: ignore[unnecessary-type-conversion] probe_pins = tuple(int(k) for k in np.ravel(probe_pins)) tiling = hyperbolic_tiling(p, q, shells) free = tiling.free_pins if not probe_pins: raise ValueError("probe_pins must name at least one free pin.") if len(set(probe_pins)) != len(probe_pins) or not all( 0 <= k < len(free) for k in probe_pins ): raise ValueError( f"probe_pins must be distinct indices below {len(free)}, got {probe_pins}." ) f_in = jnp.asarray(f, dtype=float) f_arr = jnp.ravel(f_in) z0 = cpw_z0_from_cross_section(cross_section, f_arr) # Pin k of coupler v is node v * q + k, renumbered so the probes come first. n_nodes = tiling.num_vertices * q probes = [free[k][0] * q + free[k][1] for k in probe_pins] order = probes + [n for n in range(n_nodes) if n not in probes] position = np.empty(n_nodes, dtype=int) position[order] = np.arange(n_nodes) n_p = len(probes) c_c, c_g = hyperbolic_vertex_coupler_capacitances( q, pin_radius, pin_gap, ground_gap, cross_section ) c_nodes = jnp.kron( jnp.eye(tiling.num_vertices), _coupler_capacitance_matrix(q, c_c, c_g) )[np.ix_(order, order)] ends = position[ np.array([ [i * q + tiling.pins[i][1].index(e), j * q + tiling.pins[j][1].index(e)] for e, (i, j) in enumerate(tiling.edges.tolist()) ]) ] stubs = position[[v * q + k for v, k in free if v * q + k not in probes]] # Element admittances from their S-matrices, Z0 Y = C(S). s_line, line_ports = sax.sdense( straight(f=f_arr, length=resonator_length, cross_section=cross_section) ) order_line = [line_ports["o1"], line_ports["o2"]] line = _cayley(s_line[..., order_line, :][..., order_line]) / z0[..., None, None] s_stub = straight_shorted(f=f_arr, length=stub_length, cross_section=cross_section) y_stub = _cayley(s_stub["o1", "o1"][..., None, None])[..., 0, 0] / z0 def solve_one(args: tuple[jax.Array, ...]) -> jax.Array: """Probe S-matrix at one frequency; mapped so memory stays O(N²).""" omega_k, line_k, y_stub_k, z0_k = args y = 1j * omega_k * c_nodes for a in range(2): for b in range(2): y = y.at[ends[:, a], ends[:, b]].add(line_k[a, b]) y = y.at[stubs, stubs].add(y_stub_k) y_ports = y[:n_p, :n_p] - y[:n_p, n_p:] @ jnp.linalg.solve( y[n_p:, n_p:], y[n_p:, :n_p] ) return _cayley(z0_k * y_ports) s = jax.lax.map( solve_one, (2 * jnp.pi * f_arr, line, y_stub, z0), batch_size=_BATCH_SIZE ) t = straight(f=f_arr, length=lead_length, cross_section=cross_section)["o2", "o1"] s = s * (t**2)[:, None, None] s = s.reshape(*f_in.shape, len(probes), len(probes)) names = [f"o{n + 1}" for n in range(len(probes))] return { (names[i], names[j]): s[..., i, j] for i in range(len(probes)) for j in range(len(probes)) }