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,
}