Source code for semi_cr.core.lab.virtualization.virtual_gate_layer

from typing import cast

import numpy as np
from qcodes.instrument import Instrument, InstrumentModule
from qcodes.parameters import Parameter
from qcodes.validators import Numbers

_EPS: float = 1e-12  # threshold for treating a matrix coefficient as zero


def _infer_bounds(downstream: list[Parameter]) -> list[tuple[float, float]] | None:
    """Read (min, max) from each parameter's Numbers validator, or return None.

    Returns None if any parameter lacks a Numbers validator, so callers can
    decide whether to proceed without bounds.
    """
    bounds = []
    for param in downstream:
        if isinstance(param.vals, Numbers):
            bounds.append((float(param.vals._min_value), float(param.vals._max_value)))
        else:
            return None
    return bounds


[docs] class VirtualGateChannel(InstrumentModule): """One virtual gate channel of a VirtualGateLayer. Setting the voltage triggers propagation through the transformation matrix to all downstream (physical) parameters. """ def __init__(self, parent: "VirtualGateLayer", name: str, index: int, **kwargs): super().__init__(parent, name, **kwargs) self._index = index self._voltage = 0.0 # No initial_value — would fire set_cmd before parent._downstream is assigned. self.add_parameter( "voltage", unit="V", get_cmd=lambda: self._voltage, set_cmd=self._set_voltage, label=f"Virtual gate {name} voltage", ) def _set_voltage(self, value: float): self._voltage = value self.parent._propagate()
[docs] class VirtualGateLayer(Instrument): """Linear virtualization layer: physical = M @ virtual. Maps N virtual gate voltages to M physical gate voltages (or the next virtualization layer's inputs) via a transformation matrix. Layers can be chained or arranged in a tree structure. When multiple virtual layers share the same physical downstream (tree structure), they are automatically registered as siblings. Propagating one sibling recomputes the others' virtual values to stay consistent with the new physical state. Args: name: QCoDeS instrument name. virtual_gate_names: Names for the virtual gates (must be valid Python identifiers, e.g. ["vP1", "vP2"]). downstream: List of QCoDeS Parameters to set when voltages propagate. Length determines the number of physical outputs (rows in matrix). matrix: Transformation matrix of shape (len(downstream), len(virtual_gate_names)). Defaults to identity when the layer is square (n_virtual == n_physical). Must be provided explicitly for non-square layers. Example (single layer):: d5a = DummyD5a("d5a_1") m1 = VirtualGateLayer( "m1", ["vP1", "vP2"], downstream=[d5a.ch0.voltage, d5a.ch1.voltage], matrix=[ [1.0, 0.1,], [0.1, 1.0,], ], ) m1.vP1.voltage(0.5) # propagates: G = M1 @ [0.5, 0.0] Example (chained layers):: # G ◄──M1── vG ◄──M2── vvG d5a = DummyD5a("d5a_1") m1 = VirtualGateLayer( "m1", ["vP1", "vP2"], downstream=[d5a.ch0.voltage, d5a.ch1.voltage], matrix=[ [1.0, 0.1,], [0.1, 1.0,], ], ) m2 = VirtualGateLayer( "m2", ["vvP1", "vvP2"], downstream=[m1.vP1.voltage, m1.vP2.voltage], matrix=[ [1.0, 0.05,], [0.05, 1.0,], ], ) m2.vvP1.voltage(0.5) # triggers: m2._propagate → m1.vPx.voltage.set → m1._propagate → d5a.chX.voltage.set Example (tree: two virtual layers over one physical):: # G ◄──M1── vG ◄──M2── vvG # ◄──M3── O d5a = DummyD5a("d5a_1") m1 = VirtualGateLayer( "m1", ["vP1", "vP2"], downstream=[d5a.ch0.voltage, d5a.ch1.voltage], ) m2 = VirtualGateLayer( "m2", ["vvP1", "vvP2"], downstream=[m1.vP1.voltage, m1.vP2.voltage], matrix=[ [1.0, 0.1,], [0.1, 1.0,], ], ) m3 = VirtualGateLayer( "m3", ["oP1", "oP2"], downstream=[m1.vP1.voltage, m1.vP2.voltage], matrix=[ [1.0, 0.2,], [0.2, 1.0,], ], ) # m2 and m3 are automatically registered as siblings. m2.vvP1.voltage(0.5) # triggers: m2._propagate → vG updated → m1._propagate → G updated # → m3._recompute_virtual: O recomputed from new vG """ def __init__( self, name: str, virtual_gate_names: list[str], downstream: list[Parameter], matrix: np.ndarray | None = None, **kwargs, ): super().__init__(name, **kwargs) n_virtual = len(virtual_gate_names) n_physical = len(downstream) # Assign before add_submodule so _propagate is safe to call from set_cmd. self._downstream = downstream self._siblings: list[VirtualGateLayer] = [] # Layers that use this layer's virtual channels as their downstream. # Used to discover siblings at registration time. self._virtual_consumers: list[VirtualGateLayer] = [] if n_virtual != n_physical: raise ValueError(f"n_virtual ({n_virtual}) must equal n_physical ({n_physical})") self._matrix = np.eye(n_virtual) if matrix is None else np.array(matrix, dtype=float) if self._matrix.shape != (n_virtual, n_virtual): raise ValueError(f"matrix shape {self._matrix.shape} does not match ({n_virtual}, {n_virtual})") for i, gate_name in enumerate(virtual_gate_names): self.add_submodule(gate_name, VirtualGateChannel(self, gate_name, i)) self._register_with_siblings() self._apply_virtual_bounds() def _register_with_siblings(self): """Auto-detect shared physical layers and register as siblings. If this layer's downstream parameters are virtual gate channels of another VirtualGateLayer, register with any existing consumers of that physical layer as siblings. Siblings keep each other's virtual values consistent when one propagates. """ physical_layers: set[VirtualGateLayer] = set() for param in self._downstream: if isinstance(param.instrument, VirtualGateChannel): parent = param.instrument.parent if isinstance(parent, VirtualGateLayer): physical_layers.add(parent) for physical_layer in physical_layers: for existing in physical_layer._virtual_consumers: existing._siblings.append(self) self._siblings.append(existing) physical_layer._virtual_consumers.append(self) @property def matrix(self) -> np.ndarray: """Return a copy of the current transformation matrix.""" return self._matrix.copy()
[docs] def set_matrix(self, M: np.ndarray): """Update the transformation matrix, pinning physical and recomputing virtual. Per propagation rule 3: changing the matrix pins the left (physical) vector and recomputes the right (virtual) vector. Any layers using this layer's virtual channels as their downstream will also have their virtual values recomputed (rightward propagation). Args: M: New matrix with the same shape as the current matrix. """ M = np.array(M, dtype=float) if M.shape != self._matrix.shape: raise ValueError(f"New matrix shape {M.shape} does not match existing shape {self._matrix.shape}") self._matrix = M self._recompute_virtual() self._apply_virtual_bounds()
[docs] def compute_virtual_bounds(self, physical_bounds: list[tuple[float, float]]) -> list[tuple[float, float]]: """Derive per-axis bounds for virtual gates from physical gate bounds. Given that ``physical = M @ virtual``, the feasible virtual voltages form a parallelepiped (the pre-image of the physical bounding box under M). The tight per-axis bounds — the range each virtual gate can reach independently — are computed analytically from the rows of M⁻¹: v_j^{min} = Σᵢ min(M⁻¹ⱼᵢ · lb_i, M⁻¹ⱼᵢ · ub_i) v_j^{max} = Σᵢ max(M⁻¹ⱼᵢ · lb_i, M⁻¹ⱼᵢ · ub_i) Only valid for square, invertible matrices. For non-square layers this would require a linear program; raise ValueError instead. Args: physical_bounds: Sequence of (min, max) voltage tuples, one per physical gate (i.e. one per row of M). Returns: List of (min, max) tuples, one per virtual gate. Example — 10 % nearest-neighbour crosstalk, physical limits ±2 V:: M = [[1.0, 0.1], [0.1, 1.0]] bounds = layer.compute_virtual_bounds([(-2, 2), (-2, 2)]) # bounds ≈ [(-2.222, 2.222), (-2.222, 2.222)] # Virtual gates can exceed the physical limits because the coupling # allows one gate to compensate while the other is pushed further. """ try: M_inv = np.linalg.inv(self._matrix) except np.linalg.LinAlgError: return [(-np.inf, np.inf)] * len(physical_bounds) lbs = np.array([b[0] for b in physical_bounds]) ubs = np.array([b[1] for b in physical_bounds]) result = [] for row in M_inv: lb = float(np.sum(np.where(row >= 0, row * lbs, row * ubs))) ub = float(np.sum(np.where(row >= 0, row * ubs, row * lbs))) result.append((lb, ub)) return result
[docs] def set_voltages(self, voltages: "dict[str, float] | list[float]") -> None: """Set multiple virtual gate voltages simultaneously. Unlike setting each gate individually — which propagates after every assignment and can create an out-of-range intermediate physical state due to cross-coupling — this method updates all stored voltages first and propagates exactly once. Args: voltages: Gate name → voltage mapping, or a sequence in gate order. Example:: layer.set_voltages({"vvP1": 0.0, "vvP2": 0.0}) layer.set_voltages([0.0, 0.0]) """ gate_names = list(self.submodules.keys()) if isinstance(voltages, dict): v_new = {n: voltages.get(n, self.submodules[n]._voltage) for n in gate_names} else: if len(voltages) != len(gate_names): raise ValueError(f"Expected {len(gate_names)} voltages, got {len(voltages)}") v_new = dict(zip(gate_names, voltages)) for name, val in v_new.items(): cast("VirtualGateChannel", self.submodules[name])._voltage = float(val) self._propagate()
[docs] def joint_sweep_range(self) -> list[tuple[float, float]]: """Safe per-axis ranges for sweeping all gates simultaneously from the current position. Per-axis bounds from :meth:`settable_range` are too generous for a joint sweep: at the corner ``(hi_1, hi_2, …)`` cross-coupling can push a physical output out of range. This method finds the largest scale factor ``t ∈ [0, 1]`` such that the corner of the n-dimensional sweep grid — each axis moved by ``t x (marginal_bound - current)`` from the current position — still maps to a valid physical state. The result is proportional to the marginal per-axis bounds, so relative sweep widths are preserved. Returns: List of ``(lo, hi)`` tuples in absolute voltage, one per virtual gate. Example:: bounds = layer.joint_sweep_range() lo1, hi1 = bounds[0] # for the first virtual gate lo2, hi2 = bounds[1] # for the second virtual gate """ n = len(self.submodules) v_curr = np.array([ch._voltage for ch in self.submodules.values()]) physical_bounds = _infer_bounds(self._downstream) if physical_bounds is None: return [self.settable_range(k) for k in range(n)] lb = np.array([b[0] for b in physical_bounds]) ub = np.array([b[1] for b in physical_bounds]) marg = np.array([self.settable_range(k) for k in range(n)]) delta_hi = marg[:, 1] - v_curr delta_lo = marg[:, 0] - v_curr # negative or zero physical_curr = self._matrix @ v_curr residual_ub = ub - physical_curr residual_lb = physical_curr - lb phys_delta_hi = self._matrix @ delta_hi phys_delta_lo = self._matrix @ (-delta_lo) with np.errstate(divide="ignore", invalid="ignore"): t = max( min( float(np.min(np.where(phys_delta_hi > _EPS, residual_ub / phys_delta_hi, np.inf))), float(np.min(np.where(phys_delta_lo > _EPS, residual_lb / phys_delta_lo, np.inf))), 1.0, ), 0.0, ) return [(float(v_curr[k] + t * delta_lo[k]), float(v_curr[k] + t * delta_hi[k])) for k in range(n)]
[docs] def settable_range(self, gate: "str | int") -> tuple[float, float]: """Return the safe voltage range for one virtual gate given the current values of all others. Unlike :meth:`compute_virtual_bounds`, which returns *marginal* bounds (the per-axis maximum range assuming all other virtual gates are at their optimal position), this method returns the *conditional* range: how far the specified gate can move while the others stay exactly where they are right now. Args: gate: Virtual gate name (e.g. ``"vvP1"``) or 0-based index. Returns: ``(min, max)`` voltage tuple for the requested gate. Returns ``(-inf, inf)`` if no downstream validators are available. Example:: lo, hi = layer.settable_range("vvP1") layer.vvP1.voltage(lo) # guaranteed in-range given current vvP2 """ gate_names = list(self.submodules.keys()) k = gate_names.index(gate) if isinstance(gate, str) else gate v_current = np.array([ch._voltage for ch in self.submodules.values()]) physical_bounds = _infer_bounds(self._downstream) if physical_bounds is None: return (-np.inf, np.inf) lo, hi = -np.inf, np.inf for i, (lb, ub) in enumerate(physical_bounds): # Constraint: lb <= M[i,k]*vk + offset <= ub offset = float(sum(self._matrix[i, j] * v_current[j] for j in range(len(v_current)) if j != k)) m_ik = self._matrix[i, k] if abs(m_ik) < _EPS: continue a = (lb - offset) / m_ik b = (ub - offset) / m_ik if m_ik > 0: lo = max(lo, a) hi = min(hi, b) else: lo = max(lo, b) hi = min(hi, a) return (lo, hi)
def _apply_virtual_bounds(self) -> None: """Infer physical bounds from downstream validators, compute virtual bounds, and apply. Re-reads downstream validators each call so changes to upstream layers (e.g. a new matrix in a parent VirtualGateLayer) are always reflected. Skipped silently if any downstream parameter lacks a Numbers validator or if the matrix is non-square. Propagates to _virtual_consumers so a full chain updates in one call. """ physical_bounds = _infer_bounds(self._downstream) if physical_bounds is None: return bounds = self.compute_virtual_bounds(physical_bounds) for ch, (lb, ub) in zip(self.submodules.values(), bounds): ch.voltage.vals = Numbers(lb, ub) for consumer in self._virtual_consumers: consumer._apply_virtual_bounds() def _propagate(self): """Recompute physical voltages from current virtual voltages and push downstream. Validates all physical outputs before writing any, so a range violation leaves the hardware state unchanged. Raises ValueError with a message that names the offending virtual voltages and suggests compute_virtual_bounds() for finding safe setpoints. After updating downstream, notifies sibling layers to recompute their virtual values from the new physical state. """ gate_names = list(self.submodules.keys()) v = np.array([ch._voltage for ch in self.submodules.values()]) physical = self._matrix @ v for param, value in zip(self._downstream, physical): try: param.validate(value) except Exception as exc: voltages_str = ", ".join(f"{n}={val:.4f}" for n, val in zip(gate_names, v)) raise ValueError( f"[{self.name}] Virtual voltages ({voltages_str}) map to " f"{value:.6f} V on '{param.name}', which is out of range. " f"Use {self.name}.settable_range('<gate>') to get the safe range " f"given the current values of all other gates." ) from exc for param, value in zip(self._downstream, physical): param.set(value) for sibling in self._siblings: sibling._recompute_virtual() def _recompute_virtual(self): """Update virtual gate voltages from current downstream (physical) state. Called when a sibling layer has propagated and updated the shared physical values. Updates virtual voltages silently without triggering further left-propagation. """ physical = np.array([p.get() for p in self._downstream]) virtual = np.linalg.solve(self._matrix, physical) for ch, v in zip(self.submodules.values(), virtual): if isinstance(ch, VirtualGateChannel): ch._voltage = v for consumer in self._virtual_consumers: consumer._recompute_virtual()
[docs] def get_virtual_voltages(self) -> np.ndarray: """Return current virtual gate voltages as an array.""" return np.array([ch._voltage for ch in self.submodules.values()])
[docs] def get_idn(self): return { "vendor": "NQCP", "model": "VirtualGateLayer", "serial": None, "firmware": None, }