from __future__ import annotations
import enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from semi_cr.core.lab.station.graphing.ids import pin_node_id
from semi_cr.core.station.graph import Conduction, GraphEdge, GraphNode
from semi_cr.core.station.instruments.qcodes_proxy import QCoDeSInstrumentSpec
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from semi_cr.core.station.chip import Chip
from semi_cr.core.station.instruments.opx1000 import OPX1000LFTerminal
[docs]
@dataclass
class DevicePin:
name: str
pin_type: str
config: Mapping[str, Any]
parent_ref: QuantumDevice = field(repr=False, compare=False)
@property
def node_id(self) -> str:
return pin_node_id(self.parent_ref.parent_ref.chip_id, self.parent_ref.device_id, self.name)
@property
def station(self):
return self.parent_ref.parent_ref.parent_ref
[docs]
def get_ac_driver(self):
return self.station.graph.opx_source_for_pin(self)
[docs]
def get_dc_driver(self):
return self.station.graph.dc_driver_for_pin(self)
[docs]
@classmethod
def from_instrument_spec(cls, spec: QCoDeSInstrumentSpec, parent_ref: QuantumDevice) -> DevicePin:
"""Instantiates a DevicePin (or subclass thereof) depending on the spec. Currently, the specification uses
the `type` property to denote the pin type. When hydrating we'd like to have that reflected in what object is
instantiated. So the logic is this:
1. Extract the pin type
2. Look up what class is appropriate for that type
3. Extract that class with the spec, ensure it points back to the QuantumDevice containing it, and return."""
pin_name, pin_spec = spec
pin_type = pin_spec["type"]
pin_class = PIN_CLASS_BY_TYPE.get(pin_type, DevicePin)
role = getattr(pin_class, "Role", None)
pin = pin_class(
name=pin_name,
pin_type=pin_type,
config=pin_spec,
parent_ref=parent_ref,
)
if role is not None:
pin.role = role[pin_type]
return pin
[docs]
def graph_nodes(self) -> Iterable[GraphNode]:
"""Returns the graph nodes corresponding to this pin."""
yield GraphNode(
id=self.node_id,
obj=self,
kind="device_pin",
attrs={"pin_type": self.pin_type},
)
[docs]
def graph_edges(self) -> Iterable[GraphEdge]:
"""Returns graph edges from declared pads to this pin."""
for pad in self.config.get("pads", []):
yield GraphEdge(
id=f"{pad}->{self.node_id}",
source=pad,
target=self.node_id,
kind="device",
)
[docs]
@dataclass
class Gate(DevicePin):
[docs]
class Role(enum.Enum):
Plunger = enum.auto()
Barrier = enum.auto()
Screening = enum.auto()
Cutter = enum.auto()
role: Role | None = None
[docs]
@dataclass
class Ohmic(DevicePin):
[docs]
class Role(enum.Enum):
Source = enum.auto()
Drain = enum.auto()
role: Role | None = None
[docs]
@dataclass
class RFPin(DevicePin):
[docs]
class Role(enum.Enum):
RFInput = enum.auto()
RFOutput = enum.auto()
role: Role | None = None
PIN_CLASS_BY_TYPE = {
"Plunger": Gate,
"Barrier": Gate,
"Screening": Gate,
"Cutter": Gate,
"Source": Ohmic,
"Drain": Ohmic,
"RFInput": RFPin,
"RFOutput": RFPin,
}
[docs]
@dataclass
class ReadoutPath:
"""OPX terminals used to drive and acquire a measurement profile.
The path is derived from a :class:`MeasurementProfile` and the station graph. It stores terminal objects rather
than raw node IDs so callers can convert them directly to OPX references.
"""
drive: OPX1000LFTerminal
acquire: OPX1000LFTerminal
drive_root: DevicePin
acquire_root: DevicePin
[docs]
@dataclass
class MeasurementMethod:
"""Measurement method declared inside a :class:`MeasurementProfile`.
A method gives semantic roles to the profile's pins for one concrete measurement procedure. For example, an
S11-like method may use the same pin for ``rf_source`` and ``rf_receiver``, while an S21-like method may receive
on a different pin.
"""
name: str
method_type: str
roles: Mapping[str, Any]
config: Mapping[str, Any]
parent_ref: MeasurementProfile
[docs]
@classmethod
def from_instrument_spec(cls, spec: QCoDeSInstrumentSpec, parent_ref: MeasurementProfile) -> MeasurementMethod:
"""Hydrate a measurement method from a profile config fragment.
:param spec: ``(method_name, method_config)`` from the profile's ``methods`` section.
:param parent_ref: The owning measurement profile.
:returns: A method with role names resolved to hydrated :class:`DevicePin` objects.
:raises KeyError: If a role references a profile pin that does not exist.
"""
name, config = spec
return cls(
name=name,
method_type=config["type"],
roles={role_name: hydrate_measurement_role(parent_ref, role_spec) for role_name, role_spec in config.get("roles", {}).items()},
config=config,
parent_ref=parent_ref,
)
@property
def available_roles(self) -> list[str]:
"""Semantic role names declared by this method."""
return list(self.roles)
[docs]
def role(self, role_name: str) -> Any:
"""Return the hydrated value assigned to ``role_name`` in this method.
Role values mirror the config shape, but profile pin references are replaced with hydrated :class:`DevicePin`
objects. A role may therefore be a pin, a list of pins, or a nested mapping containing pins and scalar values.
:param role_name: Semantic method role name, such as ``rf_source``.
:returns: The hydrated role value.
:raises KeyError: If the method has no role with this name.
"""
return self.roles[role_name]
[docs]
def get_readout_path(self, source_role: str = "rf_source", receiver_role: str = "rf_receiver") -> ReadoutPath:
"""Return the OPX readout path for this measurement method.
:param source_role: Method role that identifies the source pin.
:param receiver_role: Method role that identifies the receiver pin.
:returns: The OPX drive/acquire terminal pair for this method.
:raises KeyError: If either role does not exist.
:raises LookupError: If either OPX terminal cannot be found.
"""
drive_root = self.role(source_role)
acquire_root = self.role(receiver_role)
station_graph = drive_root.station.graph
drive = station_graph.opx_source_for_pin(drive_root)
acquire = station_graph.opx_receiver_for_pin(acquire_root)
if drive is None or acquire is None:
raise LookupError(f"Could not resolve readout path for measurement method {self.name!r}")
return ReadoutPath(drive=drive, acquire=acquire, drive_root=drive_root, acquire_root=acquire_root)
[docs]
def is_applicable(self) -> bool:
"""Return whether this method's readout path is resolvable in the current station graph."""
try:
self.get_readout_path()
except (KeyError, LookupError):
return False
return True
[docs]
def hydrate_measurement_role(profile: MeasurementProfile, role_spec: Any) -> Any:
"""Hydrate a measurement-method role value.
Strings that match profile pin names are resolved to :class:`DevicePin` objects. Other strings are kept as scalar
values. Lists and mappings are hydrated recursively so nested role structures keep their original shape.
"""
if isinstance(role_spec, str):
return profile.pin(role_spec) if role_spec in profile.pins else role_spec
if isinstance(role_spec, list):
return [hydrate_measurement_role(profile, item) for item in role_spec]
if isinstance(role_spec, dict):
return {key: hydrate_measurement_role(profile, value) for key, value in role_spec.items()}
return role_spec
[docs]
@dataclass
class MeasurementProfile:
"""Measurement profile declared on a :class:`QuantumDevice`.
A profile gives semantic names to device pins for a measurement mode. For example, an RF profile may map
``rf_in`` to the device's ``RF_IN`` pin and ``rf_out`` to ``RF_OUT``. The profile stores direct references to the
hydrated :class:`DevicePin` objects so downstream code does not need to re-resolve config strings.
"""
name: str
profile_type: str
pins: Mapping[str, DevicePin]
methods: list[MeasurementMethod]
config: Mapping[str, Any]
parent_ref: QuantumDevice
[docs]
@classmethod
def from_instrument_spec(cls, spec: QCoDeSInstrumentSpec, parent_ref: QuantumDevice) -> MeasurementProfile:
"""Hydrate a measurement profile from a device config fragment.
:param spec: ``(profile_name, profile_config)`` from the device's ``measurement_profiles`` section.
:param parent_ref: The owning quantum device.
:returns: A measurement profile with profile pin names resolved to hydrated :class:`DevicePin` objects.
:raises KeyError: If a profile references a device pin that does not exist on ``parent_ref``.
"""
name, config = spec
profile = cls(
name=name,
profile_type=config["type"],
pins={profile_pin_name: parent_ref.pin(profile_pin_spec["device_pin"]) for profile_pin_name, profile_pin_spec in config.get("pins", {}).items()},
methods=[],
config=config,
parent_ref=parent_ref,
)
profile.methods = [MeasurementMethod.from_instrument_spec(method_spec, profile) for method_spec in config.get("methods", {}).items()]
return profile
@property
def available_pins(self) -> list[str]:
"""Semantic pin names declared by this profile."""
return list(self.pins)
@property
def available_methods(self) -> list[str]:
"""Names of measurement methods declared by this profile."""
return [method.name for method in self.methods]
[docs]
def pin(self, pin_name: str) -> DevicePin:
"""Return the device pin assigned to ``pin_name`` in this profile.
:param pin_name: Semantic pin name inside the profile, such as ``rf_in``.
:returns: The hydrated :class:`DevicePin` referenced by the profile.
:raises KeyError: If the profile has no pin with this name.
"""
return self.pins[pin_name]
[docs]
def method(self, method_name: str) -> MeasurementMethod:
"""Return a measurement method by name.
:param method_name: Name of the method, such as ``vna_s11``.
:returns: The matching :class:`MeasurementMethod`.
:raises KeyError: If no method with this name exists.
"""
for method in self.methods:
if method.name == method_name:
return method
raise KeyError(f"Measurement method not found: {method_name}")
[docs]
def get_readout_path(self, method_name: str, source_role: str = "rf_source", receiver_role: str = "rf_receiver") -> ReadoutPath:
"""Return the OPX readout path for a measurement method.
:param method_name: Name of the measurement method, such as ``vna_s11``.
:param source_role: Method role that identifies the source profile pin.
:param receiver_role: Method role that identifies the receiver profile pin.
:returns: The OPX drive/acquire terminal pair for this method.
:raises KeyError: If the method or roles do not exist.
:raises LookupError: If either OPX terminal cannot be found.
"""
return self.method(method_name).get_readout_path(source_role=source_role, receiver_role=receiver_role)
[docs]
@dataclass
class QuantumDevice:
device_id: str
qcodes_type: str
parent_ref: Chip = field(repr=False, compare=False)
pins: list[DevicePin] = field(default_factory=list, compare=False, repr=False)
measurement_profiles: list[MeasurementProfile] = field(default_factory=list, compare=False, repr=False)
[docs]
def gate(self, gate_id: str) -> Gate:
"""Return a gate pin by name.
:param gate_id: Name of the gate pin.
:returns: The matching :class:`Gate`.
:raises ValueError: If no gate with this name exists.
"""
for pin in self.pins:
if pin.name == gate_id and isinstance(pin, Gate):
return pin
raise ValueError(f"Gate not found: {gate_id}")
[docs]
def pin(self, pin_id: str) -> DevicePin:
"""Return a device pin by name.
:param pin_id: Name of the pin.
:returns: The matching :class:`DevicePin` or subclass.
:raises KeyError: If no pin with this name exists.
"""
for pin in self.pins:
if pin.name == pin_id:
return pin
raise KeyError(f"Pin not found: {pin_id}")
@property
def available_measurement_profiles(self) -> list[str]:
"""Names of measurement profiles declared on this device."""
return [profile.name for profile in self.measurement_profiles]
[docs]
def measurement_profile(self, profile_id: str) -> MeasurementProfile:
"""Return a measurement profile by name.
:param profile_id: Name of the profile, such as ``rf``.
:returns: The matching :class:`MeasurementProfile`.
:raises KeyError: If no profile with this name exists.
"""
for profile in self.measurement_profiles:
if profile.name == profile_id:
return profile
raise KeyError(f"Measurement profile not found: {profile_id}")
@property
def gates(self) -> list[Gate]:
"""Returns the list of Gates for a QuantumDevice. Gates are a special case of DevicePin so we check if
members of the pins list are in fact subclasses of Gate."""
return [pin for pin in self.pins if isinstance(pin, Gate)]
[docs]
@classmethod
def from_instrument_spec(cls, spec: QCoDeSInstrumentSpec, parent_ref: Chip) -> QuantumDevice:
"""Quantum devices are described in the QCoDeS configuration schema in the same way whether they are chips of
our own design, or special-case things like for instance a tank inductor. In the general case we inflate the
devices such that all pins become graph nodes that are responsible for their own edge relations (if any).
Special cases like the :class:`TankInductor` are registered in :data:`DEVICE_CLASS_BY_TYPE` if they require
special treatment during hydration.
:return: A :class:`QuantumDevice` instance containing references to a number of :class:`DevicePin` instances.
"""
declared_qcodes_type = spec[1]["type"]
# Figure out if we're a special-case class. If not, default to QuantumDevice
device_class = DEVICE_CLASS_BY_TYPE.get(declared_qcodes_type, cls)
device = device_class(
parent_ref=parent_ref,
device_id=spec[0],
qcodes_type=declared_qcodes_type,
)
device.pins = [DevicePin.from_instrument_spec(pin_spec, device) for pin_spec in spec[1].get("pins", {}).items()]
device.measurement_profiles = [
MeasurementProfile.from_instrument_spec(profile_spec, device) for profile_spec in spec[1].get("measurement_profiles", {}).items()
]
return device
[docs]
def graph_nodes(self) -> Iterable[GraphNode]:
"""Iterates over all pins to return the graph nodes for this device."""
for pin in self.pins:
yield from pin.graph_nodes()
[docs]
def graph_edges(self) -> Iterable[GraphEdge]:
"""Iterates over all pins to return any graph edges for this device."""
for pin in self.pins:
yield from pin.graph_edges()
[docs]
class TankInductor(QuantumDevice):
"""A special case of a QuantumDevice including edges between the graph nodes on its periphery,
to model its actual conductance."""
[docs]
def graph_edges(self) -> Iterable[GraphEdge]:
yield from super().graph_edges()
# Tank inductors couple RF_IN to RF_OUT internally (mirrors legacy TankInductor._add_internal_connections),
# but as opposed to most/all other edges in the graph this one only carries AC.
rf_in, rf_out = self.pin("RF_IN"), self.pin("RF_OUT")
yield GraphEdge(
id=f"{rf_in.node_id}->{rf_out.node_id}",
source=rf_in.node_id,
target=rf_out.node_id,
conducts=Conduction.AC,
kind="device",
)
DEVICE_CLASS_BY_TYPE = {
"semi_cr.core.lab.devices.tankinductor.TankInductor": TankInductor,
# "semi_cr.core.lab.devices.tenqubit.TenQubitDevice"
# "semi_cr.core.lab.devices.resistor.Resistor"
}