Source code for reasitic.network.twoport

"""2-port network parameter conversions.

Mirrors the binary's Y/Z/S conversion machinery
(``y_to_z_2port_invert``, ``y_to_s_2port_50ohm`` in
``asitic_kernel.c``). Where the original keeps the parameters in
flat global cells, we use 2×2 NumPy complex arrays.

All matrices are indexed ``[port_i, port_j]`` (so ``Y[0][1]`` is
``Y_12``). Reference impedance defaults to **50 Ω** which matches
the binary's hardcoded ``Y_0 = 0.02 = 1/50``.

The Pi-equivalent representation models a planar inductor as a
series impedance ``Z_s`` between the two ports plus shunt
admittances ``Y_p1`` and ``Y_p2`` to ground:

::

    port1 ─┬─[ Z_s ]─┬─ port2
           │         │
          Y_p1     Y_p2
           │         │
          GND       GND

Conversion to Y::

    Y[0][0] = 1/Z_s + Y_p1
    Y[1][1] = 1/Z_s + Y_p2
    Y[0][1] = Y[1][0] = -1/Z_s

This is the same Pi extraction emitted by the binary's ``Pi`` /
``Pi2`` REPL commands (``cmd_pi3_emit``, ``analyze_narrow_band_2port``).
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from reasitic.geometry import Shape
from reasitic.inductance import compute_self_inductance
from reasitic.resistance import compute_ac_resistance
from reasitic.tech import Tech
from reasitic.units import GHZ_TO_HZ, NH_TO_H, TWO_PI

_Y0_DEFAULT = 0.02  # 1/50 Ω, matches the binary's hardcoded reference


[docs] def y_to_z(Y: np.ndarray) -> np.ndarray: """Invert a 2×2 admittance matrix to its impedance matrix. .. math:: Z = Y^{-1} = \\frac{1}{\\det Y} \\begin{pmatrix} Y_{22} & -Y_{12} \\\\ -Y_{21} & Y_{11} \\end{pmatrix} Example: >>> import numpy as np >>> from reasitic.network import y_to_z >>> Y = np.diag([0.02 + 0j, 0.02 + 0j]) >>> Z = y_to_z(Y) >>> round(Z[0, 0].real) 50 """ if Y.shape != (2, 2): raise ValueError(f"expected 2x2 matrix, got {Y.shape}") det = Y[0, 0] * Y[1, 1] - Y[0, 1] * Y[1, 0] return np.array( [ [Y[1, 1] / det, -Y[0, 1] / det], [-Y[1, 0] / det, Y[0, 0] / det], ], dtype=complex, )
[docs] def z_to_y(Z: np.ndarray) -> np.ndarray: """Inverse of :func:`y_to_z` — they share the same formula.""" return y_to_z(Z)
[docs] def y_to_s(Y: np.ndarray, y0: float = _Y0_DEFAULT) -> np.ndarray: """Convert a 2-port Y matrix to a scattering matrix S. Reference admittance ``y0`` defaults to ``0.02`` (i.e. 50 Ω), matching the binary's hardcoded reference. The closed form is: .. math:: S = (I + Y/Y_0)^{-1} (I - Y/Y_0) """ if Y.shape != (2, 2): raise ValueError(f"expected 2x2 matrix, got {Y.shape}") eye = np.eye(2, dtype=complex) Yn = Y / y0 return np.linalg.solve(eye + Yn, eye - Yn)
[docs] def s_to_y(S: np.ndarray, y0: float = _Y0_DEFAULT) -> np.ndarray: """Inverse of :func:`y_to_s`. .. math:: Y = Y_0 (I - S)(I + S)^{-1} """ if S.shape != (2, 2): raise ValueError(f"expected 2x2 matrix, got {S.shape}") eye = np.eye(2, dtype=complex) return np.asarray(y0 * np.linalg.solve(eye + S.T, (eye - S).T).T)
[docs] @dataclass class PiModel: """Pi-equivalent of a 2-port network at one frequency. All quantities are complex except ``freq_ghz`` which is real. ``Z_s`` is the series impedance between the ports (Ω); ``Y_p1`` and ``Y_p2`` are shunt admittances to ground at ports 1 and 2 (S). """ freq_ghz: float Z_s: complex Y_p1: complex Y_p2: complex
[docs] def pi_to_y(model: PiModel) -> np.ndarray: """Synthesise the 2×2 Y matrix from a Pi model.""" if model.Z_s == 0: raise ValueError("Pi-model series impedance is zero") Yseries = 1.0 / model.Z_s return np.array( [ [Yseries + model.Y_p1, -Yseries], [-Yseries, Yseries + model.Y_p2], ], dtype=complex, )
[docs] def pi_equivalent(Y: np.ndarray, freq_ghz: float) -> PiModel: """Extract the Pi-equivalent (Z_s, Y_p1, Y_p2) from a Y-matrix. .. math:: Z_s = -1/Y_{12}, \\quad Y_{p1} = Y_{11} + Y_{12}, \\quad Y_{p2} = Y_{22} + Y_{12} Two ports' shunt admittance is whatever each diagonal element has *in excess of* the through term. This is the same extraction the binary performs in ``extract_pi_equivalent`` (``asitic_kernel.c:8945``). """ if Y.shape != (2, 2): raise ValueError(f"expected 2x2 matrix, got {Y.shape}") Y12 = Y[0, 1] if Y12 == 0: raise ValueError("Y12 is zero, cannot invert") Z_s = -1.0 / Y12 return PiModel( freq_ghz=freq_ghz, Z_s=Z_s, Y_p1=Y[0, 0] + Y12, Y_p2=Y[1, 1] + Y12, )
[docs] def deembed_pad_open(Y_meas: np.ndarray, Y_open: np.ndarray) -> np.ndarray: """De-embed shunt pad capacitance from measured Y using an open-only structure. Standard "open" de-embedding: the open structure's Y captures the pad shunts; subtract from the measured Y. .. math:: Y_\\text{DUT} = Y_\\text{meas} - Y_\\text{open} Both arguments must be 2×2. Returns the de-embedded Y matrix. """ if Y_meas.shape != (2, 2) or Y_open.shape != (2, 2): raise ValueError("both Y matrices must be 2x2") return np.asarray(Y_meas - Y_open, dtype=complex)
[docs] def deembed_pad_open_short( Y_meas: np.ndarray, Y_open: np.ndarray, Y_short: np.ndarray ) -> np.ndarray: """Open-then-short de-embedding: removes pad shunts (open) and series losses in the test-structure access lines (short). .. math:: Y_\\text{DUT} = \\bigl[(Y_\\text{meas} - Y_\\text{open})^{-1} - (Y_\\text{short} - Y_\\text{open})^{-1} \\bigr]^{-1} All three matrices must be 2×2. Returns the de-embedded Y. """ for name, M in (("meas", Y_meas), ("open", Y_open), ("short", Y_short)): if M.shape != (2, 2): raise ValueError(f"Y_{name} must be 2x2, got {M.shape}") Z_meas_open = np.linalg.inv(Y_meas - Y_open) Z_short_open = np.linalg.inv(Y_short - Y_open) return np.asarray(np.linalg.inv(Z_meas_open - Z_short_open), dtype=complex)
[docs] def z_2port_from_y( Y: np.ndarray, *, differential: bool = False, port: int = 1, ) -> complex: """Convert a 2-port Y matrix to a single complex impedance. Mirrors the binary's ``z_2port_from_y`` (decomp ``0x0804e8b0``): * Single-ended (``differential=False``): - ``port == 1`` → ``Z = 1 / Y[1, 1]`` (look into port 1 with port 2 short-circuited via Y inversion convention). - ``port != 1`` → ``Z = 1 / Y[0, 0]``. * Differential (``differential=True``) — the LC-mode impedance of a symmetric pair under ``Y[0,1] == Y[1,0]``: .. math:: Z_d = (Y_{11} + Y_{22} + 2 Y_{21}) / (Y_{11} Y_{22} - Y_{21}^2) The binary's globals ``Y22_re/im`` correspond to ``Y[1, 1]`` and ``g_Y11_re/im`` to ``Y[0, 0]``; the matrix layout convention is the standard ``Y[i, j]`` indexing here. """ if Y.shape != (2, 2): raise ValueError(f"expected 2x2 Y, got {Y.shape}") if differential: Y11 = Y[0, 0] Y22 = Y[1, 1] Y21 = Y[1, 0] det = Y11 * Y22 - Y21 * Y21 num = Y11 + Y22 + 2.0 * Y21 return complex(num / det) if port == 1: return complex(1.0 / Y[1, 1]) return complex(1.0 / Y[0, 0])
[docs] def imag_z_2port_from_y( Y: np.ndarray, *, differential: bool = False, port: int = 1, ) -> float: """Imaginary part of :func:`z_2port_from_y`. Mirrors the binary's ``imag_z_2port_from_y`` (decomp ``0x0804e7c0``). Convenience wrapper that takes the imaginary component directly so callers extracting a reactance don't need to remember the indexing convention. """ return float(z_2port_from_y( Y, differential=differential, port=port ).imag)
[docs] def zin_terminated_2port( Y: np.ndarray, Y_load: complex, *, port: int = 1, ) -> complex: """Input impedance with the *other* port terminated in admittance ``Y_load``. Mirrors the binary's ``zin_terminated_2port`` (decomp ``0x0804e9b0``). Implements the standard 2-port reduction identity:: Y_in = Y_ii − Y_ij · Y_ji / (Y_jj + Y_load) Z_in = 1 / Y_in Unlike :func:`z_2port_from_y`, this routine reads ``Y[0, 1]`` independently of ``Y[1, 0]`` — it does **not** assume reciprocity, matching the binary's only function in this group that pulls the Y12 slot separately. Args: Y: 2×2 admittance matrix. Y_load: Load admittance terminating the *other* port. port: Which port we're looking into (1 or 2). """ if Y.shape != (2, 2): raise ValueError(f"expected 2x2 Y, got {Y.shape}") if port == 1: # Look into port 1; port 2 terminated by Y_load Y_in = Y[0, 0] - Y[0, 1] * Y[1, 0] / (Y[1, 1] + Y_load) else: # Look into port 2; port 1 terminated by Y_load Y_in = Y[1, 1] - Y[0, 1] * Y[1, 0] / (Y[0, 0] + Y_load) return complex(1.0 / Y_in)
[docs] def spiral_y_at_freq( shape: Shape, tech: Tech, freq_ghz: float, *, y_p1: complex | None = None, y_p2: complex | None = None, include_substrate: bool = True, use_segment_cap: bool = False, n_div: int = 2, ) -> np.ndarray: """Build the 2-port Y matrix for ``shape`` at ``freq_ghz``. Default series leg is ``Z_s = R + jωL``; default shunts come from the parallel-plate-plus-fringe approximation in :func:`reasitic.substrate.shape_shunt_capacitance`. Setting ``use_segment_cap=True`` switches the shunt path to the per-segment Maxwell cap matrix reduced via :func:`reasitic.substrate.shape_pi_capacitances` — this routes through ``analyze_capacitance_driver`` and is closer to the binary's ``analyze_narrow_band_2port`` (``asitic_kernel.c:1465``) pipeline. The diagonal of the underlying P matrix now uses the analytical rectangular self-tile term, but the layered-stack reflection coefficient is still a quasi-static stub — see TODO.md §3 — so the segment-cap shunt magnitudes can disagree with physical intuition (e.g. metal-layer ordering) until the Sommerfeld pipeline is ported faithfully. Args: y_p1 / y_p2: Explicit shunt admittances overriding the substrate solve. include_substrate: When False, both shunts default to zero. use_segment_cap: Opt-in to the segment-cap reduction. n_div: Per-segment subdivision forwarded to the cap solver. Substrate-loss conductance (the imaginary frequency-dependent term in the binary's complex P matrix) is not modelled here. """ L_nH = compute_self_inductance(shape) R = compute_ac_resistance(shape, tech, freq_ghz) omega = TWO_PI * freq_ghz * GHZ_TO_HZ Z_s = complex(R, omega * L_nH * NH_TO_H) if Z_s == 0: raise ValueError("series impedance is zero (zero L and zero R)") if (y_p1 is None or y_p2 is None) and include_substrate: # Avoid a circular import; reach for substrate only when needed. if use_segment_cap: from reasitic.substrate import shape_pi_capacitances C_p1, C_p2, _ = shape_pi_capacitances(shape, tech, n_div=n_div) Y_sub_p1 = 1j * omega * C_p1 Y_sub_p2 = 1j * omega * C_p2 else: from reasitic.substrate import shape_shunt_capacitance C_total_F = shape_shunt_capacitance(shape, tech) Y_sub_p1 = Y_sub_p2 = 1j * omega * (C_total_F * 0.5) else: Y_sub_p1 = Y_sub_p2 = 0j if y_p1 is None: y_p1 = Y_sub_p1 if y_p2 is None: y_p2 = Y_sub_p2 return pi_to_y(PiModel(freq_ghz=freq_ghz, Z_s=Z_s, Y_p1=y_p1, Y_p2=y_p2))