diff --git a/docs/components/qubits-and-qubit-pairs.md b/docs/components/qubits-and-qubit-pairs.md new file mode 100644 index 00000000..aa0ee813 --- /dev/null +++ b/docs/components/qubits-and-qubit-pairs.md @@ -0,0 +1,132 @@ +# Qubits and Qubit Pairs + +## Overview +Qubits and qubit pairs are essential components in quantum processing units (QPUs), implemented as subclasses of `QuantumComponent`. + +### Qubits +The `Qubit` class models a physical qubit on the QPU, encapsulating: +- Qubit-specific attributes (e.g., frequency) +- Quantum control channels (drive, flux, readout) +- Single-qubit gate operations +- Hardware-specific logic and calibration data + +The `Qubit` class is typically subclassed to add channels and other qubit-specific information as properties. In this example, we define a `Transmon` class, a subclass of `Qubit`, with specific channels: + +```python +from quam.components import BasicQuam, Qubit, IQChannel, SingleChannel +from quam.core import quam_dataclass + +@quam_dataclass +class Transmon(Qubit): + drive: IQChannel + flux: SingleChannel + +machine = BasicQuam() +``` + +We create two qubit instances, `q1` and `q2`, as follows: + +```python +q1 = machine.qubits["q1"] = Transmon( + drive=IQChannel( + opx_output_I=("con1", 1, 1), + opx_output_Q=("con1", 1, 2), + frequency_converter_up=None), + flux=SingleChannel(opx_output=("con1", 1, 3)), +) + +q2 = machine.qubits["q2"] = Transmon( + drive=IQChannel( + opx_output_I=("con1", 1, 5), + opx_output_Q=("con1", 1, 6), + frequency_converter_up=None), + flux=SingleChannel(opx_output=("con1", 1, 7)), +) +``` + +### Qubit Pairs +The `QubitPair` class models the interaction between two qubits, managing: +- Two-qubit gate operations +- Coupling elements (e.g., tunable couplers) +- Interaction-specific properties and calibrations +- Hardware topology constraints + +We create a `QubitPair` using the qubits `q1` and `q2`: + +```python +machine.qubit_pairs["q1@q2"] = QubitPair( + qubit_control=q1.get_reference(), # "#/qubits/q1" + qubit_target=q2.get_reference() # "#/qubits/q2" +) +``` + +The `get_reference()` method is used to obtain a reference to the qubit, ensuring each QuAM component has a single parent, which for qubits is the `machine.qubits` dictionary. + +Both components offer interfaces for quantum operations through macros, enabling hardware-agnostic control while maintaining device-specific implementations. + +## Quantum Components +The `QuantumComponent` class is the base class for qubits and qubit pairs, providing: +- A unique identifier via the `id` property +- A collection of macros defining operations +- An abstract `name` property that derived classes must implement +- A standardized method to apply operations through the `apply()` method + +## Qubits +A `Qubit` represents a single quantum bit, acting as: +- A container for quantum control channels (drive, flux, readout, etc.) +- A collection point for pulse operations on its channels +- An endpoint for single-qubit operations via macros + + + +### Key Features + +```python +# Accessing channels +channels = q1.channels # Returns a dictionary of all channels + +# Finding pulses +pulse = q1.get_pulse("pi") # Retrieves the pulse named "pi" from any channel + +# Aligning operations +q1.align(q2) # Synchronizes all channels of q1 and q2 +``` + +## Qubit Pairs +A `QubitPair` represents the relationship between two qubits, typically used for two-qubit operations. It includes: +- References to both the control and target qubits +- Macros for two-qubit operations +- Automatic naming based on the constituent qubits + +### Key Features + +Once the qubit pair is added to the root-level [QuamRoot.qubit_pairs][quam.core.quam_classes.QuamRoot.qubit_pairs] dictionary, it can be accessed directly from the qubits using the `@` operator: + +```python +q1 @ q2 # Returns the qubit pair +``` +```python +# Automatic naming +pair = machine.qubit_pairs["q1@q2"] +pair.name # Returns "q1@q2" + +# Accessing qubits +pair.qubit_control, pair.qubit_target # Returns the control and target qubits + +# Applying two-qubit operations +pair.apply("cz_gate") # Applies the CZ gate macro +``` + + +## Macros and Operations +Both qubits and qubit pairs can contain macros, which serve as high-level interfaces to quantum operations. These macros: +- Define the implementation of quantum gates +- Can be registered using the `@QuantumComponent.register_macro` decorator +- Are accessible through the `apply()` method or directly as methods +- Provide a bridge between the hardware configuration and gate-level operations + +For detailed information about macros and gate-level operations, see: +- [Macros Documentation](./macros.md) +- [Gate-Level Operations Documentation](./operations.md) + +This documentation provides a high-level overview of the qubit and qubit pair functionality while referencing the macro and gate-level operations that will be detailed in other documentation pages. \ No newline at end of file diff --git a/docs/examples/Qubit operations.ipynb b/docs/examples/Qubit operations.ipynb new file mode 100644 index 00000000..ac13063c --- /dev/null +++ b/docs/examples/Qubit operations.ipynb @@ -0,0 +1,136 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2024-11-27 20:46:27,774 - qm - INFO - Starting session: 181cc6be-9c4a-4b37-a91e-51deab4fa291\n" + ] + } + ], + "source": [ + "from quam.core import OperationsRegistry\n", + "from quam.components import Qubit, pulses\n", + "from quam.components.channels import IQChannel\n", + "from quam.core import quam_dataclass, QuamRoot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define QUAM components" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "@quam_dataclass\n", + "class Transmon(Qubit):\n", + " xy: IQChannel\n", + "\n", + "\n", + "@quam_dataclass\n", + "class QUAM(QuamRoot):\n", + " qubit: Transmon = None" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create QUAM components" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "machine = QUAM()\n", + "\n", + "qubit = machine.qubit = Transmon(\n", + " id=\"qubit\",\n", + " xy=IQChannel(\n", + " opx_output_I=(\"con1\", 1),\n", + " opx_output_Q=(\"con1\", 2),\n", + " frequency_converter_up=None,\n", + " ),\n", + ")\n", + "\n", + "pulse = pulses.SquarePulse(length=100, amplitude=0.1)\n", + "qubit.xy.operations[\"X\"] = pulse" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "ename": "IndexError", + "evalue": "list index out of range", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mIndexError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[4], line 8\u001b[0m\n\u001b[1;32m 6\u001b[0m qubit\u001b[38;5;241m.\u001b[39mimplementations[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mX\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m PulseGateImplementation(pulse\u001b[38;5;241m=\u001b[39mpulse\u001b[38;5;241m.\u001b[39mget_reference())\n\u001b[1;32m 7\u001b[0m \u001b[38;5;28;01massert\u001b[39;00m qubit\u001b[38;5;241m.\u001b[39mimplementations[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mX\u001b[39m\u001b[38;5;124m\"\u001b[39m]\u001b[38;5;241m.\u001b[39mid \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mX\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[0;32m----> 8\u001b[0m \u001b[43mqubit\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mimplementations\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mX\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m]\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mapply\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/Repositories/quam/quam/components/implementations/qubit_implementations.py:39\u001b[0m, in \u001b[0;36mPulseGateImplementation.apply\u001b[0;34m(self, amplitude_scale, duration, **kwargs)\u001b[0m\n\u001b[1;32m 38\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mapply\u001b[39m(\u001b[38;5;28mself\u001b[39m, \u001b[38;5;241m*\u001b[39m, amplitude_scale\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m, duration\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m, \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs):\n\u001b[0;32m---> 39\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mpulse\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mplay\u001b[49m\u001b[43m(\u001b[49m\u001b[43mamplitude_scale\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mamplitude_scale\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mduration\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mduration\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/Repositories/quam/quam/components/pulses.py:232\u001b[0m, in \u001b[0;36mPulse.play\u001b[0;34m(self, amplitude_scale, duration, condition, chirp, truncate, timestamp_stream, continue_chirp, target, validate)\u001b[0m\n\u001b[1;32m 229\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mchannel \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[1;32m 230\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mPulse \u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mname\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m is not attached to a channel\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[0;32m--> 232\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mchannel\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mplay\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 233\u001b[0m \u001b[43m \u001b[49m\u001b[43mpulse_name\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mname\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 234\u001b[0m \u001b[43m \u001b[49m\u001b[43mamplitude_scale\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mamplitude_scale\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 235\u001b[0m \u001b[43m \u001b[49m\u001b[43mduration\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mduration\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 236\u001b[0m \u001b[43m \u001b[49m\u001b[43mcondition\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcondition\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 237\u001b[0m \u001b[43m \u001b[49m\u001b[43mchirp\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mchirp\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 238\u001b[0m \u001b[43m \u001b[49m\u001b[43mtruncate\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtruncate\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 239\u001b[0m \u001b[43m \u001b[49m\u001b[43mtimestamp_stream\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtimestamp_stream\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 240\u001b[0m \u001b[43m \u001b[49m\u001b[43mcontinue_chirp\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcontinue_chirp\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 241\u001b[0m \u001b[43m \u001b[49m\u001b[43mtarget\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtarget\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 242\u001b[0m \u001b[43m \u001b[49m\u001b[43mvalidate\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mvalidate\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 243\u001b[0m \u001b[43m\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/Repositories/quam/quam/components/channels.py:389\u001b[0m, in \u001b[0;36mChannel.play\u001b[0;34m(self, pulse_name, amplitude_scale, duration, condition, chirp, truncate, timestamp_stream, continue_chirp, target, validate)\u001b[0m\n\u001b[1;32m 384\u001b[0m pulse \u001b[38;5;241m=\u001b[39m pulse_name\n\u001b[1;32m 386\u001b[0m \u001b[38;5;66;03m# At the moment, self.name is not defined for Channel because it could\u001b[39;00m\n\u001b[1;32m 387\u001b[0m \u001b[38;5;66;03m# be a property or dataclass field in a subclass.\u001b[39;00m\n\u001b[1;32m 388\u001b[0m \u001b[38;5;66;03m# # TODO Find elegant solution for Channel.name.\u001b[39;00m\n\u001b[0;32m--> 389\u001b[0m \u001b[43mplay\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 390\u001b[0m \u001b[43m \u001b[49m\u001b[43mpulse\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mpulse\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 391\u001b[0m \u001b[43m \u001b[49m\u001b[43melement\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mname\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 392\u001b[0m \u001b[43m \u001b[49m\u001b[43mduration\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mduration\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 393\u001b[0m \u001b[43m \u001b[49m\u001b[43mcondition\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcondition\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 394\u001b[0m \u001b[43m \u001b[49m\u001b[43mchirp\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mchirp\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 395\u001b[0m \u001b[43m \u001b[49m\u001b[43mtruncate\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtruncate\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 396\u001b[0m \u001b[43m \u001b[49m\u001b[43mtimestamp_stream\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtimestamp_stream\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 397\u001b[0m \u001b[43m \u001b[49m\u001b[43mcontinue_chirp\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcontinue_chirp\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 398\u001b[0m \u001b[43m \u001b[49m\u001b[43mtarget\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mtarget\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 399\u001b[0m \u001b[43m\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/Repositories/quam/.venv/lib/python3.11/site-packages/qm/qua/_dsl.py:170\u001b[0m, in \u001b[0;36mplay\u001b[0;34m(pulse, element, duration, condition, chirp, truncate, timestamp_stream, continue_chirp, target)\u001b[0m\n\u001b[1;32m 87\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mplay\u001b[39m(\n\u001b[1;32m 88\u001b[0m pulse: PlayPulseType,\n\u001b[1;32m 89\u001b[0m element: \u001b[38;5;28mstr\u001b[39m,\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 96\u001b[0m target: \u001b[38;5;28mstr\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m\"\u001b[39m,\n\u001b[1;32m 97\u001b[0m ):\n\u001b[1;32m 98\u001b[0m \u001b[38;5;250m \u001b[39m\u001b[38;5;124mr\u001b[39m\u001b[38;5;124;03m\"\"\"Play a `pulse` based on an 'operation' defined in `element`.\u001b[39;00m\n\u001b[1;32m 99\u001b[0m \n\u001b[1;32m 100\u001b[0m \u001b[38;5;124;03m The pulse will be modified according to the properties of the element\u001b[39;00m\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 168\u001b[0m \u001b[38;5;124;03m ```\u001b[39;00m\n\u001b[1;32m 169\u001b[0m \u001b[38;5;124;03m \"\"\"\u001b[39;00m\n\u001b[0;32m--> 170\u001b[0m body \u001b[38;5;241m=\u001b[39m \u001b[43m_get_scope_as_blocks_body\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 171\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m duration \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[1;32m 172\u001b[0m duration \u001b[38;5;241m=\u001b[39m _unwrap_exp(exp(duration))\n", + "File \u001b[0;32m~/Repositories/quam/.venv/lib/python3.11/site-packages/qm/qua/_dsl.py:1957\u001b[0m, in \u001b[0;36m_get_scope_as_blocks_body\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1955\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21m_get_scope_as_blocks_body\u001b[39m() \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m _StatementsCollection:\n\u001b[1;32m 1956\u001b[0m \u001b[38;5;28;01mglobal\u001b[39;00m _block_stack\n\u001b[0;32m-> 1957\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28missubclass\u001b[39m(\u001b[38;5;28mtype\u001b[39m(\u001b[43m_block_stack\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;241;43m-\u001b[39;49m\u001b[38;5;241;43m1\u001b[39;49m\u001b[43m]\u001b[49m), _BodyScope):\n\u001b[1;32m 1958\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m QmQuaException(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mExpecting scope with body.\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 1959\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m _block_stack[\u001b[38;5;241m-\u001b[39m\u001b[38;5;241m1\u001b[39m]\u001b[38;5;241m.\u001b[39mbody()\n", + "\u001b[0;31mIndexError\u001b[0m: list index out of range" + ] + } + ], + "source": [ + "from quam.components.implementations.qubit_implementations import PulseGateImplementation\n", + "\n", + "\n", + "qubit = Qubit(id=\"qubit\")\n", + "\n", + "qubit.implementations[\"X\"] = PulseGateImplementation(pulse=pulse.get_reference())\n", + "assert qubit.implementations[\"X\"].id == \"X\"\n", + "qubit.implementations[\"X\"].apply()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/quam/components/__init__.py b/quam/components/__init__.py index 5c3d6a38..87c5294d 100644 --- a/quam/components/__init__.py +++ b/quam/components/__init__.py @@ -3,11 +3,15 @@ from .octave import * from .channels import * from . import pulses +from .quantum_components import * +from . import macro __all__ = [ *basic_quam.__all__, *hardware.__all__, *channels.__all__, *octave.__all__, + *quantum_components.__all__, "pulses", + "macro", ] diff --git a/quam/components/macro/__init__.py b/quam/components/macro/__init__.py new file mode 100644 index 00000000..5b2865ea --- /dev/null +++ b/quam/components/macro/__init__.py @@ -0,0 +1,8 @@ +from .qubit_macros import * +from .qubit_pair_macros import * + + +__all__ = [ + *qubit_macros.__all__, + *qubit_pair_macros.__all__, +] diff --git a/quam/components/macro/qubit_macros.py b/quam/components/macro/qubit_macros.py new file mode 100644 index 00000000..4f5608f6 --- /dev/null +++ b/quam/components/macro/qubit_macros.py @@ -0,0 +1,43 @@ +from abc import ABC +from typing import Optional, Union, List +from quam.core.macro import QuamMacro +from quam.components.pulses import Pulse +from quam.core import quam_dataclass + + +__all__ = ["QubitMacro", "PulseMacro"] + + +@quam_dataclass +class QubitMacro(QuamMacro, ABC): + @property + def qubit(self): + from quam.components.quantum_components.qubit import Qubit + + if isinstance(self.parent, Qubit): + return self.parent + elif hasattr(self.parent, "parent") and isinstance(self.parent.parent, Qubit): + return self.parent.parent + else: + raise AttributeError("QubitOperation is not attached to a qubit: {self}") + + +@quam_dataclass +class PulseMacro(QubitMacro): + """Single-qubit gate for a qubit consisting of a single pulse + + Args: + pulse: Name of pulse to be played on qubit. Should be a key in + `channel.operations` for one of the qubit's channels + """ + + pulse: Union[Pulse, str] # type: ignore + + def apply(self, *, amplitude_scale=None, duration=None, **kwargs): + if isinstance(self.pulse, Pulse): + pulse = self.pulse + else: + pulse = self.qubit.get_pulse(self.pulse) + pulse.play( + amplitude_scale=amplitude_scale, duration=duration, **kwargs # type: ignore + ) diff --git a/quam/components/macro/qubit_pair_macros.py b/quam/components/macro/qubit_pair_macros.py new file mode 100644 index 00000000..411ea6a8 --- /dev/null +++ b/quam/components/macro/qubit_pair_macros.py @@ -0,0 +1,35 @@ +from abc import ABC + +from quam.core.macro import QuamMacro +from quam.core import quam_dataclass + +from quam.components.quantum_components.qubit import Qubit + + +__all__ = ["QubitPairMacro"] + + +@quam_dataclass +class QubitPairMacro(QuamMacro, ABC): + @property + def qubit_pair(self): # TODO Add QubitPair return type + from quam.components.quantum_components.qubit_pair import QubitPair + + if isinstance(self.parent, QubitPair): + return self.parent + elif hasattr(self.parent, "parent") and isinstance( + self.parent.parent, QubitPair + ): + return self.parent.parent + else: + raise AttributeError( + "TwoQubitGate is not attached to a QubitPair. 2Q_gate: {self}" + ) + + @property + def qubit_control(self) -> Qubit: + return self.qubit_pair.qubit_control + + @property + def qubit_target(self) -> Qubit: + return self.qubit_pair.qubit_target diff --git a/quam/components/quantum_components/__init__.py b/quam/components/quantum_components/__init__.py new file mode 100644 index 00000000..aa9854da --- /dev/null +++ b/quam/components/quantum_components/__init__.py @@ -0,0 +1,5 @@ +from quam.components.quantum_components.quantum_component import QuantumComponent +from quam.components.quantum_components.qubit import Qubit +from quam.components.quantum_components.qubit_pair import QubitPair + +__all__ = ["QuantumComponent", "Qubit", "QubitPair"] diff --git a/quam/components/quantum_components/quantum_component.py b/quam/components/quantum_components/quantum_component.py new file mode 100644 index 00000000..f43909d7 --- /dev/null +++ b/quam/components/quantum_components/quantum_component.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod +from dataclasses import field +import inspect +from typing import Any, Callable, Dict, Union, TypeVar, cast +from quam.core.quam_classes import quam_dataclass, QuamComponent +from quam.core.macro import BaseMacro, MethodMacro + +__all__ = ["QuantumComponent"] + + +T = TypeVar("T", bound=Callable) + + +@quam_dataclass +class QuantumComponent(QuamComponent, ABC): + id: Union[str, int] + macros: Dict[str, BaseMacro] = field(default_factory=dict) + + @property + @abstractmethod + def name(self) -> str: + pass + + def apply(self, operation: str, *args, **kwargs) -> Any: + operation_obj = self.get_macros()[operation] + operation_obj.apply(*args, **kwargs) + + @staticmethod + def register_macro(func: T) -> T: + """Decorator to register a method as a macro entry point""" + return cast(T, MethodMacro(func)) + + def _get_method_macros(self) -> Dict[str, MethodMacro]: + return dict( + inspect.getmembers(self, predicate=lambda x: isinstance(x, MethodMacro)) + ) + + def get_macros(self) -> Dict[str, BaseMacro]: + return {**self.macros, **self._get_method_macros()} diff --git a/quam/components/quantum_components/qubit.py b/quam/components/quantum_components/qubit.py new file mode 100644 index 00000000..424d2289 --- /dev/null +++ b/quam/components/quantum_components/qubit.py @@ -0,0 +1,137 @@ +from collections import UserDict +from collections.abc import Iterable +from typing import Dict, List, Optional, Union, TYPE_CHECKING, Any +from dataclasses import field + +from qm import qua +from qm.qua import align + +from quam.components.channels import Channel +from quam.components.pulses import Pulse +from quam.components.quantum_components import QuantumComponent +from quam.core import quam_dataclass +from quam.utils import string_reference as str_ref + +if TYPE_CHECKING: + from quam.components.macro import QubitMacro + + MacroType = QubitMacro +else: + MacroType = Any + + +__all__ = ["Qubit"] + + +@quam_dataclass +class Qubit(QuantumComponent): + id: Union[str, int] = "#./inferred_id" + macros: Dict[str, MacroType] = field(default_factory=dict) + + @property + def inferred_id(self) -> Union[str, int]: + if not str_ref.is_reference(self.get_unreferenced_value("id")): + return self.id + elif self.parent is not None: + name = self.parent.get_attr_name(self) + return name + else: + raise AttributeError( + f"Cannot infer id of {self} because it is not attached to a parent" + ) + + @property + def name(self) -> str: + """Returns the name of the qubit""" + return self.id if isinstance(self.id, str) else f"q{self.id}" + + @property + def channels(self) -> Dict[str, Channel]: + """Returns a dictionary of all channels of the qubit""" + return { + key: val + for key, val in self.get_attrs( + follow_references=True, include_defaults=True + ).items() + if isinstance(val, Channel) + } + + def get_pulse(self, pulse_name: str) -> Pulse: + """Returns the pulse with the given name + + Goes through all channels and returns the unique pulse with the given name. + + Raises a ValueError if the pulse is not found or if there are multiple pulses + with the same name. + """ + pulses = [ + pulse + for channel in self.channels.values() + for key, pulse in channel.operations.items() + if key == pulse_name + ] + if len(pulses) == 0: + raise ValueError(f"Pulse {pulse_name} not found") + elif len(pulses) > 1: + raise ValueError(f"Pulse {pulse_name} is not unique") + else: + return pulses[0] + + @QuantumComponent.register_macro + def align( + self, + other_qubits: Optional[Union["Qubit", Iterable["Qubit"]]] = None, + *args: "Qubit", + ): + """Aligns the execution of all channels of this qubit and all other qubits""" + quantum_components = [self] + + if isinstance(other_qubits, Qubit): + quantum_components.append(other_qubits) + elif isinstance(other_qubits, Iterable): + quantum_components.extend(other_qubits) + elif other_qubits is not None: + raise ValueError(f"Invalid type for other_qubits: {type(other_qubits)}") + + if args: + assert all(isinstance(arg, Qubit) for arg in args) + quantum_components.extend(args) + + channel_names = { + ch.name for qubit in quantum_components for ch in qubit.channels.values() + } + + align(*channel_names) + + def __matmul__(self, other): # TODO Add QubitPair return type + """Allows access to qubit pairs using the '@' operator, e.g. (q1 @ q2)""" + if not isinstance(other, Qubit): + raise ValueError( + "Cannot create a qubit pair (q1 @ q2) with a non-qubit object, " + f"where q1={self} and q2={other}" + ) + + if self is other: + raise ValueError( + "Cannot create a qubit pair with same qubit (q1 @ q1), where q1={self}" + ) + + if not hasattr(self._root, "qubit_pairs"): + raise AttributeError( + "Qubit pairs not found in the root component. " + "Please add a 'qubit_pairs' attribute to the root component." + ) + + if isinstance(self._root.qubit_pairs, UserDict): + qubit_pairs = self._root.qubit_pairs.values() + else: + qubit_pairs = self._root.qubit_pairs + + for qubit_pair in qubit_pairs: + if qubit_pair.qubit_control is self and qubit_pair.qubit_target is other: + return qubit_pair + else: + raise ValueError( + "Qubit pair not found: qubit_control={self.name}, " + "qubit_target={other.name}" + ) diff --git a/quam/components/quantum_components/qubit_pair.py b/quam/components/quantum_components/qubit_pair.py new file mode 100644 index 00000000..f5f335bf --- /dev/null +++ b/quam/components/quantum_components/qubit_pair.py @@ -0,0 +1,32 @@ +from typing import Dict, TYPE_CHECKING, Any +from dataclasses import field + +from quam.core import quam_dataclass +from quam.components.quantum_components import QuantumComponent, Qubit +from quam.utils import string_reference as str_ref + +if TYPE_CHECKING: + from quam.components.macro import QubitPairMacro + + MacroType = QubitPairMacro +else: + MacroType = Any + + +@quam_dataclass +class QubitPair(QuantumComponent): + id: str = "#./name" + qubit_control: Qubit + qubit_target: Qubit + macros: Dict[str, MacroType] = field(default_factory=dict) + + @property + def name(self) -> str: + if not str_ref.is_reference(self.get_unreferenced_value("id")): + return self.id + else: + return f"{self.qubit_control.name}@{self.qubit_target.name}" + + def align(self): + """Aligns the execution of all channels of both qubits""" + self.qubit_control.align(self.qubit_target) diff --git a/quam/core/__init__.py b/quam/core/__init__.py index 9795b884..ab8205d5 100644 --- a/quam/core/__init__.py +++ b/quam/core/__init__.py @@ -1,4 +1,5 @@ from .quam_classes import * +from .operation.operations_registry import OperationsRegistry # Exec statement needed to trick Pycharm type checker into recognizing it as a dataclass diff --git a/quam/core/macro/__init__.py b/quam/core/macro/__init__.py new file mode 100644 index 00000000..df4a7b2a --- /dev/null +++ b/quam/core/macro/__init__.py @@ -0,0 +1,5 @@ +from quam.core.macro.base_macro import BaseMacro +from quam.core.macro.method_macro import MethodMacro +from quam.core.macro.quam_macro import QuamMacro + +__all__ = ["BaseMacro", "MethodMacro", "QuamMacro"] diff --git a/quam/core/macro/base_macro.py b/quam/core/macro/base_macro.py new file mode 100644 index 00000000..4e617830 --- /dev/null +++ b/quam/core/macro/base_macro.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import Any + + +__all__ = ["BaseMacro"] + + +class BaseMacro(ABC): + """Base class for all macro types in the system""" + + @abstractmethod + def apply(self, *args, **kwargs) -> Any: + """Applies the macro operation""" + pass diff --git a/quam/core/macro/method_macro.py b/quam/core/macro/method_macro.py new file mode 100644 index 00000000..83c842fa --- /dev/null +++ b/quam/core/macro/method_macro.py @@ -0,0 +1,40 @@ +from typing import Any, Callable, TypeVar +import functools + +from quam.core.macro.base_macro import BaseMacro + + +__all__ = ["MethodMacro"] + + +T = TypeVar("T", bound=Callable) + + +class MethodMacro(BaseMacro): + """Decorator that marks methods which should be exposed as macros.""" + + def __init__(self, func: T) -> None: + functools.wraps(func)(self) + self.func = func + self.instance = None + + def __get__(self, instance, owner): + # Store the instance to which this method is bound + self.instance = instance + return self + + def apply(self, *args, **kwargs) -> Any: + """Implements BaseMacro.apply by calling the wrapped function""" + if self.instance is not None: + # Call the function with the instance as the first argument + return self.func(self.instance, *args, **kwargs) + return self.func(*args, **kwargs) + + def __call__(self, *args, **kwargs): + if args and args[0] is self.instance: + args = args[1:] + return self.apply(*args, **kwargs) + + @staticmethod + def is_macro_method(obj: Any) -> bool: + return isinstance(obj, MethodMacro) diff --git a/quam/core/macro/quam_macro.py b/quam/core/macro/quam_macro.py new file mode 100644 index 00000000..551f579f --- /dev/null +++ b/quam/core/macro/quam_macro.py @@ -0,0 +1,24 @@ +from abc import ABC +from quam.core.quam_classes import quam_dataclass, QuamComponent +from quam.utils import string_reference as str_ref +from quam.core.macro.base_macro import BaseMacro + + +__all__ = ["QuamMacro"] + + +@quam_dataclass +class QuamMacro(QuamComponent, BaseMacro, ABC): + id: str = "#./inferred_id" + + @property + def inferred_id(self): + if not str_ref.is_reference(self.get_unreferenced_value("id")): + return self.id + elif self.parent is not None: + name = self.parent.get_attr_name(self) + return name + else: + raise AttributeError( + f"Cannot infer id of {self} because it is not attached to a parent" + ) diff --git a/quam/core/operation/__init__.py b/quam/core/operation/__init__.py new file mode 100644 index 00000000..3c4d16d7 --- /dev/null +++ b/quam/core/operation/__init__.py @@ -0,0 +1,5 @@ +from quam.core.operation.function_properties import FunctionProperties +from quam.core.operation.operation import Operation +from quam.core.operation.operations_registry import OperationsRegistry + +__all__ = ["FunctionProperties", "Operation", "OperationsRegistry"] diff --git a/quam/core/operation/function_properties.py b/quam/core/operation/function_properties.py new file mode 100644 index 00000000..1a1c3432 --- /dev/null +++ b/quam/core/operation/function_properties.py @@ -0,0 +1,122 @@ +from typing import Any, Callable, Optional, Type, get_origin, get_args, TypeVar +import inspect +from typing import get_type_hints +from dataclasses import dataclass, field +import keyword + +from quam.components import QuantumComponent + + +__all__ = ["FunctionProperties"] + + +QC = TypeVar("QC", bound=QuantumComponent) + + +@dataclass +class FunctionProperties: + """ + Properties of a quantum operation function. + + This class extracts and stores metadata about functions that operate on + quantum components, including argument information and type requirements. + + Attributes: + quantum_component_name: Name of the parameter accepting the quantum component + quantum_component_type: Type of quantum component the function operates on + name: Name of the function + required_args: List of required argument names after the quantum component + optional_args: Dictionary of optional arguments and their default values + """ + + quantum_component_name: str + quantum_component_type: Type[QC] + name: str = "" + required_args: list[str] = field(default_factory=list) + optional_args: dict[str, Any] = field(default_factory=dict) + return_type: Optional[Type] = None + + def __post_init__(self): + # Make a new list/dict to avoid sharing between instances + self.required_args = list(self.required_args) + self.optional_args = dict(self.optional_args) + + # Validate argument names + all_args = self.required_args + list(self.optional_args) + for arg in all_args: + if not arg.isidentifier(): + raise ValueError(f"Invalid argument name: {arg!r}") + if keyword.iskeyword(arg): + raise ValueError(f"Argument name cannot be a Python keyword: {arg!r}") + + @staticmethod + def _is_quantum_component_type(type_hint: Optional[Type]) -> bool: + """Check if type is or inherits from QuantumComponent.""" + try: + return ( + type_hint is not None + and isinstance(type_hint, type) + and issubclass(type_hint, QuantumComponent) + ) + except TypeError: + return False + + @classmethod + def from_function(cls, func: Callable) -> "FunctionProperties": + if not callable(func): + raise ValueError(f"Input {func!r} must be a callable") + + signature = inspect.signature(func) + parameters = signature.parameters + + if not parameters: + raise ValueError( + f"Operation {func.__name__!r} must accept at least one argument " + "(a QuantumComponent)" + ) + + # Try to get type hints, gracefully handle missing annotations + try: + type_hints = get_type_hints(func) + except (NameError, TypeError): + # Fallback to using the raw annotations if get_type_hints fails + type_hints = getattr(func, "__annotations__", {}) + + parameters_iterator = iter(parameters) + first_param_name = next(parameters_iterator) + + # Get and resolve the type of the first parameter + first_param_type = type_hints.get(first_param_name) + + if not cls._is_quantum_component_type(first_param_type): + if first_param_type is None: + msg = ( + f"Operation {func.__name__!r} is missing type annotation for " + f"first parameter {first_param_name!r}" + ) + else: + msg = ( + f"Operation {func.__name__!r} must accept a QuantumComponent " + f"as its first argument, got {first_param_type!r}" + ) + raise ValueError(msg) + + function_properties = cls( + quantum_component_name=first_param_name, + quantum_component_type=first_param_type, # type: ignore + name=func.__name__, + ) + + # Process remaining parameters + for param_name in parameters_iterator: + param = parameters[param_name] + if param.default == inspect.Parameter.empty: + function_properties.required_args.append(param_name) + else: + # Store the default value directly + function_properties.optional_args[param_name] = param.default + + # Get the return type from the function annotations + function_properties.return_type = type_hints.get("return") + + return function_properties diff --git a/quam/core/operation/operation.py b/quam/core/operation/operation.py new file mode 100644 index 00000000..02021668 --- /dev/null +++ b/quam/core/operation/operation.py @@ -0,0 +1,74 @@ +from typing import Callable, Optional, Any + +from quam.core.operation.function_properties import FunctionProperties +from quam.components import QuantumComponent + + +__all__ = ["Operation"] + + +class Operation: + def __init__(self, func: Callable): + """ + Initialize a quantum operation. + + This is typically used implicitly from the decorator @operations_registry.register_operation. + + Args: + func: The function implementing the operation + """ + self.func = func + self.properties = FunctionProperties.from_function(func) + + def get_macro(self, quantum_component: QuantumComponent): + """ + Get the macro implementation for this operation from a quantum component. + + Args: + quantum_component: Component to get the macro from + + Returns: + The macro implementation + + Raises: + KeyError: If the macro is not implemented for this component + """ + macros = quantum_component.get_macros() + try: + return macros[self.properties.name] + except KeyError: + raise KeyError( + f"Operation '{self.properties.name}' is not implemented for " + f"{quantum_component.__class__.__name__}" + ) + + def __call__(self, *args, **kwargs): + """ + Execute the operation on a quantum component. + + Args: + *args: Positional arguments, first must be a quantum component + **kwargs: Keyword arguments for the operation + + Returns: + Result of the macro execution + + Raises: + ValueError: If first argument is not the correct quantum component type + """ + if not args: + raise ValueError( + f"Operation {self.properties.name} requires at least one argument" + ) + + quantum_component = args[0] + if not isinstance(quantum_component, self.properties.quantum_component_type): + raise ValueError( + f"First argument to {self.properties.name} must be a " + f"{self.properties.quantum_component_type.__name__}, got " + f"{type(quantum_component).__name__}" + ) + + quantum_component, *required_args = args + macro = self.get_macro(quantum_component) + return macro.apply(*required_args, **kwargs) diff --git a/quam/core/operation/operations_registry.py b/quam/core/operation/operations_registry.py new file mode 100644 index 00000000..ffecea94 --- /dev/null +++ b/quam/core/operation/operations_registry.py @@ -0,0 +1,39 @@ +from collections import UserDict +import functools +from typing import Callable, Optional, TypeVar, Any + +from quam.core.operation import Operation + + +__all__ = ["OperationsRegistry"] + +T = TypeVar("T", bound=Callable[..., Any]) + + +class OperationsRegistry(UserDict): + """A registry to store and manage operations.""" + + def register_operation(self, func: Optional[T]) -> T: + """ + Register a function as an operation. + + This method stores the function in the operations dictionary and returns a + wrapped version of the function that maintains the original function's + signature and docstring. + + Args: + func (callable): The function to register as an operation. + + Returns: + callable: The wrapped function. + """ + # Optionally add this later such that we can pass parameters to the decorator + # if func is None: + # return functools.partial(self.register_operation) + + operation = Operation(func) + operation = functools.update_wrapper(operation, func) + + self[func.__name__] = operation + + return operation # type: ignore diff --git a/quam/examples/superconducting_qubits/cz_implementation.py b/quam/examples/superconducting_qubits/cz_implementation.py new file mode 100644 index 00000000..71e2c033 --- /dev/null +++ b/quam/examples/superconducting_qubits/cz_implementation.py @@ -0,0 +1,20 @@ +from quam.core import quam_dataclass +from quam.components.pulses import Pulse +from quam.components.macro import QubitPairMacro + + +@quam_dataclass +class CZImplementation(QubitPairMacro): + """CZ Operation for a qubit pair""" + + flux_pulse: Pulse + + phase_shift_control: float = 0.0 + phase_shift_target: float = 0.0 + + def apply(self, *, amplitude_scale=None): + self.flux_pulse.play(amplitude_scale=amplitude_scale) + self.qubit_control.align(self.qubit_target) + self.qubit_control.xy.frame_rotation(self.phase_shift_control) + self.qubit_target.xy.frame_rotation(self.phase_shift_target) + self.qubit_pair.align() diff --git a/quam/examples/superconducting_qubits/operations.py b/quam/examples/superconducting_qubits/operations.py new file mode 100644 index 00000000..1a93923c --- /dev/null +++ b/quam/examples/superconducting_qubits/operations.py @@ -0,0 +1,37 @@ +from typing import Tuple +from qm.qua import QuaVariableType +from quam.components import Qubit, QubitPair +from quam.core import OperationsRegistry + + +operations_registry = OperationsRegistry() + + +@operations_registry.register_operation +def x(qubit: Qubit, **kwargs): + pass + + +@operations_registry.register_operation +def y(qubit: Qubit, **kwargs): + pass + + +@operations_registry.register_operation +def Rx(qubit: Qubit, angle: float, **kwargs): + pass + + +@operations_registry.register_operation +def cz(qubit_pair: QubitPair, **kwargs): + pass + + +@operations_registry.register_operation +def measure(qubit: Qubit, **kwargs) -> QuaVariableType: + pass + + +@operations_registry.register_operation +def align(qubits: Tuple[Qubit, ...]): + pass diff --git a/quam/utils/reference_class.py b/quam/utils/reference_class.py index 9a624a0e..5a3430cd 100644 --- a/quam/utils/reference_class.py +++ b/quam/utils/reference_class.py @@ -28,7 +28,10 @@ def _is_reference(self, attr: str) -> bool: raise NotImplementedError def get_unreferenced_value(self, attr: str) -> Any: - """Get the raw value of an attribute, returning the reference if it is one""" + """Get the value of an attribute without following references. + + If the value is a reference, the reference string is returned + """ return super().__getattribute__(attr) def __getattribute__(self, attr: str) -> Any: diff --git a/tests/components/quantum_components/conftest.py b/tests/components/quantum_components/conftest.py new file mode 100644 index 00000000..00e58915 --- /dev/null +++ b/tests/components/quantum_components/conftest.py @@ -0,0 +1,62 @@ +from typing import Dict, Optional +import pytest +from quam.components import Qubit, QubitPair +from quam.components.channels import IQChannel +from quam.core.quam_classes import QuamRoot, quam_dataclass +from dataclasses import field + + +@quam_dataclass +class MockQubit(Qubit): + xy: IQChannel + resonator: Optional[IQChannel] = None + + +@quam_dataclass +class TestQUAM(QuamRoot): + qubits: Dict[str, MockQubit] = field(default_factory=dict) + qubit_pairs: Dict[str, QubitPair] = field(default_factory=dict) + + +@pytest.fixture +def mock_qubit(): + """Basic mock qubit with xy channel""" + return MockQubit( + id="q0", + xy=IQChannel( + opx_output_I=("con1", 1), + opx_output_Q=("con1", 2), + frequency_converter_up=None, + ), + ) + + +@pytest.fixture +def mock_qubit_with_resonator(): + """Mock qubit with both xy and resonator channels""" + return MockQubit( + id="q1", + xy=IQChannel( + opx_output_I=("con1", 1), + opx_output_Q=("con1", 2), + frequency_converter_up=None, + ), + resonator=IQChannel( + opx_output_I=("con1", 3), + opx_output_Q=("con1", 4), + frequency_converter_up=None, + ), + ) + + +@pytest.fixture +def test_quam(mock_qubit, mock_qubit_with_resonator): + """Test QUAM instance with qubits and qubit pairs""" + machine = TestQUAM( + qubits={"q0": mock_qubit, "q1": mock_qubit_with_resonator}, + ) + machine.qubit_pairs["pair_0"] = QubitPair( + qubit_control=mock_qubit.get_reference(), + qubit_target=mock_qubit_with_resonator.get_reference(), + ) + return machine diff --git a/tests/components/quantum_components/test_qubit.py b/tests/components/quantum_components/test_qubit.py new file mode 100644 index 00000000..f5c8ba39 --- /dev/null +++ b/tests/components/quantum_components/test_qubit.py @@ -0,0 +1,105 @@ +from typing import Optional +import pytest +from quam.components import Qubit +from quam.components.channels import IQChannel +from quam.components.pulses import SquarePulse +from quam.core.quam_classes import QuamRoot, quam_dataclass + + +def test_qubit_name_int(): + qubit = Qubit(id=0) + assert qubit.name == "q0" + + +def test_qubit_name_str(): + qubit = Qubit(id="qubit0") + assert qubit.name == "qubit0" + + +def test_qubit_channels(mock_qubit_with_resonator): + assert mock_qubit_with_resonator.channels == { + "xy": mock_qubit_with_resonator.xy, + "resonator": mock_qubit_with_resonator.resonator, + } + + +def test_qubit_channels_referenced(mock_qubit): + # Set resonator as a reference to xy channel + mock_qubit.resonator = "#./xy" + + assert mock_qubit.channels == { + "xy": mock_qubit.xy, + "resonator": mock_qubit.xy, + } + + +def test_qubit_get_pulse_not_found(mock_qubit): + with pytest.raises(ValueError, match="Pulse test_pulse not found"): + mock_qubit.get_pulse("test_pulse") + + +def test_qubit_get_pulse_not_unique(mock_qubit_with_resonator): + mock_qubit_with_resonator.xy.operations["test_pulse"] = SquarePulse( + length=100, amplitude=1.0 + ) + mock_qubit_with_resonator.resonator.operations["test_pulse"] = SquarePulse( + length=100, amplitude=1.0 + ) + + with pytest.raises(ValueError, match="Pulse test_pulse is not unique"): + mock_qubit_with_resonator.get_pulse("test_pulse") + + +def test_qubit_get_pulse_unique(mock_qubit): + pulse = SquarePulse(length=100, amplitude=1.0) + mock_qubit.xy.operations["test_pulse"] = pulse + + assert mock_qubit.get_pulse("test_pulse") == pulse + + +def test_qubit_align(mock_qubit_with_resonator, mock_qubit, mocker): + mocker.patch("quam.components.quantum_components.qubit.align") + mock_qubit_with_resonator.align(mock_qubit) + + from quam.components.quantum_components.qubit import align + + align.assert_called_once() + called_args, _ = align.call_args + assert set(called_args) == {"q1.xy", "q1.resonator", "q0.xy"} + + +def test_qubit_get_macros(mock_qubit): + assert mock_qubit.macros == {} + assert mock_qubit.get_macros() == {"align": mock_qubit.align} + + +def test_qubit_apply_align(mock_qubit_with_resonator, mocker): + mocker.patch("quam.components.quantum_components.qubit.align") + mock_qubit_with_resonator.align() + + from quam.components.quantum_components.qubit import align + + align.assert_called_once() + called_args, _ = align.call_args + assert set(called_args) == {"q1.xy", "q1.resonator"} + + +def test_qubit_inferred_id_direct(): + """Test inferred_id when id is a direct value""" + qubit = Qubit(id=0) + assert qubit.inferred_id == 0 + + +def test_qubit_inferred_id_with_parent(test_quam): + """Test inferred_id when id is a reference and qubit has parent""" + test_quam.qubits["q2"] = Qubit() + assert test_quam.qubits["q2"].inferred_id == "q2" + + +def test_qubit_inferred_id_no_parent(): + """Test inferred_id when id is a reference but qubit has no parent""" + qubit = Qubit(id="#./inferred_id") + with pytest.raises( + AttributeError, match="Cannot infer id .* not attached to a parent" + ): + _ = qubit.inferred_id diff --git a/tests/components/quantum_components/test_qubit_pair.py b/tests/components/quantum_components/test_qubit_pair.py new file mode 100644 index 00000000..8da1f449 --- /dev/null +++ b/tests/components/quantum_components/test_qubit_pair.py @@ -0,0 +1,124 @@ +from collections import UserDict +from dataclasses import field +from typing import Dict, List +import pytest +from quam.components import Qubit, QubitPair +from quam.components.channels import IQChannel +from quam.core.quam_classes import QuamRoot, quam_dataclass + + +@quam_dataclass +class MockQubit(Qubit): + xy: IQChannel = None + + +@quam_dataclass +class MockQubitPair(QubitPair): + qubit_control: MockQubit + qubit_target: MockQubit + + +@quam_dataclass +class QUAM(QuamRoot): + qubits: Dict[str, MockQubit] + qubit_pairs: Dict[str, MockQubitPair] = field(default_factory=dict) + + +@pytest.fixture +def test_qubit_control(): + return MockQubit( + id="q1", + xy=IQChannel( + id="xy_control", + opx_output_I=("con1", 1), + opx_output_Q=("con1", 2), + frequency_converter_up=None, + ), + ) + + +@pytest.fixture +def test_qubit_target(): + return MockQubit( + id="q2", + xy=IQChannel( + id="xy_target", + opx_output_I=("con1", 5), + opx_output_Q=("con1", 6), + frequency_converter_up=None, + ), + ) + + +@pytest.fixture +def test_quam(test_qubit_control, test_qubit_target): + machine = QUAM( + qubits={"control": test_qubit_control, "target": test_qubit_target}, + ) + + machine.qubit_pairs["pair_1"] = MockQubitPair( + id="pair_1", + qubit_control=test_qubit_control.get_reference(), + qubit_target=test_qubit_target.get_reference(), + ) + return machine + + +@pytest.fixture +def test_qubit_pair(test_quam): + return test_quam.qubit_pairs["pair_1"] + + +def test_qubit_pair_initialization( + test_qubit_pair, test_qubit_control, test_qubit_target +): + """Test that QubitPair is initialized correctly""" + assert test_qubit_pair.qubit_control == test_qubit_control + assert test_qubit_pair.qubit_target == test_qubit_target + assert test_qubit_pair.name == "pair_1" + assert isinstance(test_qubit_pair.macros, UserDict) + assert len(test_qubit_pair.macros) == 0 + + +def test_qubit_pair_align(test_qubit_pair, mocker): + """Test that align method calls the control qubit's align method with correct args""" + mock_align = mocker.patch.object(test_qubit_pair.qubit_control, "align") + + test_qubit_pair.align() + + mock_align.assert_called_once_with(test_qubit_pair.qubit_target) + + +def test_qubit_pair_via_matmul(test_quam): + """Test that qubit pair can be accessed via @ operator""" + control = test_quam.qubits["control"] + target = test_quam.qubits["target"] + + qubit_pair = control @ target + + assert isinstance(qubit_pair, QubitPair) + assert qubit_pair.qubit_control == control + assert qubit_pair.qubit_target == target + + +def test_matmul_with_invalid_qubit(test_quam): + """Test that @ operator raises error for invalid qubit pairs""" + control = test_quam.qubits["control"] + + with pytest.raises(ValueError, match="Cannot create a qubit pair with same qubit"): + _ = control @ control + + with pytest.raises( + ValueError, match="Cannot create a qubit pair .* with a non-qubit object" + ): + _ = control @ "not_a_qubit" + + +def test_matmul_with_nonexistent_pair(test_quam): + """Test that @ operator raises error for non-existent qubit pairs""" + target = test_quam.qubits["target"] + control = test_quam.qubits["control"] + + # Try to access pair in reverse order (target @ control) when only (control @ target) exists + with pytest.raises(ValueError, match="Qubit pair not found"): + _ = target @ control diff --git a/tests/macros/test_method_macro.py b/tests/macros/test_method_macro.py new file mode 100644 index 00000000..c13e2cf4 --- /dev/null +++ b/tests/macros/test_method_macro.py @@ -0,0 +1,46 @@ +from quam.core.macro.method_macro import MethodMacro + + +class TestClass: + def __init__(self, value: int): + self.value = value + + @MethodMacro + def add(self, x: int) -> int: + return self.value + x + + +def test_method_macro_binding(): + """Test that MethodMacro correctly binds to instance methods""" + obj = TestClass(5) + assert isinstance(obj.add, MethodMacro) + assert MethodMacro.is_macro_method(obj.add) + assert obj.add.instance == obj + + +def test_method_macro_apply(): + """Test that MethodMacro.apply works with instance methods""" + obj = TestClass(5) + assert obj.add.apply(3) == 8 # 5 + 3 + assert obj.add(3) == 8 # Should work the same way + + +def test_is_macro_method(): + """Test the is_macro_method static method""" + obj = TestClass(5) + + assert MethodMacro.is_macro_method(obj.add) + assert not MethodMacro.is_macro_method(lambda x: x) + assert not MethodMacro.is_macro_method(42) + + +def test_method_macro_preserves_metadata(): + """Test that MethodMacro preserves the original function's metadata""" + + def original(x: int) -> int: + """Test docstring""" + return x + + decorated = MethodMacro(original) + assert decorated.__doc__ == original.__doc__ + assert decorated.__name__ == original.__name__ diff --git a/tests/macros/test_pulse_macro.py b/tests/macros/test_pulse_macro.py new file mode 100644 index 00000000..3a631a41 --- /dev/null +++ b/tests/macros/test_pulse_macro.py @@ -0,0 +1,94 @@ +import pytest +from quam.components import Qubit +from quam.components.channels import IQChannel +from quam.components.pulses import SquarePulse +from quam.core.quam_classes import QuamRoot, quam_dataclass +from quam.components.macro import PulseMacro + + +@quam_dataclass +class MockQubit(Qubit): + xy: IQChannel + + +@quam_dataclass +class QUAM(QuamRoot): + qubit: MockQubit + + +@pytest.fixture +def test_qubit(): + return MockQubit( + id=0, + xy=IQChannel( + opx_output_I=("con1", 1), + opx_output_Q=("con1", 2), + frequency_converter_up=None, + operations={"test_pulse": SquarePulse(length=100, amplitude=1.0)}, + ), + ) + + +def test_pulse_macro_no_pulse(test_qubit): + with pytest.raises( + TypeError, match="missing 1 required keyword-only argument: 'pulse'" + ): + PulseMacro() + + +def test_pulse_macro_pulse_string(test_qubit, mocker): + pulse_macro = PulseMacro(pulse="test_pulse") + assert pulse_macro.pulse == "test_pulse" + + with pytest.raises(AttributeError): + pulse_macro.qubit + + test_qubit.macros["test_pulse"] = pulse_macro + + assert pulse_macro.qubit is test_qubit + + assert test_qubit.get_macros() == { + "test_pulse": pulse_macro, + "align": test_qubit.align, + } + + with pytest.raises(IndexError): + test_qubit.apply("test_pulse") + + mocker.patch("quam.components.channels.play") + + test_qubit.apply("test_pulse") + + from quam.components.channels import play + + play.assert_called_once() + + +def test_pulse_macro_pulse_object_error(test_qubit): + pulse_macro = PulseMacro( + pulse=SquarePulse(id="test_pulse", length=100, amplitude=1.0) + ) + test_qubit.macros["pulse_macro"] = pulse_macro + with pytest.raises( + ValueError, match="Pulse 'test_pulse' is not attached to a channel" + ): + test_qubit.apply("pulse_macro") + + +def test_pulse_macro_pulse_reference(test_qubit, mocker): + machine = QUAM(qubit=test_qubit) # Need root to get pulse reference + + pulse_macro = PulseMacro( + pulse=test_qubit.xy.operations["test_pulse"].get_reference() + ) + assert pulse_macro.pulse == test_qubit.xy.operations["test_pulse"] + + test_qubit.macros["pulse_macro"] = pulse_macro + + mocker.patch("quam.components.channels.play") + + test_qubit.apply("pulse_macro") + + from quam.components.channels import play + + play.assert_called_once() diff --git a/tests/operations/conftest.py b/tests/operations/conftest.py new file mode 100644 index 00000000..38e7f445 --- /dev/null +++ b/tests/operations/conftest.py @@ -0,0 +1,25 @@ +import pytest +from quam.core.quam_classes import quam_dataclass +from quam.components import Qubit +from quam.components.macro import QubitMacro + + +@quam_dataclass +class TestMacro(QubitMacro): + """Simple macro class for testing purposes""" + + def apply(self, *args, **kwargs): + # Return inputs to verify they were passed correctly + return (self.qubit, args, kwargs) + + +@pytest.fixture +def test_qubit(): + """Fixture providing a qubit with common test macros""" + qubit = Qubit(id="test_qubit") + + # Add some common macros + qubit.macros["x_gate"] = TestMacro() + qubit.macros["test_op"] = TestMacro() + + return qubit diff --git a/tests/operations/test_function_properties.py b/tests/operations/test_function_properties.py new file mode 100644 index 00000000..0e4c3076 --- /dev/null +++ b/tests/operations/test_function_properties.py @@ -0,0 +1,262 @@ +import pytest +from quam.core.operation.function_properties import FunctionProperties +from quam.core.quam_classes import quam_dataclass +from quam.components import QuantumComponent + + +@quam_dataclass +class DummyQuantumComponent(QuantumComponent): + """Dummy component for testing.""" + + pass + + +def test_function_properties_initialization(): + """Test basic initialization of FunctionProperties.""" + props = FunctionProperties( + quantum_component_name="component", + quantum_component_type=DummyQuantumComponent, + name="test_function", + required_args=["arg1", "arg2"], + optional_args={"opt1": 1, "opt2": "default"}, + ) + + assert props.quantum_component_name == "component" + assert props.quantum_component_type == DummyQuantumComponent + assert props.name == "test_function" + assert props.required_args == ["arg1", "arg2"] + assert props.optional_args == {"opt1": 1, "opt2": "default"} + + +def test_from_function_with_valid_function(): + """Test from_function with a valid function signature.""" + + def valid_operation( + component: DummyQuantumComponent, arg1: int, arg2: str, opt1: int = 1 + ): + pass + + props = FunctionProperties.from_function(valid_operation) + + assert props.quantum_component_name == "component" + assert props.quantum_component_type == DummyQuantumComponent + assert props.name == "valid_operation" + assert props.required_args == ["arg1", "arg2"] + assert props.optional_args == {"opt1": 1} + + +def test_from_function_with_only_required_args(): + """Test from_function with a function that has only required arguments.""" + + def operation(component: DummyQuantumComponent, arg1: int, arg2: str): + pass + + props = FunctionProperties.from_function(operation) + + assert props.quantum_component_name == "component" + assert props.required_args == ["arg1", "arg2"] + assert props.optional_args == {} + + +def test_from_function_with_only_optional_args(): + """Test from_function with a function that has only optional arguments.""" + + def operation( + component: DummyQuantumComponent, arg1: int = 1, arg2: str = "default" + ): + pass + + props = FunctionProperties.from_function(operation) + + assert props.quantum_component_name == "component" + assert props.required_args == [] + assert props.optional_args == {"arg1": 1, "arg2": "default"} + + +def test_from_function_with_no_args(): + """Test from_function with a function that has only the component parameter.""" + + def operation(component: DummyQuantumComponent): + pass + + props = FunctionProperties.from_function(operation) + + assert props.quantum_component_name == "component" + assert props.required_args == [] + assert props.optional_args == {} + + +def test_from_function_invalid_first_arg(): + """Test from_function with a function that doesn't have QuantumComponent as + first arg.""" + + def invalid_operation(x: int, component: DummyQuantumComponent): + pass + + with pytest.raises( + ValueError, match="must accept a QuantumComponent as its first argument" + ): + FunctionProperties.from_function(invalid_operation) + + +def test_from_function_no_args(): + """Test from_function with a function that has no arguments.""" + + def invalid_operation(): + pass + + with pytest.raises(ValueError, match="must accept at least one argument"): + FunctionProperties.from_function(invalid_operation) + + +def test_from_function_wrong_type(): + """Test from_function with a function that has wrong type for first argument.""" + + def invalid_operation(component: int): + pass + + with pytest.raises( + ValueError, match="must accept a QuantumComponent as its first argument" + ): + FunctionProperties.from_function(invalid_operation) + + +def test_from_function_with_optional_type(): + """Test handling of Optional type hints.""" + from typing import Optional + + def operation(component: DummyQuantumComponent, arg: Optional[int] = None): + pass + + props = FunctionProperties.from_function(operation) + assert props.optional_args["arg"] is None + + +def test_function_properties_container_independence(): + """Test that container attributes are independent between instances.""" + props1 = FunctionProperties( + quantum_component_name="comp1", + quantum_component_type=DummyQuantumComponent, + required_args=["arg1"], + optional_args={"opt1": 1}, + ) + props2 = FunctionProperties( + quantum_component_name="comp2", + quantum_component_type=DummyQuantumComponent, + required_args=["arg1"], + optional_args={"opt1": 1}, + ) + + # Modify containers in first instance + props1.required_args.append("arg2") + props1.optional_args["opt2"] = 2 + + # Check that second instance wasn't affected + assert props2.required_args == ["arg1"] + assert props2.optional_args == {"opt1": 1} + + +def test_function_properties_invalid_argument_name(): + """Test that invalid argument names are rejected.""" + with pytest.raises(ValueError, match="Invalid argument name: '123invalid'"): + FunctionProperties( + quantum_component_name="comp", + quantum_component_type=DummyQuantumComponent, + required_args=["123invalid"], + ) + + with pytest.raises(ValueError, match="Invalid argument name: 'invalid@name'"): + FunctionProperties( + quantum_component_name="comp", + quantum_component_type=DummyQuantumComponent, + optional_args={"invalid@name": 1}, + ) + + +def test_function_properties_python_keyword_argument(): + """Test that Python keywords are rejected as argument names.""" + with pytest.raises( + ValueError, match="Argument name cannot be a Python keyword: 'class'" + ): + FunctionProperties( + quantum_component_name="comp", + quantum_component_type=DummyQuantumComponent, + required_args=["class"], + ) + + with pytest.raises( + ValueError, match="Argument name cannot be a Python keyword: 'return'" + ): + FunctionProperties( + quantum_component_name="comp", + quantum_component_type=DummyQuantumComponent, + optional_args={"return": 1}, + ) + + +def test_from_function_with_complex_type_hints(): + """Test handling of complex type hints like Union and Optional.""" + from typing import Union, Optional + + def operation( + component: DummyQuantumComponent, + arg1: Union[int, str], + arg2: Optional[float] = None, + ): + pass + + props = FunctionProperties.from_function(operation) + assert props.required_args == ["arg1"] + assert props.optional_args == {"arg2": None} + + +def test_from_function_without_annotations(): + """Test that function works with parameters that have no type annotations.""" + + def operation(component, arg1, arg2=None): + pass + + with pytest.raises(ValueError, match="missing type annotation"): + FunctionProperties.from_function(operation) + + +def test_from_function_with_return_type(): + """Test that return type is correctly captured.""" + + def operation(component: DummyQuantumComponent) -> int: + return 42 + + props = FunctionProperties.from_function(operation) + assert props.return_type == int + + +def test_from_function_with_optional_return_type(): + """Test handling of Optional return type.""" + from typing import Optional + + def operation(component: DummyQuantumComponent) -> Optional[int]: + return None + + props = FunctionProperties.from_function(operation) + assert props.return_type == Optional[int] + + +def test_from_function_with_qua_return_type(): + """Test handling of QUA variable return types.""" + from qm.qua._expressions import QuaBoolType + + def operation(component: DummyQuantumComponent) -> QuaBoolType: + pass + + props = FunctionProperties.from_function(operation) + assert props.return_type == QuaBoolType + + +def test_from_function_without_return_type(): + """Test handling of functions without return type annotation.""" + + def operation(component: DummyQuantumComponent): + pass + + props = FunctionProperties.from_function(operation) + assert props.return_type is None diff --git a/tests/operations/test_operations.py b/tests/operations/test_operations.py new file mode 100644 index 00000000..4763d3dd --- /dev/null +++ b/tests/operations/test_operations.py @@ -0,0 +1,122 @@ +import pytest +from quam.core.operation.operation import Operation +from quam.core.operation.function_properties import FunctionProperties +from quam.components import Qubit +from quam.components.macro import QubitMacro +from quam.core import quam_dataclass + + +def test_operation_initialization(): + def sample_op(qubit: Qubit): + pass + + op = Operation(sample_op) + assert op.func == sample_op + assert isinstance(op.properties, FunctionProperties) + assert op.properties.name == "sample_op" + assert op.properties.quantum_component_type == Qubit + + +def test_operation_get_macro(test_qubit): + def x_gate(qubit: Qubit): + pass + + op = Operation(x_gate) + retrieved_macro = op.get_macro(test_qubit) + assert retrieved_macro == test_qubit.macros["x_gate"] + + +def test_operation_get_macro_missing(test_qubit): + def missing_op(qubit: Qubit): + pass + + op = Operation(missing_op) + with pytest.raises(KeyError, match="Operation 'missing_op' is not implemented"): + op.get_macro(test_qubit) + + +def test_operation_call(test_qubit): + def test_op(qubit: Qubit, amplitude: float = 1.0): + pass + + op = Operation(test_op) + result = op(test_qubit, amplitude=0.5) + + # Check results + assert result[0] == test_qubit # First element should be the qubit + assert result[1] == () # No positional args + assert result[2] == {"amplitude": 0.5} # Keyword args + + +def test_operation_call_invalid_component(): + def test_op(qubit: Qubit): + pass + + op = Operation(test_op) + + # Try to call with wrong type + with pytest.raises(ValueError, match="First argument to test_op must be a Qubit"): + op("not_a_qubit") + + +def test_operation_call_no_args(): + def test_op(qubit: Qubit): + pass + + op = Operation(test_op) + + # Try to call with no arguments + with pytest.raises( + ValueError, match="Operation test_op requires at least one argument" + ): + op() + + +def test_operation_call_multiple_args(test_qubit): + def test_op(qubit: Qubit, arg1: float, arg2: str): + pass + + op = Operation(test_op) + result = op(test_qubit, 1.0, "test") + + assert result[0] == test_qubit + assert result[1] == (1.0, "test") + assert result[2] == {} # No keyword args + + +@quam_dataclass +class TestMacro2(QubitMacro): + """Test macro class that requires a positional argument""" + + def apply(self, required_arg, **kwargs): + # Return inputs to verify they were passed correctly + return (self.qubit, (required_arg,), kwargs) + + +def test_operation_call_out_of_order_kwargs(test_qubit): + def test_op(qubit: Qubit, arg1: float, arg2: str = "default"): + pass + + # Use TestMacro2 which requires a positional argument + macro = TestMacro2() + test_qubit.macros["test_op"] = macro + + op = Operation(test_op) + # Pass arg2 before arg1, making arg1 (a positional arg) into a kwarg + result = op(test_qubit, arg2="test", required_arg=1.0) + + assert result[0] == test_qubit + assert result[1] == (1.0,) # arg1 as positional arg + assert result[2] == {"arg2": "test"} # arg2 as kwarg + + +def test_measure_operation(test_qubit): + from qm.qua._expressions import QuaBoolType + + def measure(qubit: Qubit, **kwargs) -> QuaBoolType: + pass + + op = Operation(measure) + + assert op.properties.return_type == QuaBoolType + assert op.func == measure diff --git a/tests/operations/test_register_operations.py b/tests/operations/test_register_operations.py new file mode 100644 index 00000000..36055e23 --- /dev/null +++ b/tests/operations/test_register_operations.py @@ -0,0 +1,68 @@ +import pytest +from quam.core.operation.operations_registry import OperationsRegistry +from quam.core.operation.operation import Operation +from quam.components import Qubit + + +def test_operations_registry_initialization(): + registry = OperationsRegistry() + assert len(registry) == 0 + + +def test_register_operation_basic(): + def test_op(qubit: Qubit): + pass + + registry = OperationsRegistry() + wrapped_op = registry.register_operation(test_op) + + # Check the operation was registered + assert "test_op" in registry + assert isinstance(registry["test_op"], Operation) + + # Check the wrapped operation maintains the original function's metadata + assert wrapped_op.__name__ == "test_op" + assert wrapped_op.__doc__ == test_op.__doc__ + + +def test_register_operation_as_decorator(): + registry = OperationsRegistry() + + @registry.register_operation + def test_op(qubit: Qubit): + pass + + assert "test_op" in registry + assert isinstance(registry["test_op"], Operation) + + +def test_register_multiple_operations(): + registry = OperationsRegistry() + + def op1(qubit: Qubit): + pass + + def op2(qubit: Qubit): + pass + + registry.register_operation(op1) + registry.register_operation(op2) + + assert len(registry) == 2 + assert "op1" in registry + assert "op2" in registry + + +def test_registered_operation_callable(test_qubit): + registry = OperationsRegistry() + + @registry.register_operation + def test_op(qubit: Qubit, amplitude: float = 1.0): + pass + + # Verify the registered operation can be called and works correctly + result = registry["test_op"](test_qubit, amplitude=0.5) + + assert result[0] == test_qubit + assert result[1] == () + assert result[2] == {"amplitude": 0.5}