From 7147937dc73f0a590596ba0ef7cf0bd5033438d0 Mon Sep 17 00:00:00 2001 From: omrieoqm Date: Mon, 30 Mar 2026 17:37:55 +0300 Subject: [PATCH 1/9] Bump version 0.3.0 (#80) * RC for CZ * bug fix * measurment twirl init code * init measurement twirl * Add target_detuning_from_sweet_spot attribute to XYDriveMW class * Add extras attribute to BaseQuam class for additional QUAM attributes * measurment twirl init version * RC WOP * Add kappa attribute to ReadoutResonatorMW class for improved configuration * Add pumpline and signalline attenuation attributes to TWPA class * Add at_sweep_spot attribute to FluxTunableTransmon class for calibration control * Update version to 0.2.3.1 in pyproject.toml and modify set_all_fluxes method to handle None target case in FluxTunableQuam class * Update version to 0.2.2 in pyproject.toml, remove unused twirl attributes from ReadoutResonatorMW and BaseTransmon classes, and clean up CZGate class by removing commented-out code for randomized compiling. * Update CHANGELOG with new features and changes, and revert version to 0.2.1 in pyproject.toml. * changelog fix * Refactor TWPA and XYDriveBase components by removing unused attributes and adding a default dictionary for extras. Clean up imports for better clarity. * Add kappa attribute to ReadoutResonatorBase and update documentation for TWPA component. Remove unused target_detuning_from_sweet_spot attribute from XYDriveMW class. * quick fix * Remove unused at_sweep_spot parameter from FluxTunableTransmon class documentation. * Update CHANGELOG to reflect recent additions and changes * Update CHANGELOG and add extras attribute to ReadoutResonatorBase for additional configuration options. * black formatting * pre-commit hook * fix: move pylint dependencies to dev group in the toml file, remove extraneous .md planning file * fix: remove deprecated quantum-dot tests * fix: only run tests in tests dir * Allow more ancestors * fix: update quantum dot xy_channel -> xy API in tests * bump new version --------- Co-authored-by: TheoQM Co-authored-by: sebastianorbell-qm --- .github/workflows/ci.yml | 2 +- .idea/inspectionProfiles/Project_Default.xml | 138 +--- .pylintrc | 4 + CHANGELOG.md | 12 +- PLAN_mixin_improvements.md | 147 ----- pyproject.toml | 22 +- .../components/cross_resonance.py | 12 +- .../superconducting/components/flux_line.py | 4 +- .../components/readout_resonator.py | 13 +- .../superconducting/components/twpa.py | 47 +- .../superconducting/components/xy_drive.py | 13 +- .../superconducting/qpu/base_quam.py | 16 +- .../qpu/fixed_frequency_quam.py | 4 +- .../superconducting/qpu/flux_tunable_quam.py | 32 +- tests/pylint_plugin/test_pylint_qua_plugin.py | 32 +- tests/quantum_dots/test_builder_pulses.py | 8 +- tests/quantum_dots/test_macro_classes.py | 588 ------------------ tests/quantum_dots/test_macro_fluent_api.py | 540 ---------------- .../test_stage_workflow_persistence.py | 139 ----- .../test_sticky_voltage_tracking.py | 228 ------- .../test_wirer_builder_integration.py | 571 ----------------- uv.lock | 39 +- 22 files changed, 127 insertions(+), 2484 deletions(-) delete mode 100644 PLAN_mixin_improvements.md delete mode 100644 tests/quantum_dots/test_macro_classes.py delete mode 100644 tests/quantum_dots/test_macro_fluent_api.py delete mode 100644 tests/quantum_dots/test_stage_workflow_persistence.py delete mode 100644 tests/quantum_dots/test_sticky_voltage_tracking.py delete mode 100644 tests/quantum_dots/test_wirer_builder_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ea7b15..32819751 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: - name: Run tests with pytest run: | - uv run pytest --verbose --cov --cov-report=xml --cov-report=term + uv run pytest tests/ --verbose --cov --cov-report=xml --cov-report=term - name: Upload coverage to Codecov if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 590fc652..cb51ac93 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,117 +1,35 @@ - \ No newline at end of file + diff --git a/.pylintrc b/.pylintrc index d54e1b47..52e5d126 100644 --- a/.pylintrc +++ b/.pylintrc @@ -33,3 +33,7 @@ reports=no [FORMAT] # Optional — set your preferred line length max-line-length=120 + + +[DESIGN] +max-parents=15 diff --git a/CHANGELOG.md b/CHANGELOG.md index de867703..eb549cbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] + + +## [0.3.0] - 2026-03-30 ### Added - Add support for cloud-based QMM instances in `machine.connect()` - A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. +- TWPA: add `pumpline_attenuation` and `signalline_attenuation` (float, optional) attributes. +- BaseQuam , XYDriveBase and ReadoutResonatorBase: `extras` (dict) for additional QUAM-level attributes. +### Changed +- FluxTunableQuam.set_all_fluxes: `target` is now optional; when `target=None`, settle and align are applied to all qubits. ### Fixed - NV center - fix invalid `SPCM` component. ## [0.2.0] - 2025-10-29 ### Added -- Complete architecture for single NV centers. +- Complete architecture for single NV centers. - Macro class for the CZ gate on tunable transmons: `CZGate` - Add the `CZGate` fidelity and extras as dictionaries. - Architecture components for Quantum Dots: added support for `VoltageGate`, `GateSet`, `VoltageSequence`, `VirtualGateSet`, and `VirtualizationLayer`. @@ -37,7 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Builder functions for the general QUAM wiring. - Builder functions for Transmons. -[Unreleased]: https://github.com/qua-platform/quam-builder/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/qua-platform/quam-builder/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/qua-platform/quam-builder/releases/tag/v0.3.0 [0.2.0]: https://github.com/qua-platform/quam-builder/releases/tag/v0.2.0 [0.1.2]: https://github.com/qua-platform/quam-builder/releases/tag/v0.1.2 [0.1.1]: https://github.com/qua-platform/quam-builder/releases/tag/v0.1.1 diff --git a/PLAN_mixin_improvements.md b/PLAN_mixin_improvements.md deleted file mode 100644 index a8eb0d79..00000000 --- a/PLAN_mixin_improvements.md +++ /dev/null @@ -1,147 +0,0 @@ -# Plan: Mixin Improvements - -## Current Issues - -### 1. Duration Parameter Redundancy -Currently there are multiple duration parameters that overlap: - -- **VoltageTuningPoint.duration**: Default duration stored with the voltage point -- **StepPointMacro.hold_duration**: Duration override in the macro -- **add_point_with_step_macro(hold_duration, point_duration)**: Two parameters that confuse users - -The flow is: -``` -VoltageTuningPoint(duration=X) → StepPointMacro(hold_duration=Y) → apply(hold_duration=Z) -``` -Each layer can override the previous, but this is confusing and error-prone. - -### 2. VoltageTuningPoint vs Point Macros Redundancy -`VoltageTuningPoint` and `StepPointMacro`/`RampPointMacro` have overlapping purposes: -- Both store duration information -- `StepPointMacro` essentially wraps `VoltageTuningPoint` with a reference - -### 3. Macro Invocation Inconsistency -- `BasePointMacro` has `__call__` that handles parameter aliases (`duration` → `hold_duration`) -- Custom macros (e.g., `MeasureMacro`) don't have `__call__` -- Mixin's `macro_method` has fragile detection logic - ---- - -## Proposed Changes - -### Phase 1: Simplify Duration Parameters - -**Goal**: Single `duration` parameter throughout the chain. - -#### 1.1 Update VoltageTuningPoint -Keep `VoltageTuningPoint.duration` as the single source of truth for default duration. - -#### 1.2 Update Point Macros -Change `StepPointMacro` and `RampPointMacro`: -```python -@quam_dataclass -class StepPointMacro(BasePointMacro): - # Remove hold_duration attribute - use VoltageTuningPoint.duration as default - # Allow runtime override via apply(duration=...) - - def apply(self, duration: int | None = None, **kwargs): - """Execute step operation. - - Args: - duration: Override for hold duration (ns). If None, uses point's default. - """ - point = self._resolve_point(self) - effective_duration = duration if duration is not None else point.duration - self.voltage_sequence.step_to_point(self._get_point_name(), duration=effective_duration) -``` - -#### 1.3 Update Mixin Methods -Simplify `add_point_with_step_macro` and `with_step_point`: -```python -def add_point_with_step_macro( - self, - macro_name: str, - voltages: Optional[Dict[str, float]] = None, - duration: int = 100, # Single duration parameter - replace_existing_point: bool = True, -) -> StepPointMacro: -``` - -Remove `hold_duration` and `point_duration` - use single `duration`. - ---- - -### Phase 2: Move __call__ Logic to apply() - -**Goal**: Consistent macro invocation without relying on `__call__`. - -#### 2.1 Update BasePointMacro.apply() -Use `duration` directly (no aliasing needed since not rolled out): -```python -def apply(self, *args, duration: int | None = None, **kwargs): - effective_duration = duration if duration is not None else self._resolve_point(self).duration - # ... rest of implementation -``` - -#### 2.2 Remove __call__ from BasePointMacro -Delete `__call__` entirely - not needed since `apply()` handles everything. - -#### 2.3 Simplify Mixin's macro_method -Revert to simple `apply()` call: -```python -def macro_method(**kwargs): - return macros_dict[name].apply(**kwargs) -``` - ---- - -### Phase 3: Clarify VoltageTuningPoint vs Point Macros Roles - -**Goal**: Clear separation of concerns. - -#### Current Confusion -- `VoltageTuningPoint`: Data class storing voltages + duration -- `StepPointMacro`: Wrapper that references a VoltageTuningPoint and can override duration - -#### Proposed Clarification - -**VoltageTuningPoint** = "What voltages to apply" (stored in `gate_set.macros`) -- `voltages: Dict[str, float]` -- `duration: int` (default hold duration) - -**StepPointMacro/RampPointMacro** = "How to get there" (stored in `component.macros`) -- References a VoltageTuningPoint -- Defines transition behavior (step vs ramp) -- Can override duration at call time - -This is actually a reasonable separation - keep it but document clearly. - ---- - -## Implementation Order - -1. **Phase 2 first** - Move `__call__` logic to `apply()` (lowest risk, enables Phase 1) -2. **Phase 1** - Simplify duration parameters (breaking change, needs migration) -3. **Phase 3** - Documentation clarification (no code changes) - ---- - -## Files to Modify - -1. `quam_builder/architecture/quantum_dots/macros/point_macros.py` - - Remove `hold_duration` attribute from `StepPointMacro` and `RampPointMacro` - - Update `apply()` to use `duration` parameter, defaulting to point's duration - - Remove `__call__` from `BasePointMacro` - -2. `quam_builder/architecture/quantum_dots/components/mixin.py` - - Simplify `macro_method` to always use `apply()` - - Update `add_point_with_step_macro()`: remove `hold_duration` and `point_duration`, use single `duration` - - Update `add_point_with_ramp_macro()`: same simplification - - Update `with_step_point()`: remove `hold_duration` and `point_duration`, use single `duration` - - Update `with_ramp_point()`: same simplification - -3. `quam_builder/architecture/quantum_dots/components/gate_set.py` - - No changes needed to `VoltageTuningPoint` (already has single `duration`) - -4. Tests and examples - - Update to use new `duration` parameter name diff --git a/pyproject.toml b/pyproject.toml index 521185b3..de1a76ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quam-builder" -version = "0.2.0" +version = "0.3.0" description = "A Python tool designed to programmatically construct QUAM (Quantum Abstract Machine) configurations for the Quantum Orchestration Platform (QOP)." readme = "README.md" authors = [{ name = "Theo Laudat", email = "theo@quantum-machines.co" }] @@ -28,6 +28,12 @@ allow-direct-references = true dev = [ "ipykernel>=7.0.0a1", "pre-commit", + "pylint>=3.0", + "astroid>=3.0", + "black", + "ruff", + "mypy", + "commitizen", "pytest>=8.3.5", "pytest-cov", "pytest-mock>=3.14.0", @@ -42,20 +48,6 @@ prerelease = "allow" [tool.uv] prerelease = "allow" -[project.optional-dependencies] -dev = [ - "pre-commit", - "black", - "pylint>=3.0", - "astroid>=3.0", - "ruff", - "mypy", - "pytest", - "pytest-cov", - "commitizen", -] - - # ----------------------------- # Ruff configuration # ----------------------------- diff --git a/quam_builder/architecture/superconducting/components/cross_resonance.py b/quam_builder/architecture/superconducting/components/cross_resonance.py index 9cfdeb96..6db47ace 100644 --- a/quam_builder/architecture/superconducting/components/cross_resonance.py +++ b/quam_builder/architecture/superconducting/components/cross_resonance.py @@ -29,22 +29,14 @@ def upconverter_frequency(self): @property def inferred_intermediate_frequency(self): - return ( - self.target_qubit_LO_frequency - + self.target_qubit_IF_frequency - - self.LO_frequency - ) + return self.target_qubit_LO_frequency + self.target_qubit_IF_frequency - self.LO_frequency @quam_dataclass class CrossResonanceMW(MWChannel, CrossResonanceBase): @property def inferred_intermediate_frequency(self): - return ( - self.target_qubit_LO_frequency - + self.target_qubit_IF_frequency - - self.LO_frequency - ) + return self.target_qubit_LO_frequency + self.target_qubit_IF_frequency - self.LO_frequency @property def upconverter_frequency(self): diff --git a/quam_builder/architecture/superconducting/components/flux_line.py b/quam_builder/architecture/superconducting/components/flux_line.py index c513c7db..25c0d157 100644 --- a/quam_builder/architecture/superconducting/components/flux_line.py +++ b/quam_builder/architecture/superconducting/components/flux_line.py @@ -29,9 +29,7 @@ class FluxLine(SingleChannel): joint_offset: float = 0.0 min_offset: float = 0.0 arbitrary_offset: float = 0.0 - flux_point: Literal["joint", "independent", "min", "arbitrary", "zero"] = ( - "independent" - ) + flux_point: Literal["joint", "independent", "min", "arbitrary", "zero"] = "independent" settle_time: float = None def settle(self): diff --git a/quam_builder/architecture/superconducting/components/readout_resonator.py b/quam_builder/architecture/superconducting/components/readout_resonator.py index 2124a099..0a7e2a19 100644 --- a/quam_builder/architecture/superconducting/components/readout_resonator.py +++ b/quam_builder/architecture/superconducting/components/readout_resonator.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Optional, Dict, Any +from dataclasses import field from quam.core import quam_dataclass from quam.components.channels import InOutIQChannel, InOutMWChannel @@ -42,10 +43,10 @@ class ReadoutResonatorBase: gef_confusion_matrix: list = None GEF_frequency_shift: float = None + extras: Dict[str, Any] = field(default_factory=dict) + @staticmethod - def calculate_voltage_scaling_factor( - fixed_power_dBm: float, target_power_dBm: float - ): + def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -109,9 +110,7 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel( - self, power_in_dbm, gain, max_amplitude, Z, operation - ) + return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) @quam_dataclass diff --git a/quam_builder/architecture/superconducting/components/twpa.py b/quam_builder/architecture/superconducting/components/twpa.py index 8402df1f..85f433a8 100644 --- a/quam_builder/architecture/superconducting/components/twpa.py +++ b/quam_builder/architecture/superconducting/components/twpa.py @@ -2,8 +2,7 @@ from quam.components.channels import IQChannel from quam import QuamComponent from typing import Union, ClassVar -from qm.qua import align, wait, update_frequency -import numpy as np +from qm.qua import update_frequency __all__ = ["TWPA"] @@ -16,23 +15,23 @@ class TWPA(QuamComponent): Args: id (str, int): The id of the TWPA, used to generate the name. Can be a string, or an integer in which case it will add`Channel._default_label`. - pump (IQChannel): The pump component(sticky element) used for continuous output. + pump (IQChannel): The pump component(sticky element) used for continuous output. pump_ (IQChannel): The pump component(non sticky element)used for TWPA calibration - spectroscopy (IQChannel): Probe tone used for calibrating the saturation power of the TWPA max_avg_gain (float): The maximum average gain around the readout resonators related to the TWPA max_avg_snr_improvement (float): The maximum average SNR improvement around the readout resonators related to the TWPA - pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement - pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement + pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement + pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement mltpx_pump_frequency (float): calibrated pump frequency at which twpa gives proper snr improvement for multiplexed readout mltpx_pump_amplitude (float): calibrated pump amplitude at which twpa gives proper snr improvement for multiplexed readout - p_saturation (float): calibrated saturation power of the twpa + p_saturation (float): calibrated saturation power of the twpa avg_std_gain (float): standard deviation of the average gain around the readout resonators related to the TWPA avg_std_snr_improvement (float): standard deviation of the average snr improvement around the readout resonators related to the TWPA - + dispersive_feature (float): dispersive feature of the twpa defined from it's designed parameters qubits (list): list of qubits of which the signals are amplified by the twpa - + pumpline_attenuation (float): attenuation in dB of the pump line from the OPX to the input of the TWPA + signalline_attenuation (float): attenuation in dB on the signal line from the OPX to the input of the TWPA initialization (bool): whether to use the twpa in the QUA program or not _initialized_ids (ClassVar[set]): A class-level set to track initialized twpa object IDs externally. This won't be serialized since it's not an instance attribute. @@ -43,52 +42,48 @@ class TWPA(QuamComponent): pump: IQChannel = None pump_: IQChannel = None - spectroscopy: IQChannel = None max_avg_gain: float = None max_avg_snr_improvement: float = None - pump_frequency : float = None - pump_amplitude : float = None - mltpx_pump_frequency : float = None - mltpx_pump_amplitude : float = None + pump_frequency: float = None + pump_amplitude: float = None + mltpx_pump_frequency: float = None + mltpx_pump_amplitude: float = None p_saturation: float = None - avg_std_gain: float=None - avg_std_snr_improvement: float= None + avg_std_gain: float = None + avg_std_snr_improvement: float = None dispersive_feature: float = None qubits: list = None - + + pumpline_attenuation: float = None + signalline_attenuation: float = None initialization: bool = True _initialized_ids: ClassVar[set] = set() - @property def name(self): """The name of the twpa""" return self.id if isinstance(self.id, str) else f"twpa{self.id}" - - def initialize(self): # dont use twpa for the QUA program if initialization is set to False if not self.initialization: - return + return # Check initialization state using object ID (memory address) - # Initialize TWPA pump only when it hasn't been initialized yet + # Initialize TWPA pump only when it hasn't been initialized yet # This won't be serialized since it's stored in a class-level set obj_id = id(self) if obj_id in self._initialized_ids: return - + f_p = self.pump_frequency p_p = self.pump_amplitude update_frequency( self.pump.name, - f_p+ self.pump.intermediate_frequency, + f_p + self.pump.intermediate_frequency, ) self.pump.play("pump", amplitude_scale=p_p) # Store object ID externally (won't be serialized) # guarantee initializing twpa pump only once per QUA program execution self._initialized_ids.add(obj_id) - - diff --git a/quam_builder/architecture/superconducting/components/xy_drive.py b/quam_builder/architecture/superconducting/components/xy_drive.py index 6af81284..708b6d67 100644 --- a/quam_builder/architecture/superconducting/components/xy_drive.py +++ b/quam_builder/architecture/superconducting/components/xy_drive.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Optional, Dict, Any +from dataclasses import field from quam.core import quam_dataclass from quam.components.channels import IQChannel, MWChannel @@ -21,10 +22,10 @@ class XYDriveBase: QUAM component for a XY drive line. """ + extras: Dict[str, Any] = field(default_factory=dict) + @staticmethod - def calculate_voltage_scaling_factor( - fixed_power_dBm: float, target_power_dBm: float - ): + def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -92,9 +93,7 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel( - self, power_in_dbm, gain, max_amplitude, Z, operation - ) + return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) @quam_dataclass diff --git a/quam_builder/architecture/superconducting/qpu/base_quam.py b/quam_builder/architecture/superconducting/qpu/base_quam.py index 3631cd44..207f48d8 100644 --- a/quam_builder/architecture/superconducting/qpu/base_quam.py +++ b/quam_builder/architecture/superconducting/qpu/base_quam.py @@ -39,7 +39,7 @@ class BaseQuam(QuamRoot): ports (Union[FEMPortsContainer, OPXPlusPortsContainer]): The ports container. # _data_handler (ClassVar[DataHandler]): The data handler. # Unused qmm (Optional[QuantumMachinesManager]): The Quantum Machines Manager. - + extras (dict): Additional attributes for the QUAM. Methods: get_serialiser: Get the serialiser for the QuamRoot class. get_octave_config: Return the Octave configuration. @@ -69,15 +69,15 @@ class BaseQuam(QuamRoot): qmm: ClassVar[Optional[QuantumMachinesManager]] = None + extras: dict = field(default_factory=dict) + @classmethod def get_serialiser(cls) -> JSONSerialiser: """Get the serialiser for the QuamRoot class, which is the JSONSerialiser. This method can be overridden by subclasses to provide a custom serialiser. """ - return JSONSerialiser( - content_mapping={"wiring": "wiring.json", "network": "wiring.json"} - ) + return JSONSerialiser(content_mapping={"wiring": "wiring.json", "network": "wiring.json"}) def get_octave_config(self) -> Optional[QmOctaveConfig]: """Return the Octave configuration.""" @@ -290,9 +290,7 @@ def connect(self) -> QuantumMachinesManager: f"Failed to initialize {qmm_class.__name__} with provided settings: {e}" ) from e except Exception as e: - raise ConnectionError( - f"Failed to connect to Quantum Machines Manager: {e}" - ) from e + raise ConnectionError(f"Failed to connect to Quantum Machines Manager: {e}") from e def calibrate_octave_ports(self, QM: QuantumMachine) -> None: """Calibrate the Octave ports for all the active qubits. @@ -306,9 +304,7 @@ def calibrate_octave_ports(self, QM: QuantumMachine) -> None: try: self.qubits[name].calibrate_octave(QM) except NoCalibrationElements: - print( - f"No calibration elements found for {name}. Skipping calibration." - ) + print(f"No calibration elements found for {name}. Skipping calibration.") @property def active_qubits(self) -> List[AnyTransmon]: diff --git a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py index 744d383e..388a55ee 100644 --- a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py +++ b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py @@ -28,9 +28,7 @@ class FixedFrequencyQuam(BaseQuam): """ qubit_type: ClassVar[Type[FixedFrequencyTransmon]] = FixedFrequencyTransmon - qubit_pair_type: ClassVar[Type[FixedFrequencyTransmonPair]] = ( - FixedFrequencyTransmonPair - ) + qubit_pair_type: ClassVar[Type[FixedFrequencyTransmonPair]] = FixedFrequencyTransmonPair qubits: Dict[str, FixedFrequencyTransmon] = field(default_factory=dict) qubit_pairs: Dict[str, FixedFrequencyTransmonPair] = field(default_factory=dict) diff --git a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py index fb94fd6e..bea9f9c7 100644 --- a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py +++ b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py @@ -55,17 +55,13 @@ def apply_all_flux_to_joint_idle(self) -> None: if q.z is not None: q.z.to_joint_idle() else: - warnings.warn( - f"Didn't find z-element on qubit {q.name}, didn't set to joint-idle" - ) + warnings.warn(f"Didn't find z-element on qubit {q.name}, didn't set to joint-idle") for q in self.qubits: if self.qubits[q] not in self.active_qubits: if self.qubits[q].z is not None: self.qubits[q].z.to_min() else: - warnings.warn( - f"Didn't find z-element on qubit {q}, didn't set to min" - ) + warnings.warn(f"Didn't find z-element on qubit {q}, didn't set to min") self.apply_all_couplers_to_min() def apply_all_flux_to_min(self) -> None: @@ -85,7 +81,7 @@ def apply_all_flux_to_zero(self) -> None: def set_all_fluxes( self, flux_point: str, - target: Union[FluxTunableTransmon, FluxTunableTransmonPair], + target: Union[FluxTunableTransmon, FluxTunableTransmonPair] | None = None, ): """Set the fluxes to the specified point for the target qubit or qubit pair. @@ -107,7 +103,7 @@ def set_all_fluxes( self.apply_all_flux_to_joint_idle() if isinstance(target, FluxTunableTransmonPair): target_bias = target.mutual_flux_bias - else: + elif isinstance(target, FluxTunableTransmon): target_bias = target.z.joint_offset else: self.apply_all_flux_to_min() @@ -120,11 +116,16 @@ def set_all_fluxes( target.to_mutual_idle() target_bias = target.mutual_flux_bias - target.z.settle() - target.align() + if target is None: + for q in self.qubits: + self.qubits[q].z.settle() + self.qubits[q].align() + else: + target.z.settle() + target.align() + return target_bias - def initialize_qpu(self, **kwargs): """Initialize the QPU with the calibrated TWPA pumping points and with the specified flux point and target @@ -134,14 +135,7 @@ def initialize_qpu(self, **kwargs): target: The qubit under study. """ for twpa in self.twpas.values(): - twpa.initialize() + twpa.initialize() flux_point = kwargs.get("flux_point", "joint") target = kwargs.get("target", None) self.set_all_fluxes(flux_point, target) - - - - - - - \ No newline at end of file diff --git a/tests/pylint_plugin/test_pylint_qua_plugin.py b/tests/pylint_plugin/test_pylint_qua_plugin.py index 1e193f3a..1e133327 100644 --- a/tests/pylint_plugin/test_pylint_qua_plugin.py +++ b/tests/pylint_plugin/test_pylint_qua_plugin.py @@ -154,24 +154,6 @@ def test_c1805_suppressed_in_qua_helper(self): class TestRegularPythonNotSuppressed: """Test that warnings are NOT suppressed for regular Python code.""" - def test_c1805_not_suppressed_in_regular_python(self): - """ - C1805 should still appear for regular Python code outside QUA contexts. - """ - return_code, stdout, stderr = run_pylint(SAMPLE_FILE, use_plugin=True) - - lines_with_c1805 = get_lines_with_message(stdout, "C1805") - - # regular_python_function is around lines 36-48 - # mixed_function is around lines 145-159 - # These SHOULD have C1805 warnings - - # At minimum, there should be SOME C1805 warnings for regular Python code - # The exact lines may vary, but we should see warnings outside QUA contexts - assert ( - len(lines_with_c1805) > 0 - ), "C1805 should still appear for regular Python code" - def test_c0121_not_suppressed_in_regular_python(self): """ C0121 should still appear for regular Python code outside QUA contexts. @@ -181,9 +163,7 @@ def test_c0121_not_suppressed_in_regular_python(self): lines_with_c0121 = get_lines_with_message(stdout, "C0121") # regular_python_function and mixed_function should have C0121 warnings - assert ( - len(lines_with_c0121) > 0 - ), "C0121 should still appear for regular Python code" + assert len(lines_with_c0121) > 0, "C0121 should still appear for regular Python code" class TestComparisonWithoutPlugin: @@ -227,16 +207,12 @@ def test_rabi_chevron_no_qua_false_positives(self): Run pylint on the actual rabi_chevron.py and verify QUA-related false positives are suppressed. """ - rabi_file = ( - REPO_ROOT / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" - ) + rabi_file = REPO_ROOT / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" return_code, stdout, stderr = run_pylint(rabi_file, use_plugin=True) # The file uses QUA constructs - verify no false positives # in the QUA program section - assert ( - "Error loading plugin" not in stderr - ), f"Plugin failed to load: {stderr}" + assert "Error loading plugin" not in stderr, f"Plugin failed to load: {stderr}" # Print output for debugging if there are issues if "C1805" in stdout or "C0121" in stdout: @@ -248,4 +224,4 @@ def test_rabi_chevron_no_qua_false_positives(self): if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"]) diff --git a/tests/quantum_dots/test_builder_pulses.py b/tests/quantum_dots/test_builder_pulses.py index 6c2a8be4..19895fd1 100644 --- a/tests/quantum_dots/test_builder_pulses.py +++ b/tests/quantum_dots/test_builder_pulses.py @@ -8,7 +8,7 @@ from quam.components import StickyChannelAddon from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort -from quam_builder.architecture.quantum_dots.components import ReadoutResonatorSingle, XYDrive +from quam_builder.architecture.quantum_dots.components import ReadoutResonatorSingle, XYDriveSingle from quam_builder.builder.quantum_dots.pulses import ( add_default_ldv_qubit_pulses, add_default_ldv_qubit_pair_pulses, @@ -23,7 +23,7 @@ def test_add_xy_pulses_to_qubit_with_xy(self): """Test that XY pulses are added when qubit has xy.""" # Create a mock qubit with xy qubit = MagicMock() - qubit.xy = XYDrive(opx_output="/tmp/opx", id="xy_drive") + qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) qubit.xy.operations = {} # Add default pulses @@ -57,7 +57,7 @@ def test_add_readout_pulses_to_qubit_with_resonator(self): def test_add_pulses_to_qubit_with_both_xy_and_resonator(self): """Test adding pulses when qubit has both xy and resonator.""" qubit = MagicMock() - qubit.xy = XYDrive(opx_output="/tmp/opx", id="xy_drive") + qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) qubit.xy.operations = {} qubit.resonator = ReadoutResonatorSingle( id="readout_resonator", @@ -87,7 +87,7 @@ def test_no_pulses_added_when_no_channels(self): def test_xy_pulse_properties(self): """Test that XY pulses have correct properties.""" qubit = MagicMock() - qubit.xy = XYDrive(opx_output="/tmp/opx", id="xy_drive") + qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) qubit.xy.operations = {} add_default_ldv_qubit_pulses(qubit) diff --git a/tests/quantum_dots/test_macro_classes.py b/tests/quantum_dots/test_macro_classes.py deleted file mode 100644 index f846b507..00000000 --- a/tests/quantum_dots/test_macro_classes.py +++ /dev/null @@ -1,588 +0,0 @@ -""" -Comprehensive tests for macro classes: StepPointMacro, RampPointMacro, and SequenceMacro. - -This test file covers: -1. StepPointMacro - creation, execution, reference resolution -2. RampPointMacro - creation, execution, reference resolution -3. SequenceMacro - creation, composition, nested sequences -4. Macro serialization and reference system -5. Parameter overrides -6. Error handling -""" - -import pytest -from qm import qua -from unittest.mock import patch - -from quam_builder.tools.macros.point_macros import ( - SequenceMacro, - StepPointMacro, - RampPointMacro, -) - -# ============================================================================ -# StepPointMacro Tests -# ============================================================================ - - -class TestStepPointMacro: - """Tests for StepPointMacro class.""" - - def test_step_point_macro_creation(self, machine): - """Test creating a StepPointMacro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create point first - qd.add_point("test_point", {"virtual_dot_1": 0.1}) - point_ref = f"#./voltage_sequence/gate_set/macros/{qd.id}_test_point" - - # Create macro - macro = StepPointMacro(point_ref=point_ref) - - assert macro.point_ref == point_ref - assert macro.macro_type == "step" - - def test_step_point_macro_inferred_duration(self, machine): - """Test StepPointMacro uses the point's duration.""" - qd = machine.quantum_dots["virtual_dot_1"] - qd.add_point("test", {"virtual_dot_1": 0.1}) - - macro = qd.add_point_with_step_macro("test_duration", {"virtual_dot_1": 0.1}, duration=1000) - - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_test_duration" - assert gate_set.macros[expected_point_name].duration == 1000 - - def test_step_point_macro_apply_method(self, machine): - """Test StepPointMacro.apply() executes correctly.""" - qd = machine.quantum_dots["virtual_dot_1"] - qd.with_step_point("apply_test", {"virtual_dot_1": 0.1}, duration=100) - - macro = qd.macros["apply_test"] - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - macro.apply() - - # Verify step_to_point was called with correct point name - mock_step.assert_called_once() - call_args = mock_step.call_args - assert f"{qd.id}_apply_test" in str(call_args) - - def test_step_point_macro_apply_with_override(self, machine): - """Test StepPointMacro.apply() with parameter override.""" - qd = machine.quantum_dots["virtual_dot_2"] - qd.with_step_point("override_test", {"virtual_dot_2": 0.2}, duration=100) - - macro = qd.macros["override_test"] - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - macro.apply(duration=250) - - assert mock_step.called - - def test_step_point_macro_callable_interface(self, machine): - """Test StepPointMacro.apply() can be invoked directly.""" - qd = machine.quantum_dots["virtual_dot_3"] - qd.with_step_point("callable_test", {"virtual_dot_3": 0.3}, duration=100) - - macro = qd.macros["callable_test"] - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - macro.apply() - - assert mock_step.called - - def test_step_point_macro_get_point_name(self, machine): - """Test _get_point_name() extracts point name from reference.""" - qd = machine.quantum_dots["virtual_dot_1"] - qd.with_step_point("name_test", {"virtual_dot_1": 0.1}, duration=100) - - macro = qd.macros["name_test"] - point_name = macro._get_point_name() - - assert point_name == f"{qd.id}_name_test" - - -# ============================================================================ -# RampPointMacro Tests -# ============================================================================ - - -class TestRampPointMacro: - """Tests for RampPointMacro class.""" - - def test_ramp_point_macro_creation(self, machine): - """Test creating a RampPointMacro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.add_point("ramp_test", {"virtual_dot_1": 0.3}) - point_ref = f"#./voltage_sequence/gate_set/macros/{qd.id}_ramp_test" - - macro = RampPointMacro(point_ref=point_ref, ramp_duration=500) - - assert macro.point_ref == point_ref - assert macro.ramp_duration == 500 - assert macro.macro_type == "ramp" - - def test_ramp_point_macro_inferred_duration(self, machine): - """Test RampPointMacro uses the point's duration and ramp duration.""" - qd = machine.quantum_dots["virtual_dot_1"] - - macro = qd.add_point_with_ramp_macro( - "ramp_duration", {"virtual_dot_1": 0.3}, duration=200, ramp_duration=500 - ) - - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_ramp_duration" - assert gate_set.macros[expected_point_name].duration == 200 - assert macro.ramp_duration == 500 - - def test_ramp_point_macro_apply_method(self, machine): - """Test RampPointMacro.apply() executes correctly.""" - qd = machine.quantum_dots["virtual_dot_2"] - qd.with_ramp_point("ramp_apply", {"virtual_dot_2": 0.25}, duration=200, ramp_duration=500) - - macro = qd.macros["ramp_apply"] - - with patch.object(qd.voltage_sequence, "ramp_to_point") as mock_ramp: - with qua.program() as prog: - macro.apply() - - mock_ramp.assert_called_once() - - def test_ramp_point_macro_apply_with_overrides(self, machine): - """Test RampPointMacro.apply() with both hold and ramp overrides.""" - qd = machine.quantum_dots["virtual_dot_3"] - qd.with_ramp_point( - "override_ramp", {"virtual_dot_3": 0.28}, duration=200, ramp_duration=500 - ) - - macro = qd.macros["override_ramp"] - - with patch.object(qd.voltage_sequence, "ramp_to_point") as mock_ramp: - with qua.program() as prog: - macro.apply(duration=300, ramp_duration=600) - - assert mock_ramp.called - - def test_ramp_point_macro_default_ramp_duration(self, machine): - """Test RampPointMacro uses default ramp_duration=16.""" - qd = machine.quantum_dots["virtual_dot_1"] - - macro = qd.add_point_with_ramp_macro( - "default_ramp", - {"virtual_dot_1": 0.2}, - duration=100, - # ramp_duration not specified, should default to 16 - ) - - assert macro.ramp_duration == 16 - - -# ============================================================================ -# SequenceMacro Tests -# ============================================================================ - - -class TestSequenceMacro: - """Tests for SequenceMacro class.""" - - def test_sequence_macro_creation(self): - """Test creating a SequenceMacro.""" - seq = SequenceMacro(name="test_seq", macro_refs=()) - - assert seq.name == "test_seq" - assert seq.macro_refs == () - assert seq.description is None - - def test_sequence_macro_with_description(self): - """Test SequenceMacro with description.""" - seq = SequenceMacro( - name="test_seq", macro_refs=(), description="Test sequence for experiments" - ) - - assert seq.description == "Test sequence for experiments" - - def test_sequence_macro_with_reference(self): - """Test SequenceMacro.with_reference() method.""" - seq = SequenceMacro(name="test_seq", macro_refs=()) - - new_seq = seq.with_reference("#./macros/idle") - - # Should return new instance - assert new_seq is not seq - assert new_seq.macro_refs == ("#./macros/idle",) - # Original unchanged - assert seq.macro_refs == () - - def test_sequence_macro_with_multiple_references(self): - """Test chaining with_reference() calls.""" - seq = SequenceMacro(name="test_seq", macro_refs=()) - - new_seq = ( - seq.with_reference("#./macros/idle") - .with_reference("#./macros/load") - .with_reference("#./macros/measure") - ) - - assert len(new_seq.macro_refs) == 3 - assert new_seq.macro_refs[0] == "#./macros/idle" - assert new_seq.macro_refs[1] == "#./macros/load" - assert new_seq.macro_refs[2] == "#./macros/measure" - - def test_sequence_macro_with_macro_helper(self, machine): - """Test SequenceMacro.with_macro() helper method.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create some macros - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - - # Create sequence using with_macro - seq = SequenceMacro(name="test_seq", macro_refs=()) - new_seq = seq.with_macro(qd, "idle") - - assert len(new_seq.macro_refs) == 1 - assert "#./macros/idle" in new_seq.macro_refs[0] - - def test_sequence_macro_with_macros_helper(self, machine): - """Test SequenceMacro.with_macros() helper method.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create macros - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - .with_step_point("load", {"virtual_dot_1": 0.3}, duration=200) - .with_step_point("measure", {"virtual_dot_1": 0.15}, duration=300) - ) - - # Create sequence using with_macros (plural) - seq = SequenceMacro(name="test_seq", macro_refs=()) - new_seq = seq.with_macros(qd, ["idle", "load", "measure"]) - - assert len(new_seq.macro_refs) == 3 - - def test_sequence_macro_resolved_macros(self, machine): - """Test SequenceMacro.resolved_macros() resolves references.""" - qd = machine.quantum_dots["virtual_dot_1"] - - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - .with_step_point("measure", {"virtual_dot_1": 0.2}, duration=200) - .with_sequence("test_seq", ["idle", "measure"]) - ) - - seq_macro = qd.macros["test_seq"] - resolved = seq_macro.resolved_macros(qd) - - # Should resolve to actual macro objects - assert len(resolved) == 2 - assert isinstance(resolved[0], StepPointMacro) - assert isinstance(resolved[1], StepPointMacro) - - def test_sequence_macro_apply_executes_all(self, machine): - """Test SequenceMacro.apply() executes all macros in sequence.""" - qd = machine.quantum_dots["virtual_dot_1"] - - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - .with_step_point("measure", {"virtual_dot_1": 0.2}, duration=200) - .with_sequence("test_seq", ["idle", "measure"]) - ) - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - qd.test_seq() - - # Should call step_to_point twice - assert mock_step.call_count == 2 - - def test_sequence_macro_callable_interface(self, machine): - """Test SequenceMacro can be called as a function.""" - qd = machine.quantum_dots["virtual_dot_2"] - - ( - qd.with_step_point("idle", {"virtual_dot_2": 0.1}, duration=100).with_sequence( - "callable_seq", ["idle"] - ) - ) - - seq_macro = qd.macros["callable_seq"] - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - seq_macro() # Call as function - - assert mock_step.called - - def test_sequence_macro_total_duration(self, machine): - """Test SequenceMacro.total_duration_seconds() returns None when durations are unavailable.""" - qd = machine.quantum_dots["virtual_dot_1"] - - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=1000) # 1000ns - .with_step_point("measure", {"virtual_dot_1": 0.2}, duration=2000) # 2000ns - .with_sequence("timed_seq", ["idle", "measure"]) - ) - - seq_macro = qd.macros["timed_seq"] - total_duration = seq_macro.total_duration_seconds(qd) - - assert total_duration is None - - def test_nested_sequence_execution(self, machine): - """Test nested sequences execute correctly.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create primitive macros - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - .with_step_point("load", {"virtual_dot_1": 0.3}, duration=200) - .with_step_point("manipulate", {"virtual_dot_1": 0.25}, duration=150) - .with_step_point("readout", {"virtual_dot_1": 0.15}, duration=300) - ) - - # Create sub-sequences - qd.with_sequence("init", ["idle", "load"]) - qd.with_sequence("readout_seq", ["manipulate", "readout"]) - - # Create top-level sequence - qd.with_sequence("full_experiment", ["init", "readout_seq"]) - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - qd.full_experiment() - - # Should execute all 4 primitive operations - assert mock_step.call_count == 4 - - -# ============================================================================ -# Macro Reference System Tests -# ============================================================================ - - -class TestMacroReferenceSystem: - """Tests for the macro reference system.""" - - def test_point_reference_creation(self, machine): - """Test that point references are created correctly.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("ref_test", {"virtual_dot_1": 0.1}, duration=100) - - macro = qd.macros["ref_test"] - - # Get raw reference string (bypass QUAM's automatic resolution) - point_ref_raw = object.__getattribute__(macro, "__dict__").get("point_ref") - - # Reference should point to gate set macros - assert point_ref_raw is not None - assert isinstance(point_ref_raw, str) - assert "/macros/" in point_ref_raw - assert f"{qd.id}_ref_test" in point_ref_raw - - def test_macro_reference_in_sequence(self, machine): - """Test that sequence macros store references to other macros.""" - qd = machine.quantum_dots["virtual_dot_1"] - - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100).with_sequence( - "test_seq", ["idle"] - ) - ) - - seq_macro = qd.macros["test_seq"] - - # Sequence should store reference string - assert len(seq_macro.macro_refs) == 1 - assert isinstance(seq_macro.macro_refs[0], str) - assert "#./macros/idle" in seq_macro.macro_refs[0] - - def test_reference_resolution_after_modification(self, machine): - """Test that references resolve correctly even after point modification.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create point and macro - qd.with_step_point("changeable", {"virtual_dot_1": 0.1}, duration=100) - - # Modify the point - qd.add_point("changeable", {"virtual_dot_1": 0.2}, replace_existing_point=True) - - # Reference should still resolve - macro = qd.macros["changeable"] - with patch.object(qd.voltage_sequence, "step_to_point"): - with qua.program() as prog: - macro.apply() # Should not raise error - - -# ============================================================================ -# Error Handling Tests -# ============================================================================ - - -class TestMacroErrorHandling: - """Tests for error handling in macros.""" - - def test_macro_without_parent_error(self): - """Test that calling macro without parent raises error.""" - macro = StepPointMacro( - point_ref="#./voltage_sequence/gate_set/macros/test", - ) - - with pytest.raises(AttributeError): - macro.apply() - - def test_sequence_invalid_reference_error(self, machine): - """Test that SequenceMacro raises error for invalid reference.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("valid", {"virtual_dot_1": 0.1}, duration=100) - - # Manually create sequence with invalid reference - seq = SequenceMacro(name="bad_seq", macro_refs=("#./macros/nonexistent",)) - qd.macros["bad_seq"] = seq - - from quam.utils.exceptions import InvalidReferenceError - - with pytest.raises(InvalidReferenceError): - with qua.program() as prog: - seq.apply() - - def test_sequence_with_nonexistent_macro_error(self, machine): - """Test with_sequence raises error for nonexistent macro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("exists", {"virtual_dot_1": 0.1}, duration=100) - - with pytest.raises(KeyError, match="not found"): - qd.with_sequence("bad_seq", ["exists", "does_not_exist"]) - - def test_invalid_point_reference_format(self, machine): - """Test that invalid point reference format is handled.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create macro with malformed reference - macro = StepPointMacro( - point_ref="invalid_reference_format", - ) - qd.macros["bad_macro"] = macro - - # The _get_point_name should return the last segment even for invalid format - # It will only fail when trying to resolve the reference later - point_name = macro._get_point_name() - assert point_name == "invalid_reference_format" - - -# ============================================================================ -# Serialization Tests -# ============================================================================ - - -class TestMacroSerialization: - """Tests for macro serialization and deserialization.""" - - def test_step_point_macro_serializable(self, machine): - """Test that StepPointMacro can be serialized.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("serialize_test", {"virtual_dot_1": 0.1}, duration=100) - - # Get the macro - macro = qd.macros["serialize_test"] - - # Check it has serializable attributes (get raw value to avoid QUAM's auto-resolution) - point_ref_raw = object.__getattribute__(macro, "__dict__").get("point_ref") - assert point_ref_raw is not None - assert isinstance(point_ref_raw, str) - - def test_ramp_point_macro_serializable(self, machine): - """Test that RampPointMacro can be serialized.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_ramp_point( - "ramp_serialize", {"virtual_dot_1": 0.3}, duration=200, ramp_duration=500 - ) - - macro = qd.macros["ramp_serialize"] - - # Get raw reference string (bypass QUAM's automatic resolution) - point_ref_raw = object.__getattribute__(macro, "__dict__").get("point_ref") - assert isinstance(point_ref_raw, str) - assert isinstance(macro.ramp_duration, int) - - def test_sequence_macro_serializable(self, machine): - """Test that SequenceMacro can be serialized.""" - qd = machine.quantum_dots["virtual_dot_1"] - - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100).with_sequence( - "seq_serialize", ["idle"] - ) - ) - - seq = qd.macros["seq_serialize"] - - # Should only contain serializable types - assert isinstance(seq.name, str) - assert isinstance(seq.macro_refs, tuple) - assert all(isinstance(ref, str) for ref in seq.macro_refs) - - -# ============================================================================ -# Integration Tests -# ============================================================================ - - -class TestMacroIntegration: - """Integration tests for complete macro workflows.""" - - def test_complete_experiment_workflow(self, machine): - """Test a complete experimental workflow with macros.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Define complete workflow using fluent API - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.05}, duration=100) - .with_ramp_point("initialize", {"virtual_dot_1": 0.15}, duration=200, ramp_duration=500) - .with_step_point("manipulate", {"virtual_dot_1": 0.25}, duration=150) - .with_ramp_point( - "readout_pos", {"virtual_dot_1": 0.12}, duration=300, ramp_duration=400 - ) - .with_sequence("initialization", ["idle", "initialize"]) - .with_sequence("measurement", ["manipulate", "readout_pos"]) - .with_sequence("full_experiment", ["initialization", "measurement"]) - ) - - # Execute complete experiment - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with patch.object(qd.voltage_sequence, "ramp_to_point") as mock_ramp: - with qua.program() as prog: - qd.full_experiment() - - # Verify all operations were executed - assert mock_step.call_count == 2 # idle, manipulate - assert mock_ramp.call_count == 2 # initialize, readout_pos - - def test_workflow_with_parameter_overrides(self, machine): - """Test workflow where parameters are overridden at runtime.""" - qd = machine.quantum_dots["virtual_dot_2"] - - ( - qd.with_step_point("base", {"virtual_dot_2": 0.1}, duration=100).with_ramp_point( - "target", {"virtual_dot_2": 0.3}, duration=200, ramp_duration=500 - ) - ) - - with patch.object(qd.voltage_sequence, "step_to_point"): - with patch.object(qd.voltage_sequence, "ramp_to_point"): - with qua.program() as prog: - # Use default parameters - qd.base() - qd.target() - - # Override parameters - qd.base(duration=150) - qd.target(duration=300, ramp_duration=700) diff --git a/tests/quantum_dots/test_macro_fluent_api.py b/tests/quantum_dots/test_macro_fluent_api.py deleted file mode 100644 index 0a0c6d63..00000000 --- a/tests/quantum_dots/test_macro_fluent_api.py +++ /dev/null @@ -1,540 +0,0 @@ -""" -Comprehensive tests for the macro fluent API and new optional voltages functionality. - -This test file covers: -1. with_step_point() - creating new points and converting existing points -2. with_ramp_point() - creating new points and converting existing points -3. add_point_with_step_macro() - both use cases -4. add_point_with_ramp_macro() - both use cases -5. with_sequence() - creating sequence macros -6. Fluent API chaining -7. Macro execution and parameter overrides -8. Error handling for non-existent points -""" - -import pytest -from qm import qua -from unittest.mock import patch - -from quam_builder.tools.macros.point_macros import ( - SequenceMacro, - StepPointMacro, - RampPointMacro, -) - - -# ============================================================================ -# Fluent API Tests - with_step_point -# ============================================================================ - - -class TestWithStepPoint: - """Tests for with_step_point() method.""" - - def test_with_step_point_creates_new_point_and_macro(self, machine): - """Test that with_step_point creates both point and macro when voltages are provided.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Use fluent API to create point and macro - qd.with_step_point("idle", voltages={"virtual_dot_1": 0.1}, duration=100) - - # Verify point was created in gate set - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_idle" - assert expected_point_name in gate_set.macros - - # Verify macro was created - assert "idle" in qd.macros - assert isinstance(qd.macros["idle"], StepPointMacro) - assert gate_set.macros[expected_point_name].duration == 100 - - def test_with_step_point_converts_existing_point(self, machine): - """Test that with_step_point converts existing point to macro when voltages=None.""" - qd = machine.quantum_dots["virtual_dot_2"] - - # First, create a point without a macro - voltages = {"virtual_dot_2": 0.2} - qd.add_point("existing_point", voltages=voltages) - - # Verify point exists in gate set but macro doesn't exist yet - gate_set = qd.voltage_sequence.gate_set - expected_name = f"{qd.id}_existing_point" - assert expected_name in gate_set.macros - assert "existing_point" not in qd.macros - - # Now convert the existing point to a macro - qd.with_step_point("existing_point") - - # Verify macro was created for existing point - assert "existing_point" in qd.macros - assert isinstance(qd.macros["existing_point"], StepPointMacro) - assert gate_set.macros[expected_name].duration == 16 - - def test_with_step_point_nonexistent_point_raises_error(self, machine): - """Test that with_step_point raises error when point doesn't exist and voltages=None.""" - qd = machine.quantum_dots["virtual_dot_3"] - - with pytest.raises(KeyError, match="does not exist"): - qd.with_step_point("nonexistent_point", duration=100) - - def test_with_step_point_fluent_chaining(self, machine): - """Test that with_step_point returns self for fluent chaining.""" - qd = machine.quantum_dots["virtual_dot_4"] - - result = qd.with_step_point("idle", {"virtual_dot_4": 0.1}, duration=100).with_step_point( - "measure", {"virtual_dot_4": 0.2}, duration=200 - ) - - # Verify chaining works - assert result is qd - assert "idle" in qd.macros - assert "measure" in qd.macros - - def test_with_step_point_default_parameters(self, machine): - """Test with_step_point uses default parameter values correctly.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("default_test", {"virtual_dot_1": 0.15}) - - # Check defaults: duration=100 - macro = qd.macros["default_test"] - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_default_test" - assert gate_set.macros[expected_point_name].duration == 100 - - -# ============================================================================ -# Fluent API Tests - with_ramp_point -# ============================================================================ - - -class TestWithRampPoint: - """Tests for with_ramp_point() method.""" - - def test_with_ramp_point_creates_new_point_and_macro(self, machine): - """Test that with_ramp_point creates both point and macro when voltages are provided.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_ramp_point("load", voltages={"virtual_dot_1": 0.3}, duration=200, ramp_duration=500) - - # Verify point was created - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_load" - assert expected_point_name in gate_set.macros - - # Verify macro was created - assert "load" in qd.macros - assert isinstance(qd.macros["load"], RampPointMacro) - assert gate_set.macros[expected_point_name].duration == 200 - assert qd.macros["load"].ramp_duration == 500 - - def test_with_ramp_point_converts_existing_point(self, machine): - """Test that with_ramp_point converts existing point to macro when voltages=None.""" - qd = machine.quantum_dots["virtual_dot_2"] - - # Create a point without a macro - voltages = {"virtual_dot_2": 0.25} - qd.add_point("ramp_existing", voltages=voltages) - - # Convert to ramp macro - qd.with_ramp_point("ramp_existing", ramp_duration=400) - - # Verify macro was created - assert "ramp_existing" in qd.macros - assert isinstance(qd.macros["ramp_existing"], RampPointMacro) - gate_set = qd.voltage_sequence.gate_set - expected_name = f"{qd.id}_ramp_existing" - assert gate_set.macros[expected_name].duration == 16 - assert qd.macros["ramp_existing"].ramp_duration == 400 - - def test_with_ramp_point_nonexistent_point_raises_error(self, machine): - """Test that with_ramp_point raises error when point doesn't exist and voltages=None.""" - qd = machine.quantum_dots["virtual_dot_3"] - - with pytest.raises(KeyError, match="does not exist"): - qd.with_ramp_point("nonexistent", duration=100, ramp_duration=200) - - def test_with_ramp_point_fluent_chaining(self, machine): - """Test that with_ramp_point supports fluent chaining.""" - qd = machine.quantum_dots["virtual_dot_4"] - - result = qd.with_ramp_point( - "load", {"virtual_dot_4": 0.3}, duration=200, ramp_duration=500 - ).with_step_point("readout", {"virtual_dot_4": 0.15}, duration=1000) - - assert result is qd - assert "load" in qd.macros - assert "readout" in qd.macros - - def test_with_ramp_point_default_parameters(self, machine): - """Test with_ramp_point uses default parameter values correctly.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_ramp_point("ramp_default", {"virtual_dot_1": 0.35}) - - # Check defaults: duration=100, ramp_duration=16 - macro = qd.macros["ramp_default"] - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_ramp_default" - assert gate_set.macros[expected_point_name].duration == 100 - assert macro.ramp_duration == 16 - - -# ============================================================================ -# Tests - add_point_with_step_macro -# ============================================================================ - - -class TestAddPointWithStepMacro: - """Tests for add_point_with_step_macro() method.""" - - def test_add_point_with_step_macro_creates_new(self, machine): - """Test add_point_with_step_macro creates new point and macro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - macro = qd.add_point_with_step_macro( - "test_step", voltages={"virtual_dot_1": 0.12}, duration=150 - ) - - # Verify return value - assert isinstance(macro, StepPointMacro) - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_test_step" - assert gate_set.macros[expected_point_name].duration == 150 - - # Verify stored in macros dict - assert "test_step" in qd.macros - assert qd.macros["test_step"] is macro - - def test_add_point_with_step_macro_converts_existing(self, machine): - """Test add_point_with_step_macro converts existing point.""" - qd = machine.quantum_dots["virtual_dot_2"] - - # Create point first - qd.add_point("convert_step", {"virtual_dot_2": 0.22}) - - # Convert to macro - macro = qd.add_point_with_step_macro("convert_step") - - assert isinstance(macro, StepPointMacro) - assert "convert_step" in qd.macros - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_convert_step" - assert gate_set.macros[expected_point_name].duration == 16 - - def test_add_point_with_step_macro_nonexistent_raises_error(self, machine): - """Test add_point_with_step_macro raises error for nonexistent point.""" - qd = machine.quantum_dots["virtual_dot_3"] - - with pytest.raises(KeyError, match="does not exist"): - qd.add_point_with_step_macro("missing_point", duration=100) - - -# ============================================================================ -# Tests - add_point_with_ramp_macro -# ============================================================================ - - -class TestAddPointWithRampMacro: - """Tests for add_point_with_ramp_macro() method.""" - - def test_add_point_with_ramp_macro_creates_new(self, machine): - """Test add_point_with_ramp_macro creates new point and macro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - macro = qd.add_point_with_ramp_macro( - "test_ramp", voltages={"virtual_dot_1": 0.28}, duration=250, ramp_duration=600 - ) - - assert isinstance(macro, RampPointMacro) - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_test_ramp" - assert gate_set.macros[expected_point_name].duration == 250 - assert macro.ramp_duration == 600 - assert "test_ramp" in qd.macros - - def test_add_point_with_ramp_macro_converts_existing(self, machine): - """Test add_point_with_ramp_macro converts existing point.""" - qd = machine.quantum_dots["virtual_dot_2"] - - # Create point first - qd.add_point("convert_ramp", {"virtual_dot_2": 0.27}) - - # Convert to macro - macro = qd.add_point_with_ramp_macro("convert_ramp", ramp_duration=550) - - assert isinstance(macro, RampPointMacro) - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_convert_ramp" - assert gate_set.macros[expected_point_name].duration == 16 - assert macro.ramp_duration == 550 - - def test_add_point_with_ramp_macro_nonexistent_raises_error(self, machine): - """Test add_point_with_ramp_macro raises error for nonexistent point.""" - qd = machine.quantum_dots["virtual_dot_3"] - - with pytest.raises(KeyError, match="does not exist"): - qd.add_point_with_ramp_macro("missing_point", duration=100, ramp_duration=200) - - -# ============================================================================ -# Tests - with_sequence -# ============================================================================ - - -class TestWithSequence: - """Tests for with_sequence() method.""" - - def test_with_sequence_creates_sequence_macro(self, machine): - """Test with_sequence creates a SequenceMacro.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Create some macros first - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - qd.with_ramp_point("load", {"virtual_dot_1": 0.3}, duration=200, ramp_duration=500) - - # Create sequence - qd.with_sequence("init_sequence", ["idle", "load"]) - - # Verify sequence was created - assert "init_sequence" in qd.macros - assert isinstance(qd.macros["init_sequence"], SequenceMacro) - - def test_with_sequence_fluent_chaining(self, machine): - """Test with_sequence supports fluent chaining.""" - qd = machine.quantum_dots["virtual_dot_2"] - - result = ( - qd.with_step_point("idle", {"virtual_dot_2": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_2": 0.3}, duration=200, ramp_duration=500) - .with_step_point("measure", {"virtual_dot_2": 0.15}, duration=1000) - .with_sequence("full_cycle", ["idle", "load", "measure"]) - ) - - assert result is qd - assert "full_cycle" in qd.macros - - def test_with_sequence_nonexistent_macro_raises_error(self, machine): - """Test with_sequence raises error if referenced macro doesn't exist.""" - qd = machine.quantum_dots["virtual_dot_3"] - - qd.with_step_point("idle", {"virtual_dot_3": 0.1}, duration=100) - - with pytest.raises(KeyError, match="not found"): - qd.with_sequence("bad_sequence", ["idle", "nonexistent_macro"]) - - def test_with_sequence_nested_sequences(self, machine): - """Test creating nested sequences.""" - qd = machine.quantum_dots["virtual_dot_4"] - - # Create primitive macros - ( - qd.with_step_point("idle", {"virtual_dot_4": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_4": 0.3}, duration=200, ramp_duration=500) - .with_step_point("manipulate", {"virtual_dot_4": 0.25}, duration=150) - .with_step_point("readout", {"virtual_dot_4": 0.15}, duration=1000) - ) - - # Create sub-sequences - qd.with_sequence("init", ["idle", "load"]) - qd.with_sequence("readout_seq", ["manipulate", "readout"]) - - # Create higher-level sequence from sub-sequences - qd.with_sequence("full_experiment", ["init", "readout_seq"]) - - # Verify all sequences exist - assert "init" in qd.macros - assert "readout_seq" in qd.macros - assert "full_experiment" in qd.macros - - -# ============================================================================ -# Tests - Macro Execution and Parameter Overrides -# ============================================================================ - - -class TestMacroExecution: - """Tests for executing macros and parameter overrides.""" - - def test_macro_execution_via_method_call(self, machine): - """Test macros can be executed as methods via __getattr__.""" - qd = machine.quantum_dots["virtual_dot_1"] - - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - - # Mock the voltage sequence to verify the macro is called - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - qd.idle() - - # Verify the underlying method was called - mock_step.assert_called_once() - - def test_macro_parameter_override_duration(self, machine): - """Test parameter override for duration.""" - qd = machine.quantum_dots["virtual_dot_2"] - - qd.with_step_point("idle", {"virtual_dot_2": 0.1}, duration=100) - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - qd.idle(duration=250) - - # Verify override was passed through - # The call should use the overridden duration - assert mock_step.called - - def test_macro_parameter_override_ramp_duration(self, machine): - """Test parameter override for ramp_duration in RampPointMacro.""" - qd = machine.quantum_dots["virtual_dot_3"] - - qd.with_ramp_point("load", {"virtual_dot_3": 0.3}, duration=200, ramp_duration=500) - - with patch.object(qd.voltage_sequence, "ramp_to_point") as mock_ramp: - with qua.program() as prog: - qd.load(duration=300, ramp_duration=600) - - assert mock_ramp.called - - def test_sequence_macro_execution(self, machine): - """Test executing a sequence macro.""" - qd = machine.quantum_dots["virtual_dot_4"] - - ( - qd.with_step_point("idle", {"virtual_dot_4": 0.1}, duration=100) - .with_step_point("measure", {"virtual_dot_4": 0.2}, duration=200) - .with_sequence("test_seq", ["idle", "measure"]) - ) - - with patch.object(qd.voltage_sequence, "step_to_point") as mock_step: - with qua.program() as prog: - qd.test_seq() - - # Sequence should execute both macros - assert mock_step.call_count == 2 - - -# ============================================================================ -# Tests - Complex Fluent API Workflows -# ============================================================================ - - -class TestComplexFluentAPIWorkflows: - """Tests for complex workflows using the fluent API.""" - - def test_complete_fluent_workflow(self, machine): - """Test a complete workflow using fluent API.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Complete fluent chain - ( - qd.with_step_point("idle", {"virtual_dot_1": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_1": 0.3}, duration=200, ramp_duration=500) - .with_step_point("manipulate", {"virtual_dot_1": 0.25}, duration=150) - .with_step_point("readout", {"virtual_dot_1": 0.15}, duration=1000) - .with_sequence("init", ["idle", "load"]) - .with_sequence("readout_seq", ["manipulate", "readout"]) - .with_sequence("full_experiment", ["init", "readout_seq"]) - ) - - # Verify all macros were created (includes default macros) - assert len(qd.macros) == 9 - - # Verify sequence structure - assert isinstance(qd.macros["full_experiment"], SequenceMacro) - - def test_mixed_creation_methods(self, machine): - """Test mixing add_point, with_step_point, and with_sequence.""" - qd = machine.quantum_dots["virtual_dot_2"] - - # Mix different creation methods - qd.add_point("base_point", {"virtual_dot_2": 0.1}) - qd.with_step_point("base_point") # Convert existing - qd.with_step_point("new_point", {"virtual_dot_2": 0.2}, duration=200) # Create new - qd.with_sequence("mixed_seq", ["base_point", "new_point"]) - - # Verify everything works together - assert "base_point" in qd.macros - assert "new_point" in qd.macros - assert "mixed_seq" in qd.macros - - def test_updating_existing_point_and_macro(self, machine): - """Test that points and macros can be updated independently.""" - qd = machine.quantum_dots["virtual_dot_3"] - - # Create point and macro - qd.with_step_point("updateable", {"virtual_dot_3": 0.1}, duration=100) - - # Update the point (replace voltages) - qd.add_point( - "updateable", {"virtual_dot_3": 0.2}, duration=300, replace_existing_point=True - ) - - # Create new macro for same point (duration comes from point) - qd.with_step_point("updateable") - - # Verify the point duration was updated - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_updateable" - assert gate_set.macros[expected_point_name].duration == 300 - - -# ============================================================================ -# Tests - Error Handling and Edge Cases -# ============================================================================ - - -class TestErrorHandlingAndEdgeCases: - """Tests for error handling and edge cases.""" - - def test_empty_sequence_creation(self, machine): - """Test creating a sequence with no macros.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # This should succeed but create an empty sequence - qd.with_sequence("empty_seq", []) - - assert "empty_seq" in qd.macros - assert isinstance(qd.macros["empty_seq"], SequenceMacro) - - def test_macro_without_parent_raises_error(self): - """Test that calling a macro without parent raises appropriate error.""" - # Create a standalone macro (not attached to component) - macro = StepPointMacro( - point_ref="#./voltage_sequence/gate_set/macros/some_point", - ) - - with pytest.raises(AttributeError): - macro.apply() - - def test_point_name_with_special_characters(self, machine): - """Test creating points with underscores and numbers in names.""" - qd = machine.quantum_dots["virtual_dot_1"] - - # Should handle various name formats - qd.with_step_point("point_1", {"virtual_dot_1": 0.1}, duration=100) - qd.with_step_point("point_2_test", {"virtual_dot_1": 0.2}, duration=200) - qd.with_step_point("TEST_POINT_3", {"virtual_dot_1": 0.3}, duration=300) - - assert "point_1" in qd.macros - assert "point_2_test" in qd.macros - assert "TEST_POINT_3" in qd.macros - - def test_creating_macro_with_same_name_overwrites(self, machine): - """Test that creating a macro with existing name overwrites it.""" - qd = machine.quantum_dots["virtual_dot_2"] - - qd.with_step_point("overwrite", {"virtual_dot_2": 0.1}, duration=100) - original_macro = qd.macros["overwrite"] - - # Create new macro with same name (requires replace_existing_point=True) - qd.with_step_point( - "overwrite", {"virtual_dot_2": 0.2}, duration=200, replace_existing_point=True - ) - new_macro = qd.macros["overwrite"] - - # Verify it was overwritten - assert new_macro is not original_macro - gate_set = qd.voltage_sequence.gate_set - expected_point_name = f"{qd.id}_overwrite" - assert gate_set.macros[expected_point_name].duration == 200 diff --git a/tests/quantum_dots/test_stage_workflow_persistence.py b/tests/quantum_dots/test_stage_workflow_persistence.py deleted file mode 100644 index 5bce1795..00000000 --- a/tests/quantum_dots/test_stage_workflow_persistence.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for persistence of calibration data across two-stage wiring.""" - -from pathlib import Path - -import pytest - -from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD -from quam_builder.builder.qop_connectivity import build_quam_wiring -from quam_builder.builder.quantum_dots import build_base_quam, build_loss_divincenzo_quam - - -EXAMPLE_GLOBAL_GATES = [1] -EXAMPLE_SENSOR_DOTS = [1, 2] -EXAMPLE_QUANTUM_DOTS = [1, 2, 3] -EXAMPLE_QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] - - -def _make_stage1_connectivity(): - connectivity = Connectivity() - if EXAMPLE_GLOBAL_GATES: - connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") - connectivity.add_sensor_dots( - sensor_dots=EXAMPLE_SENSOR_DOTS, - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=EXAMPLE_QUANTUM_DOTS, - add_drive_lines=False, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) - return connectivity - - -def _make_stage2_connectivity(): - connectivity = Connectivity() - if EXAMPLE_GLOBAL_GATES: - connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") - connectivity.add_sensor_dots( - sensor_dots=EXAMPLE_SENSOR_DOTS, - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) - connectivity.add_quantum_dots( - quantum_dots=EXAMPLE_QUANTUM_DOTS, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - return connectivity - - -def _make_instruments(): - instruments = Instruments() - instruments.add_mw_fem(controller=1, slots=[1]) - instruments.add_lf_fem(controller=1, slots=[2, 3]) - return instruments - - -def _add_calibration_data(machine: BaseQuamQD) -> None: - gate_set = machine.virtual_gate_sets["main_qpu"] - virtual_targets = [ - name for name in gate_set.layers[0].source_gates if name.startswith("virtual_dot_") - ][:2] - gate_set.add_to_layer( - layer_id="calibration_layer", - source_gates=["virtual_calibration_axis"], - target_gates=virtual_targets, - matrix=[[1.0, -1.0]], - ) - - quantum_dot = machine.quantum_dots["virtual_dot_1"] - quantum_dot.with_step_point( - "calibration_idle", - {"virtual_dot_1": 0.12}, - hold_duration=120, - ).with_sequence("calibration_sequence", ["calibration_idle"]) - - -def test_calibration_data_persists_across_two_stage_build(tmp_path): - """Ensure virtual gates, points, and macros survive stage 2 wiring/build.""" - stage1_dir = Path(tmp_path) / "stage1" - stage2_dir = Path(tmp_path) / "stage2" - - connectivity_stage1 = _make_stage1_connectivity() - instruments_stage1 = _make_instruments() - allocate_wiring(connectivity_stage1, instruments_stage1) - - machine_stage1 = BaseQuamQD() - machine_stage1 = build_quam_wiring( - connectivity_stage1, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_stage1, - path=stage1_dir, - ) - machine_stage1 = build_base_quam( - machine_stage1, - calibration_db_path=stage1_dir, - connect_qdac=False, - save=False, - ) - - _add_calibration_data(machine_stage1) - machine_stage1.save(stage1_dir) - - machine_loaded = BaseQuamQD.load(stage1_dir) - connectivity_stage2 = _make_stage2_connectivity() - instruments_stage2 = _make_instruments() - allocate_wiring(connectivity_stage2, instruments_stage2) - - machine_stage2 = build_quam_wiring( - connectivity_stage2, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_loaded, - path=stage2_dir, - ) - machine_stage2 = build_loss_divincenzo_quam( - machine_stage2, - qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, - implicit_mapping=True, - save=False, - ) - - gate_set = machine_stage2.virtual_gate_sets["main_qpu"] - calibration_layer = next( - layer for layer in gate_set.layers if layer.id == "calibration_layer" - ) - assert calibration_layer.source_gates == ["virtual_calibration_axis"] - - quantum_dot = machine_stage2.quantum_dots["virtual_dot_1"] - assert "calibration_idle" in quantum_dot.macros - assert "calibration_sequence" in quantum_dot.macros - - point_name = f"{quantum_dot.id}_calibration_idle" - assert point_name in gate_set.macros diff --git a/tests/quantum_dots/test_sticky_voltage_tracking.py b/tests/quantum_dots/test_sticky_voltage_tracking.py deleted file mode 100644 index edf2d54f..00000000 --- a/tests/quantum_dots/test_sticky_voltage_tracking.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Test for sticky voltage tracking during non-voltage operations. - -This test demonstrates that the current implementation doesn't track -voltage compensation correctly when non-voltage macros (like x180) execute -while voltages are held at a sticky level. - -The test creates a scenario where: -1. A voltage is set to a non-zero value with a known duration -2. A non-voltage macro (like x180) executes with a known duration -3. The voltage remains "sticky" during the non-voltage operation -4. The integrated voltage should account for BOTH durations - -Current behavior: Only the voltage macro duration is tracked -Expected behavior (after fix): Both voltage and non-voltage macro durations should be tracked -""" - -import pytest -from qm import qua -from quam.components.macro import QubitMacro -from quam.components import StickyChannelAddon -from quam.components.ports import LFFEMAnalogOutputPort - -from quam_builder.architecture.quantum_dots.components import VoltageGate, QuantumDot -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD - - -@pytest.fixture -def simple_machine(): - """ - Creates a minimal BaseQuamQD machine for testing voltage tracking. - Avoids the complexity of the full conftest machine fixture. - """ - machine = BaseQuamQD() - lf_fem = 6 - - # Create a single plunger gate - p1 = VoltageGate( - id="plunger_1", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=1), - sticky=StickyChannelAddon(duration=16, digital=False), - ) - - # Create Virtual Gate Set (minimal - just one gate) - machine.create_virtual_gate_set( - virtual_channel_mapping={ - "virtual_dot_1": p1, - }, - gate_set_id="test_qpu", - ) - - # Register Quantum Dot - machine.register_channel_elements( - plunger_channels=[p1], - barrier_channels=[], - sensor_channels_resonators=[], - ) - - return machine - - -# Mock non-voltage macro with known duration -class MockX180Macro(QubitMacro): - """Mock X180 macro that represents a qubit rotation (no voltage channels).""" - - @property - def inferred_duration(self): - """Return duration in seconds (100ns).""" - return 100e-9 # 100 nanoseconds - - def apply(self, **kwargs): - """Mock apply that does nothing (simulates RF-only operation).""" - # In reality, this would play on RF channels, not voltage channels - pass - - -class TestStickyVoltageTracking: - """Tests for sticky voltage tracking during non-voltage operations.""" - - def test_voltage_tracking_without_non_voltage_operation(self, simple_machine): - """ - Baseline test: Voltage tracking works correctly for voltage-only operations. - - This test verifies that the basic voltage tracking works as expected - when only voltage macros are used. - """ - qd = simple_machine.quantum_dots["virtual_dot_1"] - - # Create a voltage point and step macro - qd.add_point_with_step_macro( - "baseline_voltage", - {"virtual_dot_1": 0.1}, - hold_duration=100 # 100ns at 0.1V - ) - - # Execute just the voltage step - with qua.program() as prog: - qd.baseline_voltage() - - # Check the integrated voltage tracking - # Note: Trackers use physical channel names, not virtual gate names - tracker = qd.voltage_sequence.state_trackers["plunger_1"] - - SCALING_FACTOR = 1024 # From sequence_state_tracker.py - expected_integrated_voltage = int(0.1 * 100 * SCALING_FACTOR) # 10240 - - assert tracker.integrated_voltage == expected_integrated_voltage, \ - f"Baseline voltage tracking failed. Expected {expected_integrated_voltage}, got {tracker.integrated_voltage}" - - def test_sticky_voltage_tracking_with_non_voltage_operation(self, simple_machine): - """ - Test that sticky voltage tracking works correctly with non-voltage operations. - - Scenario: - 1. Apply a voltage step (100ns at 0.1V) - integrated voltage tracked correctly - 2. Execute x180 macro (100ns duration) while voltage is sticky at 0.1V - 3. Integrated voltage should include BOTH durations: (100ns + 100ns) * 0.1V * 1024 - - Fixed behavior: Both 100ns periods are tracked automatically - """ - qd = simple_machine.quantum_dots["virtual_dot_1"] - - # Create a voltage point and step macro - qd.add_point_with_step_macro( - "sticky_voltage", - {"virtual_dot_1": 0.1}, - hold_duration=100 # 100ns at 0.1V - ) - - # Add a mock x180 macro with known duration - qd.macros["x180"] = MockX180Macro() - - # Execute the sequence - with qua.program() as prog: - # Apply voltage step - this sets voltage to 0.1V for 100ns - qd.sticky_voltage() - - # Execute x180 - voltage is sticky at 0.1V for another 100ns - # but the voltage tracker doesn't know about this duration - qd.x180() - - # Check the integrated voltage tracking - tracker = qd.voltage_sequence.state_trackers["plunger_1"] - - SCALING_FACTOR = 1024 # From sequence_state_tracker.py - - # Expected (correct) behavior: Both step and x180 durations should be tracked - expected_correct_value = int(0.1 * (100 + 100) * SCALING_FACTOR) # 20480 - - # This assertion should fail with current implementation (demonstrating the bug) - # After the fix, remove the @pytest.mark.xfail decorator and this should pass - assert tracker.integrated_voltage == expected_correct_value, \ - f"Integrated voltage should include non-voltage macro duration. " \ - f"Expected {expected_correct_value}, got {tracker.integrated_voltage}" - - def test_complex_sequence_with_multiple_non_voltage_operations(self, simple_machine): - """ - Test a more complex scenario with multiple voltage and non-voltage operations. - - Sequence: - 1. Voltage at 0.1V for 100ns - 2. x180 (100ns) - sticky at 0.1V - 3. Voltage at 0.2V for 52ns - 4. x180 (100ns) - sticky at 0.2V - 5. Voltage back to 0V - - Expected integrated voltage: - - 0.1V * (100ns + 100ns) = 0.1V * 200ns - - 0.2V * (52ns + 100ns) = 0.2V * 152ns - - Total: (0.1*200 + 0.2*152) = 50.4 V*ns - - Scaled: 50.4 * 1024 = 51610 (rounded to int) - """ - qd = simple_machine.quantum_dots["virtual_dot_1"] - - # Create voltage points - qd.add_point_with_step_macro("voltage_1", {"virtual_dot_1": 0.1}, hold_duration=100) - qd.add_point_with_step_macro("voltage_2", {"virtual_dot_1": 0.2}, hold_duration=52) - qd.add_point_with_step_macro("voltage_zero", {"virtual_dot_1": 0.0}, hold_duration=16) - - # Add mock x180 macro - qd.macros["x180"] = MockX180Macro() - - with qua.program() as prog: - qd.voltage_1() # 0.1V for 100ns - qd.x180() # sticky at 0.1V for 100ns (BUG: not tracked) - qd.voltage_2() # 0.2V for 50ns (also steps from 0.1V to 0.2V) - qd.x180() # sticky at 0.2V for 100ns (BUG: not tracked) - qd.voltage_zero() # Back to 0V - - tracker = qd.voltage_sequence.state_trackers["plunger_1"] - - SCALING_FACTOR = 1024 - - # Previous buggy behavior: only voltage macro durations tracked - # expected_buggy_value = int((0.1 * 100 + 0.2 * 52 + 0.0 * 16) * SCALING_FACTOR) # 20889 (rounded) - - # Fixed behavior: all durations tracked (including non-voltage macros) - expected_correct_value = int((0.1 * (100 + 100) + 0.2 * (52 + 100) + 0.0 * 16) * SCALING_FACTOR) # 51610 - - # After fix: verify correct behavior - # Note: Actual value may differ by 1 due to incremental rounding in np.round - assert abs(tracker.integrated_voltage - expected_correct_value) <= 1, \ - f"After fix: integrated voltage should be {expected_correct_value}, got {tracker.integrated_voltage}" - - def test_sticky_voltage_tracking_only_affects_non_zero_voltages(self, simple_machine): - """ - Verify that sticky voltage tracking only matters when voltage is non-zero. - - If voltage is at 0V and a non-voltage macro executes, integrated voltage - should not change (0V * duration = 0). - """ - qd = simple_machine.quantum_dots["virtual_dot_1"] - - # Create a zero voltage point - qd.add_point_with_step_macro("voltage_zero", {"virtual_dot_1": 0.0}, hold_duration=100) - - # Add mock x180 macro - qd.macros["x180"] = MockX180Macro() - - with qua.program() as prog: - qd.voltage_zero() # 0.0V for 100ns - qd.x180() # sticky at 0.0V for 100ns - - tracker = qd.voltage_sequence.state_trackers["plunger_1"] - - # Integrated voltage should be 0 (or very close to 0) - assert tracker.integrated_voltage == 0, \ - f"Zero voltage should result in zero integrated voltage, got {tracker.integrated_voltage}" diff --git a/tests/quantum_dots/test_wirer_builder_integration.py b/tests/quantum_dots/test_wirer_builder_integration.py deleted file mode 100644 index e6c59498..00000000 --- a/tests/quantum_dots/test_wirer_builder_integration.py +++ /dev/null @@ -1,571 +0,0 @@ -"""Integration tests for the wirer and builder working together.""" - -# pylint: disable=too-few-public-methods - -import shutil -import tempfile -from pathlib import Path - -import pytest - -from qualang_tools.wirer import Instruments, Connectivity, allocate_wiring -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from quam_builder.builder.qop_connectivity import build_quam_wiring -from quam_builder.builder.quantum_dots import ( - build_quam, - build_base_quam, - build_loss_divincenzo_quam, -) -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD -from quam_builder.architecture.quantum_dots.qpu.loss_divincenzo_quam import ( - LossDiVincenzoQuam, -) - - -class TestWirerBuilderIntegration: - """Integration tests for the complete wiring and building workflow.""" - - EXAMPLE_GLOBAL_GATES = [1] - EXAMPLE_SENSOR_DOTS = [1, 2] - EXAMPLE_QUANTUM_DOTS = [1, 2, 3] - EXAMPLE_QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] - - @staticmethod - def _make_stage1_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): - connectivity = Connectivity() - connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") - connectivity.add_sensor_dots( - sensor_dots=sensor_dots, - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=False, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) - return connectivity - - @staticmethod - def _make_stage2_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): - connectivity = Connectivity() - connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") - connectivity.add_sensor_dots( - sensor_dots=sensor_dots, - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - return connectivity - - @pytest.fixture - def temp_dir(self): - """Create a temporary directory for test files.""" - temp_dir = tempfile.mkdtemp() - yield temp_dir - shutil.rmtree(temp_dir) - - @pytest.fixture - def instruments(self): - """Create instruments configuration.""" - instruments = Instruments() - instruments.add_mw_fem(controller=1, slots=[1, 2]) - instruments.add_lf_fem(controller=1, slots=[3, 4, 5]) - return instruments - - def test_complete_workflow_basic_setup(self, instruments, temp_dir): - """Test the complete workflow from wiring to build for a basic setup.""" - # Setup connectivity - connectivity = Connectivity() - connectivity.add_voltage_gate_lines(voltage_gates=[1], name="rb") - connectivity.add_sensor_dots( - sensor_dots=[1], - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) - - # Allocate wiring - allocate_wiring(connectivity, instruments) - - # Build wiring - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - # Verify wiring was created - assert machine.wiring is not None - assert "readout" in machine.wiring - assert "qubits" in machine.wiring - assert "qubit_pairs" in machine.wiring - - # Build QuAM - machine_loaded = BaseQuamQD.load(temp_dir) - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify QPU elements were created - assert len(machine_loaded.sensor_dots) > 0 - assert len(machine_loaded.quantum_dots) > 0 - assert len(machine_loaded.quantum_dots) > 0 - - def test_example_two_stage_workflow(self, temp_dir): - """Exercise the two-stage flow used in wiring_example.""" - qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} - - instruments_stage1 = Instruments() - instruments_stage1.add_mw_fem(controller=1, slots=[1]) - instruments_stage1.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity_stage1 = self._make_stage1_connectivity( - self.EXAMPLE_GLOBAL_GATES, - self.EXAMPLE_SENSOR_DOTS, - self.EXAMPLE_QUANTUM_DOTS, - self.EXAMPLE_QUANTUM_DOT_PAIRS, - ) - allocate_wiring(connectivity_stage1, instruments_stage1) - - machine_stage1 = BaseQuamQD() - machine_stage1 = build_quam_wiring( - connectivity_stage1, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_stage1, - path=temp_dir, - ) - machine_stage1 = build_base_quam( - machine_stage1, - calibration_db_path=temp_dir, - connect_qdac=False, - save=False, - ) - - instruments_stage2 = Instruments() - instruments_stage2.add_mw_fem(controller=1, slots=[1]) - instruments_stage2.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity_stage2 = self._make_stage2_connectivity( - self.EXAMPLE_GLOBAL_GATES, - self.EXAMPLE_SENSOR_DOTS, - self.EXAMPLE_QUANTUM_DOTS, - self.EXAMPLE_QUANTUM_DOT_PAIRS, - ) - allocate_wiring(connectivity_stage2, instruments_stage2) - - machine_stage2 = build_quam_wiring( - connectivity_stage2, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_stage1, - path=temp_dir, - ) - machine_stage2 = build_loss_divincenzo_quam( - machine_stage2, - qubit_pair_sensor_map=qubit_pair_sensor_map, - implicit_mapping=True, - save=False, - ) - - assert len(machine_stage2.quantum_dots) == len(self.EXAMPLE_QUANTUM_DOTS) - assert len(machine_stage2.sensor_dots) == len(self.EXAMPLE_SENSOR_DOTS) - assert len(machine_stage2.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) - assert len(machine_stage2.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) - - def test_example_combined_workflow(self, temp_dir): - """Exercise the combined flow used in wiring_example.""" - qubit_pair_sensor_map = {"q1_q2": ["sensor_1"]} - - instruments_combined = Instruments() - instruments_combined.add_mw_fem(controller=1, slots=[1]) - instruments_combined.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity_combined = self._make_stage2_connectivity( - self.EXAMPLE_GLOBAL_GATES, - self.EXAMPLE_SENSOR_DOTS, - self.EXAMPLE_QUANTUM_DOTS, - self.EXAMPLE_QUANTUM_DOT_PAIRS, - ) - allocate_wiring(connectivity_combined, instruments_combined) - - machine_combined = BaseQuamQD() - machine_combined = build_quam_wiring( - connectivity_combined, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_combined, - path=temp_dir, - ) - machine_combined = build_quam( - machine_combined, - calibration_db_path=temp_dir, - qubit_pair_sensor_map=qubit_pair_sensor_map, - connect_qdac=False, - save=False, - ) - - assert len(machine_combined.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) - assert len(machine_combined.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) - - def test_example_incremental_drive_lines(self, temp_dir): - """Exercise incremental drive-line flow from wiring_example.""" - qubit_pair_sensor_map = {"q1_q2": ["sensor_1"]} - - instruments_stage1 = Instruments() - instruments_stage1.add_mw_fem(controller=1, slots=[1]) - instruments_stage1.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity_stage1 = self._make_stage1_connectivity( - self.EXAMPLE_GLOBAL_GATES, - self.EXAMPLE_SENSOR_DOTS, - self.EXAMPLE_QUANTUM_DOTS, - self.EXAMPLE_QUANTUM_DOT_PAIRS, - ) - allocate_wiring(connectivity_stage1, instruments_stage1) - - machine_stage1 = BaseQuamQD() - machine_stage1 = build_quam_wiring( - connectivity_stage1, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_stage1, - path=temp_dir, - ) - machine_stage1 = build_base_quam( - machine_stage1, - calibration_db_path=temp_dir, - connect_qdac=False, - save=False, - ) - - connectivity_drive_lines = Connectivity() - connectivity_drive_lines.add_quantum_dot_drive_lines( - quantum_dots=self.EXAMPLE_QUANTUM_DOTS, - use_mw_fem=True, - shared_line=True, - ) - allocate_wiring(connectivity_drive_lines, instruments_stage1) - - machine_stage2 = build_quam_wiring( - connectivity_drive_lines, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine_stage1, - path=temp_dir, - ) - machine_stage2 = build_loss_divincenzo_quam( - machine_stage2, - qubit_pair_sensor_map=qubit_pair_sensor_map, - implicit_mapping=True, - save=False, - ) - - assert len(machine_stage2.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) - for qubit in machine_stage2.qubits.values(): - assert getattr(qubit, "xy", None) is not None - - def test_workflow_with_multiple_qubits(self, instruments, temp_dir): - """Test workflow with multiple qubits and qubit pairs.""" - # Setup connectivity with more qubits - connectivity = Connectivity() - connectivity.add_sensor_dots( - sensor_dots=[1, 2], - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=[1, 2, 3, 4], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3), (3, 4)]) - - # Allocate wiring - allocate_wiring(connectivity, instruments) - - # Build wiring - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - # Build QuAM - machine_loaded = BaseQuamQD.load(temp_dir) - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify correct number of elements - assert len(machine_loaded.quantum_dots) == 4 - assert len(machine_loaded.quantum_dots) == 4 - assert len(machine_loaded.quantum_dot_pairs) == 3 - assert len(machine_loaded.sensor_dots) == 2 - - def test_virtual_gate_set_creation(self, instruments, temp_dir): - """Test that virtual gate set is correctly created.""" - connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1, 2, 3], - add_drive_lines=False, - ) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - machine_loaded = BaseQuamQD.load(temp_dir) - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify virtual gate set was created - assert len(machine_loaded.virtual_gate_sets) > 0 - assert "main_qpu" in machine_loaded.virtual_gate_sets - - # Verify virtual gate set has correct channels - vgs = machine_loaded.virtual_gate_sets["main_qpu"] - assert len(vgs.channels) >= 3 # At least 3 plunger gates - - def test_qubit_registration_with_xy_drives(self, instruments, temp_dir): - """Test that qubits are registered with their XY drives in Stage 2.""" - connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - machine_loaded = BaseQuamQD.load(temp_dir) - # build_quam does both Stage 1 and Stage 2, creating qubits with XY drives - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify qubits (Stage 2) have XY drives - # Note: qubits are in machine_loaded.qubits, not quantum_dots - assert hasattr(machine_loaded, "qubits"), "Machine should have qubits after build_quam" - for qubit_name, qubit in machine_loaded.qubits.items(): - # Check if qubit has an xy attribute - assert hasattr(qubit, "xy"), f"Qubit {qubit_name} should have xy attribute" - - def test_sensor_dots_with_resonators(self, instruments, temp_dir): - """Test that sensor dots are registered with resonators.""" - connectivity = Connectivity() - connectivity.add_sensor_dots( - sensor_dots=[1, 2], - shared_resonator_line=False, - use_mw_fem=False, - ) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - machine_loaded = BaseQuamQD.load(temp_dir) - build_base_quam(machine_loaded, calibration_db_path=temp_dir, save=False) - - # Verify sensor dots have resonators - assert len(machine_loaded.sensor_dots) == 2 - # Note: Resonator attachment depends on wiring allocation - - def test_pulses_are_added(self, instruments, temp_dir): - """Test that default pulses are added to qubits in Stage 2.""" - connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - machine_loaded = BaseQuamQD.load(temp_dir) - # build_quam does both Stage 1 and Stage 2, creating qubits with XY drives - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify qubits (Stage 2) have pulses (if they have XY) - assert hasattr(machine_loaded, "qubits"), "Machine should have qubits after build_quam" - for qubit_name, qubit in machine_loaded.qubits.items(): - if hasattr(qubit, "xy") and qubit.xy is not None: - # Should have XY operations - assert len(qubit.xy.operations) > 0 - - def test_network_configuration_is_set(self, instruments, temp_dir): - """Test that network configuration is properly set.""" - connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1], - add_drive_lines=False, - ) - - allocate_wiring(connectivity, instruments) - - machine = LossDiVincenzoQuam() - machine = build_quam_wiring( - connectivity, - host_ip="192.168.1.100", - cluster_name="my_cluster", - quam_instance=machine, - port=9510, - path=temp_dir, - ) - - # Verify network configuration - assert machine.network["host"] == "192.168.1.100" - assert machine.network["cluster_name"] == "my_cluster" - assert machine.network["port"] == 9510 - - def test_active_element_names_are_set(self, instruments, temp_dir): - """Test that active element names lists are populated.""" - connectivity = Connectivity() - connectivity.add_sensor_dots( - sensor_dots=[1], - shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - machine_loaded = BaseQuamQD.load(temp_dir) - build_quam(machine_loaded, calibration_db_path=temp_dir) - - # Verify elements are populated - assert len(machine_loaded.sensor_dots) > 0 - assert len(machine_loaded.quantum_dots) > 0 - assert len(machine_loaded.quantum_dot_pairs) > 0 - - -class TestWirerOnly: - """Tests specifically for the wirer connectivity setup.""" - - def test_connectivity_quantum_dot_interface(self): - """Test that the quantum dot connectivity interface works correctly.""" - connectivity = Connectivity() - - # Add various element types - connectivity.add_voltage_gate_lines(voltage_gates=[1, 2], name="g") - connectivity.add_sensor_dots( - sensor_dots=[1, 2], - shared_resonator_line=True, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=[1, 2, 3], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3)]) - - instruments = Instruments() - instruments.add_mw_fem(controller=1, slots=[1]) - instruments.add_lf_fem(controller=1, slots=[2, 3]) - allocate_wiring(connectivity, instruments) - - # Verify elements were added - assert len(connectivity.elements) > 0 - - # Check element types - element_types = set() - for element in connectivity.elements.values(): - for line_type in element.channels: - element_types.add(line_type.value) - - expected_types = { - WiringLineType.GLOBAL_GATE.value, - WiringLineType.SENSOR_GATE.value, - WiringLineType.RF_RESONATOR.value, - WiringLineType.PLUNGER_GATE.value, - WiringLineType.DRIVE.value, - WiringLineType.BARRIER_GATE.value, - } - assert element_types.intersection(expected_types) - - def test_allocate_wiring_creates_channels(self): - """Test that allocate_wiring creates proper channel allocations.""" - instruments = Instruments() - instruments.add_mw_fem(controller=1, slots=[1]) - instruments.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - - # Allocate wiring - allocate_wiring(connectivity, instruments) - - # Verify channels were allocated - for element in connectivity.elements.values(): - assert len(element.channels) > 0 - for channel_list in element.channels.values(): - assert len(channel_list) > 0 diff --git a/uv.lock b/uv.lock index df1e3561..63463140 100644 --- a/uv.lock +++ b/uv.lock @@ -3889,7 +3889,10 @@ dependencies = [ { name = "xarray", version = "2025.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -[package.optional-dependencies] +[package.dev-dependencies] +ast = [ + { name = "qua-qsim" }, +] dev = [ { name = "astroid", version = "3.3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "astroid", version = "4.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3897,6 +3900,8 @@ dev = [ { name = "black", version = "26.1a1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "commitizen", version = "4.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "commitizen", version = "4.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ipykernel", version = "7.0.0a2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mypy" }, { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3905,52 +3910,34 @@ dev = [ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.dev-dependencies] -ast = [ - { name = "qua-qsim" }, -] -dev = [ - { name = "ipykernel", version = "7.0.0a2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "astroid", marker = "extra == 'dev'", specifier = ">=3.0" }, - { name = "black", marker = "extra == 'dev'" }, - { name = "commitizen", marker = "extra == 'dev'" }, - { name = "mypy", marker = "extra == 'dev'" }, - { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.0" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "qcodes-contrib-drivers", specifier = ">=0.22.0" }, { name = "qm-qua", specifier = ">=1.2.2" }, { name = "qm-saas", specifier = ">=1.1.7" }, { name = "qualang-tools", specifier = ">=0.19.0" }, { name = "quam", specifier = ">=0.4.0" }, - { name = "ruff", marker = "extra == 'dev'" }, { name = "xarray", specifier = ">=2024.7.0" }, ] -provides-extras = ["dev"] [package.metadata.requires-dev] ast = [{ name = "qua-qsim", git = "https://github.com/qua-platform/qua-qsim.git?rev=bare-ast-visitor" }] dev = [ + { name = "astroid", specifier = ">=3.0" }, + { name = "black" }, + { name = "commitizen" }, { name = "ipykernel", specifier = ">=7.0.0a1" }, + { name = "mypy" }, { name = "pre-commit" }, + { name = "pylint", specifier = ">=3.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "ruff" }, ] [[package]] From 26dbff377de09114666ae3005b96a7f3fd99c8dd Mon Sep 17 00:00:00 2001 From: TheoLaudatQM <98808790+TheoLaudatQM@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:17:13 +0200 Subject: [PATCH 2/9] Allow Python 3.13 (#99) * Update python requirement * Update changelog * Update python requirement * bump version --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++-- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32819751..5543feec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - name: Checkout code diff --git a/CHANGELOG.md b/CHANGELOG.md index eb549cbe..69f15d5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] - -## [0.3.0] - 2026-03-30 +## [0.3.0] - 2026-03-31 ### Added +- Added support for Python 3.13. - Add support for cloud-based QMM instances in `machine.connect()` - A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. - TWPA: add `pumpline_attenuation` and `signalline_attenuation` (float, optional) attributes. diff --git a/pyproject.toml b/pyproject.toml index de1a76ba..3477d6ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.3.0" description = "A Python tool designed to programmatically construct QUAM (Quantum Abstract Machine) configurations for the Quantum Orchestration Platform (QOP)." readme = "README.md" authors = [{ name = "Theo Laudat", email = "theo@quantum-machines.co" }] -requires-python = ">=3.9,<3.13" +requires-python = ">=3.9,<=3.13" dependencies = [ "qualang-tools>=0.19.0", "quam>=0.4.0", From 273dd4cf999789517750d0de70f16ff765aa6216 Mon Sep 17 00:00:00 2001 From: TheoLaudatQM <98808790+TheoLaudatQM@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:19:25 +0200 Subject: [PATCH 3/9] Add builder function for the TWPA component (#88) * Add twpa component with isolation * Update TWPA initialization at the QPU level * add builder functions * black * add default pulses * changelog * add twpa_wiring * fix bug * fix bug * add twpa pulses * add twpa to init * Set pump_ and isolation_ as non-sticky * Simplify component * black * fix docstring * update initialize method * Add back `pumpline_attenuation` and `signalline_attenuation` * Updated qualang-tools requirement --- CHANGELOG.md | 6 +- pyproject.toml | 2 +- .../superconducting/components/__init__.py | 1 + .../superconducting/components/twpa.py | 144 ++++++++++++------ .../superconducting/qpu/base_quam.py | 13 +- .../superconducting/qpu/flux_tunable_quam.py | 7 +- .../builder/qop_connectivity/create_wiring.py | 26 ++++ .../superconducting/add_twpa_component.py | 132 ++++++++++++++++ .../builder/superconducting/build_quam.py | 45 ++++-- .../builder/superconducting/pulses.py | 45 +++++- 10 files changed, 346 insertions(+), 75 deletions(-) create mode 100644 quam_builder/builder/superconducting/add_twpa_component.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f15d5e..9c66cbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Changed +Updated qualang-tools requirement `"qualang-tools>=0.22.0"`. +### Added +- Updated the TWPA component with isolation pump and added corresponding builder functions. ## [0.3.0] - 2026-03-31 ### Added - Added support for Python 3.13. - Add support for cloud-based QMM instances in `machine.connect()` - - A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. +- A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. - TWPA: add `pumpline_attenuation` and `signalline_attenuation` (float, optional) attributes. - BaseQuam , XYDriveBase and ReadoutResonatorBase: `extras` (dict) for additional QUAM-level attributes. ### Changed diff --git a/pyproject.toml b/pyproject.toml index 3477d6ce..53b036a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Theo Laudat", email = "theo@quantum-machines.co" }] requires-python = ">=3.9,<=3.13" dependencies = [ - "qualang-tools>=0.19.0", + "qualang-tools>=0.22.0", "quam>=0.4.0", "qm-qua>=1.2.2", "xarray>=2024.7.0", diff --git a/quam_builder/architecture/superconducting/components/__init__.py b/quam_builder/architecture/superconducting/components/__init__.py index 2fd4272a..03302275 100644 --- a/quam_builder/architecture/superconducting/components/__init__.py +++ b/quam_builder/architecture/superconducting/components/__init__.py @@ -4,3 +4,4 @@ from .tunable_coupler import * from .cross_resonance import * from .zz_drive import * +from .twpa import * diff --git a/quam_builder/architecture/superconducting/components/twpa.py b/quam_builder/architecture/superconducting/components/twpa.py index 85f433a8..6fff7fd7 100644 --- a/quam_builder/architecture/superconducting/components/twpa.py +++ b/quam_builder/architecture/superconducting/components/twpa.py @@ -1,8 +1,7 @@ from quam.core import quam_dataclass -from quam.components.channels import IQChannel from quam import QuamComponent from typing import Union, ClassVar -from qm.qua import update_frequency +from .xy_drive import XYDriveMW, XYDriveIQ __all__ = ["TWPA"] @@ -10,54 +9,70 @@ @quam_dataclass class TWPA(QuamComponent): """ - Example QuAM component for a TWPA. - - Args: - id (str, int): The id of the TWPA, used to generate the name. - Can be a string, or an integer in which case it will add`Channel._default_label`. - pump (IQChannel): The pump component(sticky element) used for continuous output. - pump_ (IQChannel): The pump component(non sticky element)used for TWPA calibration - - max_avg_gain (float): The maximum average gain around the readout resonators related to the TWPA - max_avg_snr_improvement (float): The maximum average SNR improvement around the readout resonators related to the TWPA - pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement - pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement - mltpx_pump_frequency (float): calibrated pump frequency at which twpa gives proper snr improvement for multiplexed readout - mltpx_pump_amplitude (float): calibrated pump amplitude at which twpa gives proper snr improvement for multiplexed readout - p_saturation (float): calibrated saturation power of the twpa - avg_std_gain (float): standard deviation of the average gain around the readout resonators related to the TWPA - avg_std_snr_improvement (float): standard deviation of the average snr improvement around the readout resonators related to the TWPA - - dispersive_feature (float): dispersive feature of the twpa defined from it's designed parameters - qubits (list): list of qubits of which the signals are amplified by the twpa - pumpline_attenuation (float): attenuation in dB of the pump line from the OPX to the input of the TWPA - signalline_attenuation (float): attenuation in dB on the signal line from the OPX to the input of the TWPA - initialization (bool): whether to use the twpa in the QUA program or not - _initialized_ids (ClassVar[set]): A class-level set to track initialized twpa object IDs externally. - This won't be serialized since it's not an instance attribute. + QuAM component for a Traveling-Wave Parametric Amplifier (TWPA). + Each TWPA has a pump channel implemented as two elements on the same physical + output: ``pump`` (sticky, for continuous output) and ``pump_`` (non-sticky, for + calibration). Optionally, an isolation channel with ``isolation`` (sticky) and + ``isolation_`` (non-sticky) on one physical output. + + Attributes + ---------- + id : int or str + TWPA identifier. Used for the component name (string as-is, int gets a + default prefix). + pump : XYDriveIQ or XYDriveMW + Pump channel, sticky element, used for continuous pump output. + pump_ : XYDriveIQ or XYDriveMW + Pump channel, non-sticky element, used for TWPA calibration. + isolation : XYDriveIQ or XYDriveMW, optional + Isolation channel, sticky element. Present only if the TWPA has isolation. + isolation_ : XYDriveIQ or XYDriveMW, optional + Isolation channel, non-sticky element. Present only if the TWPA has isolation. + settling_time : int + Pump settling time in ns. Default 100. + pump_frequency : float, optional + Calibrated pump frequency for maximum average SNR improvement. + pump_amplitude : float + Calibrated pump amplitude for maximum average SNR improvement. Default 1.0. + isolation_frequency : float, optional + Calibrated isolation tone frequency in Hz. + isolation_amplitude : float + Calibrated isolation tone amplitude. Default 1.0. + pumpline_attenuation (float): + attenuation in dB of the pump line from the OPX to the input of the TWPA + signalline_attenuation (float): + attenuation in dB on the signal line from the OPX to the input of the TWPA + grid_location : str, optional + Grid location for layout/plotting (e.g. "0,1"). + qubits : list, optional + Qubits whose readout signals are amplified by this TWPA. + initialization : bool + If True, use this TWPA in the QUA program (e.g. call initialize). Default True. + _initialized_ids : ClassVar[set] + Class-level set of initialized instance ids to ensure pump is initialized + only once per program run. Not serialized. """ id: Union[int, str] - pump: IQChannel = None - pump_: IQChannel = None + pump: Union[XYDriveIQ, XYDriveMW] = None + pump_: Union[XYDriveIQ, XYDriveMW] = None + isolation: Union[XYDriveIQ, XYDriveMW] = None + isolation_: Union[XYDriveIQ, XYDriveMW] = None - max_avg_gain: float = None - max_avg_snr_improvement: float = None + settling_time: int = 100 pump_frequency: float = None - pump_amplitude: float = None - mltpx_pump_frequency: float = None - mltpx_pump_amplitude: float = None - p_saturation: float = None - avg_std_gain: float = None - avg_std_snr_improvement: float = None - - dispersive_feature: float = None - qubits: list = None + pump_amplitude: float = 1 + isolation_frequency: float = None + isolation_amplitude: float = 1 pumpline_attenuation: float = None signalline_attenuation: float = None + + grid_location: str = None + qubits: list = None + initialization: bool = True _initialized_ids: ClassVar[set] = set() @@ -66,24 +81,55 @@ def name(self): """The name of the twpa""" return self.id if isinstance(self.id, str) else f"twpa{self.id}" - def initialize(self): + def initialize(self, isolation: bool = False): + """ + Set the TWPA pump (and optionally isolation) to the calibrated tones for the QUA program. + + Has no effect if :attr:`initialization` is False. Each instance is initialized + at most once per program run; further calls return immediately. Sets the pump + frequency from :attr:`pump_frequency` and :attr:`pump_amplitude`, then plays + the pump operation. If ``isolation`` is True and this TWPA has isolation + channels, also sets and plays the isolation tone from :attr:`isolation_frequency` + and :attr:`isolation_amplitude`. + + Parameters + ---------- + isolation : bool, optional + If True, also configure and play the isolation tone. Use when the TWPA + has isolation and you want it active. Default False. + + Notes + ----- + Initialization state is tracked in :attr:`_initialized_ids`, so the pump is + turned on onl + """ # dont use twpa for the QUA program if initialization is set to False if not self.initialization: return - # Check initialization state using object ID (memory address) # Initialize TWPA pump only when it hasn't been initialized yet # This won't be serialized since it's stored in a class-level set obj_id = id(self) + # Check initialization state using object ID (memory address) if obj_id in self._initialized_ids: return - f_p = self.pump_frequency - p_p = self.pump_amplitude - update_frequency( - self.pump.name, - f_p + self.pump.intermediate_frequency, - ) - self.pump.play("pump", amplitude_scale=p_p) + if self.pump_frequency is not None: + self.pump.update_frequency(int(self.pump_frequency - self.pump.LO_frequency)) + if self.pump_amplitude is not None: + self.pump.play("pump", amplitude_scale=self.pump_amplitude) + else: + self.pump.play("pump") + + if isolation: + if self.isolation_frequency is not None: + self.isolation.update_frequency( + int(self.isolation_frequency - self.isolation.LO_frequency) + ) + if self.isolation_amplitude is not None: + self.isolation.play("pump", amplitude_scale=self.isolation_amplitude) + else: + self.isolation.play("pump") + # Store object ID externally (won't be serialized) # guarantee initializing twpa pump only once per QUA program execution self._initialized_ids.add(obj_id) diff --git a/quam_builder/architecture/superconducting/qpu/base_quam.py b/quam_builder/architecture/superconducting/qpu/base_quam.py index 207f48d8..e4b89f7b 100644 --- a/quam_builder/architecture/superconducting/qpu/base_quam.py +++ b/quam_builder/architecture/superconducting/qpu/base_quam.py @@ -357,6 +357,13 @@ def declare_qua_variables( Q_st = [declare_stream() for _ in range(num_IQ_pairs)] return I, I_st, Q, Q_st, n, n_st - def initialize_qpu(self, **kwargs): - """Initialize the QPU with the specified settings.""" - pass + def initialize_qpu(self, isolation: bool = True, **kwargs): + """Initialize the QPU with the calibrated TWPA pumping points. + + Args: + isolation : bool, optional + If True, also configure and play the isolation tone. Use when the TWPA + has isolation and you want it active. Default False. + """ + for twpa in self.twpas.values(): + twpa.initialize(isolation=isolation) diff --git a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py index bea9f9c7..0b17e72a 100644 --- a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py +++ b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py @@ -126,16 +126,19 @@ def set_all_fluxes( return target_bias - def initialize_qpu(self, **kwargs): + def initialize_qpu(self, isolation: bool = False, **kwargs): """Initialize the QPU with the calibrated TWPA pumping points and with the specified flux point and target Args: flux_point (str): The flux point to set. Default is 'joint'. target: The qubit under study. + isolation : bool, optional + If True, also configure and play the isolation tone. Use when the TWPA + has isolation and you want it active. Default False. """ for twpa in self.twpas.values(): - twpa.initialize() + twpa.initialize(isolation=isolation) flux_point = kwargs.get("flux_point", "joint") target = kwargs.get("target", None) self.set_all_fluxes(flux_point, target) diff --git a/quam_builder/builder/qop_connectivity/create_wiring.py b/quam_builder/builder/qop_connectivity/create_wiring.py index a3db53eb..27f7cc98 100644 --- a/quam_builder/builder/qop_connectivity/create_wiring.py +++ b/quam_builder/builder/qop_connectivity/create_wiring.py @@ -53,6 +53,14 @@ def create_wiring(connectivity: Connectivity) -> dict: wiring, f"qubit_pairs/{element_id}/{line_type.value}/{k}", v ) + elif line_type in [ + WiringLineType.TWPA_PUMP, + WiringLineType.TWPA_ISOLATION, + ]: + for k, v in twpa_wiring(channels).items(): + set_nested_value_with_path( + wiring, f"twpas/{element_id}/{line_type.value}/{k}", v + ) else: raise ValueError(f"Unknown line type {line_type}") @@ -108,6 +116,24 @@ def qubit_pair_wiring(channels: List[AnyInstrumentChannel], element_id: QubitPai return qubit_pair_line_wiring +def twpa_wiring(channels: List[AnyInstrumentChannel]) -> dict: + """Generates a dictionary containing QUAM-compatible JSON references for a list of channels from a twpa and the same line type. + + Args: + channels (List[AnyInstrumentChannel]): The list of instrument channels. + + Returns: + dict: A dictionary containing QUAM-compatible JSON references. + """ + twpa_line_wiring = {} + for channel in channels: + if not (channel.signal_type == "digital" and channel.io_type == "input"): + key, reference = get_channel_port(channel, channels) + twpa_line_wiring[key] = reference + + return twpa_line_wiring + + def get_channel_port(channel: AnyInstrumentChannel, channels: List[AnyInstrumentChannel]) -> tuple: """Determines the key and JSON reference for a given channel. diff --git a/quam_builder/builder/superconducting/add_twpa_component.py b/quam_builder/builder/superconducting/add_twpa_component.py new file mode 100644 index 00000000..9a63b18c --- /dev/null +++ b/quam_builder/builder/superconducting/add_twpa_component.py @@ -0,0 +1,132 @@ +from typing import Dict, Union +from quam.components.channels import StickyChannelAddon +from quam_builder.builder.qop_connectivity.channel_ports import ( + iq_out_channel_ports, + mw_out_channel_ports, +) +from quam_builder.architecture.superconducting.components.twpa import TWPA, XYDriveMW, XYDriveIQ +from quam_builder.builder.qop_connectivity.get_digital_outputs import ( + get_digital_outputs, +) +from quam_builder.architecture.superconducting.qubit import ( + FixedFrequencyTransmon, + FluxTunableTransmon, +) +from qualang_tools.addons.calibration.calibrations import unit + + +u = unit(coerce_to_integer=True) + + +def add_twpa_pump_component( + twpa: TWPA, + wiring_path: str, + ports: Dict[str, str], +): + """Adds an amplification pump component to a TWPA based on the provided wiring path and ports. + + Args: + twpa (TWPA): The TWPA component to which the amplification pump component will be added. + wiring_path (str): The path to the wiring configuration. + ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. + + Raises: + ValueError: If the port keys do not match any implemented mapping. + """ + digital_outputs = get_digital_outputs(wiring_path, ports) + + if all(key in ports for key in iq_out_channel_ports): + # LF-FEM & Octave or OPX+ & Octave + twpa.pump = XYDriveIQ( + opx_output_I=f"{wiring_path}/opx_output_I", + opx_output_Q=f"{wiring_path}/opx_output_Q", + frequency_converter_up=f"{wiring_path}/frequency_converter_up", + sticky=StickyChannelAddon(duration=100, digital=False), + RF_frequency=None, + digital_outputs=digital_outputs, + ) + twpa.pump_ = XYDriveIQ( + opx_output_I=f"{wiring_path}/opx_output_I", + opx_output_Q=f"{wiring_path}/opx_output_Q", + frequency_converter_up=f"{wiring_path}/frequency_converter_up", + RF_frequency=None, + digital_outputs=digital_outputs, + ) + + RF_output = twpa.pump.frequency_converter_up + RF_output.channel = twpa.pump.get_reference() + RF_output.output_mode = "always_on" # "triggered" + + elif all(key in ports for key in mw_out_channel_ports): + # MW-FEM single channel + twpa.pump = XYDriveMW( + opx_output=f"{wiring_path}/opx_output", + sticky=StickyChannelAddon(duration=100, digital=False), + digital_outputs=digital_outputs, + RF_frequency=None, + ) + twpa.pump_ = XYDriveMW( + opx_output=f"{wiring_path}/opx_output", + digital_outputs=digital_outputs, + RF_frequency=None, + ) + + else: + raise ValueError(f"Unimplemented mapping of port keys to channel for ports: {ports}") + + +def add_twpa_isolation_component( + twpa: TWPA, + wiring_path: str, + ports: Dict[str, str], +): + """Adds an isolation pump component to a TWPA based on the provided wiring path and ports. + + Args: + twpa (TWPA): The TWPA component to which the isolation pump component will be added. + wiring_path (str): The path to the wiring configuration. + ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. + + Raises: + ValueError: If the port keys do not match any implemented mapping. + """ + digital_outputs = get_digital_outputs(wiring_path, ports) + + if all(key in ports for key in iq_out_channel_ports): + # LF-FEM & Octave or OPX+ & Octave + twpa.isolation = XYDriveIQ( + opx_output_I=f"{wiring_path}/opx_output_I", + opx_output_Q=f"{wiring_path}/opx_output_Q", + frequency_converter_up=f"{wiring_path}/frequency_converter_up", + sticky=StickyChannelAddon(duration=100, digital=False), + RF_frequency=None, + digital_outputs=digital_outputs, + ) + twpa.isolation_ = XYDriveIQ( + opx_output_I=f"{wiring_path}/opx_output_I", + opx_output_Q=f"{wiring_path}/opx_output_Q", + frequency_converter_up=f"{wiring_path}/frequency_converter_up", + RF_frequency=None, + digital_outputs=digital_outputs, + ) + + RF_output = twpa.pump.frequency_converter_up + RF_output.channel = twpa.pump.get_reference() + RF_output.output_mode = "always_on" # "triggered" + + elif all(key in ports for key in mw_out_channel_ports): + # MW-FEM single channel + twpa.isolation = XYDriveMW( + opx_output=f"{wiring_path}/opx_output", + sticky=StickyChannelAddon(duration=100, digital=False), + digital_outputs=digital_outputs, + RF_frequency=None, + ) + twpa.isolation_ = XYDriveMW( + opx_output=f"{wiring_path}/opx_output", + digital_outputs=digital_outputs, + RF_frequency=None, + ) + + else: + raise ValueError(f"Unimplemented mapping of port keys to channel for ports: {ports}") diff --git a/quam_builder/builder/superconducting/build_quam.py b/quam_builder/builder/superconducting/build_quam.py index d25a3d3e..2fa80f4c 100644 --- a/quam_builder/builder/superconducting/build_quam.py +++ b/quam_builder/builder/superconducting/build_quam.py @@ -3,9 +3,15 @@ from numpy import sqrt, ceil from quam.components import Octave, LocalOscillator, FrequencyConverter from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer +from quam_builder.architecture.superconducting.components.twpa import TWPA from quam_builder.builder.superconducting.pulses import ( add_default_transmon_pulses, add_default_transmon_pair_pulses, + add_default_twpa_pulses, +) +from quam_builder.builder.superconducting.add_twpa_component import ( + add_twpa_pump_component, + add_twpa_isolation_component, ) from quam_builder.builder.superconducting.add_transmon_drive_component import ( add_transmon_drive_component, @@ -25,9 +31,7 @@ from quam_builder.architecture.superconducting.qpu import AnyQuam -def build_quam( - machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None -) -> AnyQuam: +def build_quam(machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None) -> AnyQuam: """Builds the QuAM by adding various components and saving the machine configuration. Args: @@ -132,14 +136,31 @@ def add_transmons(machine: AnyQuam): transmon_pair, wiring_path, ports ) elif line_type == WiringLineType.ZZ_DRIVE.value: - add_transmon_pair_zz_drive_component( - transmon_pair, wiring_path, ports - ) + add_transmon_pair_zz_drive_component(transmon_pair, wiring_path, ports) else: raise ValueError(f"Unknown line type: {line_type}") machine.qubit_pairs[transmon_pair.name] = transmon_pair machine.active_qubit_pair_names.append(transmon_pair.name) + elif element_type == "twpas": + number_of_twpas = len(wiring_by_element.items()) + twpa_number = 0 + for twpa_id, wiring_by_line_type in wiring_by_element.items(): + twpa = TWPA(id=twpa_id) + machine.twpas[twpa_id] = twpa + machine.twpas[twpa_id].grid_location = _set_default_grid_location( + twpa_number, number_of_twpas + ) + twpa_number += 1 + for line_type, ports in wiring_by_line_type.items(): + wiring_path = f"#/wiring/{element_type}/{twpa_id}/{line_type}" + if line_type == WiringLineType.TWPA_PUMP.value: + add_twpa_pump_component(twpa, wiring_path, ports) + elif line_type == WiringLineType.TWPA_ISOLATION.value: + add_twpa_isolation_component(twpa, wiring_path, ports) + else: + raise ValueError(f"Unknown line type: {line_type}") + def add_pulses(machine: AnyQuam): """Adds default pulses to the transmon qubits and qubit pairs in the machine. @@ -155,6 +176,10 @@ def add_pulses(machine: AnyQuam): for qubit_pair in machine.qubit_pairs.values(): add_default_transmon_pair_pulses(qubit_pair) + if hasattr(machine, "twpas"): + for twpa in machine.twpas.values(): + add_default_twpa_pulses(twpa) + def add_octaves( machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None @@ -180,9 +205,7 @@ def add_octaves( for line_type, references in wiring_by_line_type.items(): for reference in references: if "octaves" in references.get_unreferenced_value(reference): - octave_name = references.get_unreferenced_value( - reference - ).split("/")[2] + octave_name = references.get_unreferenced_value(reference).split("/")[2] octave = Octave( name=octave_name, calibration_db_path=str(calibration_db_path), @@ -207,9 +230,7 @@ def add_external_mixers(machine: AnyQuam) -> AnyQuam: for line_type, references in wiring_by_line_type.items(): for reference in references: if "mixers" in references.get_unreferenced_value(reference): - mixer_name = references.get_unreferenced_value(reference).split( - "/" - )[2] + mixer_name = references.get_unreferenced_value(reference).split("/")[2] transmon_channel = { WiringLineType.DRIVE.value: "xy", WiringLineType.RESONATOR.value: "resonator", diff --git a/quam_builder/builder/superconducting/pulses.py b/quam_builder/builder/superconducting/pulses.py index 0f6817aa..efd61291 100644 --- a/quam_builder/builder/superconducting/pulses.py +++ b/quam_builder/builder/superconducting/pulses.py @@ -8,6 +8,7 @@ FixedFrequencyTransmonPair, FluxTunableTransmonPair, ) +from quam_builder.architecture.superconducting.components.twpa import TWPA import numpy as np from typing import Union @@ -261,9 +262,7 @@ def add_Square_pulses( transmon.set_gate_shape("Square") -def add_default_transmon_pulses( - transmon: Union[FixedFrequencyTransmon, FluxTunableTransmon] -): +def add_default_transmon_pulses(transmon: Union[FixedFrequencyTransmon, FluxTunableTransmon]): """Adds default pulses to a transmon qubit: * transmon.xy.operations["saturation"] = pulses.SquarePulse(amplitude=0.25, length=20 * u.us, axis_angle=0) * transmon.z.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) @@ -280,9 +279,7 @@ def add_default_transmon_pulses( if hasattr(transmon, "z"): if transmon.z is not None: - transmon.z.operations["const"] = pulses.SquarePulse( - amplitude=0.1, length=100 - ) + transmon.z.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) if hasattr(transmon, "resonator"): if transmon.resonator is not None: @@ -292,7 +289,7 @@ def add_default_transmon_pulses( def add_default_transmon_pair_pulses( - transmon_pair: Union[FixedFrequencyTransmonPair, FluxTunableTransmonPair] + transmon_pair: Union[FixedFrequencyTransmonPair, FluxTunableTransmonPair], ): """Adds default pulses to a transmon qubit pair depending on its attributes: * transmon_pair.coupler.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) @@ -317,3 +314,37 @@ def add_default_transmon_pair_pulses( transmon_pair.zz_drive.operations["square"] = pulses.SquarePulse( amplitude=0.1, length=100 ) + + +def add_default_twpa_pulses(twpa: TWPA): + """Adds default pulses to a TWPA: + * twpa.pump.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=16, axis_angle=0) + * twpa.pump_.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=2000, axis_angle=0) + * twpa.isolation.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=16, axis_angle=0) + * twpa.isolation_.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=2000, axis_angle=0) + + Args: + twpa (TWPA): The TWPA component to which the pulses will be added. + """ + if hasattr(twpa, "pump"): + if twpa.pump is not None: + twpa.pump.operations["pump"] = pulses.SquarePulse( + amplitude=0.5, length=16, axis_angle=0 + ) + if hasattr(twpa, "pump_"): + if twpa.pump_ is not None: + twpa.pump_.operations["pump"] = pulses.SquarePulse( + amplitude=0.5, length=2000, axis_angle=0 + ) + + if hasattr(twpa, "isolation"): + if twpa.isolation is not None: + twpa.isolation.operations["pump"] = pulses.SquarePulse( + amplitude=0.5, length=16, axis_angle=0 + ) + + if hasattr(twpa, "isolation_"): + if twpa.isolation_ is not None: + twpa.isolation_.operations["pump"] = pulses.SquarePulse( + amplitude=0.5, length=2000, axis_angle=0 + ) From bee9c8d19add241fe227a48557c3bcbc7d4d7fe0 Mon Sep 17 00:00:00 2001 From: TheoLaudatQM <98808790+TheoLaudatQM@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:56:12 +0200 Subject: [PATCH 4/9] TWPA component fix (#105) * Set default to False * Update CHANGELOG.md --- CHANGELOG.md | 2 ++ quam_builder/architecture/superconducting/qpu/base_quam.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c66cbb2..a390781d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) Updated qualang-tools requirement `"qualang-tools>=0.22.0"`. ### Added - Updated the TWPA component with isolation pump and added corresponding builder functions. +### Fixed +- Fix the default behavior of `def initialize_qpu(self, isolation: bool = False, **kwargs):` in the SC `BaseQuam`. ## [0.3.0] - 2026-03-31 ### Added diff --git a/quam_builder/architecture/superconducting/qpu/base_quam.py b/quam_builder/architecture/superconducting/qpu/base_quam.py index e4b89f7b..f2816384 100644 --- a/quam_builder/architecture/superconducting/qpu/base_quam.py +++ b/quam_builder/architecture/superconducting/qpu/base_quam.py @@ -357,7 +357,7 @@ def declare_qua_variables( Q_st = [declare_stream() for _ in range(num_IQ_pairs)] return I, I_st, Q, Q_st, n, n_st - def initialize_qpu(self, isolation: bool = True, **kwargs): + def initialize_qpu(self, isolation: bool = False, **kwargs): """Initialize the QPU with the calibrated TWPA pumping points. Args: From 554b7dcc38bd21fd1c056b1a126735f9e6e92dd7 Mon Sep 17 00:00:00 2001 From: KU-QM Date: Thu, 14 May 2026 15:14:13 +0100 Subject: [PATCH 5/9] Syncing with eval branch --- .../architecture/quantum_dots/README.md | 87 ++- .../architecture/quantum_dots/__init__.py | 4 +- .../quantum_dots/components/__init__.py | 9 + .../quantum_dots/components/dac_spec.py | 117 +++ .../quantum_dots/components/gate_set.py | 26 +- .../components/mixins/__init__.py | 2 +- .../components/mixins/macro_dispatch.py | 124 +-- .../components/mixins/voltage_macro.py | 393 +--------- .../components/mixins/voltage_point.py | 68 +- .../quantum_dots/components/pulses.py | 49 ++ .../quantum_dots/components/quantum_dot.py | 25 +- .../components/quantum_dot_pair.py | 14 +- .../components/readout_resonator.py | 13 +- .../components/readout_transport.py | 7 +- .../quantum_dots/components/voltage_gate.py | 38 +- .../quantum_dots/components/xy_drive.py | 14 - .../quantum_dots/examples/__init__.py | 6 +- .../quantum_dots/examples/configs.py | 2 - .../default_macro_defaults_example.py | 159 ++++ .../default_macro_overrides_example.py | 251 ++++++ .../examples/external_macro_demo/__init__.py | 8 + .../examples/external_macro_demo/catalog.py | 75 ++ .../external_macro_package_example.py | 62 ++ .../examples/full_circuit_example.py | 429 ----------- .../examples/full_workflow_example.py | 317 ++++++++ .../quantum_dots/examples/init_circuit.py | 398 ---------- .../examples/init_macro_conditional.py | 277 ------- .../quantum_dots/examples/macro_examples.py | 472 ------------ .../quantum_dots/examples/operations.py | 189 ----- .../examples/port_constraints_bug_example.py | 236 ------ .../examples/pulse_overrides_example.py | 162 ++++ .../quantum_dots/examples/quam_ld_example.py | 61 +- .../examples/quam_ld_generator_example.py | 9 +- .../quantum_dots/examples/quam_qd_example.py | 146 ++-- .../examples/quam_qd_generator_example.py | 117 +-- .../quantum_dots/examples/rabi_chevron.py | 120 ++- .../examples/rabi_chevron_transport.py | 43 +- .../quantum_dots/examples/tutorial_machine.py | 118 +++ .../examples/virtual_dc_set_example.py | 170 ++-- .../examples/virtual_gate_set_example.py | 16 +- .../quantum_dots/macro_engine/__init__.py | 24 + .../quantum_dots/macro_engine/overrides.py | 173 +++++ .../quantum_dots/macro_engine/wiring.py | 541 +++++++++++++ .../quantum_dots/operations/README.md | 692 +++++++++++++++++ .../quantum_dots/operations/__init__.py | 15 +- .../operations/component_macro_catalog.py | 83 ++ .../operations/component_pulse_catalog.py | 163 ++++ .../operations/default_macros/__init__.py | 11 +- .../default_macros/single_qubit_macros.py | 728 +++++++++++++----- .../operations/default_macros/state_macros.py | 350 +++++++++ .../default_macros/two_qubit_macros.py | 178 ++--- .../operations/default_operations.py | 278 ++----- .../quantum_dots/operations/macro_registry.py | 95 +++ .../quantum_dots/operations/names.py | 141 ++++ .../quantum_dots/operations/pulse_registry.py | 126 +++ .../architecture/quantum_dots/qpu/__init__.py | 3 + .../quantum_dots/qpu/base_quam_qd.py | 312 ++++++-- .../quantum_dots/qpu/loss_divincenzo_quam.py | 16 +- .../quantum_dots/qubit/ld_qubit.py | 54 +- .../quantum_dots/qubit_pair/ld_qubit_pair.py | 11 +- .../superconducting/components/__init__.py | 1 - .../components/cross_resonance.py | 12 +- .../superconducting/components/flux_line.py | 4 +- .../components/readout_resonator.py | 13 +- .../superconducting/components/twpa.py | 159 ++-- .../superconducting/components/xy_drive.py | 13 +- .../superconducting/qpu/base_quam.py | 29 +- .../qpu/fixed_frequency_quam.py | 4 +- .../superconducting/qpu/flux_tunable_quam.py | 37 +- .../qop_connectivity/build_quam_wiring.py | 15 +- .../builder/qop_connectivity/create_wiring.py | 40 +- .../builder/quantum_dots/build_qpu.py | 9 + .../builder/quantum_dots/build_qpu_stage1.py | 33 +- .../builder/quantum_dots/build_qpu_stage2.py | 45 +- .../builder/quantum_dots/build_quam.py | 257 +++++-- .../builder/quantum_dots/build_utils.py | 94 ++- quam_builder/builder/quantum_dots/pulses.py | 165 ++-- .../superconducting/add_twpa_component.py | 132 ---- .../builder/superconducting/build_quam.py | 39 +- .../builder/superconducting/pulses.py | 45 +- quam_builder/tools/macros/__init__.py | 16 +- .../tools/macros/composable_macros.py | 335 -------- quam_builder/tools/macros/default_macros.py | 4 +- quam_builder/tools/macros/point_macros.py | 200 ----- quam_builder/tools/voltage_sequence/README.md | 51 +- .../voltage_sequence/voltage_sequence.py | 88 ++- tests/__init__.py | 0 tests/architecture/__init__.py | 0 tests/architecture/quantum_dots/__init__.py | 0 .../quantum_dots/components/__init__.py | 0 .../components/test_barrier_gate.py | 64 ++ .../components/test_base_quam_qd.py | 324 ++++++++ .../quantum_dots/components/test_ld_qubit.py | 79 ++ .../components/test_ld_qubit_pair.py | 73 ++ .../components/test_quantum_dot.py | 97 +++ .../components/test_quantum_dot_pair.py | 119 +++ .../components/test_readout_resonator.py | 75 ++ .../components/test_sensor_dot.py | 139 ++++ .../quantum_dots/components/test_xy_drive.py | 139 ++++ tests/architecture/quantum_dots/conftest.py | 104 +++ .../operations/test_alignment_fixes.py | 373 +++++++++ .../operations/test_catalog_reset.py | 37 + .../operations/test_pulse_catalog.py | 102 +++ .../quantum_dots/test_macro_persistence.py | 28 + .../quantum_dots/test_rabi_chevron_e2e.py | 348 +++++++++ .../quantum_dots/virtual_gates/__init__.py | 0 .../quantum_dots/virtual_gates}/conftest.py | 14 +- .../virtual_gates/test_virtual_gates.py | 0 .../test_virtual_voltage_sequence.py | 31 +- .../test_virtualisation_layer.py | 0 .../quantum_dots/voltage_sequence/__init__.py | 0 .../voltage_sequence}/conftest.py | 0 .../voltage_sequence/test_gate_set.py | 0 .../test_sequence_state_tracker.py | 0 .../voltage_sequence/test_voltage_sequence.py | 8 +- .../architecture/superconducting/__init__.py | 0 .../test_components_flux_line.py | 0 .../test_components_readout_resonator.py | 0 .../test_components_tunable_coupler.py | 0 .../test_qubit_base_transmon.py | 0 tests/builder/__init__.py | 0 tests/builder/quantum_dots/__init__.py | 0 .../quantum_dots/test_build_quam.py | 181 +++-- .../quantum_dots/test_builder_pulses.py | 186 +++++ .../quantum_dots/test_e2e_quantum_dots.py | 550 +++++++++++++ .../builder/quantum_dots/test_macro_names.py | 93 +++ .../builder/quantum_dots/test_macro_wiring.py | 323 ++++++++ .../builder/quantum_dots/test_pulse_wiring.py | 203 +++++ .../test_stage_workflow_persistence.py | 130 ++++ .../quantum_dots/test_two_stage_build.py | 237 +++++- .../test_wirer_builder_integration.py | 560 ++++++++++++++ tests/builder/superconducting/__init__.py | 0 .../test_e2e_superconducting.py | 278 +++++++ tests/conftest.py | 77 +- tests/macros/__init__.py | 0 tests/{quantum_dots => macros}/conftest.py | 0 tests/pylint_plugin/test_pylint_qua_plugin.py | 35 +- tests/quantum_dots/__init__.py | 0 tests/quantum_dots/test_builder_pulses.py | 116 --- 139 files changed, 10834 insertions(+), 4956 deletions(-) create mode 100644 quam_builder/architecture/quantum_dots/components/dac_spec.py create mode 100644 quam_builder/architecture/quantum_dots/components/pulses.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/configs.py create mode 100644 quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py create mode 100644 quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py create mode 100644 quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/full_circuit_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/full_workflow_example.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/init_circuit.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/init_macro_conditional.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/macro_examples.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/operations.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/port_constraints_bug_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/tutorial_machine.py create mode 100644 quam_builder/architecture/quantum_dots/macro_engine/__init__.py create mode 100644 quam_builder/architecture/quantum_dots/macro_engine/overrides.py create mode 100644 quam_builder/architecture/quantum_dots/macro_engine/wiring.py create mode 100644 quam_builder/architecture/quantum_dots/operations/README.md create mode 100644 quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py create mode 100644 quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py create mode 100644 quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py create mode 100644 quam_builder/architecture/quantum_dots/operations/macro_registry.py create mode 100644 quam_builder/architecture/quantum_dots/operations/names.py create mode 100644 quam_builder/architecture/quantum_dots/operations/pulse_registry.py delete mode 100644 quam_builder/builder/superconducting/add_twpa_component.py delete mode 100644 quam_builder/tools/macros/composable_macros.py delete mode 100644 quam_builder/tools/macros/point_macros.py create mode 100644 tests/__init__.py create mode 100644 tests/architecture/__init__.py create mode 100644 tests/architecture/quantum_dots/__init__.py create mode 100644 tests/architecture/quantum_dots/components/__init__.py create mode 100644 tests/architecture/quantum_dots/components/test_barrier_gate.py create mode 100644 tests/architecture/quantum_dots/components/test_base_quam_qd.py create mode 100644 tests/architecture/quantum_dots/components/test_ld_qubit.py create mode 100644 tests/architecture/quantum_dots/components/test_ld_qubit_pair.py create mode 100644 tests/architecture/quantum_dots/components/test_quantum_dot.py create mode 100644 tests/architecture/quantum_dots/components/test_quantum_dot_pair.py create mode 100644 tests/architecture/quantum_dots/components/test_readout_resonator.py create mode 100644 tests/architecture/quantum_dots/components/test_sensor_dot.py create mode 100644 tests/architecture/quantum_dots/components/test_xy_drive.py create mode 100644 tests/architecture/quantum_dots/conftest.py create mode 100644 tests/architecture/quantum_dots/operations/test_alignment_fixes.py create mode 100644 tests/architecture/quantum_dots/operations/test_catalog_reset.py create mode 100644 tests/architecture/quantum_dots/operations/test_pulse_catalog.py create mode 100644 tests/architecture/quantum_dots/test_macro_persistence.py create mode 100644 tests/architecture/quantum_dots/test_rabi_chevron_e2e.py create mode 100644 tests/architecture/quantum_dots/virtual_gates/__init__.py rename tests/{voltage_sequence => architecture/quantum_dots/virtual_gates}/conftest.py (67%) rename tests/{ => architecture/quantum_dots}/virtual_gates/test_virtual_gates.py (100%) rename tests/{ => architecture/quantum_dots}/virtual_gates/test_virtual_voltage_sequence.py (93%) rename tests/{ => architecture/quantum_dots}/virtual_gates/test_virtualisation_layer.py (100%) create mode 100644 tests/architecture/quantum_dots/voltage_sequence/__init__.py rename tests/{virtual_gates => architecture/quantum_dots/voltage_sequence}/conftest.py (100%) rename tests/{ => architecture/quantum_dots}/voltage_sequence/test_gate_set.py (100%) rename tests/{ => architecture/quantum_dots}/voltage_sequence/test_sequence_state_tracker.py (100%) rename tests/{ => architecture/quantum_dots}/voltage_sequence/test_voltage_sequence.py (98%) create mode 100644 tests/architecture/superconducting/__init__.py rename tests/{ => architecture/superconducting}/test_components_flux_line.py (100%) rename tests/{ => architecture/superconducting}/test_components_readout_resonator.py (100%) rename tests/{ => architecture/superconducting}/test_components_tunable_coupler.py (100%) rename tests/{ => architecture/superconducting}/test_qubit_base_transmon.py (100%) create mode 100644 tests/builder/__init__.py create mode 100644 tests/builder/quantum_dots/__init__.py rename tests/{ => builder}/quantum_dots/test_build_quam.py (68%) create mode 100644 tests/builder/quantum_dots/test_builder_pulses.py create mode 100644 tests/builder/quantum_dots/test_e2e_quantum_dots.py create mode 100644 tests/builder/quantum_dots/test_macro_names.py create mode 100644 tests/builder/quantum_dots/test_macro_wiring.py create mode 100644 tests/builder/quantum_dots/test_pulse_wiring.py create mode 100644 tests/builder/quantum_dots/test_stage_workflow_persistence.py create mode 100644 tests/builder/quantum_dots/test_wirer_builder_integration.py create mode 100644 tests/builder/superconducting/__init__.py create mode 100644 tests/builder/superconducting/test_e2e_superconducting.py create mode 100644 tests/macros/__init__.py rename tests/{quantum_dots => macros}/conftest.py (100%) create mode 100644 tests/quantum_dots/__init__.py delete mode 100644 tests/quantum_dots/test_builder_pulses.py diff --git a/quam_builder/architecture/quantum_dots/README.md b/quam_builder/architecture/quantum_dots/README.md index 22c2f3e3..e23ff8bc 100644 --- a/quam_builder/architecture/quantum_dots/README.md +++ b/quam_builder/architecture/quantum_dots/README.md @@ -3,11 +3,11 @@ ## 1. Introduction -This document first introduces the **GateSet** component with the **VoltageSequence** tool, a python framework for generating QUA sequences to group control of DC gate voltages, particularly useful for spin qubit experiments. +This document first introduces the **GateSet** component with the **VoltageSequence** tool, a python framework for generating QUA sequences to group control of DC gate voltages, particularly useful for spin qubit experiments. The components `GateSet` and `VoltageSequence` enable precise physical voltage control, essential for quantum dot operations and forming a basis for `VirtualGateSet`. -Spin qubit experiments are encouraged to use the **VoltageGate** QuAM channel. A `VoltageGate` channel is a Quantum Dot specific channel inheriting from QuAM's `SingleChannel` object. It adds to the `SingleChannel` by containing an `offset_parameter` and an `attenuation` value. +Spin qubit experiments are encouraged to use the **VoltageGate** QuAM channel. A `VoltageGate` channel is a Quantum Dot specific channel inheriting from QuAM's `SingleChannel` object. It adds to the `SingleChannel` by containing an `offset_parameter` and an `attenuation` value. Subsequently, this document introduces the **VirtualGateSet** and **VirtualizationLayer** components. These extend the physical gate control capabilities provided by `GateSet` and `VoltageSequence` by adding one or more layers of virtual gates. @@ -33,9 +33,9 @@ The `VirtualGateSet` framework provides the necessary tools to implement these a #### 2.1.1 VoltageGate -`VoltageGate` is a QuAM channel built specifically to handle quantum dot and spin qubit experiments. It inherits from `SingleChannel`, adding an `offset_parameter` and `attenuation` values. +`VoltageGate` is a QuAM channel built specifically to handle quantum dot and spin qubit experiments. It inherits from `SingleChannel`, adding an `offset_parameter` and `attenuation` values. -- `offset_parameter` is built to work with external voltage sources (and external drivers) in mind. For example, in the case that the external voltage is provided by the QM QDAC-II, one can use the QCoDeS driver as follows: +- `offset_parameter` is built to work with external voltage sources (and external drivers) in mind. For example, in the case that the external voltage is provided by the QM QDAC-II, one can use the QCoDeS driver as follows: ```python channel_p1 = VoltageGate(...) @@ -68,10 +68,10 @@ Represents a single linear transformation (matrix) from a set of source (virtual #### 1. Define QUAM `VoltageGate` objects for physical gates -- Below is an example of how a `VoltageGate` is instantiated. As appropriate, add `offset_parameter` and `attenuation` arguments. +- Below is an example of how a `VoltageGate` is instantiated. As appropriate, add `offset_parameter` and `attenuation` arguments. -- In order for `GateSet`, `VirtualGateSet` and `VoltageSequence` to function properly, the channels **must** be instantiated as **sticky** elements. - - This means that any applied offset is maintained. +- In order for `GateSet`, `VirtualGateSet` and `VoltageSequence` to function properly, the channels **must** be instantiated as **sticky** elements. + - This means that any applied offset is maintained. - Any `sequence` in the `GateSet`/`VirtualGateSet`, as well as core functionalities such as `ramp_to_zero`, rely on sticky elements. This is a core requirement. ```python @@ -107,7 +107,7 @@ Represents a single linear transformation (matrix) from a set of source (virtual - When creating this mapping, it is important to ensure that the string names used here match the string names in your QuAM machine. -- If your channel object are already parented by a QuAM machine (i.e. `machine.channel["channel_p1"] = VoltageGate(...)`), then the channels cannot be re-parented into your GateSet. In this case, it is important to use the channel reference as such: +- If your channel object are already parented by a QuAM machine (i.e. `machine.channel["channel_p1"] = VoltageGate(...)`), then the channels cannot be re-parented into your GateSet. In this case, it is important to use the channel reference as such: ```python channels = { @@ -119,15 +119,15 @@ Represents a single linear transformation (matrix) from a set of source (virtual #### 3. Instantiate your GateSet with your channel mapping -- Below shows an example of instantiating your `GateSet`, for basic group control of `VoltageGate` channels. +- Below shows an example of instantiating your `GateSet`, for basic group control of `VoltageGate` channels. - ```python + ```python from quam_builder.architecture.quantum_dots.components import GateSet my_gate_set = GateSet(id="dot_plungers", channels=channels) ``` -- If virtual gates are necessary in your setup, use the `VirtualGateSet` instead. The instantiation of `VirtualGateSet` is identical to the `GateSet`. +- If virtual gates are necessary in your setup, use the `VirtualGateSet` instead. The instantiation of `VirtualGateSet` is identical to the `GateSet`. ```python from quam_builder.architecture.quantum_dots.components import VirtualGateSet @@ -136,9 +136,9 @@ Represents a single linear transformation (matrix) from a set of source (virtual ``` -##### 3.1 (Optional) Add Virtualization Layers +##### 3.1 (Optional) Add Virtualization Layers -- If you are using the `VirtualGateSet`, you can map virtualization layers onto your existing physical or virtual gates using the `.add_layer()` method. You must name the new virtual `source_gates` and input a transformation matrix. This does not need to map onto all of your existing physical or virtual gates. +- If you are using the `VirtualGateSet`, you can map virtualization layers onto your existing physical or virtual gates using the `.add_layer()` method. You must name the new virtual `source_gates` and input a transformation matrix. This does not need to map onto all of your existing physical or virtual gates. ```python # Add coarse tuning layer (virtual gates for overall dot positions) @@ -157,16 +157,16 @@ Represents a single linear transformation (matrix) from a set of source (virtual ``` #### 4. Add `VoltageTuningPoint` macros to the `GateSet` or `VirtualGateSet` - + - This is useful for when you have set points in your charge-stability that must be re-used in the experiment. GateSet can hold VoltageTuningPoints which can easily be accessed by VoltageSequence ```python my_gate_set.add_point(name="idle", voltages={"channel_P1": 0.1, "channel_P2": -0.05}, duration=1000) ``` - + - Internally this adds a **`VoltageTuningPoint` to GateSet.macros** -- This is not unique to `GateSet`, or indeed physical gates. `VirtualGateSet` is capable of holding virtual tuning points. The input dictionary mapping can contain any combination of physical or virtual gates, from any layer in the `VirtualGateSet`. The exact mechanism with which the output voltage is calculated is covered later. +- This is not unique to `GateSet`, or indeed physical gates. `VirtualGateSet` is capable of holding virtual tuning points. The input dictionary mapping can contain any combination of physical or virtual gates, from any layer in the `VirtualGateSet`. The exact mechanism with which the output voltage is calculated is covered later. ```python my_virtual_gate_set.add_point(name="idle", voltages={"v_FineTune1": 0.1, "v_Coarse2": -0.05}, duration=1000) @@ -178,15 +178,15 @@ Represents a single linear transformation (matrix) from a set of source (virtual - `voltage_seq` in the below example can be used in QUA programs to easily step/ramp to points defined as macros in your `GateSet` or `VirtualGateSet` - ```python - with program() as basic_control: + ```python + with program() as basic_control: voltage_seq = my_gate_set.new_sequence() ``` -- Or, if using the `VirtualGateSet`, +- Or, if using the `VirtualGateSet`, - ```python - with program() as complex_control: + ```python + with program() as complex_control: voltage_seq = my_virtual_gate_set.new_sequence() ``` @@ -194,7 +194,7 @@ Represents a single linear transformation (matrix) from a set of source (virtual - Instantiate your new sequence in the QUA programme, and step/ramp to any point. -- For a basic `GateSet`, +- For a basic `GateSet`, ```python with qua.program() as basic_control: @@ -248,9 +248,9 @@ Represents a single linear transformation (matrix) from a set of source (virtual ## 3. `GateSet` -A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` or `SingleChannel` channels and treats them as a single, coordinated object for unified control. This is especially useful in Quantum Dot architectures, where one often has many physical gate electrodes that must be tuned together. Instead of controlling each `VoltageGate`/`SingleChannel` channel in isolation, the GateSet allows +A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` or `SingleChannel` channels and treats them as a single, coordinated object for unified control. This is especially useful in Quantum Dot architectures, where one often has many physical gate electrodes that must be tuned together. Instead of controlling each `VoltageGate`/`SingleChannel` channel in isolation, the GateSet allows -- Unified control: Iterate, configure, and programme multiple gates at once through a single object +- Unified control: Iterate, configure, and programme multiple gates at once through a single object - Pre-defined DC points: Store named DC working points using add_point(...) @@ -267,7 +267,7 @@ A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` ```python # Assume gate_set has channels: {"P1": channel_P1, "P2": channel_P2, "B1": channel_B1} - # Only specify the voltages of a partial subset of all the gates in the GateSet. + # Only specify the voltages of a partial subset of all the gates in the GateSet. partial_voltages = {"P1": 0.3, "B1": -0.1} # resolve_voltages fills in missing channels, creating a complete voltages dict internally by replacing all the un-named gate voltages with 0.0V @@ -306,26 +306,26 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir **Creating a `VoltageSequence`:** -- The sequence must be defined within a QUA program. +- The sequence must be defined within a QUA program. **Core Methods (used in `qua.program()` context):** -- `step_to_voltages(voltages: Dict[str, float], duration: int)` +- `step_to_voltages(voltages: Dict[str, float], duration: int)` Steps all specified channels directly to the given voltage levels and holds them for the specified duration (in nanoseconds). This creates immediate voltage changes without ramping. Both `voltages` values and `duration` can be QUA variables for dynamic control. ```python voltage_seq.step_to_voltages(voltages={"P1": 0.3, "P2": 0.1}, duration=1000) ``` -- `ramp_to_voltages(voltages: Dict[str, float], duration: int, ramp_duration: int)` +- `ramp_to_voltages(voltages: Dict[str, float], duration: int, ramp_duration: int)` Ramps all specified channels to the given voltage levels over the specified ramp duration, then holds them for the duration (both in nanoseconds). This provides smooth voltage transitions useful for avoiding voltage spikes that could affect sensitive quantum systems. All parameters can be QUA variables. ```python voltage_seq.ramp_to_voltages(voltages={"P1": 0.0}, duration=500, ramp_duration=40) ``` -- `step_to_point(name: str, duration: Optional[int] = None)` +- `step_to_point(name: str, duration: Optional[int] = None)` Steps all channels to the voltages defined in a predefined `VoltageTuningPoint` macro. If no duration is provided, uses the default duration from the tuning point definition. This enables quick transitions to well-defined system states. The `duration` parameter can be a QUA variable. ```python @@ -333,14 +333,14 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.step_to_point("readout", duration=2000) # Override default duration ``` -- `ramp_to_point(name: str, ramp_duration: int, duration: Optional[int] = None)` +- `ramp_to_point(name: str, ramp_duration: int, duration: Optional[int] = None)` Ramps all channels to the voltages defined in a predefined `VoltageTuningPoint` over the specified ramp duration, then holds them. Combines the smooth transitions of ramping with the convenience of predefined voltage states. Both `ramp_duration` and `duration` can be QUA variables. ```python voltage_seq.ramp_to_point("idle", ramp_duration=50, duration=1000) ``` -- `ramp_to_zero(ramp_duration: Optional[int] = None)` +- `ramp_to_zero(ramp_duration: Optional[int] = None)` Ramps the voltage on all channels in the GateSet to zero and resets the integrated voltage tracking for each channel. If no duration is specified, uses QUA's built-in `ramp_to_zero` command for immediate ramping. Essential for safely returning to a neutral state. The `ramp_duration` parameter can be a QUA variable. ```python @@ -348,7 +348,7 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.ramp_to_zero(ramp_duration=100) # Controlled ramp over 100ns ``` -- `apply_compensation_pulse(max_voltage: float = 0.49)` +- `apply_compensation_pulse(max_voltage: float = 0.49)` Applies a compensation pulse to each channel to counteract integrated voltage drift when tracking is enabled. The compensation amplitude is calculated based on the accumulated integrated voltage, with the pulse duration optimized to stay within the specified maximum voltage limit. Only available when `track_integrated_voltage=True`. ```python @@ -356,6 +356,19 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.apply_compensation_pulse(max_voltage=0.3) # Custom voltage limit ``` +### Custom Macro Duration Contract + +When voltage channels are sticky and compensation tracking is enabled, non-voltage +macros must expose a deterministic `inferred_duration` (in **seconds**) so hold +time at the current DC level can be tracked correctly. + +- Implement `inferred_duration` on custom macros in seconds (for example, `100e-9`). +- If a macro directly performs voltage-sequence operations that already update + integrated-voltage tracking, set `updates_voltage_tracking = True` on that macro + class to avoid double counting. +- Prefer calling macros through component dispatch (`component.macro_name(...)`) + so sticky-duration interception is applied. + ## 5. Foundation for Virtual Gates `GateSet` and `VoltageSequence` provide the physical voltage control layer necessary for `VirtualGateSet`. @@ -574,8 +587,8 @@ To validate a rectangular virtualization layer: ```python from quam.components import ( - BasicQuam, - StickyChannelAddon, + BasicQuam, + StickyChannelAddon, pulses ) from quam_builder.architecture.quantum_dots.components import VoltageGate @@ -640,7 +653,7 @@ machine.virtual_gate_set.add_point("meas", {"ch3": -0.12}, duration = 3_000) ### 8.5. Write QUA program ```python -with program() as prog: +with program() as prog: my_new_seq = machine.virtual_gate_set.new_sequence(track_integrated_voltage=True) my_new_seq.step_to_point("init") # also valid: my_new_seq.step_to_voltages(voltages = {"ch1": -0.25, "ch3": 0.12}, duration = 10_000) my_new_seq.step_to_point("op") @@ -648,8 +661,8 @@ with program() as prog: ``` **What is happening here?** -- In `init`, the input dict is `{"ch1": -0.25, "ch3": 0.12}`. Since `ch2` is omitted in this layer, this will internally translate to a full dict of `{"ch1": -0.25, "ch2": 0.0, "ch3": 0.12}`. +- In `init`, the input dict is `{"ch1": -0.25, "ch3": 0.12}`. Since `ch2` is omitted in this layer, this will internally translate to a full dict of `{"ch1": -0.25, "ch2": 0.0, "ch3": 0.12}`. - In `op`, the input dict is comprised of virtual gates `{"V1": 0.2, "V2": 0.1}`. `ch3` is absent, and since `V1` and `V2` map only to `ch1` and `ch2`, `ch3` is interpreted as having an input 0.0, to produce a dict of `{"V1": 0.2, "V2": 0.1, "ch3": 0.0}`. Internally, the physical gate voltages are calculated using the inverse of the virtual gate matrix, to a physical gate dict of `{"ch1": 0.05, "ch2": 0.1, "ch3": 0.0}`. Bear in mind that these voltages are absolute, not relative, despite the sticky elements. -- In `meas`, the input dict is simply `{"ch3": -0.12}`, which is interpreted as `{"ch1": 0.0, "ch2": 0.0, "ch3": -0.12}`. +- In `meas`, the input dict is simply `{"ch3": -0.12}`, which is interpreted as `{"ch1": 0.0, "ch2": 0.0, "ch3": -0.12}`. diff --git a/quam_builder/architecture/quantum_dots/__init__.py b/quam_builder/architecture/quantum_dots/__init__.py index 4a9c67dd..8388308b 100644 --- a/quam_builder/architecture/quantum_dots/__init__.py +++ b/quam_builder/architecture/quantum_dots/__init__.py @@ -6,6 +6,7 @@ from .components import * from .examples import * +from .macro_engine import * from .operations import * from .qpu import * from .qubit import * @@ -14,8 +15,9 @@ __all__ = [ *components.__all__, *examples.__all__, + *macro_engine.__all__, *operations.__all__, *qpu.__all__, *qubit.__all__, *qubit_pair.__all__, -] \ No newline at end of file +] diff --git a/quam_builder/architecture/quantum_dots/components/__init__.py b/quam_builder/architecture/quantum_dots/components/__init__.py index 2ca62efe..b1a859d7 100644 --- a/quam_builder/architecture/quantum_dots/components/__init__.py +++ b/quam_builder/architecture/quantum_dots/components/__init__.py @@ -1,5 +1,7 @@ """Quantum dots components module.""" +from typing import Union + from . import voltage_gate from . import virtual_gate_set from . import virtual_dc_set @@ -13,9 +15,12 @@ from . import xy_drive from . import mixins from . import qpu +from . import pulses from . import readout_transport from . import reservoir +from . import dac_spec +from .pulses import * from .voltage_gate import * from .virtual_gate_set import * from .virtual_dc_set import * @@ -33,7 +38,10 @@ from .mixins import * from .qpu import * +from .dac_spec import * + __all__ = [ + *pulses.__all__, *voltage_gate.__all__, *virtual_gate_set.__all__, *global_gate.__all__, @@ -49,4 +57,5 @@ *qpu.__all__, *readout_transport.__all__, *reservoir.__all__, + *dac_spec.__all__, ] diff --git a/quam_builder/architecture/quantum_dots/components/dac_spec.py b/quam_builder/architecture/quantum_dots/components/dac_spec.py new file mode 100644 index 00000000..233ade04 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/components/dac_spec.py @@ -0,0 +1,117 @@ +import numpy as np +from typing import Union, List, Optional + +from quam.core import quam_dataclass, QuamComponent +from quam.components import Channel + +__all__ = ["DacSpec", "QdacSpec"] + + +@quam_dataclass +class DacSpec(QuamComponent): + """ + Quam Component for an agnostic DAC, to be parented by VoltageGate. + Attributes: + - output_port: An integer to indicate which channel you are outputting via. The driver structuer + should look like driver.channel_method(output_port). + - opx_trigger_out: A digital channel associated to the VoltageGate, used for sending a digital trigger pulse to the DAC. + """ + + output_port: int = None + opx_trigger_out: Channel = None + dac_name: str = "main" + + def __post_init__(self): + super().__post_init__() + if self.output_port is None and isinstance(self, DacSpec): + raise ValueError("output_port is required for DacSpec") + + @property + def machine(self) -> "BaseQuamQD": + # Climb up the parent ladder in order to find the machine in the machine + obj = self + while obj.parent is not None: + obj = obj.parent + machine = obj + return machine + + @property + def dac(self): + dac_obj = self.machine.dacs.get(self.dac_name, None) + if dac_obj is None: + raise ValueError( + f"DAC name {self.dac_name} not found in machine. Have you run machine.connect_to_external_source() ?" + ) + return dac_obj["driver"] + + +@quam_dataclass +class QdacSpec(DacSpec): + """ + Quam Component for a QDAC Channel, to be parented by VoltageGate. + Attributes: + - qdac_trigger_in: The QDAC external trigger port associated with the VoltageGate DC component. + - qdac_output_port: The QDAC port associated with the VoltageGate DC component. + """ + + qdac_trigger_in: int = None + qdac_output_port: int = None + + def __post_init__(self): + if self.qdac_output_port is None and self.output_port is None: + raise ValueError("Either output_port or qdac_output_port must be provided") + if self.qdac_output_port is None: # Means only the output_port is defined + self.qdac_output_port = self.output_port + else: # Means that the user has inputted a qdac_output_port. We can sync them again + self.output_port = self.qdac_output_port + super().__post_init__() + + @property + def qdac(self): + return self.dac + + def free_all_triggers(self) -> None: + self.qdac.free_all_triggers() + + def load_dc_list( + self, + voltages: Union[List, np.ndarray], + dwell_s: float = 200e-6, + stepped: bool = True, + ) -> None: + dc_list = self.qdac.channel(self.qdac_output_port).dc_list( + voltages=voltages, + dwell_s=dwell_s, + stepped=stepped, + ) + if stepped: # This means it is triggered + dc_list.start_on_external(trigger=self.qdac_trigger_in) + + def play_triangle_wave( + self, + frequency_Hz: Optional[float] = None, + period_s: Optional[float] = None, + repetitions: int = -1, + duty_cycle_percent: float = 50.0, + inverted: bool = False, + span_V: float = 0.2, + offset_V: float = 0.0, + delay_s: float = 0, + slew_V_s: Optional[float] = None, + triggered: bool = True, + ): + """An example of how to fully utilise the QDAC API in the QdacSpec. This example plays a triangle wave.""" + + triangle_wave = self.qdac.channel(self.qdac_output_port).triangle_wave( + frequency_Hz=frequency_Hz, + repetitions=repetitions, + period_s=period_s, + duty_cycle_percent=duty_cycle_percent, + inverted=inverted, + span_V=span_V, + offset_V=offset_V, + delay_s=delay_s, + slew_V_s=slew_V_s, + ) + if triggered: + triangle_wave.start_on_external(trigger=self.qdac_trigger_in) diff --git a/quam_builder/architecture/quantum_dots/components/gate_set.py b/quam_builder/architecture/quantum_dots/components/gate_set.py index a8831795..e6fc7437 100644 --- a/quam_builder/architecture/quantum_dots/components/gate_set.py +++ b/quam_builder/architecture/quantum_dots/components/gate_set.py @@ -11,13 +11,8 @@ VoltageSequence, ) -from quam_builder.tools.qua_tools import ( - VoltageLevelType, - CLOCK_CYCLE_NS, - MIN_PULSE_DURATION_NS, -) +from quam_builder.tools.qua_tools import VoltageLevelType -DEFAULT_PULSE_NAME = "half_max_square" __all__ = ["GateSet", "VoltageTuningPoint"] @@ -60,8 +55,6 @@ class GateSet(QuantumComponent): sequences - Resolve voltages for all channels with default fallbacks - Create voltage sequences with proper channel configuration - - Automatically configures DEFAULT_PULSE_NAME operations for all channels based on - their output mode (amplified vs direct) before creating the sequence. The GateSet acts as a logical grouping of related channels (e.g., gates controlling a specific quantum dot) and enables high-level voltage control @@ -99,23 +92,6 @@ class GateSet(QuantumComponent): channels: Dict[str, SingleChannel] adjust_for_attenuation: bool = False - def __post_init__(self): - for ch in self.channels.values(): - if isinstance(ch, str): - continue - if hasattr(ch.opx_output, "output_mode"): - if ch.opx_output.output_mode == "amplified": - ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( - amplitude=1.25, length=MIN_PULSE_DURATION_NS - ) - else: - ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( - amplitude=0.25, length=MIN_PULSE_DURATION_NS - ) - else: - ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( - amplitude=0.25, length=MIN_PULSE_DURATION_NS - ) @property def name(self) -> str: diff --git a/quam_builder/architecture/quantum_dots/components/mixins/__init__.py b/quam_builder/architecture/quantum_dots/components/mixins/__init__.py index bc8cb4ba..38ce7c75 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/__init__.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/__init__.py @@ -15,7 +15,7 @@ VoltageMacroMixin(VoltagePointMixin, MacroDispatchMixin) - Full API combining all functionality - - Fluent API methods (with_step_point, with_ramp_point, etc.) + - Direct voltage-point helpers plus dynamic macro dispatch """ from .voltage_control import VoltageControlMixin diff --git a/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py b/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py index ac41871c..3291b0b5 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py @@ -1,96 +1,102 @@ -"""Macro dispatch mixin for dynamic macro invocation. +"""Macro dispatch mixin for quantum-dot components. -This module provides the infrastructure for storing and dynamically -accessing macros as methods. +Provides the ``MacroDispatchMixin`` base class that gives any quantum-dot +component automatic macro storage, default materialization from the +:mod:`~quam_builder.architecture.quantum_dots.operations.macro_registry`, +and attribute-based macro invocation. + +Macro execution goes directly through ``macro.apply(**kwargs)`` without +any additional tracking or wrapping. """ +from __future__ import annotations + +import warnings from typing import Dict from dataclasses import field +from quam.components import QuantumComponent from quam.core import quam_dataclass from quam.core.macro import QuamMacro -from quam.components import QuantumComponent -from quam_builder.tools.macros.default_macros import DEFAULT_MACROS +from quam_builder.architecture.quantum_dots.operations.component_macro_catalog import ( + register_default_component_macro_factories, +) +from quam_builder.architecture.quantum_dots.operations.macro_registry import ( + get_default_macro_factories, +) __all__ = ["MacroDispatchMixin"] @quam_dataclass class MacroDispatchMixin(QuantumComponent): - """Mixin providing macro storage and dynamic dispatch infrastructure. + """Mixin for macro storage and dispatch. - This mixin enables components to: - - Store macros in a serializable dictionary - - Access macros as methods via __getattr__ - - Automatically initialize default macros + Any component that inherits from this mixin gains: - Features: - - Dynamic macro access: Macros in self.macros are callable as methods - - Serializable: All state stored in self.macros dict, compatible with QuAM serialization + * A ``macros`` dict populated with architecture defaults on construction. + * Attribute-based macro invocation (``component.x180()`` dispatches to + ``component.macros["x180"].apply()``). - Example: - component.macros['idle'] = StepPointMacro(...) - component.idle() # Calls the macro via __getattr__ + Example:: + + qubit = machine.qubits["q1"] + qubit.x180() # attribute dispatch + qubit.macros["x180"].apply() # direct access """ macros: Dict[str, QuamMacro] = field(default_factory=dict) def __post_init__(self): - """Initialize macro containers and set parent links.""" - # Ensure macro containers exist and set parent links when possible - if not hasattr(self, "macros") or self.macros is None: + """Initialize macro storage, defaults, and parent links.""" + self._ensure_macros_dict() + self.ensure_default_macros() + self._ensure_macro_parents() + + def _ensure_macros_dict(self) -> None: + """Ensure ``self.macros`` exists before default materialization.""" + if getattr(self, "macros", None) is None: self.macros = {} - # Add default macros if not already present - for macro_name, macro_class in DEFAULT_MACROS.items(): + def ensure_default_macros(self) -> None: + """Materialize default macro instances for this component type.""" + register_default_component_macro_factories() + for macro_name, macro_class in get_default_macro_factories(self).items(): if macro_name not in self.macros: - # Use a fresh copy per component to avoid sharing parent links self.macros[macro_name] = macro_class() - # Attach parents for any pre-populated entries + def _ensure_macro_parents(self) -> None: + """Ensure all macros have their parent set to this component.""" for macro in self.macros.values(): if getattr(macro, "parent", None) is None: macro.parent = self - def __getattr__(self, name): - """Enable calling macros as methods via attribute access. + def set_macro(self, name: str, macro: QuamMacro) -> None: + """Add or replace a macro.""" + self.macros[name] = macro + if getattr(macro, "parent", None) is None: + macro.parent = self - This allows dynamically-registered macros to be called as if they were - methods decorated with @QuantumComponent.register_macro, providing a - cleaner API: component.my_macro() instead of component.macros['my_macro']() + def __getattr__(self, name): + """Expose macros via attribute access. - Example: - component.macros['idle'] = StepPointMacro(...) - component.idle() # Calls the macro via __getattr__ + Returns the macro object itself when callable (has ``__call__``), + enabling both ``component.x()`` and ``component.x.update()``. + Falls back to returning ``macro.apply`` for macros that are not + directly callable. """ - # __getattr__ is only called after normal attribute lookup fails, - # so we only need to check macros here - macros = self.__dict__.get("macros", {}) - if name in macros: - macro = macros[name] - return lambda **kwargs: macro.apply(**kwargs) + if name in self.macros: + macro = self.macros[name] + if getattr(macro, "parent", None) is None: + warnings.warn( + f"Macro '{name}' on {type(self).__name__} has no parent set. " + f"This may indicate it was added without using set_macro().", + stacklevel=2, + ) + macro.parent = self + if callable(macro): + return macro + return macro.apply raise AttributeError(f"'{type(self).__name__}' object has no attribute or macro '{name}'") - - def _resolve_macro_ref(self, name_or_ref: str, description: str) -> str: - """Convert macro name to reference string, validating existence. - - Args: - name_or_ref: Either a macro name (e.g., 'measure') or reference string (starts with '#') - description: Description for error messages (e.g., 'Measurement macro') - - Returns: - str: Reference string to the macro - - Raises: - KeyError: If name_or_ref is a macro name that doesn't exist in self.macros - """ - if name_or_ref.startswith("#"): - return name_or_ref - if name_or_ref not in self.macros: - raise KeyError( - f"{description} '{name_or_ref}' not found in macros. " - f"Available macros: {list(self.macros.keys())}" - ) - return f"#../{name_or_ref}" diff --git a/quam_builder/architecture/quantum_dots/components/mixins/voltage_macro.py b/quam_builder/architecture/quantum_dots/components/mixins/voltage_macro.py index 7c3458b4..905222b9 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/voltage_macro.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/voltage_macro.py @@ -1,33 +1,18 @@ -"""Full voltage macro mixin combining all voltage and macro functionality. +"""Combined voltage-point and macro-dispatch mixin.""" -This module provides the complete API for voltage point macros, combining -voltage control, point management, and macro dispatch capabilities. -""" - -from typing import TYPE_CHECKING, Dict, List, Optional +# pylint: disable=too-many-ancestors from quam.core import quam_dataclass -from quam.core.macro import QuamMacro - -from quam_builder.tools.macros import ( - SequenceMacro, - StepPointMacro, - RampPointMacro, - ConditionalMacro, -) from .voltage_point import VoltagePointMixin from .macro_dispatch import MacroDispatchMixin -if TYPE_CHECKING: - pass - __all__ = ["VoltageMacroMixin"] @quam_dataclass class VoltageMacroMixin(VoltagePointMixin, MacroDispatchMixin): - """Full mixin providing voltage point macro methods for quantum dot components. + """Full mixin combining voltage-point helpers with macro dispatch. This mixin consolidates all voltage control methods to reduce code duplication across BarrierGate, QuantumDot, QuantumDotPair, LDQubit, and LDQubitPair classes. @@ -42,7 +27,7 @@ class VoltageMacroMixin(VoltagePointMixin, MacroDispatchMixin): Features: - Dynamic macro access: Macros in self.macros are callable as methods via __getattr__ - - Fluent API: Chain macro definitions with with_step_point(), with_ramp_point(), with_sequence() + - Direct voltage-point creation and navigation via VoltagePointMixin - Serializable: All state stored in self.macros dict, compatible with QuAM serialization Example usage: @@ -57,369 +42,13 @@ def voltage_sequence(self) -> VoltageSequence: # Implementation to return voltage sequence ... - # Define macros during calibration - component.with_step_point("idle", {"gate": 0.1}, duration=100) - component.with_ramp_point("load", {"gate": 0.3}, duration=200, ramp_duration=500) - component.with_sequence("init", ["idle", "load"]) + # Define reusable voltage points during calibration + component.add_point("idle", {"gate": 0.1}, duration=100) + component.add_point("load", {"gate": 0.3}, duration=200) - # Call as methods in QUA program + # Use direct point navigation or default architecture macros in QUA with program() as prog: - component.idle() # Calls StepPointMacro - component.load() # Calls RampPointMacro - component.init() # Calls SequenceMacro + component.step_to_point("idle") + component.ramp_to_point("load", ramp_duration=500) + component.align() """ - - def add_point_with_step_macro( - self, - macro_name: str, - voltages: Optional[Dict[str, float]] = None, - duration: int = 100, - replace_existing_point: bool = True, - ) -> StepPointMacro: - """Convenience method: Create a voltage point and StepPointMacro in one step. - - This method supports two use cases: - 1. Creating a new point with voltages (voltages provided) - 2. Creating a macro for an existing point (voltages=None) - - This method follows quam's Pulse -> Macro -> Operation pattern by: - 1. Creating a VoltageTuningPoint in gate_set.macros (or using existing one) - 2. Creating a StepPointMacro with a reference to that point - 3. Storing the macro in self.macros[macro_name] - - Args: - macro_name: Name for the macro (stored in self.macros[macro_name]) - voltages: Optional voltage values for each gate. If None, looks up existing point. - duration: Duration to hold the target voltage (nanoseconds, default: 100). - replace_existing_point: If True, overwrites existing point (default: True) - - Returns: - StepPointMacro: The created macro instance - - Raises: - KeyError: If voltages is None and the point doesn't exist in the gate set - - Example: - .. code-block:: python - - # Use case 1: Create new point and macro together - quantum_dot.add_point_with_step_macro( - 'idle', - voltages={'virtual_dot_0': 0.1}, - duration=100 - ) - - # Use case 2: Create macro for existing point - quantum_dot.add_point('readout', voltages={'virtual_dot_0': 0.2}, duration=200) - quantum_dot.add_point_with_step_macro('readout') - - # Execute the macros - quantum_dot.macros['idle']() - quantum_dot.macros['readout']() - """ - point_ref = self._get_or_create_point_ref( - macro_name, voltages, duration, replace_existing_point - ) - macro = StepPointMacro(point_ref=point_ref) - self.macros[macro_name] = macro - return macro - - def add_point_with_ramp_macro( - self, - macro_name: str, - voltages: Optional[Dict[str, float]] = None, - duration: int = 100, - ramp_duration: int = 16, - replace_existing_point: bool = True, - ) -> RampPointMacro: - """Convenience method: Create a voltage point and RampPointMacro in one step. - - This method supports two use cases: - 1. Creating a new point with voltages (voltages provided) - 2. Creating a macro for an existing point (voltages=None) - - This method follows quam's Pulse -> Macro -> Operation pattern by: - 1. Creating a VoltageTuningPoint in gate_set.macros (or using existing one) - 2. Creating a RampPointMacro with a reference to that point - 3. Storing the macro in self.macros[macro_name] - - Args: - macro_name: Name for the macro (stored in self.macros[macro_name]) - voltages: Optional voltage values for each gate. If None, looks up existing point. - duration: Duration to hold the target voltage (nanoseconds, default: 100). - ramp_duration: Time for gradual voltage transition (nanoseconds, default: 16). - replace_existing_point: If True, overwrites existing point (default: True) - - Returns: - RampPointMacro: The created macro instance - - Raises: - KeyError: If voltages is None and the point doesn't exist in the gate set - - Example: - .. code-block:: python - - # Use case 1: Create new point and macro together - quantum_dot.add_point_with_ramp_macro( - 'load', - voltages={'virtual_dot_0': 0.3}, - duration=200, - ramp_duration=500 - ) - - # Use case 2: Create macro for existing point - quantum_dot.add_point('measure', voltages={'virtual_dot_0': 0.25}, duration=300) - quantum_dot.add_point_with_ramp_macro('measure', ramp_duration=400) - - # Execute the macros - quantum_dot.macros['load']() - quantum_dot.macros['measure']() - """ - point_ref = self._get_or_create_point_ref( - macro_name, voltages, duration, replace_existing_point - ) - macro = RampPointMacro(point_ref=point_ref, ramp_duration=ramp_duration) - self.macros[macro_name] = macro - return macro - - def with_step_point( - self, - name: str, - voltages: Optional[Dict[str, float]] = None, - duration: int = 100, - replace_existing_point: bool = True, - ) -> "VoltageMacroMixin": - """Fluent API: Add a voltage point with step macro and return self for chaining. - - This is a convenience wrapper around add_point_with_step_macro() that - returns self to enable method chaining for defining multiple macros. - - Supports two use cases: - 1. Creating a new point with voltages (voltages provided) - 2. Creating a macro for an existing point (voltages=None) - - Args: - name: Name for both the point and the macro - voltages: Optional voltage values for each gate. If None, looks up existing point. - duration: Duration to hold the target voltage (nanoseconds, default: 100) - replace_existing_point: If True, overwrites existing point (default: True) - - Returns: - self: The component instance for method chaining - - Raises: - KeyError: If voltages is None and the point doesn't exist - - Example: - .. code-block:: python - - # Use case 1: Create new points with macros - (component - .with_step_point("idle", {"gate": 0.1}, duration=100) - .with_step_point("measure", {"gate": 0.2}, duration=200) - .with_sequence("init", ["idle", "measure"])) - - # Use case 2: Create macro for existing point - component.add_point("readout", {"gate": 0.15}, duration=300) - component.with_step_point("readout") - - # Use in QUA program - with program() as prog: - component.idle() - component.measure() - """ - self.add_point_with_step_macro( - macro_name=name, - voltages=voltages, - duration=duration, - replace_existing_point=replace_existing_point, - ) - return self - - def with_ramp_point( - self, - name: str, - voltages: Optional[Dict[str, float]] = None, - duration: int = 100, - ramp_duration: int = 16, - replace_existing_point: bool = True, - ) -> "VoltageMacroMixin": - """Fluent API: Add a voltage point with ramp macro and return self for chaining. - - This is a convenience wrapper around add_point_with_ramp_macro() that - returns self to enable method chaining for defining multiple macros. - - Supports two use cases: - 1. Creating a new point with voltages (voltages provided) - 2. Creating a macro for an existing point (voltages=None) - - Args: - name: Name for both the point and the macro - voltages: Optional voltage values for each gate. If None, looks up existing point. - duration: Duration to hold the target voltage (nanoseconds, default: 100) - ramp_duration: Time for gradual voltage transition (nanoseconds, default: 16) - replace_existing_point: If True, overwrites existing point (default: True) - - Returns: - self: The component instance for method chaining - - Raises: - KeyError: If voltages is None and the point doesn't exist - - Example: - .. code-block:: python - - # Use case 1: Create new points with macros - (component - .with_ramp_point("load", {"gate": 0.3}, duration=200, ramp_duration=500) - .with_step_point("readout", {"gate": 0.15}, duration=1000) - .with_sequence("load_and_read", ["load", "readout"])) - - # Use case 2: Create macro for existing point - component.add_point("measure", {"gate": 0.25}, duration=300) - component.with_ramp_point("measure", ramp_duration=400) - - # Use in QUA program - with program() as prog: - component.load() - component.readout() - """ - self.add_point_with_ramp_macro( - macro_name=name, - voltages=voltages, - duration=duration, - ramp_duration=ramp_duration, - replace_existing_point=replace_existing_point, - ) - return self - - def with_sequence( - self, - name: str, - macro_names: List[str], - description: Optional[str] = None, - return_index: Optional[int] = None, - ) -> "VoltageMacroMixin": - """Fluent API: Create a sequence macro from existing macros and return self for chaining. - - This method creates a SequenceMacro that executes multiple macros in order. - All referenced macros must already exist in self.macros. - - Args: - name: Name for the sequence macro - macro_names: List of macro names to execute in sequence - description: Optional description for the sequence - return_index: Optional index of macro whose return value to return - - Returns: - self: The component instance for method chaining - - Raises: - KeyError: If any macro in macro_names doesn't exist in self.macros - - Example: - .. code-block:: python - - # Define points and sequence in one chain - (component - .with_step_point("idle", {"gate": 0.1}, duration=100) - .with_ramp_point("load", {"gate": 0.3}, duration=200, ramp_duration=500) - .with_step_point("measure", {"gate": 0.15}, duration=1000) - .with_sequence("full_cycle", ["idle", "load", "measure"])) - - # Call the sequence as a method - with program() as prog: - component.full_cycle() - """ - # Validate that all referenced macros exist - for macro_name in macro_names: - if macro_name not in self.macros: - raise KeyError( - f"Cannot create sequence '{name}': macro '{macro_name}' not found. " - f"Available macros: {list(self.macros.keys())}" - ) - - # Create and register the sequence - sequence = SequenceMacro( - name=name, description=description, return_index=return_index - ).with_macros(self, macro_names) - self.macros[name] = sequence - - return self - - def with_macro( - self, - name: str, - macro: QuamMacro, - ) -> "VoltageMacroMixin": - """Fluent API: Add an arbitrary macro and return self for chaining. - - Args: - name: Name to store the macro under - macro: The macro instance to add - - Returns: - self: The component instance for method chaining - """ - self.macros[name] = macro - return self - - def with_conditional_macro( - self, - name: str, - measurement_macro: str, - conditional_macro: str, - invert_condition: bool = False, - ) -> "VoltageMacroMixin": - """Fluent API: Add a conditional macro and return self for chaining. - - This creates a ConditionalMacro that executes a measurement and conditionally - applies another macro based on the result. - - Args: - name: Name for the conditional macro - measurement_macro: Name of measurement macro in self.macros, or a reference string - conditional_macro: Name of macro in self.macros, or a reference string - invert_condition: If False (default), apply when measurement is True. - If True, apply when measurement is False. - - Returns: - self: The component instance for method chaining - - Raises: - KeyError: If macro names don't exist (when not using references) - - Example: - .. code-block:: python - - # Option 1: Use macro names (must exist in self.macros) - component.with_conditional_macro( - name='reset', - measurement_macro='measure', - conditional_macro='x180', - invert_condition=False - ) - - # Option 2: Use reference strings - component.with_conditional_macro( - name='reset', - measurement_macro='measure', # Local macro - conditional_macro=component.qubit_target.macros["x180"].get_reference(), - invert_condition=False - ) - - # Execute in QUA program - with program() as prog: - was_excited = component.reset() - """ - measurement_ref = self._resolve_macro_ref(measurement_macro, "Measurement macro") - conditional_ref = self._resolve_macro_ref(conditional_macro, "Conditional macro") - - macro = ConditionalMacro( - measurement_macro=measurement_ref, - conditional_macro=conditional_ref, - invert_condition=invert_condition, - ) - self.macros[name] = macro - - return self diff --git a/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py b/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py index 987c44f3..779cb4ca 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py @@ -23,12 +23,6 @@ class VoltagePointMixin(VoltageControlMixin): This mixin extends VoltageControlMixin with point-based operations: - Creating and registering named voltage points - Navigating to points via step or ramp transitions - - Point reference management for macro creation - - Following quam's Pulse -> Macro -> Operation pattern: - - Voltage point ~ Pulse definition - - PointMacro ~ PulseMacro - - Sequence/Operation ~ Gate """ def add_point( @@ -38,11 +32,10 @@ def add_point( duration: int = 16, replace_existing_point: bool = True, ) -> str: - """Define a voltage point in the gate set for later use by macros. + """Define a reusable voltage point in the gate set. This method registers a named voltage configuration in the VirtualGateSet - with a prefixed name: "{component.id}_{point_name}". Once registered, - the point can be referenced by StepPointMacro and RampPointMacro instances. + with a prefixed name: "{component.id}_{point_name}". Args: point_name: Local name for the point (without prefix). Used in macro references. @@ -51,7 +44,7 @@ def add_point( - LDQubit/LDQubitPair: Can use qubit names (e.g., 'Q0'), automatically mapped to quantum dot IDs if _should_map_qubit_names() returns True duration: Default hold duration for this point (nanoseconds, default: 16). - Can be overridden when creating macros or during execution. + Can be overridden at call time by ``step_to_point`` or ``ramp_to_point``. replace_existing_point: If True (default), overwrites existing point with same name. If False, raises ValueError if point exists. @@ -104,53 +97,11 @@ def _create_point_name(self, point_name: str) -> str: """ return f"{self.id}_{point_name}" - def _get_or_create_point_ref( - self, - macro_name: str, - voltages: Optional[Dict[str, float]], - duration: int, - replace_existing_point: bool, - ) -> str: - """Get reference to an existing point or create a new one. - - Args: - macro_name: Name for the point (will be prefixed with component id) - voltages: Optional voltage values. If None, looks up existing point. - duration: Duration for the point (used when creating new point) - replace_existing_point: If True, overwrites existing point - - Returns: - str: Reference string to the point - - Raises: - KeyError: If voltages is None and the point doesn't exist - """ - full_name = self._create_point_name(macro_name) - - if voltages is not None: - self.add_point( - point_name=macro_name, - voltages=voltages, - duration=duration, - replace_existing_point=replace_existing_point, - ) - else: - existing_points = self.voltage_sequence.gate_set.get_macros() - if full_name not in existing_points: - raise KeyError( - f"Point '{macro_name}' (full name: '{full_name}') does not exist. " - f"Available points: {list(existing_points.keys())}. " - f"To create a new point, provide the 'voltages' parameter." - ) - - return self.voltage_sequence.gate_set.macros[full_name].get_reference() - def step_to_point(self, point_name: str, duration: Optional[int] = None) -> None: """Step instantly to a pre-defined voltage point (convenience method). This is a convenience wrapper around voltage_sequence.step_to_point() - that handles automatic name prefixing. For reusable operations, consider - creating a StepPointMacro instead and storing it in self.macros. + that handles automatic name prefixing. Args: point_name: Local point name (without prefix, must be added via add_point()) @@ -165,10 +116,6 @@ def step_to_point(self, point_name: str, duration: Optional[int] = None) -> None # Direct usage (convenience) quantum_dot.add_point('idle', voltages={'virtual_dot_0': 0.1}) quantum_dot.step_to_point('idle', duration=100) - - # Equivalent using macro (reusable, serializable) - quantum_dot.macros['idle'] = StepPointMacro('idle', 100) - quantum_dot.macros['idle']() """ full_name = self._create_point_name(point_name) self.voltage_sequence.step_to_point(name=full_name, duration=duration) @@ -179,8 +126,7 @@ def ramp_to_point( """Ramp gradually to a pre-defined voltage point (convenience method). This is a convenience wrapper around voltage_sequence.ramp_to_point() - that handles automatic name prefixing. For reusable operations, consider - creating a RampPointMacro instead and storing it in self.macros. + that handles automatic name prefixing. Args: point_name: Local point name (without prefix, must be added via add_point()) @@ -196,10 +142,6 @@ def ramp_to_point( # Direct usage (convenience) quantum_dot.add_point('loading', voltages={'virtual_dot_0': 0.3}) quantum_dot.ramp_to_point('loading', ramp_duration=500, duration=200) - - # Equivalent using macro (reusable, serializable) - quantum_dot.macros['load'] = RampPointMacro('loading', 200, 500) - quantum_dot.macros['load']() """ full_name = self._create_point_name(point_name) self.voltage_sequence.ramp_to_point( diff --git a/quam_builder/architecture/quantum_dots/components/pulses.py b/quam_builder/architecture/quantum_dots/components/pulses.py new file mode 100644 index 00000000..db4c2e43 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/components/pulses.py @@ -0,0 +1,49 @@ +"""Custom pulse classes for quantum-dot architectures.""" + +from __future__ import annotations + +import numpy as np +from quam.components.pulses import GaussianPulse +from quam.core import quam_dataclass + +__all__ = ["ScalableGaussianPulse"] + + +@quam_dataclass +class ScalableGaussianPulse(GaussianPulse): + """Gaussian pulse whose sigma is always derived from ``length * sigma_ratio``. + + This avoids having to manually rescale sigma when the pulse duration + changes. Only ``length`` and ``sigma_ratio`` are independent + parameters; ``sigma`` is kept in sync automatically. + + Args: + amplitude (float): Peak amplitude of the pulse in volts. + length (int): Duration of the pulse in ns (samples). + sigma_ratio (float): Ratio ``sigma / length``. Default ``1/6`` + matches the conventional ``sigma = length / 6``. + axis_angle (float, optional): IQ axis angle in radians. + subtracted (bool): If True, subtract the edge value so the + waveform starts and ends at zero. Default True. + """ + + sigma: float = None + sigma_ratio: float = 1 / 6 + + def __post_init__(self): + super().__post_init__() + self.sigma = self.length * self.sigma_ratio + + def waveform_function(self): + sigma = self.length * self.sigma_ratio + t = np.arange(self.length, dtype=int) + center = (self.length - 1) / 2 + waveform = self.amplitude * np.exp(-((t - center) ** 2) / (2 * sigma**2)) + + if self.subtracted: + waveform = waveform - waveform[-1] + + if self.axis_angle is not None: + waveform = waveform * np.exp(1j * self.axis_angle) + + return waveform diff --git a/quam_builder/architecture/quantum_dots/components/quantum_dot.py b/quam_builder/architecture/quantum_dots/components/quantum_dot.py index d93e8f6d..e510a6ab 100644 --- a/quam_builder/architecture/quantum_dots/components/quantum_dot.py +++ b/quam_builder/architecture/quantum_dots/components/quantum_dot.py @@ -1,3 +1,7 @@ +"""Quantum-dot component.""" + +# pylint: disable=too-many-ancestors,not-callable + from typing import Dict, Union, Optional, Sequence, TYPE_CHECKING from quam.core import quam_dataclass, QuamComponent @@ -38,7 +42,7 @@ class QuantumDot(VoltageMacroMixin): ramp_to_voltages: Enters a dictionary to the VoltageSequence to ramp to the particular voltage. get_offset: Returns the current value of the external voltage source. set_offset: Sets the external voltage source to the new value. - add_point: Adds a point macro to the associated VirtualGateSet. Also registers said point in the internal points attribute. Can NOT accept qubit names + add_point: Adds a named voltage point to the associated VirtualGateSet. Can NOT accept qubit names step_to_point: Steps to a pre-defined point in the internal points dict. ramp_to_point: Ramps to a pre-defined point in the internal points dict. """ @@ -87,6 +91,25 @@ def set_offset(self, value: float): return raise ValueError("External offset source not connected") + def __matmul__(self, other: "QuantumDot") -> "QuantumDotPair": + """Return the QuantumDotPair for ``self @ other``. + + Looks up the pair by dot IDs on the machine. Raises ``ValueError`` + if the two dots are not registered as a pair. + """ + from .quantum_dot_pair import QuantumDotPair + + if not isinstance(other, QuantumDot): + return NotImplemented + + machine = self.machine + pair_name = machine.find_quantum_dot_pair(self.id, other.id) + if pair_name is None: + raise ValueError( + f"No QuantumDotPair registered for dots " f"'{self.id}' and '{other.id}'." + ) + return machine.quantum_dot_pairs[pair_name] + def play( self, pulse_name: str, diff --git a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py index 0b9ada0f..2f43395b 100644 --- a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py +++ b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py @@ -17,7 +17,7 @@ @quam_dataclass -class QuantumDotPair(VoltageMacroMixin): +class QuantumDotPair(VoltageMacroMixin): # pylint: disable=too-many-ancestors """ Class representing a Quantum Dot Pair. Attributes: @@ -32,7 +32,7 @@ class QuantumDotPair(VoltageMacroMixin): go_to_detuning: In a simultaneous block, registers a dict input to the VoltageSequence to step or ramp the detuning to specified voltage. step_to_detuning: Step the voltage to the specified detuning value. Can only be used after the detuning axis is defined. ramp_to_detuning: Ramp the voltage to the specified detuning value. Can only be used after the detuning axis is defined. - add_point: Adds a point macro to the associated VirtualGateSet. Also registers said point in the internal points attribute. Can accept qubit names + add_point: Adds a named voltage point to the associated VirtualGateSet. Can accept qubit names step_to_point: Steps to a pre-defined point in the internal points dict. ramp_to_point: Ramps to a pre-defined point in the internal points dict. """ @@ -133,7 +133,7 @@ def step_to_detuning(self, voltage: float, duration: int = 16) -> None: def ramp_to_detuning(self, voltage: float, ramp_duration: int, duration: int = 16): """Ramps the detuning to the specified value. Can only be used after define_detuning_axis.""" - return self.voltage_sequence.step_to_voltages( + return self.voltage_sequence.ramp_to_voltages( {self.detuning_axis_name: voltage}, duration=duration, ramp_duration=ramp_duration ) @@ -155,12 +155,12 @@ def readout_state( pulse_name: str = "readout", ): - if self.sensor_dots.__len__() == 0: + if not self.sensor_dots: raise ValueError("No sensor dots") - elif self.sensor_dots.__len__() == 1: + elif len(self.sensor_dots) == 1: pass else: - raise NotImplementedError(f"self.sensor_dots.__len__() is {len(self.sensor_dots)}") + raise NotImplementedError(f"len(sensor_dots) is {len(self.sensor_dots)}") I = declare(fixed) Q = declare(fixed) @@ -176,4 +176,4 @@ def readout_state( assign(state, Cast.to_int(x > threshold)) - # Voltage point macro methods (add_point, step_to_point, ramp_to_point) are now provided by VoltageMacroMixin + # Voltage point methods (add_point, step_to_point, ramp_to_point) are provided by VoltageMacroMixin diff --git a/quam_builder/architecture/quantum_dots/components/readout_resonator.py b/quam_builder/architecture/quantum_dots/components/readout_resonator.py index 2dfa3248..b31dc9b9 100644 --- a/quam_builder/architecture/quantum_dots/components/readout_resonator.py +++ b/quam_builder/architecture/quantum_dots/components/readout_resonator.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Union from quam.core import quam_dataclass from quam.components.channels import InOutIQChannel, InOutMWChannel, InOutSingleChannel @@ -17,6 +17,7 @@ "ReadoutResonatorIQ", "ReadoutResonatorMW", "ReadoutResonatorSingle", + "ANY_READOUT_RESONATOR", ] @@ -50,10 +51,6 @@ def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: f class ReadoutResonatorSingle(InOutSingleChannel, ReadoutResonatorBase): intermediate_frequency: int = "#/inferred_intermediate_frequency" - def __post_init__(self): - if hasattr(self.opx_output, "upsampling_mode"): - self.opx_output.upsampling_mode = "mw" - def set_output_power( self, power_in_dbm: float, @@ -70,10 +67,6 @@ def set_output_power( class ReadoutResonatorIQ(InOutIQChannel, ReadoutResonatorBase): intermediate_frequency: int = "#./inferred_intermediate_frequency" - def __post_init__(self): - if hasattr(self.opx_output, "upsampling_mode"): - self.opx_output.upsampling_mode = "mw" - @property def upconverter_frequency(self): """Returns the up-converter/LO frequency in Hz.""" @@ -168,3 +161,5 @@ def set_output_power( return set_output_power_mw_channel( self, power_in_dbm, operation, full_scale_power_dbm, max_amplitude ) + +ANY_READOUT_RESONATOR = Union[ReadoutResonatorBase, ReadoutResonatorIQ, ReadoutResonatorMW, ReadoutResonatorSingle] diff --git a/quam_builder/architecture/quantum_dots/components/readout_transport.py b/quam_builder/architecture/quantum_dots/components/readout_transport.py index b5f70c50..d623f778 100644 --- a/quam_builder/architecture/quantum_dots/components/readout_transport.py +++ b/quam_builder/architecture/quantum_dots/components/readout_transport.py @@ -1,10 +1,11 @@ """Transport readout channel components.""" from quam.core import quam_dataclass -from quam.components.channels import InSingleChannel, InOutSingleChannel, InMWChannel, InIQChannel +from quam.components.channels import InSingleChannel, InOutSingleChannel +from typing import Union -__all__ = ["ReadoutTransportBase", "ReadoutTransportSingle", "ReadoutTransportSingleIO"] +__all__ = ["ReadoutTransportBase", "ReadoutTransportSingle", "ReadoutTransportSingleIO", "ANY_READOUT_TRANSPORT"] @quam_dataclass @@ -37,3 +38,5 @@ class ReadoutTransportSingleIO( """ pass + +ANY_READOUT_TRANSPORT = Union[ReadoutTransportBase, ReadoutTransportSingle, ReadoutTransportSingleIO] diff --git a/quam_builder/architecture/quantum_dots/components/voltage_gate.py b/quam_builder/architecture/quantum_dots/components/voltage_gate.py index c82304e3..7235373a 100644 --- a/quam_builder/architecture/quantum_dots/components/voltage_gate.py +++ b/quam_builder/architecture/quantum_dots/components/voltage_gate.py @@ -1,12 +1,14 @@ -from typing import Optional, Dict, Union +from typing import Any, Callable, Optional, Union -from quam.components import SingleChannel, Channel -from quam.core import quam_dataclass, QuamComponent +from quam.components import SingleChannel +from quam.core import quam_dataclass -from .readout_resonator import ReadoutResonatorBase -from .readout_transport import ReadoutTransportBase +from .readout_transport import ANY_READOUT_TRANSPORT +from .readout_resonator import ANY_READOUT_RESONATOR -__all__ = ["VoltageGate", "QdacSpec"] +from .dac_spec import DacSpec, QdacSpec + +__all__ = ["VoltageGate"] @quam_dataclass @@ -42,8 +44,8 @@ class VoltageGate(SingleChannel): settling_time: float = None # current_external_voltage, an attribute to help with serialising the experimental state current_external_voltage: Optional[float] = None - qdac_spec: "QdacSpec" = None - readout: Union[ReadoutTransportBase, ReadoutResonatorBase] = None + dac_spec: DacSpec = None + readout: Union[ANY_READOUT_RESONATOR, ANY_READOUT_TRANSPORT] = None def __post_init__(self): super().__post_init__() @@ -54,6 +56,11 @@ def __post_init__(self): def physical_channel(self): return self + @property + def qdac_spec(self): + if self.dac_spec is not None and isinstance(self.dac_spec, QdacSpec): + return self.dac_spec + @property def offset_parameter(self): return self._offset_parameter @@ -68,18 +75,3 @@ def settle(self): """Wait for the voltage bias to settle""" if self.settling_time is not None: self.wait(int(self.settling_time) // 4 * 4) - - -@quam_dataclass -class QdacSpec(QuamComponent): - """ - Quam Component for a QDAC Channel, to be parented by VoltageGate. - Attributes: - - opx_trigger_out: A digital channel associated to the VoltageGate, used for sending a digital trigger pulse to the Qdac. - - qdac_trigger_in: The QDAC external trigger port associated with the VoltageGate DC component. - - qdac_output_port: The QDAC port associated with the VoltageGate DC component. - """ - - opx_trigger_out: Channel = None - qdac_trigger_in: int = None - qdac_output_port: int diff --git a/quam_builder/architecture/quantum_dots/components/xy_drive.py b/quam_builder/architecture/quantum_dots/components/xy_drive.py index 7be793b8..b8230011 100644 --- a/quam_builder/architecture/quantum_dots/components/xy_drive.py +++ b/quam_builder/architecture/quantum_dots/components/xy_drive.py @@ -45,7 +45,6 @@ class XYDriveSingle(SingleChannel, XYDriveBase): """ RF_frequency: int - add_default_pulses: bool = True @property def intermediate_frequency( @@ -57,19 +56,6 @@ def intermediate_frequency( def intermediate_frequency(self, val): self.RF_frequency = val - def __post_init__(self): - super().__post_init__() - if self.add_default_pulses: - if "gaussian" not in self.operations: - self.operations["gaussian"] = pulses.GaussianPulse( - length=100, amplitude=0.2, sigma=40 - ) - if "pi" not in self.operations: - self.operations["pi"] = pulses.SquarePulse(length=104, amplitude=0.2) - - if "pi_half" not in self.operations: - self.operations["pi_half"] = pulses.SquarePulse(length=52, amplitude=0.2) - def add_pulse(self, name: str, pulse: pulses.Pulse) -> None: """Add or update a pulse in the drive operations""" self.operations[name] = pulse diff --git a/quam_builder/architecture/quantum_dots/examples/__init__.py b/quam_builder/architecture/quantum_dots/examples/__init__.py index 9c5dd100..a9a2c5b3 100644 --- a/quam_builder/architecture/quantum_dots/examples/__init__.py +++ b/quam_builder/architecture/quantum_dots/examples/__init__.py @@ -1,5 +1 @@ -from .macro_examples import * - -__all__ = [ - *macro_examples.__all__, -] \ No newline at end of file +__all__ = [] diff --git a/quam_builder/architecture/quantum_dots/examples/configs.py b/quam_builder/architecture/quantum_dots/examples/configs.py deleted file mode 100644 index 28c96c77..00000000 --- a/quam_builder/architecture/quantum_dots/examples/configs.py +++ /dev/null @@ -1,2 +0,0 @@ -EMAIL = "sebastian.orbell@quantum-machines.co" -PASSWORD = "mugzoc-juFgez-matbe5" diff --git a/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py b/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py new file mode 100644 index 00000000..67a24f47 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py @@ -0,0 +1,159 @@ +"""Example: wire, parameterize, and use built-in default macros (no overrides). + +This script demonstrates: +1. Building a small two-qubit quantum-dots machine. +2. Wiring architecture defaults with ``wire_machine_macros(machine)`` only. +3. Parameterizing instantiated default macro objects and reference pulses directly on components. +4. Building a QUA program that calls those default macros. +""" + +# pylint: disable=no-member # WiringLineType enum members are runtime-only + +from __future__ import annotations + +from typing import Dict + +import numpy as np +from qm import qua +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam.components import pulses + +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder +from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder + + +def _plunger_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} + + +def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} + + +def _barrier_ports(pair_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} + + +def build_demo_machine() -> LossDiVincenzoQuam: + """Build a small machine with 2 qubits and 1 qubit pair.""" + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + }, + "q2": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), + WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + }, + }, + "qubit_pairs": { + "q1_q2": { + WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2") + }, # pylint: disable=no-member + }, + } + machine = _BaseQpuBuilder(machine).build() + machine = _LDQubitBuilder(machine).build() + + if getattr(machine, "qpu", None) is None: + machine.qpu = QPU() + + # Seed minimal reference pulse used by default XY macros. + # Only one pulse is needed — XYDriveMacro scales amplitude for angle + # and applies virtual-Z for rotation axis. + for qubit in machine.qubits.values(): + if qubit.xy is None: + continue + qubit.xy.operations.setdefault( + DrivePulseName.GAUSSIAN, + pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16), + ) + + # Defaults only: no profile and no runtime overrides. + wire_machine_macros(machine) + return machine + + +def add_default_state_points(machine: LossDiVincenzoQuam) -> None: + """Define canonical voltage points consumed by state macros.""" + for qubit in machine.qubits.values(): + dot_id = qubit.quantum_dot.id + qubit.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) + qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=220) + qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=180) + + +def parameterize_default_macros(machine: LossDiVincenzoQuam) -> None: + """Tune parameters on already-wired default macro instances and pulses.""" + for qubit in machine.qubits.values(): + qubit.macros[VoltagePointName.INITIALIZE].ramp_duration = 64 + qubit.macros[VoltagePointName.MEASURE].hold_duration = 240 + qubit.xy.operations[DrivePulseName.GAUSSIAN].amplitude = 0.0085 + # Identity duration may start as a reference; concretize before assigning numeric value. + qubit.macros[SingleQubitMacroName.IDENTITY].duration = None + qubit.macros[SingleQubitMacroName.IDENTITY].duration = 24 + + +def print_macro_parameters(machine: LossDiVincenzoQuam) -> None: + """Print key default-macro class bindings and tuned parameters.""" + q1 = machine.qubits["q1"] + print("\n=== Default Macro Parameterization ===") + print("q1.initialize class:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) + print("q1.xy_drive class:", type(q1.macros[SingleQubitMacroName.XY_DRIVE]).__name__) + print("q1.I class:", type(q1.macros[SingleQubitMacroName.IDENTITY]).__name__) + print( + "q1.initialize.ramp_duration:", + q1.macros[VoltagePointName.INITIALIZE].ramp_duration, + ) + print( + "q1.measure.hold_duration:", + q1.macros[VoltagePointName.MEASURE].hold_duration, + ) + print( + "q1.gaussian.amplitude:", + q1.xy.operations[DrivePulseName.GAUSSIAN].amplitude, + ) + print("q1.I.duration:", q1.macros[SingleQubitMacroName.IDENTITY].duration) + + +def build_program(machine: LossDiVincenzoQuam): + """Build a QUA program that uses default macros only.""" + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + + with qua.program() as prog: + q1.initialize() + q2.initialize() + q1.x90() + q1.x(angle=-np.pi / 2) # Negative-angle behavior comes from default XY logic. + q2.y(angle=np.pi / 3) + q1.z90() + q2.I() + q1.measure() + q2.measure() + + return prog + + +def main() -> None: + machine = build_demo_machine() + add_default_state_points(machine) + parameterize_default_macros(machine) + print_macro_parameters(machine) + + _ = build_program(machine) + print("\nBuilt QUA program successfully using parameterized default macros (no overrides).") + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py b/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py new file mode 100644 index 00000000..f3636c1a --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py @@ -0,0 +1,251 @@ +"""Example: macro wiring with typed override helpers. + +Demonstrates the recommended Python API for overriding macros and pulses: + +1. ``wire_machine_macros(machine)`` — wire all defaults. +2. ``component_overrides={LDQubit: overrides(...)}`` — override all qubits of a type. +3. ``instance_overrides={"qubits.q1": overrides(...)}`` — override one specific qubit. +4. ``macro(Factory, **params)`` — validated macro entry (catches bad factories early). +5. ``pulse("GaussianPulse", length=500, ...)`` — typed pulse entry. +6. ``disabled()`` — remove a macro or pulse from a component. + +Key imports:: + + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, # wiring entry point + macro, # build a macro override entry + disabled, # remove a macro/pulse + pulse, # build a pulse override entry + overrides, # group macros + pulses for one component + ) +""" + +# pylint: disable=no-member # WiringLineType enum members are runtime-only + +# Macro classes in this demo inherit from framework mixins with deep MRO. +# pylint: disable=too-many-ancestors + +from __future__ import annotations + +from typing import Dict + +from qm import qua +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam.components import pulses +from quam.components.macro import QubitPairMacro + +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + macro, + overrides, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + X180Macro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + InitializeStateMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder +from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder + + +# --------------------------------------------------------------------------- +# Custom macro classes (users would define these in their lab package) +# --------------------------------------------------------------------------- + + +class TunedX180Macro(X180Macro): + """Lab-calibrated X180 macro for a specific qubit. + + Inheriting from the default X180Macro means the delegation chain + (x180 → x → xy_drive → qubit.xy.play) is preserved. + """ + + pass + + +class DemoCZMacro(QubitPairMacro): + """Placeholder CZ gate showing how users replace default 2Q stubs. + + Default two-qubit macros raise NotImplementedError — users replace + them with calibration-specific logic via overrides. + """ + + duration_ns: int = 64 + + @property + def inferred_duration(self) -> float: + return self.duration_ns * 1e-9 + + def apply(self, duration_ns: int | None = None, **kwargs): + duration = self.duration_ns if duration_ns is None else duration_ns + duration_cycles = max(0, int(round(duration / 4.0))) + + control_xy = self.qubit_pair.qubit_control.xy.name + target_xy = self.qubit_pair.qubit_target.xy.name + qua.align(control_xy, target_xy) + if duration_cycles > 0: + qua.wait(duration_cycles, control_xy, target_xy) + + +# --------------------------------------------------------------------------- +# Machine construction helpers (same as other examples) +# --------------------------------------------------------------------------- + + +def _plunger_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} + + +def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} + + +def _barrier_ports(pair_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} + + +def build_demo_machine() -> LossDiVincenzoQuam: + """Build a small machine with 2 qubits and 1 qubit pair.""" + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + }, + "q2": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), + WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + }, + }, + "qubit_pairs": { + "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, + }, + } + machine = _BaseQpuBuilder(machine).build() + machine = _LDQubitBuilder(machine).build() + if getattr(machine, "qpu", None) is None: + machine.qpu = QPU() + + for qubit in machine.qubits.values(): + if qubit.xy is None: + continue + qubit.xy.operations.setdefault( + DrivePulseName.GAUSSIAN, + pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16), + ) + + # Wire all defaults — no overrides yet + wire_machine_macros(machine) + return machine + + +def add_default_state_points(machine: LossDiVincenzoQuam) -> None: + """Define canonical voltage points used by default state macros.""" + for qubit in machine.qubits.values(): + dot_id = qubit.quantum_dot.id + qubit.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) + qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=200) + qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=200) + + +def print_macro_summary(machine: LossDiVincenzoQuam, title: str) -> None: + """Print macro class bindings for key components/macros.""" + q1 = machine.qubits["q1"] + pair = machine.qubit_pairs["q1_q2"] + print(f"\n=== {title} ===") + print("q1 macros:", sorted(q1.macros.keys())) + print("q1.initialize:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) + print("q1.x180:", type(q1.macros[SingleQubitMacroName.X_180]).__name__) + print("q1_q2.cz:", type(pair.macros[TwoQubitMacroName.CZ]).__name__) + + +# --------------------------------------------------------------------------- +# Override wiring using the typed API +# --------------------------------------------------------------------------- + + +def apply_macro_overrides(machine: LossDiVincenzoQuam) -> None: + """Apply component-type and instance-level overrides using the typed API. + + This demonstrates the three key helpers: + - ``overrides(macros={...})`` groups macro overrides for a component + - ``macro(Factory, **params)`` creates a validated macro entry + - Component type keys use the actual class (LDQubit, LDQubitPair) + - Instance keys are path strings ("qubits.q1") + """ + wire_machine_macros( + machine, + # --- Override all LDQubits: custom initialize with longer ramp --- + # --- Override all LDQubitPairs: replace placeholder CZ --- + component_overrides={ + LDQubit: overrides( + macros={ + SingleQubitMacroName.INITIALIZE: macro( + InitializeStateMacro, + ramp_duration=64, + ), + } + ), + LDQubitPair: overrides( + macros={ + TwoQubitMacroName.CZ: macro(DemoCZMacro), + } + ), + }, + # --- Override one specific qubit: custom X180 on q1 only --- + instance_overrides={ + "qubits.q1": overrides( + macros={ + SingleQubitMacroName.X_180: macro(TunedX180Macro), + } + ), + }, + strict=True, + ) + + +def build_program(machine: LossDiVincenzoQuam): + """Build a QUA program using default and overridden macros.""" + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + pair = machine.qubit_pairs["q1_q2"] + + with qua.program() as prog: + q1.initialize() + q2.initialize() + q1.x90() + q1.x180() # Uses TunedX180Macro after override + q2.empty() + pair.cz() # Uses DemoCZMacro after override + q1.measure() + q2.measure() + + return prog + + +def main() -> None: + machine = build_demo_machine() + add_default_state_points(machine) + print_macro_summary(machine, "Defaults") + + apply_macro_overrides(machine) + print_macro_summary(machine, "After Overrides") + + _ = build_program(machine) + print("\nBuilt QUA program successfully with wired default+override macros.") + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py new file mode 100644 index 00000000..829cee52 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py @@ -0,0 +1,8 @@ +"""Demo package for external macro catalog workflow. + +Provides build_component_overrides for use with wire_machine_macros. +""" + +from .catalog import LabInitializeMacro, build_component_overrides + +__all__ = ["LabInitializeMacro", "build_component_overrides"] diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py new file mode 100644 index 00000000..fcd0de4c --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py @@ -0,0 +1,75 @@ +"""External macro catalog demonstrating lab-owned macro package pattern. + +This module provides ``build_component_overrides()`` for use with +``wire_machine_macros``. Custom macro classes use ``@quam_dataclass`` +so they survive QuAM serialization. + +Usage:: + + from my_lab_macros.catalog import build_component_overrides + from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + + wire_machine_macros( + machine, + component_overrides=build_component_overrides(), + strict=True, + ) +""" + +from __future__ import annotations + +from quam.core import quam_dataclass + +from quam_builder.architecture.quantum_dots.components import QuantumDot +from quam_builder.architecture.quantum_dots.macro_engine import macro, overrides +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + InitializeStateMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, +) + + +@quam_dataclass +class LabInitializeMacro(InitializeStateMacro): + """Custom initialize macro with lab-specific ramp duration. + + Demonstrates parametrization via an extra serializable field. + """ + + lab_ramp_duration: int = 64 + + def apply( + self, + ramp_duration: int | None = None, + hold_duration: int | None = None, + **kwargs, + ): + """Ramp to initialize point using lab_ramp_duration when ramp_duration not specified.""" + ramp = ramp_duration if ramp_duration is not None else self.lab_ramp_duration + return super().apply(ramp_duration=ramp, hold_duration=hold_duration, **kwargs) + + +def build_component_overrides() -> dict: + """Build component_overrides for wire_machine_macros. + + Returns a mapping keyed by component class, suitable for:: + + wire_machine_macros( + machine, + component_overrides=build_component_overrides(), + strict=True, + ) + + Overrides QuantumDot.initialize with LabInitializeMacro. + """ + return { + QuantumDot: overrides( + macros={ + SingleQubitMacroName.INITIALIZE: macro( + LabInitializeMacro, + lab_ramp_duration=80, + ), + } + ), + } diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py b/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py new file mode 100644 index 00000000..203c5f1c --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py @@ -0,0 +1,62 @@ +"""Example: external macro package workflow. + +This script demonstrates the external macro package pattern — custom macros +in a separate package, imported and passed to wire_machine_macros. +Runs without QM hardware (no qm.open, qm.run, or machine.connect). + +The key idea: keep lab-owned macro logic in a separate package so it +survives upstream quam-builder pulls. The package exports a dict suitable +for the ``component_overrides`` kwarg of ``wire_machine_macros``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Allow running as: python quam_builder/.../external_macro_package_example.py +_project_root = Path(__file__).resolve().parents[4] +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) + +from qm import qua # noqa: E402 +from quam_builder.architecture.quantum_dots.examples.external_macro_demo.catalog import ( # noqa: E402 + build_component_overrides, +) +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( # noqa: E402 + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros # noqa: E402 + + +def main() -> None: + """Build machine, wire macros with external overrides, and verify.""" + machine = build_tutorial_machine() + + # Pass the external catalog's overrides to wire_machine_macros. + # component_overrides is keyed by actual class objects (e.g. QuantumDot), + # so typos in class names are caught at import time, not wiring time. + wire_machine_macros( + machine, + component_overrides=build_component_overrides(), + strict=True, + ) + + q1 = machine.qubits["Q1"] + q2 = machine.qubits["Q2"] + pair = machine.quantum_dot_pairs["dot1_dot2_pair"] + sensor_dot = machine.sensor_dots["virtual_sensor_1"] + + with qua.program() as _: + q1.initialize() + q2.initialize() + pair.initialize() + sensor_dot.macros["measure"].apply("readout") + q1.measure() + q2.measure() + + print("Built QUA program successfully with external macro overrides.") + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/full_circuit_example.py b/quam_builder/architecture/quantum_dots/examples/full_circuit_example.py deleted file mode 100644 index e1497e3d..00000000 --- a/quam_builder/architecture/quantum_dots/examples/full_circuit_example.py +++ /dev/null @@ -1,429 +0,0 @@ -# pylint: skip-file - -""" -Full Multi-Qubit Circuit Implementation - -This script implements the full circuit shown in Picture 1.png panel b: -- Initialization: Read Init sequences for qubit pairs (12, 3, 45) -- Operation: Two-qubit exchange gate -- Readout: Final measurements - -The circuit structure: -1. Initialize Q1-Q2 with Read Init 12 (×2) -2. Initialize Q3 with Read Init 3 -3. Initialize Q4-Q5 with Read Init 45 -4. Apply two-qubit exchange operation -5. Readout all qubits -""" - -# ============================================================================ -# Imports -# ============================================================================ -from qm.qua import * -from qm import SimulationConfig -from quam import QuamComponent - -from quam.components import pulses -from quam.components.macro import PulseMacro -from quam.components.quantum_components import Qubit, QubitPair -from quam.utils.qua_types import QuaVariableBool -from quam.core import quam_dataclass -from quam.core.macro import QuamMacro - -from quam_builder.architecture.quantum_dots.examples.operations import operations_registry -from quam_builder.architecture.quantum_dots.macros import AlignMacro, WaitMacro, MeasureMacro - -from quam_qd_generator_example import machine - -# ============================================================================ -# Configuration Parameters -# ============================================================================ - -# Configuration for Q1-Q2 pair (Read Init 12) -CONFIG_Q1_Q2 = { - "voltage_points": { - "measure_point": { - "virtual_dot_0": -0.12, - "virtual_dot_1": -0.12, - "virtual_barrier_1": -0.0, - }, - "load_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.0}, - "exchange_point": { - "virtual_dot_0": 0.12, - "virtual_dot_1": 0.12, - "virtual_barrier_1": 0.9, # High barrier for exchange - }, - }, - "readout": {"length": 240, "amplitude": 0.12}, - "x180": {"amplitude": 0.25, "length": 120}, - "timing": {"duration": 100, "wait_duration": 240}, - "threshold": 0.05, -} - -# Configuration for Q2-Q3 pair (Read Init 3) -CONFIG_Q2_Q3 = { - "voltage_points": { - "measure_point": { - "virtual_dot_0": -0.12, - "virtual_dot_1": -0.12, - "virtual_barrier_1": -0.0, - }, - "load_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.0}, - "exchange_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.95}, - }, - "readout": {"length": 240, "amplitude": 0.12}, - "x180": {"amplitude": 0.25, "length": 120}, - "timing": {"duration": 100, "wait_duration": 240}, - "threshold": 0.05, -} - -# Exchange gate timing parameters -EXCHANGE_PARAMS = { - "ramp_duration": 50, # Time to ramp barrier up (ns) - "exchange_duration": 300, # Time at high barrier (ns) -} - - -# ============================================================================ -# Register Operations -# ============================================================================ - - -@operations_registry.register_operation -def measure(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Measure qubit state.""" - pass - - -@operations_registry.register_operation -def x180(qubit: Qubit, **kwargs): - """Apply π-pulse (bit flip).""" - pass - - -@operations_registry.register_operation -def reset(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Perform conditional reset.""" - pass - - -@operations_registry.register_operation -def init_sequence(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Execute full initialization sequence.""" - pass - - -@operations_registry.register_operation -def measure_init(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Execute measure and return to load point.""" - pass - - -@operations_registry.register_operation -def exchange(qubit_pair: QubitPair, **kwargs): - """Execute two-qubit exchange gate.""" - pass - - -@operations_registry.register_operation -def full_circuit(qubit_pair: QubitPair, **kwargs): - """Execute full circuit: init + operation + readout.""" - pass - - -@operations_registry.register_operation -def psb(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """PSB (Pauli Spin Blockade) measurement.""" - pass - - -@operations_registry.register_operation -def init3(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - pass - - -@operations_registry.register_operation -def correlated_init( - psb_pair: QubitPair, individual_qubit: Qubit, exchange_pair: QubitPair, **kwargs -) -> QuaVariableBool: - """ - Generalized correlated initialization sequence. - - Performs: - 1. Exchange gate on exchange_pair - 2. PSB measurement on psb_pair - 3. Conditional X180 on individual_qubit based on PSB result - - Args: - psb_pair: The qubit pair for PSB measurement - individual_qubit: The individual qubit for conditional X180 - exchange_pair: The qubit pair containing both qubits for exchange gate - **kwargs: Additional parameters - - Returns: - PSB measurement result - """ - pass - - -# ============================================================================ -# Configuration Helper Functions -# ============================================================================ - - -@quam_dataclass -class CorrelatedInitMacro(QuamMacro): - """Runtime-created correlated init macro.""" - - invert: bool = False - - def apply( - self, - exchange_pair: QuamComponent = None, - psb_pair: QuamComponent = None, - target_qubit: QuamComponent = None, - **kwargs, - ): - # Step 1: Exchange - exchange_pair.exchange() - qua.align() - - # Step 2: PSB - state = psb_pair.psb() - qua.align() - - # Step 3: Conditional X180 - condition = ~state if self.invert else state - with qua.if_(condition): - target_qubit.x180() - qua.align() - - return state - - -def configure_qubit_pair_for_reset(qubit_pair, config): - """ - Configure a qubit pair with voltage points, readout, and conditional reset. - Same as init_macro_improved.py but with exchange point added. - """ - # Extract parameters from config dictionary - voltage_points = config["voltage_points"] - readout_params = config["readout"] - x180_params = config["x180"] - measure_threshold = config["threshold"] - duration = config["timing"]["duration"] - wait_duration = config["timing"]["wait_duration"] - - # Add Voltage Points - for point_name, voltages in voltage_points.items(): - qubit_pair.add_point(point_name, voltages) - - # Configure Macros and Operations - qubit_pair.macros["align"] = AlignMacro() - qubit_pair.macros["wait"] = WaitMacro(duration=wait_duration) - - # Readout system configuration - qubit_pair.resonator = qubit_pair.quantum_dot_pair.sensor_dots[ - 0 - ].readout_resonator.get_reference() - qubit_pair.resonator.operations["readout"] = pulses.SquareReadoutPulse(**readout_params) - qubit_pair.macros["measure"] = MeasureMacro( - threshold=measure_threshold, component=qubit_pair.resonator.get_reference() - ) - - # X180 pulse configuration - qubit_pair.qubit_target.xy.operations["x180"] = pulses.SquarePulse(**x180_params) - qubit_pair.qubit_target.macros["x180"] = PulseMacro( - pulse=qubit_pair.qubit_target.xy.operations["x180"].get_reference() - ) - - # Build Complete Configuration Using Fluent API Chain - return ( - qubit_pair - # Configure step points - .with_step_point("measure_point", duration=duration) - .with_step_point("load_point", duration=duration) - .with_ramp_point( - "exchange_point", duration=16, ramp_duration=EXCHANGE_PARAMS["exchange_duration"] - ) - # PSB (Pauli Spin Blockade) measurement - just measure at measurement point - .with_sequence( - "psb", - ["measure_point", "align", "measure", "align", "load_point", "align"], - return_index=2, - ) - # Measure-and-return-to-load sequence - .with_sequence( - "measure_init", - ["measure_point", "align", "measure", "align", "load_point", "align"], - return_index=2, - ) - # Conditional reset macro - .with_conditional_macro( - name="reset", - measurement_macro="measure_init", - conditional_macro=qubit_pair.qubit_target.macros["x180"].get_reference(), - invert_condition=False, - ) - # Full initialization sequence - .with_sequence( - "init_sequence", ["reset", "align", "wait", "align", "measure_init"], return_index=-1 - ) - # Exchange gate sequence (ramp to exchange point and back) - .with_sequence("exchange", ["exchange_point", "align", "load_point", "align"]) - # Full circuit: init + exchange + readout - .with_sequence( - "full_circuit", - ["init_sequence", "align", "exchange", "align", "measure_init"], - return_index=-1, - ) - ) - - -# ============================================================================ -# Setup All Qubit Pairs -# ============================================================================ - -# Get qubit pairs -q1 = machine.quantum_dots[f"Q{0}"] -q2 = machine.quantum_dots[f"Q{1}"] -q3 = machine.quantum_dots[f"Q{2}"] - - -qp_12 = machine.quantum_dot_pairs[f"{q1.id}_{q2.id}"] # Represents Q1-Q2 -qp_23 = machine.quantum_dot_pairs[f"{q2.id}_{q3.id}"] - -# Configure each qubit pair with their specific settings -configure_qubit_pair_for_reset(qp_12, config=CONFIG_Q1_Q2) -configure_qubit_pair_for_reset(qp_23, config=CONFIG_Q2_Q3) - -# Create the correlated_init macro for Read Init 3 sequence -# This sequence involves Q1-Q2 (PSB), Q3 (conditional X180), and Q2-Q3 (exchange) -# Create and add the macro (don't call it yet - that happens in the QUA program) -machine.qpu.macros["init3"] = CorrelatedInitMacro() - -# ============================================================================ -# Generate QM Configuration -# ============================================================================ -config = machine.generate_config() - - -# ============================================================================ -# Main Execution - Full Circuit Implementation -# ============================================================================ -if __name__ == "__main__": - from qm import qua - from qm import QuantumMachinesManager - import matplotlib - import matplotlib.pyplot as plt - - matplotlib.use("TkAgg") - # ----------------------------------------------------------------------- - # Define Full Circuit QUA Program - # ----------------------------------------------------------------------- - with program() as prog: - # Declare streams for saving results - state_12_st = declare_stream() - state_23_st = declare_stream() - state_45_st = declare_stream() - - print("Executing full multi-qubit circuit...") - - # =================================================================== - # INITIALIZATION PHASE - # =================================================================== - # Read Init 12 - First initialization of Q1-Q2 - state_12_init = init_sequence(qp_12) - qua.align() - # 2× loop: Read Init 3 → Read Init 12 - with for_(n := declare(int), 0, n < 2, n + 1): - # --------------------------------------------------------------- - # Read Init 3: Correlated initialization of Q1, Q2, Q3 - # Uses the init3 macro stored in machine.qpu - # --------------------------------------------------------------- - state_12_psb = machine.qpu.init3(exchange_pair=qp_23, psb_pair=qp_12, target_qubit=q3) - qua.align() - - # --------------------------------------------------------------- - # Read Init 12 - Re-initialize Q1-Q2 - # --------------------------------------------------------------- - state_12_reinit = init_sequence(qp_12) - qua.align() - - # Read Init 45 - Initialize Q4-Q5 - # state_45_init = init_sequence(qp_45) - # qua.align() - - # =================================================================== - # OPERATION PHASE - Two-Qubit Exchange Gate - # =================================================================== - # Apply exchange operation between qubits (Q2-Q3) - # exchange(qp_23) - # qua.align() - - # =================================================================== - # READOUT PHASE - # =================================================================== - # Read Init 12 - Final readout of Q1-Q2 - # state_12_final = measure_init(qp_12) - # qua.align() - # - # # Read Init 3 - Final readout of Q2-Q3 - # state_23_final = measure_init(qp_23) - # qua.align() - - # Read Init 45 - Final readout of Q4-Q5 - # state_45_final = measure_init(qp_45) - # qua.align() - - # =================================================================== - # CLEANUP - # =================================================================== - qua.wait(100) - - # Ramp all voltages to zero - qp_12.voltage_sequence.ramp_to_zero() - qp_23.voltage_sequence.ramp_to_zero() - # qp_45.voltage_sequence.ramp_to_zero() - - # Save results to streams - # save(state_12_final, state_12_st) - # save(state_23_final, state_23_st) - # save(state_45_final, state_45_st) - - # # Stream processing - # with stream_processing(): - # state_12_st.save("state_q12") - # state_23_st.save("state_q23") - # state_45_st.save("state_q45") - - # ----------------------------------------------------------------------- - # Execute Circuit - # ----------------------------------------------------------------------- - qmm = QuantumMachinesManager(host="172.16.33.114", cluster_name="CS_4") - qm = qmm.open_qm(config) - - qmm.clear_all_job_results() - - # Run simulation (longer duration for full circuit) - simulation_config = SimulationConfig(duration=2000) - job = qmm.simulate(config, prog, simulation_config) - - # Plot results - job.get_simulated_samples().con1.plot() - plt.title("Full Multi-Qubit Circuit: Init → Exchange → Readout") - plt.show() - - # Print results - # res = job.result_handles - # out = res.fetch_results() - # print(f'\nCircuit Results:') - # print(f' Q1-Q2 final state: {out.get("state_q12", "N/A")}') - # print(f' Q2-Q3 final state: {out.get("state_q23", "N/A")}') - # print(f' Q4-Q5 final state: {out.get("state_q45", "N/A")}') - - # ----------------------------------------------------------------------- - # Hardware Execution (commented out) - # ----------------------------------------------------------------------- - # job = qm.execute(prog) - # print(job.get_status()) diff --git a/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py b/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py new file mode 100644 index 00000000..a5a79ea9 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py @@ -0,0 +1,317 @@ +"""Full workflow example: wiring, macros, pulses, and overrides. + +This script demonstrates the complete end-to-end workflow for building +a Loss-DiVincenzo qubit machine with default macros and pulses, and +shows how to: + +1. Wire a Loss-DiVincenzo qubit machine (combined single-stage workflow). +2. Wire default macros via ``wire_machine_macros()``. +3. Update operation parameters and the reference pulse itself. +4. Update the drive pulse type (e.g. swap Gaussian for DRAG). +5. Replace a particular macro (instance-level and type-level overrides). +""" + +# pylint: disable=no-member +# pylint: disable=too-many-ancestors + +from __future__ import annotations + +from typing import Dict + +import numpy as np +from qm import qua +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam.components import pulses +from quam.components.macro import QubitPairMacro + +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + macro, + overrides, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + X180Macro, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import build_quam + + +######################################################################################################################## +# %% Static parameters +######################################################################################################################## + +host_ip = "172.16.33.115" +cluster_name = "CS_3" + +global_gates = [1, 2] +sensor_dots = [1, 2] +quantum_dots = [1, 2, 3] +quantum_dot_pairs = [(1, 2), (2, 3)] + +qubit_pair_sensor_map = { + "q1_q2": ["sensor_1"], + "q2_q3": ["sensor_2"], +} + + +######################################################################################################################## +# %% STEP 1: Wire a Loss-DiVincenzo qubit machine +######################################################################################################################## + + +def build_wired_machine() -> LossDiVincenzoQuam: + """Build a machine using the combined single-stage workflow. + + This creates connectivity with all components (dots, sensors, drive lines) + in one go, allocates wiring, and builds the full Loss-DiVincenzo QUAM. + """ + print("=" * 80) + print("STEP 1: Build wired Loss-DiVincenzo machine") + print("=" * 80) + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring(connectivity, host_ip, cluster_name, machine) + machine = build_quam( + machine, + qubit_pair_sensor_map=qubit_pair_sensor_map, + connect_qdac=False, + save=False, + ) + + print(f" Qubits: {list(machine.qubits.keys())}") + print(f" Qubit pairs: {list(machine.qubit_pairs.keys())}") + print(f" Sensor dots: {list(machine.sensor_dots.keys())}") + return machine + + +######################################################################################################################## +# %% STEP 2: Wire default macros +######################################################################################################################## + + +def wire_defaults(machine: LossDiVincenzoQuam) -> None: + """Wire all default macros and pulses onto the machine. + + ``wire_machine_macros()`` materializes defaults from the component catalog: + - State macros (initialize, measure, empty) on qubits, pairs, dots + - Single-qubit gate macros (xy_drive, x, y, z, x180, x90, ...) on qubits + - Two-qubit gate placeholders (cnot, cz, swap, iswap) on qubit pairs + - Default ``gaussian`` reference pulse on each qubit's XY drive + - Default ``readout`` pulse on sensor dot resonators + """ + print("\n" + "=" * 80) + print("STEP 2: Wire default macros and pulses") + print("=" * 80) + + wire_machine_macros(machine) + + q1 = machine.qubits["q1"] + print(f" q1 macros: {sorted(q1.macros.keys())}") + print(f" q1 xy_drive pulse: {type(q1.xy.operations.get(DrivePulseName.GAUSSIAN)).__name__}") + print( + f" q1 reference_pulse_name: {q1.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name}" + ) + + +######################################################################################################################## +# %% STEP 3: Update operation parameters +######################################################################################################################## + + +def update_operation_parameters(machine: LossDiVincenzoQuam) -> None: + """Update parameters on already-wired default macro instances. + + After wiring, macro objects are accessible via ``qubit.macros[name]``. + Their parameters can be tuned directly without re-wiring. + """ + print("\n" + "=" * 80) + print("STEP 3: Update operation parameters") + print("=" * 80) + + for qubit in machine.qubits.values(): + # Tune initialize ramp duration + qubit.macros[VoltagePointName.INITIALIZE].ramp_duration = 64 + + # Tune measure hold duration + qubit.macros[VoltagePointName.MEASURE].hold_duration = 240 + + # Update the source-of-truth gaussian amplitude directly. + qubit.xy.operations[DrivePulseName.GAUSSIAN].amplitude = 0.17 + + # Set identity wait duration + qubit.macros[SingleQubitMacroName.IDENTITY].duration = 24 + + q1 = machine.qubits["q1"] + print(f" q1.initialize.ramp_duration = {q1.macros[VoltagePointName.INITIALIZE].ramp_duration}") + print(f" q1.measure.hold_duration = {q1.macros[VoltagePointName.MEASURE].hold_duration}") + print(" q1.gaussian.amplitude = " f"{q1.xy.operations[DrivePulseName.GAUSSIAN].amplitude}") + print(f" q1.I.duration = {q1.macros[SingleQubitMacroName.IDENTITY].duration}") + + +######################################################################################################################## +# %% STEP 4: Update the drive pulse type (Gaussian -> DRAG) +######################################################################################################################## + + +def update_drive_pulse_type(machine: LossDiVincenzoQuam) -> None: + """Replace the default Gaussian drive with a DRAG pulse. + + The ``XYDriveMacro`` uses ``reference_pulse_name`` to select which pulse + from ``qubit.xy.operations`` is the single source of truth for all gates. + + To switch from Gaussian to DRAG: + 1. Register a DRAG pulse under ``DrivePulseName.DRAG`` + 2. Update ``reference_pulse_name`` on the xy_drive macro + + All gate macros (x90, x180, y90, etc.) automatically pick up the change. + """ + print("\n" + "=" * 80) + print("STEP 4: Update drive pulse type (Gaussian -> DRAG)") + print("=" * 80) + + for qubit in machine.qubits.values(): + if qubit.xy is None: + continue + + # 1. Register a DRAG pulse alongside the existing Gaussian + qubit.xy.operations[DrivePulseName.DRAG] = pulses.DragPulse( + length=500, + amplitude=0.25, + sigma=83, + alpha=0.5, + anharmonicity=-200e6, + detuning=0, + ) + + # 2. Point the macro at the new pulse + qubit.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name = DrivePulseName.DRAG + + q1 = machine.qubits["q1"] + print(f" q1.xy.operations keys: {sorted(q1.xy.operations.keys())}") + print( + f" q1.xy_drive.reference_pulse_name = {q1.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name}" + ) + print(f" Both gaussian and drag are registered; macro now uses drag.") + print(f" All gates (x90, y180, etc.) derive from the drag pulse automatically.") + + +######################################################################################################################## +# %% STEP 5: Replace a particular macro +######################################################################################################################## + + +class TunedX180Macro(X180Macro): + """Custom X180 macro placeholder used for macro replacement examples.""" + + pass + + +class DemoCZMacro(QubitPairMacro): + """Demo CZ macro replacing the default placeholder.""" + + duration_ns: int = 64 + + @property + def inferred_duration(self) -> float: + return self.duration_ns * 1e-9 + + def apply(self, duration_ns: int | None = None, **kwargs): + duration = self.duration_ns if duration_ns is None else duration_ns + duration_cycles = max(0, int(round(duration / 4.0))) + control_xy = self.qubit_pair.qubit_control.xy.name + target_xy = self.qubit_pair.qubit_target.xy.name + qua.align(control_xy, target_xy) + if duration_cycles > 0: + qua.wait(duration_cycles, control_xy, target_xy) + + +def replace_macros(machine: LossDiVincenzoQuam) -> None: + """Replace macros using instance-level and type-level overrides. + + Override precedence: instance > type > default + """ + print("\n" + "=" * 80) + print("STEP 5: Replace macros (instance + type overrides)") + print("=" * 80) + + wire_machine_macros( + machine, + # Type-level: replace CZ on ALL qubit pairs + component_overrides={ + LDQubitPair: overrides( + macros={ + TwoQubitMacroName.CZ: macro(DemoCZMacro), + } + ), + }, + # Instance-level: replace X180 on q1 only + instance_overrides={ + "qubits.q1": overrides( + macros={ + SingleQubitMacroName.X_180: macro(TunedX180Macro), + } + ), + }, + strict=True, + ) + + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + pair = machine.qubit_pairs["q1_q2"] + + print( + " q1.x180 class: " f"{type(q1.macros[SingleQubitMacroName.X_180]).__name__} (overridden)" + ) + print(" q2.x180 class: " f"{type(q2.macros[SingleQubitMacroName.X_180]).__name__} (default)") + print(" q1_q2.cz class: " f"{type(pair.macros[TwoQubitMacroName.CZ]).__name__} (overridden)") + + +######################################################################################################################## +# %% Main +######################################################################################################################## + + +def main() -> None: + machine = build_wired_machine() + wire_defaults(machine) + update_operation_parameters(machine) + update_drive_pulse_type(machine) + replace_macros(machine) + + print("\n" + "=" * 80) + print("All steps completed successfully.") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/init_circuit.py b/quam_builder/architecture/quantum_dots/examples/init_circuit.py deleted file mode 100644 index 085c8b9d..00000000 --- a/quam_builder/architecture/quantum_dots/examples/init_circuit.py +++ /dev/null @@ -1,398 +0,0 @@ -""" -# pylint: disable=duplicate-code,wrong-import-order,import-outside-toplevel,ungrouped-imports,consider-using-with,too-many-lines,too-many-branches - -Full Multi-Qubit Circuit Implementation - -This script implements the full circuit shown in Picture 1.png panel b: -- Initialization: Read Init sequences for qubit pairs (12, 3, 45) -- Operation: Two-qubit exchange gate -- Readout: Final measurements - -The circuit structure: -1. Initialize Q1-Q2 with Read Init 12 (×2) -2. Initialize Q3 with Read Init 3 -3. Initialize Q4-Q5 with Read Init 45 -4. Apply two-qubit exchange operation -5. Readout all qubits -""" - -# ============================================================================ -# Imports -# ============================================================================ -from qm.qua import * -from qm import qua -from quam import QuamComponent - -from quam.components import pulses -from quam.components.macro import PulseMacro -from quam.components.quantum_components import Qubit, QubitPair -from quam.utils.qua_types import QuaVariableBool -from quam.core import quam_dataclass -from quam.core.macro import QuamMacro - -from quam_builder.architecture.quantum_dots.examples.operations import operations_registry -from quam_builder.tools.macros import AlignMacro, WaitMacro, MeasureMacro - -from quam_qd_generator_example import machine - - -# ============================================================================ -# Configuration Parameters -# ============================================================================ - -# Configuration for Q1-Q2 pair (Read Init 12) -CONFIG_Q1_Q2 = { - "voltage_points": { - "measure_point": { - "virtual_dot_0": -0.12, - "virtual_dot_1": -0.12, - "virtual_barrier_1": -0.0, - }, - "load_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.0}, - "exchange_point": { - "virtual_dot_0": 0.12, - "virtual_dot_1": 0.12, - "virtual_barrier_1": 0.9, # High barrier for exchange - }, - }, - "readout": {"length": 240, "amplitude": 0.12}, - "x180": {"amplitude": 0.25, "length": 120}, - "timing": {"duration": 100, "wait_duration": 240}, - "threshold": 0.05, -} - -# Configuration for Q2-Q3 pair (Read Init 3) -CONFIG_Q2_Q3 = { - "voltage_points": { - "measure_point": { - "virtual_dot_0": -0.12, - "virtual_dot_1": -0.12, - "virtual_barrier_1": -0.0, - }, - "load_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.0}, - "exchange_point": {"virtual_dot_0": 0.12, "virtual_dot_1": 0.12, "virtual_barrier_1": 0.95}, - }, - "readout": {"length": 240, "amplitude": 0.12}, - "x180": {"amplitude": 0.25, "length": 120}, - "timing": {"duration": 100, "wait_duration": 240}, - "threshold": 0.05, -} - -# Exchange gate timing parameters -EXCHANGE_PARAMS = { - "ramp_duration": 50, # Time to ramp barrier up (ns) - "exchange_duration": 300, # Time at high barrier (ns) -} - - -def configure_qubit_pair_for_reset(qubit_pair, config): - """ - Configure a qubit pair with voltage points, readout, and conditional reset. - Same as init_macro_improved.py but with exchange point added. - """ - # Extract parameters from config dictionary - voltage_points = config["voltage_points"] - readout_params = config["readout"] - x180_params = config["x180"] - measure_threshold = config["threshold"] - duration = config["timing"]["duration"] - wait_duration = config["timing"]["wait_duration"] - - # Add Voltage Points - for point_name, voltages in voltage_points.items(): - qubit_pair.add_point(point_name, voltages) - - # Configure Macros and Operations - qubit_pair.macros["align"] = AlignMacro() - qubit_pair.macros["wait"] = WaitMacro(duration=wait_duration) - - # Readout system configuration - qubit_pair.resonator = qubit_pair.quantum_dot_pair.sensor_dots[ - 0 - ].readout_resonator.get_reference() - qubit_pair.resonator.operations["readout"] = pulses.SquareReadoutPulse(**readout_params) - qubit_pair.macros["measure"] = MeasureMacro( - threshold=measure_threshold, component=qubit_pair.resonator.get_reference() - ) - - # X180 pulse configuration - qubit_pair.qubit_target.xy.operations["x180"] = pulses.SquarePulse(**x180_params) - qubit_pair.qubit_target.macros["x180"] = PulseMacro( - pulse=qubit_pair.qubit_target.xy.operations["x180"].get_reference() - ) - qubit_pair.qubit_control.xy.operations["x180"] = pulses.SquarePulse(**x180_params) - qubit_pair.qubit_control.macros["x180"] = PulseMacro( - pulse=qubit_pair.qubit_control.xy.operations["x180"].get_reference() - ) - - # Build Complete Configuration Using Fluent API Chain - return ( - qubit_pair - # Configure step points - .with_step_point("load", {"virtual_dot_1": 0.1}, duration=duration).with_ramp_point( - "cnot", - {"virtual_dot_1": 0.1, "virtual_dot_2": 0.3}, - duration=16, - ramp_duration=EXCHANGE_PARAMS["exchange_duration"], - ) - ) - - -q1 = machine.get_component("Q1") -q2 = machine.get_component("Q2") -q3 = machine.get_component("Q3") -q4 = machine.get_component("Q4") -q5 = machine.get_component("Q5") - -q3.with_step_point( - "load", {"virtual_dot_1": 0.1, "virtual_dot_2": 0.3, "virtual_dot_3": 0.4}, duration=16 -) - -configure_qubit_pair_for_reset(q1 @ q2, config=CONFIG_Q2_Q3) -configure_qubit_pair_for_reset(q2 @ q3, config=CONFIG_Q2_Q3) -configure_qubit_pair_for_reset(q3 @ q4, config=CONFIG_Q2_Q3) -configure_qubit_pair_for_reset(q4 @ q5, config=CONFIG_Q2_Q3) - - -@quam_dataclass -class Init12Macro(QuamMacro): - q1: str - q2: str - - def _get_component(self, id): - return self.parent.parent.machine.get_component(id) - - def apply(self, **kwargs): - """Execute conditional operation. - Returns: - Measured state - """ - q1 = self._get_component(self.q1) - q2 = self._get_component(self.q2) - q1_q2 = q1 @ q2 - # Measure PSB - state = q1_q2.measure() - # Reload dots - q1_q2.load() - # Execute conditional pulse - with qua.if_(state): - q1.x180() - return state - - -@quam_dataclass -class Init3Macro(QuamMacro): - q1: str - q2: str - q3: str - - def _get_component(self, id): - return self.parent.parent.machine.get_component(id) - - def apply(self, **kwargs): - """Execute conditional operation. - Returns: - Measured state - """ - # Retrieve components - q1 = self._get_component(self.q1) - q2 = self._get_component(self.q2) - q3 = self._get_component(self.q3) - q1_q2 = q1 @ q2 - q2_q3 = q2 @ q3 - # CNOT macro - q2_q3.cnot() - # Measure macro - state = q1_q2.measure() - # Load dots - q1_q2.load() - # Conditional pulse - with qua.if_(state): - q3.x180() - qua.align() - # Reload dots - q1_q2.load() - return state - - -@quam_dataclass -class InitAllMacro(QuamMacro): - q1: str - q2: str - q3: str - q4: str - q5: str - - def _get_component(self, id): - return self.parent.parent.machine.get_component(id) - - def apply(self, **kwargs): - # Declare streams for saving results - state_12_st = declare_stream() - state_3_st = declare_stream() - state_45_st = declare_stream() - # Retrieve components - q1 = self._get_component(self.q1) - q2 = self._get_component(self.q2) - q3 = self._get_component(self.q3) - q4 = self._get_component(self.q4) - q5 = self._get_component(self.q5) - qpu = self.parent.parent - q1_q2 = q1 @ q2 - q4_q5 = q4 @ q5 - # Load dots - q1_q2.load() - q3.load() - q4_q5.load() - # Init 12 - state_12 = qpu.init12() - save(state_12, state_12_st) - # 2× loop: Read Init 3 → Read Init 12 - with for_(n := declare(int), 0, n < 2, n + 1): - state_12 = qpu.init3() - state_3 = qpu.init12() - save(state_12, state_12_st) - save(state_3, state_3_st) - # Init45 - state_45 = qpu.init45() - save(state_45, state_45_st) - return state_12_st, state_3_st, state_45_st - - -machine.qpu.macros["initAll"] = InitAllMacro(q1="Q1", q2="Q2", q3="Q3", q4="Q4", q5="Q5") - - -@quam_dataclass -class Init12Macro(QuamMacro): - q1: str - q2: str - - def _get_component(self, id): - return self.parent.parent.machine.get_component(id) - - def apply(self, **kwargs): - """Execute conditional operation. - Returns: - Measured state - """ - q1 = self._get_component(self.q1) - q2 = self._get_component(self.q2) - q1_q2 = q1 @ q2 - # Measure PSB - state = q1_q2.measure() - # Reload dots - q1_q2.load() - # Execute conditional pulse - with qua.if_(state): - q1.x180() - return state - - -# ----------------------------------------------------------------------- -# Define Full Circuit QUA Program -# ----------------------------------------------------------------------- -q1 = machine.qubits["Q1"] -q2 = machine.qubits["Q2"] -q3 = machine.qubits["Q3"] -q4 = machine.qubits["Q4"] -q5 = machine.qubits["Q5"] - -q1_q2 = q1 @ q2 -q2_q3 = q2 @ q3 -q3_q4 = q3 @ q4 -q4_q5 = q4 @ q5 - -with program() as prog: - # Declare streams for saving results - state_12_st = declare_stream() - state_3_st = declare_stream() - state_45_st = declare_stream() - - # Load dots - q1_q2.load() - q3.load() - q4_q5.load() - - # Init 12 - state_12 = machine.qpu.init12() - save(state_12, state_12_st) - - # 2× loop: Read Init 3 → Read Init 12 - with for_(n := declare(int), 0, n < 2, n + 1): - state_12 = machine.qpu.init3() - state_3 = machine.qpu.init12() - - save(state_12, state_12_st) - save(state_3, state_3_st) - - # Init45 - state_45 = machine.qpu.init45() - save(state_45, state_45_st) - - # Stream processing - with stream_processing(): - state_12_st.save("state_init_12") - state_3_st.save("state_init_3") - state_45_st.save("state_init_45") - - -@quam_dataclass -class InitAllMacro(QuamMacro): - q1: str - q2: str - q3: str - q4: str - q5: str - - def _get_component(self, id): - return self.parent.parent.machine.get_component(id) - - def apply(self, **kwargs): - # Declare streams for saving results - state_12_st = declare_stream() - state_3_st = declare_stream() - state_45_st = declare_stream() - # Retrieve components - q1 = self._get_component(self.q1) - q2 = self._get_component(self.q2) - q3 = self._get_component(self.q3) - q4 = self._get_component(self.q4) - q5 = self._get_component(self.q5) - qpu = self.parent.parent - q1_q2 = q1 @ q2 - q4_q5 = q4 @ q5 - # Load dots - q1_q2.load() - q3.load() - q4_q5.load() - # Init 12 - state_12 = qpu.init12() - save(state_12, state_12_st) - # 2× loop: Read Init 3 → Read Init 12 - with for_(n := declare(int), 0, n < 2, n + 1): - state_12 = qpu.init3() - state_3 = qpu.init12() - save(state_12, state_12_st) - save(state_3, state_3_st) - # Init45 - state_45 = qpu.init45() - save(state_45, state_45_st) - return state_12_st, state_3_st, state_45_st - - -machine.qpu.macros["initAll"] = InitAllMacro(q1="Q1", q2="Q2", q3="Q3", q4="Q4", q5="Q5") - -with program() as prog_init: - machine.qpu.initAll() - # Stream processing - with stream_processing(): - state_12_st.save("state_init_12") - state_3_st.save("state_init_3") - state_45_st.save("state_init_45") - - -from qm import generate_qua_script - -sourceFile = open("debug.py", "w") -print(generate_qua_script(prog), file=sourceFile) -sourceFile.close() diff --git a/quam_builder/architecture/quantum_dots/examples/init_macro_conditional.py b/quam_builder/architecture/quantum_dots/examples/init_macro_conditional.py deleted file mode 100644 index 363015d4..00000000 --- a/quam_builder/architecture/quantum_dots/examples/init_macro_conditional.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -# pylint: disable=duplicate-code,wrong-import-order,import-outside-toplevel,ungrouped-imports,consider-using-with,too-many-lines,too-many-branches - -Improved Initialization Macro with Conditional Reset - -This module demonstrates how to use the ConditionalMacro from point_macros.py -for creating initialization sequences with conditional reset capabilities -for quantum dot systems. - -Key Features: -- Uses ConditionalMacro from point_macros.py (unified with QUAM patterns) -- Invertible condition: apply pulse when excited OR when ground -- Fluent API via with_conditional_macro() method -- Support for both method-based and function-based execution patterns -- Configuration-driven setup for easy customization - -Example Usage: - # Construction API using fluent method from VoltageMacroMixin - qubit_pair.with_conditional_macro( - name='reset', - measurement_macro='measure', - conditional_macro='x180', - invert_condition=False, - ) - - # Execution API (both patterns supported) - with program() as prog: - # Method-based (recommended for readability) - state = qubit_pair.measure() - qubit_pair.reset() - - # Function-based (good for dynamic dispatch) - state = measure(qubit_pair) - reset(qubit_pair) - init_sequence(qubit_pair) -""" - -# ============================================================================ -# Imports -# ============================================================================ -from qm.qua import * - -from quam.components import pulses -from quam.components.macro import PulseMacro -from quam.components.quantum_components import Qubit, QubitPair -from quam.utils.qua_types import QuaVariableBool - -from quam_builder.architecture.quantum_dots.operations import operations_registry -from quam_builder.tools.macros import MeasureMacro - -from quam_qd_generator_example import machine - -# ============================================================================ -# Register Operations for Function-Based Execution Pattern -# ============================================================================ -# These decorators register operations that can be called as functions. -# The 'pass' statements are intentional - the decorator handles dispatching -# to the actual macro implementations on the quantum components. - - -@operations_registry.register_operation -def measure(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Measure qubit state. Returns True if excited.""" - pass - - -@operations_registry.register_operation -def x180(qubit: Qubit, **kwargs) -> QuaVariableBool: - """Apply π-pulse (bit flip) to qubit.""" - pass - - -@operations_registry.register_operation -def reset(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Perform conditional reset on qubit pair. Returns measured state.""" - pass - - -@operations_registry.register_operation -def init_sequence(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Execute full initialization sequence (reset + wait + measure).""" - pass - - -@operations_registry.register_operation -def measure_init(qubit_pair: QubitPair, **kwargs) -> QuaVariableBool: - """Execute measure and return to load point.""" - pass - - -# ============================================================================ -# Configuration Helper Function -# ============================================================================ - - -def configure_qubit_pair_for_reset(qubit_pair, config): - """ - Configure a qubit pair with voltage points, readout, and conditional reset. - - Args: - qubit_pair: QubitPair instance to configure - config: Configuration dictionary with structure: - { - "voltage_points": { - "measure_point": {"virtual_dot_0": ..., "virtual_dot_1": ..., ...}, - "load_point": {...}, - "operate": {...} - }, - "readout": {"length": ..., "amplitude": ...}, - "x180": {"amplitude": ..., "length": ...}, - "timing": {"duration": ..., "wait_duration": ...}, - "threshold": ... - } - If None, uses DEFAULT_RESET_CONFIG - - Returns: - Configured qubit_pair for method chaining - """ - - # Extract parameters from config dictionary - voltage_points = config["voltage_points"] - readout_params = config["readout"] - x180_params = config["x180"] - measure_threshold = config["threshold"] - duration = config["timing"]["duration"] - wait_duration = config["timing"]["wait_duration"] - - # ---------------------------------------------------------------------------- - # Add Voltage Points (non-fluent operation) - # ---------------------------------------------------------------------------- - for point_name, voltages in voltage_points.items(): - qubit_pair.add_point(point_name, voltages) - - # ---------------------------------------------------------------------------- - # Configure Macros and Operations (non-fluent assignments) - # ---------------------------------------------------------------------------- - - # Readout system configuration - qubit_pair.resonator = qubit_pair.quantum_dot_pair.sensor_dots[ - 0 - ].readout_resonator.get_reference() - qubit_pair.resonator.operations["readout"] = pulses.SquareReadoutPulse(**readout_params) - qubit_pair.macros["measure"] = MeasureMacro( - threshold=measure_threshold, component=qubit_pair.resonator.get_reference() - ) - - # X180 pulse configuration for conditional reset - qubit_pair.qubit_target.xy.operations["x180"] = pulses.SquarePulse(**x180_params) - qubit_pair.qubit_target.macros["x180"] = PulseMacro( - pulse=qubit_pair.qubit_target.xy.operations["x180"].get_reference() - ) - - # ---------------------------------------------------------------------------- - # Build Complete Configuration Using Fluent API Chain - # ---------------------------------------------------------------------------- - ( - qubit_pair - # Configure step points with hold durations - .with_step_point("measure_point", duration=duration).with_step_point( - "load_point", duration=duration - ) - # Create measure-and-return-to-load sequence - .with_sequence( - "measure_init", - ["measure_point", "measure", "load_point"], - return_index=2, # Return measurement result - ) - # Create conditional reset macro (measure + conditional X180) - .with_conditional_macro( - name="reset", - measurement_macro="measure_init", - conditional_macro=qubit_pair.qubit_target.macros["x180"].get_reference(), - invert_condition=False, # Apply X180 when in ground state - ) - # Create full initialization sequence (reset + wait + measure) - .with_sequence( - "init_sequence", - ["reset", "measure_init"], - return_index=-1, # Return final measurement result - ) - ) - - -# ============================================================================ -# Setup Quantum Dot Configuration -# ============================================================================ - -# Configure all qubit pairs with default configuration -custom_config = { - "voltage_points": { - "measure_point": {"virtual_dot_0": -0.4, "virtual_dot_1": -0.6, "virtual_barrier_1": -0.5}, - "load_point": {"virtual_dot_0": 0.3, "virtual_dot_1": 0.5, "virtual_barrier_1": 0.4}, - "operate": {"virtual_dot_0": 0.6, "virtual_dot_1": 0.8, "virtual_barrier_1": 0.8}, - }, - "readout": {"length": 240, "amplitude": 0.12}, - "x180": {"amplitude": 0.25, "length": 120}, - "timing": {"duration": 100, "wait_duration": 240}, - "threshold": 0.05, -} - -for qubit_pair in machine.qubit_pairs.values(): - configure_qubit_pair_for_reset(qubit_pair, config=custom_config) - -# ============================================================================ -# Generate QM Configuration -# ============================================================================ -# IMPORTANT: Config generation must happen AFTER all operations are defined -config = machine.generate_config() - - -# ============================================================================ -# Main Execution Block -# ============================================================================ -if __name__ == "__main__": - from qm import qua - import matplotlib - - # Configure matplotlib for interactive plotting - matplotlib.use("TkAgg") - - # ----------------------------------------------------------------------- - # Define QUA Program - # ----------------------------------------------------------------------- - with program() as prog: - # Declare stream for saving measurement results - n_st = declare_stream() - - print("Executing conditional reset sequence...") - - # Execute initialization sequence on all qubit pairs - for qp_id, qubit_pair in machine.qubit_pairs.items(): - # Option 1: function based - state = init_sequence(qubit_pair) - # Option 2: method based - # state = qubit_pair.init_sequence() - - qua.wait(100) - - # Ramp all voltages back to zero at end of sequence - qubit_pair.voltage_sequence.ramp_to_zero() - - # Save the measurement result to stream - save(state, n_st) - - # Configure stream processing to save data - with stream_processing(): - n_st.save("state") - - # ----------------------------------------------------------------------- - # Connect to Quantum Machines and Execute - # ----------------------------------------------------------------------- - - # # Connect to the OPX controller - # qmm = QuantumMachinesManager(host="172.16.33.114", cluster_name="CS_4") - # qm = qmm.open_qm(config) - # - # # Clear previous job results - # qmm.clear_all_job_results() - # - # # Run simulation (800 clock cycles = 3200ns with 4ns clock period) - # simulation_config = SimulationConfig(duration=800) - # job = qmm.simulate(config, prog, simulation_config) - # - # # Plot simulated waveforms - # job.get_simulated_samples().con1.plot() - # plt.show() - # - # # Retrieve and print results - # res = job.result_handles - # out = res.fetch_results() - # print(f'Results: {out}') - - # ----------------------------------------------------------------------- - # Hardware Execution (commented out - uncomment to run on real hardware) - # ----------------------------------------------------------------------- - # job = qm.execute(prog) - # print(job.get_status()) diff --git a/quam_builder/architecture/quantum_dots/examples/macro_examples.py b/quam_builder/architecture/quantum_dots/examples/macro_examples.py deleted file mode 100644 index 72c8eba2..00000000 --- a/quam_builder/architecture/quantum_dots/examples/macro_examples.py +++ /dev/null @@ -1,472 +0,0 @@ -""" -# pylint: disable=duplicate-code,wrong-import-order,import-outside-toplevel,ungrouped-imports,too-few-public-methods,missing-class-docstring - -Essential examples demonstrating voltage point and sequence macro functionality. - -This module showcases the 6 most important features of the macro system: - -1. **Fluent API**: Modern method chaining for clean macro definition -2. **Dynamic Method Calling**: Call macros as methods via __getattr__ -3. **Parameter Overrides**: Runtime customization of macro parameters -4. **Nested Sequences**: Hierarchical composition of complex protocols -5. **Mixed Macros**: Combining pulse macros and point macros in sequences -6. **Operations Registry**: Type-safe, clean operation calls (RECOMMENDED) - -Key Features: -- Clean, modern API with method chaining -- Type-safe operations with IDE autocomplete -- Flexibility: Define macros dynamically during calibration -- Composability: Build complex sequences from simple primitives -- Serializable: All state stored in self.macros dict -""" - -from quam.core import quam_dataclass -from qm import qua - -from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin - - -# ============================================================================ -# Example Component Setup -# ============================================================================ - - -@quam_dataclass -class ExampleQuantumDot(VoltageMacroMixin): # pylint: disable=too-many-ancestors - """ - Example quantum dot component demonstrating macro functionality. - - In practice, this would be a QuantumDot, QuantumDotPair, LDQubit, or - LDQubitPair component that inherits from VoltageMacroMixin. - """ - - id: str - _voltage_sequence: any = None # In real usage, this would be a VoltageSequence - - @property - def voltage_sequence(self): - """Return the voltage sequence (placeholder for example).""" - return self._voltage_sequence - - def __post_init__(self): - # Initialize VoltageMacroMixin - super().__post_init__() - - -# ============================================================================ -# Example 1: Fluent API (Recommended) -# ============================================================================ - - -def example_01_fluent_api(quantum_dot: VoltageMacroMixin) -> None: - """ - Modern fluent API with method chaining. - - This is the RECOMMENDED approach for new code. Provides: - - Clean, readable syntax - - Method chaining for multiple macros - - Automatic reference and parent setup - - Full serialization support - """ - # Define multiple macros in one fluent chain - ( - quantum_dot.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_0": 0.3}, duration=200, ramp_duration=500) - .with_step_point("measure", {"virtual_dot_0": 0.15}, duration=1000) - .with_sequence("full_cycle", ["idle", "load", "measure"]) - ) - - # Can also define macros incrementally during calibration - quantum_dot.with_step_point("readout", {"virtual_dot_0": 0.2}, duration=500) - - # Add to existing sequence - quantum_dot.with_sequence("extended_cycle", ["idle", "load", "measure", "readout"]) - - -# ============================================================================ -# Example 2: Dynamic Method Calling -# ============================================================================ - - -def example_02_method_calling(quantum_dot: VoltageMacroMixin) -> None: - """ - Calling macros as methods using __getattr__ magic. - - After defining macros (via any method), they can be called as if they - were methods decorated with @QuantumComponent.register_macro. - - This provides the same clean API as statically-defined macros while - maintaining full flexibility for dynamic calibration-time definition. - """ - # Define macros using fluent API - ( - quantum_dot.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_0": 0.3}, duration=200, ramp_duration=500) - .with_sequence("init", ["idle", "load"]) - ) - - # Call macros as methods (via __getattr__) - with qua.program() as prog: - quantum_dot.idle() # Calls StepPointMacro - quantum_dot.load() # Calls RampPointMacro - quantum_dot.init() # Calls SequenceMacro - - # With parameter overrides - quantum_dot.idle(duration=200) - quantum_dot.load(duration=300, ramp_duration=600) - - # This is equivalent to: - with qua.program() as prog: - quantum_dot.macros["idle"].apply() - quantum_dot.macros["load"].apply() - quantum_dot.macros["init"].apply() - - # or - with qua.program() as prog: - quantum_dot.apply("idle") - quantum_dot.apply("load") - quantum_dot.apply("init") - - -# ============================================================================ -# Example 3: Parameter Overrides -# ============================================================================ - - -def example_03_parameter_overrides(quantum_dot: VoltageMacroMixin) -> None: - """ - Runtime parameter overrides for macro customization. - - All macros support parameter overrides at call time, allowing you to - reuse the same macro definition with different timing parameters. - """ - # Define base macros - - ( - quantum_dot.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100).with_ramp_point( - "load", {"virtual_dot_0": 0.3}, duration=200, ramp_duration=500 - ) - ) - - with qua.program() as prog: - # Use default parameters - quantum_dot.idle() - - # Override hold duration - quantum_dot.idle(duration=152) - - # Override both ramp and hold duration - quantum_dot.load(ramp_duration=580, duration=240) - - # Overrides work with dictionary access too - quantum_dot.macros["idle"].apply(duration=400) - quantum_dot.macros["load"].apply(ramp_duration=400) - - -# ============================================================================ -# Example 4: Nested Sequences -# ============================================================================ - - -def example_04_nested_sequences(quantum_dot: VoltageMacroMixin) -> None: - """ - Creating nested sequences by composing sequence macros. - - Sequences can reference other sequences, enabling hierarchical - composition of complex protocols. - """ - # Define primitive macros - ( - quantum_dot.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_0": 0.3}, duration=200, ramp_duration=400) - .with_step_point("manipulate", {"virtual_dot_0": 0.25}, duration=152) - .with_step_point("readout", {"virtual_dot_0": 0.15}, duration=1000) - ) - - # Build sub-sequences - quantum_dot.with_sequence("init", ["idle", "load"]) - quantum_dot.with_sequence("readout_seq", ["manipulate", "readout"]) - - # Compose higher-level sequence from sub-sequences - # The init and readout_seq are already stored as macros, so they can be referenced - quantum_dot.with_sequence("full_experiment", ["init", "readout_seq"]) - - # Execute nested sequence - # Each SequenceMacro uses self.parent to resolve its macro references - with qua.program() as prog: - quantum_dot.full_experiment() # Executes all 4 primitive operations - - -# ============================================================================ -# Example 5: Mixed Pulse and Point Sequence Macros -# ============================================================================ - - -def example_05_mixed_pulse_and_point_sequence(qubit) -> None: - """ - Combining pulse macros and point macros in a single sequence. - - This example demonstrates a realistic quantum dot experiment workflow where - you need to coordinate voltage point operations (moving between charge states) - with microwave pulse operations (rotating qubit state). - - This is particularly relevant for: - - Loss-DiVincenzo qubits where voltage tunes frequency and pulses drive transitions - - Experiments requiring precise timing of charge and spin operations - - Complex sequences like dynamical decoupling with voltage modulation - - Prerequisites: - - The qubit must inherit from both VoltageMacroMixin and have pulse capabilities - - Example: LDQubit has both voltage_sequence (for points) and xy (for pulses) - """ - from quam.components.macro.qubit_macros import PulseMacro - - # === STAGE 1: Define Voltage Point Macros === - # These control the charge state / detuning of the quantum dot - ( - qubit.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - .with_ramp_point("sweetspot", {"virtual_dot_0": 0.22}, duration=200, ramp_duration=400) - .with_step_point("readout", {"virtual_dot_0": 0.15}, duration=1000) - ) - - # === STAGE 2: Define Pulse Macros === - # These drive microwave transitions (assuming pulses are already added to xy) - # Note: Pulses must be added first via qubit.add_xy_pulse(name, pulse_obj) - - x180_macro = PulseMacro(pulse="x180") - qubit.macros["x180"] = x180_macro - - y90_macro = PulseMacro(pulse="y90") - qubit.macros["y90"] = y90_macro - - x90_macro = PulseMacro(pulse="x90") - qubit.macros["x90"] = x90_macro - - # === STAGE 3: Create Mixed Sequences === - - # Simple Rabi sequence: Move to sweetspot, apply X rotation, readout - qubit.with_sequence("rabi_experiment", ["sweetspot", "x180", "readout"]) - - # Ramsey sequence: Move to sweetspot, apply Y90, wait, apply Y90, readout - qubit.with_sequence("ramsey_sequence", ["sweetspot", "y90", "idle", "y90", "readout"]) - - # Complex sequence mixing multiple operations - qubit.with_sequence( - "complex_protocol", - ["idle", "sweetspot", "y90", "idle", "y90", "sweetspot", "x180", "readout"], - ) - - # === STAGE 4: Execute Mixed Sequences === - with qua.program() as prog: - # Execute simple Rabi experiment - # This will: ramp to sweetspot voltage → play X180 pulse → step to readout voltage - qubit.rabi_experiment() - - # Execute Ramsey sequence - # This will: ramp to sweetspot → Y90 pulse → step to idle → Y90 pulse → step to readout - qubit.ramsey_sequence() - - # Execute complex protocol - qubit.complex_protocol() - - # Can still override parameters for individual calls - qubit.rabi_experiment() # Use default durations - # Note: pulse macros don't support duration override by default, - # but point macros do: qubit.sweetspot(duration=300) - - # === STAGE 5: Nested Mixed Sequences === - # You can also create sequences that reference other mixed sequences - - # Define a calibration sub-sequence - qubit.with_sequence("calibrate_pi_pulse", ["sweetspot", "x180", "readout", "idle"]) - - # Define a main experiment that uses the calibration - qubit.with_sequence( - "full_experiment_with_calibration", - ["calibrate_pi_pulse", "ramsey_sequence", "calibrate_pi_pulse"], - ) - - with qua.program() as prog: - qubit.full_experiment_with_calibration() - - -# ============================================================================ -# Example 6: Using Operations Registry (RECOMMENDED) -# ============================================================================ - - -def example_06_operations_registry(machine) -> None: - """ - Using QuAM's OperationsRegistry for type-safe, clean macro calls. - - This is the RECOMMENDED approach for production code. The operations registry - provides: - - Type-safe operation calls with IDE autocomplete - - Clean, readable QUA code - - Automatic dispatch to macro implementations - - Consistent API across all components - - This example mirrors the usage in quam_qd_generator_example.py - """ - from quam.components.macro.qubit_macros import PulseMacro - from quam.components import pulses - from quam_builder.architecture.quantum_dots.examples.operations import ( - operations_registry, - # Import individual operations for type safety - idle, - load, - readout, - x180, - rabi, - ) - - # === SETUP: Register operations with machine === - machine.operations_registry = operations_registry - - # === SETUP: Configure qubits with pulse and voltage macros === - qubit = machine.qubits["Q0"] - qubit2 = machine.qubits["Q1"] - - for q in [qubit, qubit2]: - # Add pulse operations - q.xy.operations["x180"] = pulses.SquarePulse(amplitude=0.2, length=100) - q.macros["x180"] = PulseMacro(pulse=q.xy.operations["x180"].get_reference()) - - q.xy.operations["y90"] = pulses.SquarePulse(amplitude=0.1, length=100) - q.macros["y90"] = PulseMacro(pulse=q.xy.operations["y90"].get_reference()) - - q.xy.operations["x90"] = pulses.SquarePulse(amplitude=0.1, length=100) - q.macros["x90"] = PulseMacro(pulse=q.xy.operations["x90"].get_reference()) - - # Add voltage point macros using fluent API - ( - q.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - .with_ramp_point("load", {"virtual_dot_0": 0.3}, duration=200, ramp_duration=500) - .with_step_point("sweetspot", {"virtual_dot_0": 0.22}, duration=200) - .with_ramp_point( - "readout", {"virtual_dot_0": 0.15, "virtual_dot_1": 0.33}, duration=200 - ) - .with_sequence("init", ["load", "sweetspot"]) - ) - - # Define mixed sequences (voltage + pulse) - q.with_sequence("rabi", ["init", "x180", "readout"]) - - @operations_registry.register_operation - def sweetspot(component: VoltageMacroMixin, **kwargs): - pass - - # === EXAMPLE 1: Using gate-level operations (RECOMMENDED) === - print("\n" + "=" * 70) - print("Example 1: Gate-level operations (recommended)") - print("=" * 70) - - with qua.program() as prog_operations: - print("\n--- Rabi Experiment ---") - # Clean, readable syntax with type checking! - rabi(qubit) # Executes: init → x180 → readout - rabi(qubit2) # Same for second qubit - - print("\n--- Individual Operations ---") - # Call individual operations - idle(qubit) - load(qubit) - sweetspot(qubit) - x180(qubit) - readout(qubit) - - print("\n--- With Parameter Overrides ---") - # Override parameters for voltage operations - idle(qubit, duration=152) - load(qubit, duration=240, ramp_duration=600) - - -if __name__ == "__main__": - from quam_qd_generator_example import machine - - qubit = machine.qubits["Q0"] - quantum_dot = qubit.quantum_dot - - example_01_fluent_api(quantum_dot) - example_02_method_calling(quantum_dot) - example_03_parameter_overrides(quantum_dot) - example_04_nested_sequences(quantum_dot) - example_05_mixed_pulse_and_point_sequence(qubit) - example_06_operations_registry(machine) - -# ============================================================================ -# Summary and Best Practices -# ============================================================================ - -""" -BEST PRACTICES SUMMARY: - -1. **Use Fluent API for New Code** - - Cleaner syntax with method chaining - - Automatically handles references and parent linking - - Example: .with_step_point().with_ramp_point().with_sequence() - -2. **Macros Use self.parent Internally** - - Macros access their component via self.parent (set automatically by QuAM) - - All apply() methods use self.parent.voltage_sequence, not passed parameters - - Follows QuAM convention where apply(*args, **kwargs) doesn't receive component - -3. **Call Macros as Methods** - - Use dot notation: quantum_dot.idle() instead of quantum_dot.macros["idle"]() - - Provides @register_macro-like API while maintaining flexibility - - Works automatically via __getattr__ - -4. **Compose Complex Sequences** - - Build simple primitive macros first - - Compose them into higher-level sequences - - Sequences can reference other sequences - -5. **Override Parameters When Needed** - - All macros support runtime parameter overrides - - Use overrides for testing and optimization - - Example: quantum_dot.load(duration=300) - -6. **Mix Pulse and Point Macros** - - Both types follow the same apply() interface - - Can be freely combined in sequence macros - - SequenceMacro resolves references through self.parent - -7. **Everything is Serializable** - - All state stored in self.macros dict - - No special serialization handling needed - - Macros work immediately after deserialization - -MIGRATION GUIDE: - -From (Old API with point_name): - quantum_dot.add_point("idle", {"virtual_dot_0": 0.1}, duration=100) - macro = StepPointMacro(point_name="idle", duration=100) # Old: used point_name - quantum_dot.macros["idle"] = macro - -To (New API with point_ref): - # Fluent API (recommended) - quantum_dot.with_step_point("idle", {"virtual_dot_0": 0.1}, duration=100) - -From (Dictionary access): - quantum_dot.macros["idle"]() - -To (Method call): - quantum_dot.idle() - -KEY CHANGES IN NEW SYSTEM: -- Macros use point_ref (reference string) instead of point_name -- apply() methods use self.parent to access component, not passed as parameter -- Parent is set automatically by QuAM when macro is assigned to macros dict -- SequenceMacro.apply() uses self.parent to resolve macro references -""" - - -__all__ = [ - "ExampleQuantumDot", - "example_01_fluent_api", - "example_02_method_calling", - "example_03_parameter_overrides", - "example_04_nested_sequences", - "example_05_mixed_pulse_and_point_sequence", - "example_06_operations_registry", -] diff --git a/quam_builder/architecture/quantum_dots/examples/operations.py b/quam_builder/architecture/quantum_dots/examples/operations.py deleted file mode 100644 index 91601dbf..00000000 --- a/quam_builder/architecture/quantum_dots/examples/operations.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -Gate-level operations for quantum dot components using QuAM's OperationsRegistry. - -This module demonstrates how to register voltage point operations and pulse operations -as gate-level operations that can be called directly in QUA code. - -The OperationsRegistry allows you to: -1. Define operations at a high level with type hints -2. Automatically dispatch to the correct macro implementation -3. Get type checking and IDE autocomplete support -4. Write cleaner QUA code -""" - -from quam import QuamComponent -from typing import TYPE_CHECKING - -from quam.core import OperationsRegistry - -from quam_builder.architecture.quantum_dots.components import QuantumDot, SensorDot -from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair -from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin - -__all__ = [ - "operations_registry", - # Voltage operations - "idle", - "load", - "readout", - # Pulse operations - "x180", - "y180", - "x90", - "y90", - # Mixed sequences - "rabi", -] - - -# ============================================================================ -# Operations Registry -# ============================================================================ - -# Main registry for all quantum dot operations (voltage + pulse) -operations_registry = OperationsRegistry() - -# ============================================================================ -# Generic Voltage Point Operations -# ============================================================================ -# These work with any component that has VoltageMacroMixin - - -@operations_registry.register_operation -def idle(component: VoltageMacroMixin, **kwargs): - """ - Move component to idle voltage point. - - This will trigger component.macros["idle"].apply(**kwargs) - - Args: - component: QuantumDot, SensorDot, LDQubit, or any VoltageMacroMixin component - **kwargs: Optional parameter overrides (e.g., duration=200) - """ - pass - - -@operations_registry.register_operation -def X90(component: LDQubit, **kwargs): - pass # - - -operations_registry = OperationsRegistry() - - -@operations_registry.register_operation -# The function name below becomes the gate-level call (e.g., X(q1)). -def X(qubit: LDQubit, **kwargs): - # Implementation is resolved by the macros attached to the qubit. - pass - - -@operations_registry.register_operation -def load(component: VoltageMacroMixin, **kwargs): - """ - Move component to load voltage point. - - Args: - component: Any component with voltage_sequence capability - **kwargs: Optional parameter overrides - """ - pass - - -@operations_registry.register_operation -def init(component: VoltageMacroMixin, **kwargs): - """ - Move component to init voltage point. - :param component: - :param kwargs: - :return: - """ - pass - - -@operations_registry.register_operation -def readout(component: VoltageMacroMixin, **kwargs): - """ - Move component to readout voltage point. - - Args: - component: Any component with voltage_sequence capability - **kwargs: Optional parameter overrides - """ - pass - - -# ============================================================================ -# Pulse Operations -# ============================================================================ - - -@operations_registry.register_operation -def x180(qubit: LDQubit, **kwargs): - """ - Apply X180 pulse (π rotation around X axis). - - This will trigger qubit.macros["x180"].apply(**kwargs) - - Args: - qubit: LDQubit with xy - **kwargs: Optional pulse parameters (amplitude_scale, duration, etc.) - """ - pass - - -@operations_registry.register_operation -def y180(qubit: LDQubit, **kwargs): - """ - Apply Y180 pulse (π rotation around Y axis). - - Args: - qubit: LDQubit with xy - **kwargs: Optional pulse parameters - """ - pass - - -@operations_registry.register_operation -def x90(qubit: LDQubit, **kwargs): - """ - Apply X90 pulse (π/2 rotation around X axis). - - Args: - qubit: LDQubit with xy - **kwargs: Optional pulse parameters - """ - pass - - -@operations_registry.register_operation -def y90(qubit: LDQubit, **kwargs): - """ - Apply Y90 pulse (π/2 rotation around Y axis). - - Args: - qubit: LDQubit with xy - **kwargs: Optional pulse parameters - """ - pass - - -# ============================================================================ -# Mixed Pulse + Voltage Operations -# ============================================================================ - - -@operations_registry.register_operation -def rabi(qubit: VoltageMacroMixin, **kwargs): - """ - Execute Rabi experiment sequence (voltage + pulse). - - Example macro definition: - qubit.with_sequence("rabi", ["init", "x180", "readout"]) - - Args: - qubit: LDQubit with both voltage and pulse macros - **kwargs: Optional parameter overrides - """ - pass diff --git a/quam_builder/architecture/quantum_dots/examples/port_constraints_bug_example.py b/quam_builder/architecture/quantum_dots/examples/port_constraints_bug_example.py deleted file mode 100644 index a45b17a5..00000000 --- a/quam_builder/architecture/quantum_dots/examples/port_constraints_bug_example.py +++ /dev/null @@ -1,236 +0,0 @@ -import os -from pathlib import Path - -from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring -from qualang_tools.wirer.wirer.channel_specs import lf_fem_spec - - -EXAMPLES_DIR = Path(__file__).resolve().parent -os.environ.setdefault("QUAM_STATE_PATH", str(EXAMPLES_DIR / "quam_state")) - -######################################################################################################################## -# %% Define static parameters -######################################################################################################################## -sensor_dots = [1, 2, 3] -quantum_dots = [1, 2, 3] -quantum_dot_pairs = list(zip(quantum_dots, quantum_dots[1:])) - -######################################################################################################################## -# %% Define custom/hardcoded channel addresses -######################################################################################################################## -# Multiplexed readout for sensor 1 to 2 and 3 to 4 on two feed-lines -s_ch = lf_fem_spec(con=1, out_slot=3) -s1_res_ch = lf_fem_spec(con=1, in_slot=2, out_slot=2, in_port=1, out_port=1) -s1_res_ch_port2 = lf_fem_spec(con=1, in_slot=2, out_slot=2, in_port=1, out_port=2) -s2to3_res_ch = lf_fem_spec(con=1, in_slot=3, out_slot=3, in_port=2, out_port=8) -barrier_pair_1_ch = lf_fem_spec(con=1, out_slot=2, out_port=2) -barrier_pair_2_ch = lf_fem_spec(con=1, out_slot=2, out_port=3) -barrier_pair_1_alt_ch = lf_fem_spec(con=1, out_slot=2, out_port=3) -barrier_pair_2_alt_ch = lf_fem_spec(con=1, out_slot=2, out_port=4) - - -def _make_instruments() -> Instruments: - instruments = Instruments() - instruments.add_lf_fem(controller=1, slots=[2, 3]) - return instruments - - -def _make_connectivity( - include_barrier_gates: bool, - barrier_constraints=None, - s1_res_constraints=None, -) -> Connectivity: - connectivity = Connectivity() - - # Option 2: explicit resonator and voltage-gate wiring with constraints - connectivity.add_sensor_dot_resonator_line( - sensor_dots[0], - shared_line=False, - constraints=s1_res_constraints or s1_res_ch, - ) - connectivity.add_sensor_dot_resonator_line( - sensor_dots[1:], - shared_line=True, - constraints=s2to3_res_ch, - ) - connectivity.add_sensor_dot_voltage_gate_lines(sensor_dots, constraints=s_ch) - - # Plunger gates for quantum dots (constrained to the same LF-FEM) - connectivity.add_quantum_dot_voltage_gate_lines(quantum_dots, constraints=s_ch) - - if include_barrier_gates: - connectivity.add_quantum_dot_pairs( - quantum_dot_pairs=quantum_dot_pairs, - constraints=barrier_constraints, - ) - - return connectivity - - -def _dump_instruments(instruments: Instruments, label: str) -> None: - print(f"\n--- Instruments snapshot ({label}) ---") - available_channels = getattr(instruments, "available_channels", None) - if available_channels is None: - print("No available_channels attribute found on instruments.") - return - for channel_type, channels in available_channels.items(): - print(f"{channel_type}: {len(channels)} available") - print(channels) - - -def _dump_connectivity(connectivity: Connectivity, label: str) -> None: - print(f"\n--- Connectivity snapshot ({label}) ---") - elements = getattr(connectivity, "elements", None) - if not elements: - print("No elements found on connectivity.") - else: - element_items = elements.items() if isinstance(elements, dict) else enumerate(elements) - print(f"Total elements: {len(elements)}") - for key, element in element_items: - name = getattr(element, "name", None) or getattr(element, "id", None) or str(key) - element_type = type(element) - print(f"- {name} ({element_type})") - try: - element_vars = vars(element) - except TypeError: - element_vars = None - if element_vars: - print(f" fields: {element_vars}") - else: - print(f" repr: {element!r}") - - specs = getattr(connectivity, "specs", None) - if not specs: - print("No wiring specs found on connectivity.") - return - print(f"\nWiring specs: {len(specs)}") - for idx, spec in enumerate(specs, start=1): - print(f"- spec[{idx}]") - try: - spec_vars = vars(spec) - except TypeError: - spec_vars = None - if spec_vars: - print(f" fields: {spec_vars}") - else: - print(f" repr: {spec!r}") - - -def _print_exception_details(exc: Exception) -> None: - print(f" type: {type(exc)}") - print(f" message: {exc}") - print(f" repr: {exc!r}") - - -def example_port_constraints_ok_without_barriers() -> None: - """Port constraints are respected; allocation succeeds without barrier gates.""" - print("=" * 80) - print("EXAMPLE: Port constraints WITHOUT barrier gates (expected to succeed)") - print("=" * 80) - - instruments = _make_instruments() - connectivity = _make_connectivity(include_barrier_gates=False) - - print("Allocating wiring (no barrier gates)...") - try: - allocate_wiring(connectivity, instruments) - print("✓ Allocation succeeded without barrier gates") - except Exception as exc: - print(f"✗ Allocation failed without barrier gates: {exc}") - _print_exception_details(exc) - _dump_connectivity(connectivity, label="no barriers") - _dump_instruments(instruments, label="no barriers") - raise - - -def example_port_constraints_fail_with_barriers() -> bool: - """Port constraints are respected; allocation fails once barrier gates are added.""" - print("=" * 80) - print("EXAMPLE: Port constraints WITH barrier gates (expected to fail)") - print("=" * 80) - - instruments = _make_instruments() - connectivity = _make_connectivity(include_barrier_gates=True) - - print("Allocating wiring (with barrier gates)...") - try: - allocate_wiring(connectivity, instruments) - print("✓ Allocation succeeded with barrier gates (unexpected)") - return True - except Exception as exc: - print(f"✗ Allocation failed with barrier gates: {exc}") - _print_exception_details(exc) - wiring_spec = getattr(exc, "wiring_spec", None) or getattr(exc, "spec", None) - if wiring_spec is not None: - print(f"\n--- Exception wiring spec ---\n{wiring_spec}") - _dump_connectivity(connectivity, label="with barriers") - _dump_instruments(instruments, label="with barriers") - return False - - -def example_barrier_constraints_avoid_rf() -> None: - """Constrain each barrier to a non-conflicting port (expected to succeed).""" - print("=" * 80) - print("EXAMPLE: Constrain barrier gates per-pair (expected to succeed)") - print("=" * 80) - - instruments = _make_instruments() - connectivity = _make_connectivity(include_barrier_gates=False) - connectivity.add_quantum_dot_pairs( - quantum_dot_pairs=[quantum_dot_pairs[0]], - constraints=barrier_pair_1_ch, - ) - connectivity.add_quantum_dot_pairs( - quantum_dot_pairs=[quantum_dot_pairs[1]], - constraints=barrier_pair_2_ch, - ) - - print("Allocating wiring (barrier gates constrained per pair)...") - try: - allocate_wiring(connectivity, instruments) - print("✓ Allocation succeeded with barrier constraints") - except Exception as exc: - print(f"✗ Allocation failed with barrier constraints: {exc}") - _print_exception_details(exc) - _dump_connectivity(connectivity, label="barriers constrained") - _dump_instruments(instruments, label="barriers constrained") - raise - - -def example_move_rf_port() -> None: - """Move the s1 resonator RF output to port 2 (expected to succeed).""" - print("=" * 80) - print("EXAMPLE: Move s1 RF output to port 2 (expected to succeed)") - print("=" * 80) - - instruments = _make_instruments() - connectivity = _make_connectivity( - include_barrier_gates=False, - s1_res_constraints=s1_res_ch_port2, - ) - connectivity.add_quantum_dot_pairs( - quantum_dot_pairs=[quantum_dot_pairs[0]], - constraints=barrier_pair_1_alt_ch, - ) - connectivity.add_quantum_dot_pairs( - quantum_dot_pairs=[quantum_dot_pairs[1]], - constraints=barrier_pair_2_alt_ch, - ) - - print("Allocating wiring (s1 RF output moved to port 2)...") - try: - allocate_wiring(connectivity, instruments) - print("✓ Allocation succeeded with moved RF port") - except Exception as exc: - print(f"✗ Allocation failed with moved RF port: {exc}") - _print_exception_details(exc) - _dump_connectivity(connectivity, label="s1 RF moved") - _dump_instruments(instruments, label="s1 RF moved") - raise - - -if __name__ == "__main__": - example_port_constraints_ok_without_barriers() - example_port_constraints_fail_with_barriers() - example_barrier_constraints_avoid_rf() - example_move_rf_port() diff --git a/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py b/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py new file mode 100644 index 00000000..314288ac --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py @@ -0,0 +1,162 @@ +"""Example: default pulse wiring and selective pulse overrides. + +Demonstrates using the typed override API for pulse configuration: + +1. ``wire_machine_macros(machine)`` — adds default ``gaussian`` pulse to XY drives. +2. ``component_overrides`` with ``pulse()`` helper — override all qubits of a type. +3. ``instance_overrides`` with ``pulse()`` helper — override one specific qubit. + +Key imports for pulse overrides:: + + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + pulse, # build a pulse override entry + overrides, # group macros + pulses for one component + ) + +Only one reference pulse (``gaussian``) is registered per qubit by default. +``XYDriveMacro`` scales amplitude for rotation angle and applies virtual-Z +for rotation axis, so all single-qubit gates derive from this single pulse. +""" + +# pylint: disable=no-member # WiringLineType enum members are runtime-only + +# Components in this demo inherit from framework mixins with deep MRO. +# pylint: disable=too-many-ancestors + +from __future__ import annotations + +from typing import Dict + +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType + +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + pulse, + overrides, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder +from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder + + +def _plunger_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} + + +def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} + + +def _barrier_ports(pair_id: str) -> Dict[str, str]: + return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} + + +def build_demo_machine() -> LossDiVincenzoQuam: + """Build a small machine with 2 qubits and default pulse. + + ``wire_machine_macros()`` adds the default ``gaussian`` GaussianPulse + to each qubit's XY drive. All single-qubit gate macros derive from + this single pulse. + """ + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + }, + "q2": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), + WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + }, + }, + "qubit_pairs": { + "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, + }, + } + machine = _BaseQpuBuilder(machine).build() + machine = _LDQubitBuilder(machine).build() + if getattr(machine, "qpu", None) is None: + machine.qpu = QPU() + + wire_machine_macros(machine) + return machine + + +def print_pulse_summary(machine: LossDiVincenzoQuam, title: str) -> None: + """Print pulse operations for each qubit's XY drive.""" + print(f"\n=== {title} ===") + for qubit_id, qubit in machine.qubits.items(): + xy = getattr(qubit, "xy", None) + if xy is None: + continue + ops = getattr(xy, "operations", {}) + print(f"\n {qubit_id}.xy.operations:") + for pulse_name, p in sorted(ops.items()): + cls_name = type(p).__name__ + length = getattr(p, "length", "?") + amp = getattr(p, "amplitude", "?") + axis = getattr(p, "axis_angle", "N/A") + print( + f" {pulse_name:>10s}: {cls_name}(length={length}, amplitude={amp}, axis_angle={axis})" + ) + + +def apply_type_level_overrides(machine: LossDiVincenzoQuam) -> None: + """Override gaussian pulse for ALL LDQubit instances. + + Uses ``component_overrides`` keyed by the ``LDQubit`` class. + The ``pulse()`` helper constructs a validated pulse entry. + + Since all gate macros derive from this single pulse, the change + affects x90, x180, y90, y180, etc. automatically. + """ + wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides( + pulses={ + "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), + } + ), + }, + ) + + +def apply_instance_level_overrides(machine: LossDiVincenzoQuam) -> None: + """Override gaussian on q1 only. + + Uses ``instance_overrides`` keyed by the component path string. + Instance-level overrides take precedence over type-level overrides. + """ + wire_machine_macros( + machine, + instance_overrides={ + "qubits.q1": overrides( + pulses={ + "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), + } + ), + }, + ) + + +def main() -> None: + # 1. Build machine with default pulse + machine = build_demo_machine() + print_pulse_summary(machine, "Default Pulse") + + # 2. Apply type-level override: all qubits get a shorter gaussian + apply_type_level_overrides(machine) + print_pulse_summary(machine, "After Type-Level Override (gaussian on all LDQubits)") + + # 3. Apply instance-level override: q1 gets custom gaussian + apply_instance_level_overrides(machine) + print_pulse_summary(machine, "After Instance-Level Override (q1.gaussian custom)") + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py b/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py index 99d1e793..416d2778 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py @@ -19,6 +19,8 @@ """ +import os + from quam.components.ports import MWFEMAnalogOutputPort from quam.components import pulses @@ -27,7 +29,11 @@ from qm.qua import * -machine = LossDiVincenzoQuam.load() +# state_path = os.environ.get("QUAM_STATE_PATH") +# if not state_path: +# print("QUAM_STATE_PATH not set; skipping LD example. Set it to run.") +# raise SystemExit(0) +machine = LossDiVincenzoQuam.load("/Users/kalidu_laptop/_nodes/CS_installations/quam_state") lf_fem = 6 mw_fem = 1 @@ -35,7 +41,7 @@ xy_q1 = XYDriveMW( id="Q1_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=5, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", mw_fem, port_id=1, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 ), intermediate_frequency=10e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -43,7 +49,7 @@ xy_q2 = XYDriveMW( id="Q2_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=6, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", mw_fem, port_id=2, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 ), intermediate_frequency=12e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -51,7 +57,7 @@ xy_q3 = XYDriveMW( id="Q3_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=7, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", mw_fem, port_id=3, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 ), intermediate_frequency=13e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -59,7 +65,7 @@ xy_q4 = XYDriveMW( id="Q4_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=8, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", mw_fem, port_id=4, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 ), intermediate_frequency=14e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -72,6 +78,11 @@ # Register qubits. For ST qubits, quantum_dots should be a tuple +required_dots = {f"virtual_dot_{i}" for i in range(1, 5)} +missing = required_dots - set(machine.quantum_dots.keys()) +if missing: + print(f"Missing required quantum dots in state: {sorted(missing)}. Skipping.") + raise SystemExit(0) machine.register_qubit( qubit_name="Q1", quantum_dot_id="virtual_dot_1", @@ -103,7 +114,7 @@ # Fill out the grid location and arbitrary larmor frequencies of the qubit for i in range(1, 5): machine.qubits[f"Q{i}"].grid_location = f"0,{i}" - machine.qubits[f"Q{i}"].larmor_frequency = 5e6 + 1e6 * i + object.__setattr__(machine.qubits[f"Q{i}"], "larmor_frequency", 5e6 + 1e6 * i) ################################## ###### Register Qubit Pairs ###### @@ -162,6 +173,42 @@ replace_existing_point=True, ) +qdp1 = machine.quantum_dot_pairs["dot1_dot2_pair"] +qdp2 = machine.quantum_dot_pairs["dot3_dot4_pair"] +qdp1.add_point("empty", voltages = { + "virtual_dot_1": 0.06, + "virtual_dot_2": -0.04, + "virtual_barrier_1": 0.08 +}, duration = 1000) +qdp1.add_point("measure", voltages = { + "virtual_dot_1": -0.03, + "virtual_dot_2": 0.09, + "virtual_barrier_1": 0.07 +}, duration = 1000) +qdp1.add_point("initialize", voltages = { + "virtual_dot_1": 0.02, + "virtual_dot_2": -0.10, + "virtual_barrier_1": 0.09 +}, duration = 1000) +qdp1.sensor_dots[0]._add_readout_params(name, threshold = 0.01) + +qdp2.add_point("empty", voltages = { + "virtual_dot_3": 0.08, + "virtual_dot_4": -0.03, + "virtual_barrier_3": 0.15 +}, duration = 1000) +qdp2.add_point("measure", voltages = { + "virtual_dot_3": -0.09, + "virtual_dot_4": 0.03, + "virtual_barrier_3": 0.09 +}, duration = 1000) +qdp2.add_point("initialize", voltages = { + "virtual_dot_3": 0.05, + "virtual_dot_4": -0.06, + "virtual_barrier_3": 0.1 +}, duration = 1000) +qdp2.sensor_dots[0]._add_readout_params(name, threshold = 0.01) + # # Example QUA programme: # with program() as prog: @@ -188,7 +235,7 @@ # machine.qubits["Q3"].step_to_voltages(0.5, duration = 1000) # machine.qubits["Q4"].step_to_voltages(0.1, duration = 1000) -# # Can also use point macros saved in qubit and QD objects, inside a simultaneous block. +# # Can also use saved voltage points on qubit and QD objects inside a simultaneous block. # # Remember that no point should have repeated dict entries, as this would indicate a gate should be at two voltages at once! # with seq.simultaneous(duration = 1000): # machine.qubits["Q1"].step_to_point("initialisation") diff --git a/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py b/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py index 43db216c..ef6d0f62 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py @@ -1,3 +1,6 @@ +import os +from pathlib import Path + from quam.components import StickyChannelAddon, pulses from quam.components.ports import ( LFFEMAnalogOutputPort, @@ -13,7 +16,11 @@ import numpy as np # Instantiate Quam -machine = LossDiVincenzoQuam.load("/Users/kalidu_laptop/.qualibrate/quam_state") +state_path = Path(os.environ.get("QUAM_STATE_PATH", "/Users/kalidu_laptop/.qualibrate/quam_state")) +if not state_path.exists(): + print(f"State path not found: {state_path}. Set QUAM_STATE_PATH to run.") + raise SystemExit(0) +machine = LossDiVincenzoQuam.load(str(state_path)) ############################# ###### Register Qubits ###### diff --git a/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py b/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py index 26140a85..92e938fe 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py @@ -47,7 +47,8 @@ BarrierGate, ) from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.components.voltage_gate import VoltageGate, QdacSpec +from quam_builder.architecture.quantum_dots.components.voltage_gate import VoltageGate +from quam_builder.architecture.quantum_dots.components.dac_spec import QdacSpec from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD from quam_builder.architecture.quantum_dots.components.readout_resonator import ( ReadoutResonatorSingle, @@ -74,41 +75,56 @@ id=f"plunger_1", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=1), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p2 = VoltageGate( id=f"plunger_2", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=2), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p3 = VoltageGate( id=f"plunger_3", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=3), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p4 = VoltageGate( id=f"plunger_4", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=4), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b1 = VoltageGate( id=f"barrier_1", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=5), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b2 = VoltageGate( id=f"barrier_2", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=6), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b3 = VoltageGate( id=f"barrier_3", opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=7), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) s1 = VoltageGate( id=f"sensor_DC", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=8), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=8), sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} +) + +s2 = VoltageGate( + id=f"sensor_DC_2", + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=7), + sticky=StickyChannelAddon(duration=16, digital=False), + operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) @@ -116,20 +132,29 @@ resonator = ReadoutResonatorSingle( id="readout_resonator", frequency_bare=0, - intermediate_frequency=500e6, + intermediate_frequency=350e6, operations={"readout": readout_pulse}, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1, upsampling_mode="mw"), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=1), +) +readout_pulse2 = pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) +resonator2 = ReadoutResonatorSingle( + id="readout_resonator_2", + frequency_bare=0, + intermediate_frequency=250e6, + operations={"readout": readout_pulse2}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=2, upsampling_mode="mw"), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), ) -drain = DrainSingle( - id="drain", - opx_output=("con1", 1), # Dummy output - readout=ReadoutTransportSingle( - id="readout_transport", - opx_input=LFFEMAnalogInputPort("con1", lf_fem, port_id=2), - ), -) +# drain = DrainSingle( +# id="drain", +# opx_output=("con1", lf_fem, 1), # Dummy output +# readout=ReadoutTransportSingle( +# id="readout_transport", +# opx_input=LFFEMAnalogInputPort("con1", lf_fem, port_id=2), +# ), +# ) ##################################### @@ -148,6 +173,7 @@ "virtual_barrier_2": b2, "virtual_barrier_3": b3, "virtual_sensor_1": s1, + "virtual_sensor_2": s2, }, gate_set_id="main_qpu", ) @@ -161,20 +187,34 @@ machine.register_channel_elements( plunger_channels=[p1, p2, p3, p4], barrier_channels=[b1, b2, b3], - sensor_resonator_mappings={s1: resonator}, - sensor_transport_mappings={s1: drain}, + sensor_resonator_mappings={s1: resonator, s2: resonator2}, ) ################################################################## ###### Connect the physical channels to the external source ###### ################################################################## -qdac_connect = True +qdac_connect = False if qdac_connect: + qdac_ip = "172.16.33.101" + qdac_name = "main_QDAC" + machine.set_dac_config( + { + qdac_name: { + "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", + "driver_class": "QDac2", + "connection": {"visalib": "@py", "address": f"TCPIP::{qdac_ip}::5025::SOCKET"}, + "channel_method": "channel", + "accessor": "dc_constant_V", + "is_qdac": True, + } + } + ) # Set up the QDAC port specs for i, (ch_name, ch_obj) in enumerate(machine.physical_channels.items()): if isinstance(ch_obj, VoltageGate): - ch_obj.qdac_spec = QdacSpec( + ch_obj.dac_spec = QdacSpec( + dac_name=qdac_name, opx_trigger_out=Channel( id=f"{ch_name}_qdac_trigger", digital_outputs={ @@ -187,9 +227,7 @@ qdac_output_port=i + 1, ) - qdac_ip = "172.16.33.101" - machine.network.update({"qdac_ip": qdac_ip}) - machine.connect_to_external_source(external_qdac=True) + machine.connect_to_external_source() machine.create_virtual_dc_set("main_qpu") ######################################## @@ -207,7 +245,7 @@ machine.register_quantum_dot_pair( id="dot3_dot4_pair", quantum_dot_ids=["virtual_dot_3", "virtual_dot_4"], - sensor_dot_ids=["virtual_sensor_1"], + sensor_dot_ids=["virtual_sensor_2"], barrier_gate_id="virtual_barrier_3", ) @@ -244,6 +282,8 @@ target="opx", ) +machine.save("/Users/kalidu_laptop/_nodes/CS_installations/quam_state") + ########################### ###### Example Usage ###### ########################### @@ -256,40 +296,40 @@ # In this example, we purposefully keep all the barrier and sensor voltages identical, so that they can be initialised together, and no gate should hold two voltages at once. -machine.quantum_dots["virtual_dot_1"].add_point( - point_name="loading", - voltages={ - "virtual_dot_1": 0.1, - "virtual_barrier_1": 0.4, - "virtual_barrier_2": 0.45, - "virtual_barrier_3": 0.42, - "virtual_sensor_1": 0.15, - }, -) - -machine.quantum_dots["virtual_dot_2"].add_point( - point_name="loading", - voltages={ - "virtual_dot_2": 0.15, - "virtual_barrier_1": 0.4, - "virtual_barrier_2": 0.45, - "virtual_barrier_3": 0.42, - "virtual_sensor_1": 0.15, - }, -) - -# We can also initialise a tuning point for a qubit pair: -machine.quantum_dot_pairs["dot3_dot4_pair"].add_point( - point_name="some_detuning_points", - voltages={ - "virtual_dot_3": 0.2, - "virtual_dot_4": 0.25, - "virtual_barrier_1": 0.4, - "virtual_barrier_2": 0.45, - "virtual_barrier_3": 0.42, - "virtual_sensor_1": 0.15, - }, -) +# machine.quantum_dots["virtual_dot_1"].add_point( +# point_name="loading", +# voltages={ +# "virtual_dot_1": 0.1, +# "virtual_barrier_1": 0.4, +# "virtual_barrier_2": 0.45, +# "virtual_barrier_3": 0.42, +# "virtual_sensor_1": 0.15, +# }, +# ) + +# machine.quantum_dots["virtual_dot_2"].add_point( +# point_name="loading", +# voltages={ +# "virtual_dot_2": 0.15, +# "virtual_barrier_1": 0.4, +# "virtual_barrier_2": 0.45, +# "virtual_barrier_3": 0.42, +# "virtual_sensor_1": 0.15, +# }, +# ) + +# # We can also initialise a tuning point for a qubit pair: +# machine.quantum_dot_pairs["dot3_dot4_pair"].add_point( +# point_name="some_detuning_points", +# voltages={ +# "virtual_dot_3": 0.2, +# "virtual_dot_4": 0.25, +# "virtual_barrier_1": 0.4, +# "virtual_barrier_2": 0.45, +# "virtual_barrier_3": 0.42, +# "virtual_sensor_1": 0.15, +# }, +# ) # # Example QUA programme: diff --git a/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py b/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py index 8298d8ce..5df571b4 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py @@ -31,6 +31,8 @@ """ +import os + from quam.components import StickyChannelAddon, pulses from quam.components.ports import ( LFFEMAnalogOutputPort, @@ -38,7 +40,7 @@ MWFEMAnalogOutputPort, ) -from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDrive, QPU +from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveMW, QPU from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam from quam_builder.architecture.quantum_dots.components import ReadoutResonatorSingle from qm.qua import * @@ -48,53 +50,80 @@ # Instantiate Quam machine = LossDiVincenzoQuam() machine.qpu = QPU() -lf_fem_dots = 6 -lf_fem_resonators = 5 -mw_fem = 1 -plunger_gates = 6 + +try: + from configs import ( # isort:skip + LF_FEM_DOTS, + LF_FEM_RESONATORS, + MW_FEM, + PLUNGER_GATES, + SENSOR_GATES, + RESONATORS, + ) +except ModuleNotFoundError: + LF_FEM_DOTS = 6 + LF_FEM_RESONATORS = 5 + MW_FEM = 1 + PLUNGER_GATES = 6 + SENSOR_GATES = 2 + RESONATORS = 2 + +lf_fem_dots = LF_FEM_DOTS +lf_fem_resonators = LF_FEM_RESONATORS +mw_fem = MW_FEM +plunger_gates = PLUNGER_GATES barrier_gates = plunger_gates - 1 -sensor_gates = 2 -resonators = 2 +sensor_gates = SENSOR_GATES +resonators = RESONATORS ########################################### ###### Instantiate Physical Channels ###### ########################################### -next_port_id = 0 - -ps = [ - VoltageGate( - id=f"plunger_{i}", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem_dots, port_id=i + next_port_id), - sticky=StickyChannelAddon(duration=16, digital=False), - ) - for i in range(plunger_gates) -] +lf_fem_slots = [lf_fem_dots] +if lf_fem_resonators not in lf_fem_slots: + lf_fem_slots.append(lf_fem_resonators) -next_port_id += plunger_gates -bs = [ - VoltageGate( - id=f"barrier_{i}", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem_dots, port_id=i + next_port_id), - sticky=StickyChannelAddon(duration=16, digital=False), - ) - for i in range(barrier_gates) -] +def _alloc_lf_port(index: int) -> tuple[int, int]: + slot = lf_fem_slots[index // 8] + port = index % 8 + 1 + return slot, port -next_port_id += barrier_gates -ss = [ - VoltageGate( - id=f"sensor_{i}", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem_dots, port_id=i + next_port_id), - sticky=StickyChannelAddon(duration=16, digital=False), +ps = [] +for i in range(plunger_gates): + slot, port = _alloc_lf_port(i) + ps.append( + VoltageGate( + id=f"plunger_{i}", + opx_output=LFFEMAnalogOutputPort("con1", slot, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) ) - for i in range(sensor_gates) -] -next_port_id += sensor_gates +bs = [] +offset = plunger_gates +for i in range(barrier_gates): + slot, port = _alloc_lf_port(offset + i) + bs.append( + VoltageGate( + id=f"barrier_{i}", + opx_output=LFFEMAnalogOutputPort("con1", slot, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + ) -next_port_id = 0 +ss = [] +offset += barrier_gates +for i in range(sensor_gates): + slot, port = _alloc_lf_port(offset + i) + ss.append( + VoltageGate( + id=f"sensor_{i}", + opx_output=LFFEMAnalogOutputPort("con1", slot, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + ) rs = [ ReadoutResonatorSingle( @@ -102,13 +131,12 @@ frequency_bare=0, intermediate_frequency=500e6, operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, - opx_output=LFFEMAnalogOutputPort("con1", lf_fem_resonators, port_id=i * 2 + next_port_id), - opx_input=LFFEMAnalogInputPort("con1", lf_fem_resonators, port_id=i * 2 + 1 + next_port_id), + opx_output=LFFEMAnalogOutputPort("con1", lf_fem_resonators, port_id=i + 1), + opx_input=LFFEMAnalogInputPort("con1", lf_fem_resonators, port_id=i + 1), sticky=StickyChannelAddon(duration=16, digital=False), ) for i in range(resonators) ] -next_port_id += resonators * 2 ##################################### @@ -165,7 +193,7 @@ # Register one qubit per dot mw_start_port = 1 for i in range(plunger_gates): - xy_drive = XYDrive( + xy_drive = XYDriveMW( id=f"Q{i}_xy", opx_output=MWFEMAnalogOutputPort( "con1", @@ -215,6 +243,9 @@ config = machine.generate_config() machine.network = {"host": "172.16.33.115", "cluster_name": "CS_3"} -machine.connect() -config_path = "config" -machine.save(config_path) +if os.environ.get("QUAM_CONNECT") == "1": + machine.connect() + config_path = "config" + machine.save(config_path) +else: + print("Skipping machine.connect/save. Set QUAM_CONNECT=1 to enable.") diff --git a/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py b/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py index 30dd6915..6be17872 100644 --- a/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py +++ b/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py @@ -31,6 +31,8 @@ - Voltage sequence with compensation pulse capability """ +import json +from pathlib import Path from typing import List, Tuple from qm.qua import ( program, @@ -54,10 +56,7 @@ from quam.components.channels import StickyChannelAddon from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam -from quam_builder.architecture.quantum_dots.components import ( - VoltageGate, - XYDrive, -) +from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveMW from quam_builder.architecture.quantum_dots.components.readout_resonator import ( ReadoutResonatorSingle, ) @@ -165,7 +164,7 @@ def create_minimal_machine() -> LossDiVincenzoQuam: # XY Drive Channel # ------------------------------------------------------------------------- # Microwave channel for qubit rotations - xy_drive = XYDrive( + xy_drive = XYDriveMW( id="Q1_xy", opx_output=MWFEMAnalogOutputPort( controller_id=controller, @@ -176,7 +175,6 @@ def create_minimal_machine() -> LossDiVincenzoQuam: full_scale_power_dbm=10, ), intermediate_frequency=100e6, - add_default_pulses=True, ) # Add a variable-length drive pulse for Rabi experiments length = 100 @@ -235,7 +233,7 @@ def create_minimal_machine() -> LossDiVincenzoQuam: def register_qubit_with_points( machine: LossDiVincenzoQuam, - xy_drive: XYDrive, + xy_drive: XYDriveMW, ) -> LDQubit: """ Register an LDQubit and define voltage points for the experiment. @@ -264,29 +262,23 @@ def register_qubit_with_points( qubit = machine.qubits["Q1"] # ------------------------------------------------------------------------- - # Define Voltage Points using Fluent API + # Define voltage points explicitly # ------------------------------------------------------------------------- # Note: All durations must be multiples of 4ns (OPX clock cycles) - ( - qubit - # Init point: Load electron into dot at low voltage - .with_step_point( - name="init", - voltages={"virtual_dot_1": 0.05}, - duration=500, # 500ns hold time - ) - # Operate point: Move to manipulation sweet spot - .with_step_point( - name="operate", - voltages={"virtual_dot_1": 0.15}, - duration=2000, # 2us hold time (will be overridden by drive duration) - ) - # Readout point: Configure for PSB readout - .with_step_point( - name="readout", - voltages={"virtual_dot_1": -0.05}, - duration=2000, # 2us readout window - ) + qubit.add_point( + point_name="init", + voltages={"virtual_dot_1": 0.05}, + duration=500, # 500ns hold time + ) + qubit.add_point( + point_name="operate", + voltages={"virtual_dot_1": 0.15}, + duration=2000, # 2us hold time (will be overridden by drive duration) + ) + qubit.add_point( + point_name="readout", + voltages={"virtual_dot_1": -0.05}, + duration=2000, # 2us readout window ) return qubit @@ -301,9 +293,9 @@ def add_qubit_macros(qubit: LDQubit): """ Add drive and measure macros to the qubit using the QuamMacro pattern. - This follows the recommended approach from macro_examples.py where macros - are defined as QuamMacro subclasses and registered in qubit.macros. This - allows them to be called as methods (e.g., qubit.drive(duration=t)). + This follows the core QuAM pattern where custom QuamMacro subclasses are + registered in qubit.macros and then called as methods (for example, + qubit.drive(duration=t)). Macros defined: - DriveMacro: Applies MW pulse with optional duration override (for Rabi sweep) @@ -338,6 +330,16 @@ class DriveMacro(QuamMacro): pulse_name: str = "drive" amplitude_scale: float = None + @property + def inferred_duration(self) -> float | None: + """Default drive duration in seconds when using the configured pulse length.""" + try: + parent_qubit = self.parent.parent + pulse = parent_qubit.xy.operations[self.pulse_name] + return pulse.length * 1e-9 + except Exception: + return None + def apply(self, **kwargs): """ Apply a microwave drive pulse to the qubit's XY. @@ -382,6 +384,28 @@ class MeasureMacro(QuamMacro): pulse_name: str = "readout" + @property + def inferred_duration(self) -> float | None: + """Readout macro duration in seconds (wait + pulse) when resolvable.""" + try: + parent_qubit = self.parent.parent + machine = parent_qubit.machine + qd_pair_id = machine.find_quantum_dot_pair( + parent_qubit.quantum_dot.id, parent_qubit.preferred_readout_quantum_dot + ) + if ( + qd_pair_id is not None + and qd_pair_id in machine.quantum_dot_pairs + and machine.quantum_dot_pairs[qd_pair_id].sensor_dots + ): + sensor_dot = machine.quantum_dot_pairs[qd_pair_id].sensor_dots[0] + else: + sensor_dot = next(iter(machine.sensor_dots.values())) + readout_pulse = sensor_dot.readout_resonator.operations[self.pulse_name] + return (64 * 4 + readout_pulse.length) * 1e-9 + except Exception: + return None + def apply(self, **kwargs) -> Tuple: """ Perform demodulated measurement and return I, Q values. @@ -397,7 +421,18 @@ def apply(self, **kwargs) -> Tuple: # Navigate to qubit and get the associated sensor dot parent_qubit = self.parent.parent - sensor_dot = parent_qubit.sensor_dots[0] + machine = parent_qubit.machine + qd_pair_id = machine.find_quantum_dot_pair( + parent_qubit.quantum_dot.id, parent_qubit.preferred_readout_quantum_dot + ) + if ( + qd_pair_id is not None + and qd_pair_id in machine.quantum_dot_pairs + and machine.quantum_dot_pairs[qd_pair_id].sensor_dots + ): + sensor_dot = machine.quantum_dot_pairs[qd_pair_id].sensor_dots[0] + else: + sensor_dot = next(iter(machine.sensor_dots.values())) # Wait for transients, then perform measurement sensor_dot.readout_resonator.wait(64) @@ -559,14 +594,25 @@ def setup_rabi_chevron_experiment(): # ------------------------------------------------------------------------- # Cloud Simulator Configuration (QM SaaS Dev Server) # ------------------------------------------------------------------------- - EMAIL = "email" - PASSWORD = "password" print("\nConnecting to QM SaaS cloud simulator...") + + # Load SaaS credentials from config file + repo_root = Path(__file__).resolve().parents[4] + saas_config_path = repo_root / ".qm_saas_credentials.json" + if not saas_config_path.exists(): + raise FileNotFoundError( + f"SaaS credentials not found at {saas_config_path}. " + "Copy .qm_saas_credentials.json.example to .qm_saas_credentials.json " + "and fill in your credentials." + ) + with open(saas_config_path) as f: + saas_credentials = json.load(f) + client = qm_saas.QmSaas( - email=EMAIL, - password=PASSWORD, - host="qm-saas.dev.quantum-machines.co", + email=saas_credentials["email"], + password=saas_credentials["password"], + host=saas_credentials.get("host", "qm-saas.dev.quantum-machines.co"), ) print("Connected to QM SaaS cloud simulator...") diff --git a/quam_builder/architecture/quantum_dots/examples/rabi_chevron_transport.py b/quam_builder/architecture/quantum_dots/examples/rabi_chevron_transport.py index 98df3370..e03cc505 100644 --- a/quam_builder/architecture/quantum_dots/examples/rabi_chevron_transport.py +++ b/quam_builder/architecture/quantum_dots/examples/rabi_chevron_transport.py @@ -187,7 +187,6 @@ def create_minimal_machine() -> LossDiVincenzoQuam: full_scale_power_dbm=10, ), intermediate_frequency=100e6, - add_default_pulses=True, ) # Add a variable-length drive pulse for Rabi experiments length = 100 @@ -275,29 +274,23 @@ def register_qubit_with_points( qubit = machine.qubits["Q1"] # ------------------------------------------------------------------------- - # Define Voltage Points using Fluent API + # Define voltage points explicitly # ------------------------------------------------------------------------- # Note: All durations must be multiples of 4ns (OPX clock cycles) - ( - qubit - # Init point: Load electron into dot at low voltage - .with_step_point( - name="init", - voltages={"virtual_dot_1": 0.05}, - duration=500, # 500ns hold time - ) - # Operate point: Move to manipulation sweet spot - .with_step_point( - name="operate", - voltages={"virtual_dot_1": 0.15}, - duration=2000, # 2us hold time (will be overridden by drive duration) - ) - # Readout point: Configure for PSB readout - .with_step_point( - name="readout", - voltages={"virtual_dot_1": -0.05, "virtual_sensor_1": 0.1}, - duration=2000, # 2us readout window - ) + qubit.add_point( + point_name="init", + voltages={"virtual_dot_1": 0.05}, + duration=500, # 500ns hold time + ) + qubit.add_point( + point_name="operate", + voltages={"virtual_dot_1": 0.15}, + duration=2000, # 2us hold time (will be overridden by drive duration) + ) + qubit.add_point( + point_name="readout", + voltages={"virtual_dot_1": -0.05, "virtual_sensor_1": 0.1}, + duration=2000, # 2us readout window ) return qubit @@ -312,9 +305,9 @@ def add_qubit_macros(qubit: LDQubit): """ Add drive and measure macros to the qubit using the QuamMacro pattern. - This follows the recommended approach from macro_examples.py where macros - are defined as QuamMacro subclasses and registered in qubit.macros. This - allows them to be called as methods (e.g., qubit.drive(duration=t)). + This follows the core QuAM pattern where custom QuamMacro subclasses are + registered in qubit.macros and then called as methods (for example, + qubit.drive(duration=t)). Macros defined: - DriveMacro: Applies MW pulse with optional duration override (for Rabi sweep) diff --git a/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py b/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py new file mode 100644 index 00000000..c474b759 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py @@ -0,0 +1,118 @@ +"""Shared machine builder for the macro customization tutorial. + +Provides a minimal LossDiVincenzoQuam with quantum_dots, quantum_dot_pairs, +sensor_dots, qubits, and qubit_pairs. Includes voltage step points so default +state macros can run. Does NOT call wire_machine_macros — that is done by +the tutorial notebook. +""" + +from __future__ import annotations + +from typing import Dict + +from quam.components import StickyChannelAddon, pulses +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.operations.names import VoltagePointName +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam + + +def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def build_tutorial_machine() -> LossDiVincenzoQuam: + """Build a minimal machine for the macro customization tutorial. + + Returns a LossDiVincenzoQuam with: + - 2 quantum dots, 1 quantum dot pair, 1 sensor dot + - 2 qubits, 1 qubit pair + - Voltage gates, readout resonator, virtual gate set + - Voltage step points (initialize, measure, empty) for state macros + + Does not call wire_machine_macros. The caller (tutorial/script) does that. + """ + machine = LossDiVincenzoQuam() + lf = 6 + + p1 = _make_voltage_gate(lf, 1, "plunger_1") + p2 = _make_voltage_gate(lf, 2, "plunger_2") + b1 = _make_voltage_gate(lf, 5, "barrier_1") + b2 = _make_voltage_gate(lf, 6, "barrier_2") + s1 = _make_voltage_gate(lf, 8, "sensor_DC") + + resonator = ReadoutResonatorSingle( + id="readout_resonator", + frequency_bare=0, + intermediate_frequency=500e6, + operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "virtual_dot_1": p1, + "virtual_dot_2": p2, + "virtual_barrier_1": b1, + "virtual_barrier_2": b2, + "virtual_sensor_1": s1, + }, + gate_set_id="main_qpu", + ) + + machine.register_channel_elements( + plunger_channels=[p1, p2], + barrier_channels=[b1, b2], + sensor_resonator_mappings={s1: resonator}, + ) + + machine.register_qubit(quantum_dot_id="virtual_dot_1", qubit_name="Q1") + machine.register_qubit(quantum_dot_id="virtual_dot_2", qubit_name="Q2") + + machine.register_quantum_dot_pair( + id="dot1_dot2_pair", + quantum_dot_ids=["virtual_dot_1", "virtual_dot_2"], + sensor_dot_ids=["virtual_sensor_1"], + barrier_gate_id="virtual_barrier_2", + ) + + machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( + matrix=[[1, -1]], + detuning_axis_name="dot1_dot2_epsilon", + set_dc_virtual_axis=False, + ) + + machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") + + machine.reset_voltage_sequence("main_qpu") + _add_tutorial_state_points(machine) + return machine + + +def _add_tutorial_state_points(machine: LossDiVincenzoQuam) -> None: + """Add voltage step points so default state macros can run.""" + for qubit in machine.qubits.values(): + dot_id = qubit.quantum_dot.id + qubit.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) + qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=200) + qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=200) + + # Pairs share voltage sequence with dots; add points for both dots + for pair in machine.quantum_dot_pairs.values(): + dot_ids = [qd.id for qd in pair.quantum_dots] + v_init: Dict[str, float] = {d: 0.10 for d in dot_ids} + v_meas: Dict[str, float] = {d: 0.15 for d in dot_ids} + v_empty: Dict[str, float] = {d: 0.00 for d in dot_ids} + pair.add_point(VoltagePointName.INITIALIZE, v_init, duration=200) + pair.add_point(VoltagePointName.MEASURE, v_meas, duration=200) + pair.add_point(VoltagePointName.EMPTY, v_empty, duration=200) diff --git a/quam_builder/architecture/quantum_dots/examples/virtual_dc_set_example.py b/quam_builder/architecture/quantum_dots/examples/virtual_dc_set_example.py index 638c78b0..e2145fd7 100644 --- a/quam_builder/architecture/quantum_dots/examples/virtual_dc_set_example.py +++ b/quam_builder/architecture/quantum_dots/examples/virtual_dc_set_example.py @@ -1,41 +1,43 @@ """ -This is an example script on how to instantiate a QPU which contains Loss-DiVincenzo qubits, with other barrier gates and sensor dots. +This is an example script on how to instantiate a QPU which contains Loss-DiVincenzo qubits, with other barrier gates and sensor dots. -Workflow: +Workflow: -1. Instantiate your machine. +1. Instantiate your machine. -2. Instantiate the base hardware channels for the machine. +2. Instantiate the base hardware channels for the machine. - In this example, arbitrary HW gates are created as VoltageGates. For QuantumDots and SensorDots, the base channel must be VoltageGate and sticky. They are instantiated in a mapping dictionary to be input into the machine -3. Create your VirtualGateSet. You do not need to manually add all the channels, the function create_virtual_gate_set should do it automatically. - Ensure that the mapping of the desired virtual gate to the relevant HW channel is correct, as the QuantumDot names will be extracted from this input dict. +3. Create your VirtualGateSet. You do not need to manually add all the channels, the function create_virtual_gate_set should do it automatically. + Ensure that the mapping of the desired virtual gate to the relevant HW channel is correct, as the QuantumDot names will be extracted from this input dict. -4. Register your components. - - Register the relevant QuantumDots, SensorDots and BarrierGates, mapped correctly to the relevant output channel. As long as the channel is correctly mapped, +4. Register your components. + - Register the relevant QuantumDots, SensorDots and BarrierGates, mapped correctly to the relevant output channel. As long as the channel is correctly mapped, the name of the element will be made consistent to that in the VirtualGateSet 5. Create your QUA programme - - For simultaneous stepping/ramping, use either + - For simultaneous stepping/ramping, use either sequence = machine.voltage_sequences[gate_set_id] sequence.step_to_voltages({"virtual_dot_1": ..., "virtual_dot_2": ...}) - or use sequence.simultaneous: - with sequence.simultaneous(duration = ...): + or use sequence.simultaneous: + with sequence.simultaneous(duration = ...): machine.qubits["virtual_dot_1"].step_to_voltages(...) machine.qubits["virtual_dot_2"].step_to_voltages(...) """ +import os + from quam.components import ( - StickyChannelAddon, - pulses -) + StickyChannelAddon, + pulses, +) from quam.components.ports import ( - LFFEMAnalogOutputPort, + LFFEMAnalogOutputPort, LFFEMAnalogInputPort, MWFEMAnalogOutputPort, - MWFEMAnalogInputPort + MWFEMAnalogInputPort, ) from quam_builder.architecture.quantum_dots.components import VoltageGate @@ -49,24 +51,72 @@ ########################################### ###### Instantiate Physical Channels ###### ########################################### -from qcodes import Instrument -from qcodes_contrib_drivers.drivers.QDevil.QDAC2 import QDac2 +if os.environ.get("QUAM_QDAC") != "1": + print("Skipping QDAC example. Set QUAM_QDAC=1 to run.") + raise SystemExit(0) + +try: + from qcodes import Instrument + from qcodes_contrib_drivers.drivers.QDevil.QDAC2 import QDac2 +except ImportError: + print("QCoDeS/QDAC drivers not installed; skipping QDAC example.") + raise SystemExit(0) qdac_ip = "172.16.33.101" lf_fem = 5 name = "QDAC" try: qdac = Instrument.find_instrument(name) except KeyError: - qdac = QDac2(name, visalib='@py', address=f'TCPIP::{qdac_ip}::5025::SOCKET') + qdac = QDac2(name, visalib="@py", address=f"TCPIP::{qdac_ip}::5025::SOCKET") -p1 = VoltageGate(id = f"plunger_1", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 1), qdac_channel = 1, sticky = StickyChannelAddon(duration = 16, digital = False)) -p2 = VoltageGate(id = f"plunger_2", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 2), qdac_channel = 2, sticky = StickyChannelAddon(duration = 16, digital = False)) -p3 = VoltageGate(id = f"plunger_3", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 3), qdac_channel = 3, sticky = StickyChannelAddon(duration = 16, digital = False)) -p4 = VoltageGate(id = f"plunger_4", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 4), qdac_channel = 4, sticky = StickyChannelAddon(duration = 16, digital = False)) -b1 = VoltageGate(id = f"barrier_1", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 5), qdac_channel = 5, sticky = StickyChannelAddon(duration = 16, digital = False)) -b2 = VoltageGate(id = f"barrier_2", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 6), qdac_channel = 6, sticky = StickyChannelAddon(duration = 16, digital = False)) -b3 = VoltageGate(id = f"barrier_3", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 7), qdac_channel = 7, sticky = StickyChannelAddon(duration = 16, digital = False)) -s1 = VoltageGate(id = f"sensor_DC", opx_output = LFFEMAnalogOutputPort("con1", lf_fem, port_id = 8), qdac_channel = 8, sticky = StickyChannelAddon(duration = 16, digital = False)) +p1 = VoltageGate( + id=f"plunger_1", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=1), + qdac_channel=1, + sticky=StickyChannelAddon(duration=16, digital=False), +) +p2 = VoltageGate( + id=f"plunger_2", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=2), + qdac_channel=2, + sticky=StickyChannelAddon(duration=16, digital=False), +) +p3 = VoltageGate( + id=f"plunger_3", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=3), + qdac_channel=3, + sticky=StickyChannelAddon(duration=16, digital=False), +) +p4 = VoltageGate( + id=f"plunger_4", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=4), + qdac_channel=4, + sticky=StickyChannelAddon(duration=16, digital=False), +) +b1 = VoltageGate( + id=f"barrier_1", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=5), + qdac_channel=5, + sticky=StickyChannelAddon(duration=16, digital=False), +) +b2 = VoltageGate( + id=f"barrier_2", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=6), + qdac_channel=6, + sticky=StickyChannelAddon(duration=16, digital=False), +) +b3 = VoltageGate( + id=f"barrier_3", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=7), + qdac_channel=7, + sticky=StickyChannelAddon(duration=16, digital=False), +) +s1 = VoltageGate( + id=f"sensor_DC", + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=8), + qdac_channel=8, + sticky=StickyChannelAddon(duration=16, digital=False), +) p1.offset_parameter = qdac.channel(p1.qdac_channel).dc_constant_V p2.offset_parameter = qdac.channel(p2.qdac_channel).dc_constant_V @@ -81,47 +131,47 @@ from quam_builder.architecture.quantum_dots.components import VirtualDCSet virtual_dc_set = VirtualDCSet( - id = "Dots DC", - channels = { - "plunger_1": p1, - "plunger_2": p2, - "plunger_3": p3, - "plunger_4": p4, - "barrier_1": b1, - "barrier_2": b2, - "barrier_3": b3, - "sensor_DC": s1 - } + id="Dots DC", + channels={ + "plunger_1": p1, + "plunger_2": p2, + "plunger_3": p3, + "plunger_4": p4, + "barrier_1": b1, + "barrier_2": b2, + "barrier_3": b3, + "sensor_DC": s1, + }, ) virtual_dc_set.add_layer( - layer_id = "cross_compensation", - source_gates = ["VP1", "VP2", "VP3", "VP4"], - target_gates = ["plunger_1", "plunger_2", "plunger_3", "plunger_4"], - matrix = [ - [1, 0.2, 0, 0.3], - [0.5, 1, 0.7, 0], - [0.3, 0, 1, 0.6], + layer_id="cross_compensation", + source_gates=["VP1", "VP2", "VP3", "VP4"], + target_gates=["plunger_1", "plunger_2", "plunger_3", "plunger_4"], + matrix=[ + [1, 0.2, 0, 0.3], + [0.5, 1, 0.7, 0], + [0.3, 0, 1, 0.6], [0, 0.1, 0.3, 1], - ] + ], ) virtual_dc_set.add_layer( - layer_id = "detuning_1", - source_gates = ["det_1", "det_2"], - target_gates = ["VP1", "VP2"], - matrix = [ - [0.8, 0.5], - [0.3, 0.7], - ] + layer_id="detuning_1", + source_gates=["det_1", "det_2"], + target_gates=["VP1", "VP2"], + matrix=[ + [0.8, 0.5], + [0.3, 0.7], + ], ) virtual_dc_set.add_layer( - layer_id = "detuning_2", - source_gates = ["det_3", "det_4"], - target_gates = ["VP3", "VP4"], - matrix = [ - [0.7, 0.4], - [0.6, 0.9], - ] + layer_id="detuning_2", + source_gates=["det_3", "det_4"], + target_gates=["VP3", "VP4"], + matrix=[ + [0.7, 0.4], + [0.6, 0.9], + ], ) diff --git a/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py b/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py index 93d1e2b2..fdb18927 100644 --- a/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py +++ b/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py @@ -1,6 +1,6 @@ - import numpy as np +import os import matplotlib import matplotlib.pyplot as plt from quam.components.channels import SingleChannel @@ -9,13 +9,15 @@ VirtualGateSet, ) + def _channels(names): return { - name: SingleChannel(id=name, opx_output=("con", idx + 1)) - for idx, name in enumerate(names) + name: SingleChannel(id=name, opx_output=("con", idx + 1)) for idx, name in enumerate(names) } -matplotlib.use('TkAgg') + +if not os.environ.get("MPLBACKEND"): + matplotlib.use("Agg") gate_set = VirtualGateSet( id="rectangular_roundtrip", channels=_channels(["P1", "P2", "P3"]), @@ -52,9 +54,7 @@ def _channels(names): resolved = gate_set.resolve_voltages( {gate: value for gate, value in zip(source_gates, source_vector)} ) - resolved_samples.append( - np.array([resolved["P1"], resolved["P2"], resolved["P3"]]) - ) + resolved_samples.append(np.array([resolved["P1"], resolved["P2"], resolved["P3"]])) resolved_samples = np.array(resolved_samples) np.testing.assert_allclose(resolved_samples, physical_samples, rtol=1e-9, atol=1e-9) @@ -128,4 +128,4 @@ def _channels(names): ax2.set_title("Tall matrix resolution check") ax2.legend() fig2.tight_layout() -plt.show() \ No newline at end of file +plt.show() diff --git a/quam_builder/architecture/quantum_dots/macro_engine/__init__.py b/quam_builder/architecture/quantum_dots/macro_engine/__init__.py new file mode 100644 index 00000000..6e53f4ab --- /dev/null +++ b/quam_builder/architecture/quantum_dots/macro_engine/__init__.py @@ -0,0 +1,24 @@ +"""Macro runtime entry points for quantum-dot components. + +Exports: + load_macro_profile: Read TOML macro profile data. + wire_machine_macros: Materialize defaults and apply user overrides. + macro: Create a macro override entry with early validation. + disabled: Create a disabled override entry (removes macro/pulse). + pulse: Create a pulse override entry. + overrides: Create a ComponentOverrides grouping macros and pulses. + ComponentOverrides: Typed container for macro and pulse overrides. +""" + +from .wiring import load_macro_profile, wire_machine_macros +from .overrides import macro, disabled, pulse, overrides, ComponentOverrides + +__all__ = [ + "load_macro_profile", + "wire_machine_macros", + "macro", + "disabled", + "pulse", + "overrides", + "ComponentOverrides", +] diff --git a/quam_builder/architecture/quantum_dots/macro_engine/overrides.py b/quam_builder/architecture/quantum_dots/macro_engine/overrides.py new file mode 100644 index 00000000..391db008 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/macro_engine/overrides.py @@ -0,0 +1,173 @@ +"""Typed helpers for macro and pulse override construction. + +These helpers replace raw nested dicts with validated, IDE-friendly calls. +They produce the same internal format consumed by :func:`wire_machine_macros`, +so raw dicts still work for backward compatibility and TOML profiles. + +Usage:: + + from quam_builder.architecture.quantum_dots.macro_engine.overrides import ( + macro, disabled, pulse, overrides, + ) + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), + }), + }, + instance_overrides={ + "qubits.q2": overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), + SingleQubitMacroName.X_180: disabled(), + }), + }, + ) +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +from quam.core.macro import QuamMacro + +__all__ = [ + "macro", + "disabled", + "pulse", + "overrides", + "ComponentOverrides", +] + + +def macro(factory: type[QuamMacro] | str, **params: Any) -> dict[str, Any]: + """Create a macro override entry with early validation. + + Args: + factory: A ``QuamMacro`` subclass or ``"module.path:ClassName"`` string. + **params: Keyword arguments forwarded to the macro constructor or set + as attributes after construction. + + Returns: + Override dict in the format expected by :func:`wire_machine_macros`. + + Raises: + TypeError: If *factory* is not a QuamMacro subclass or import string. + """ + if isinstance(factory, type): + if not issubclass(factory, QuamMacro): + raise TypeError(f"Macro factory must be a QuamMacro subclass, got {factory.__name__}.") + elif not isinstance(factory, str): + raise TypeError( + f"Macro factory must be a QuamMacro subclass or 'module:Class' string, " + f"got {type(factory).__name__}." + ) + entry: dict[str, Any] = {"factory": factory} + if params: + entry["params"] = params + return entry + + +def disabled() -> dict[str, Any]: + """Create a disabled override entry that removes the macro or pulse.""" + return {"enabled": False} + + +def pulse(pulse_type: str, **params: Any) -> dict[str, Any]: + """Create a pulse override entry. + + Args: + pulse_type: Pulse class name (``"GaussianPulse"``, ``"SquarePulse"``, + ``"SquareReadoutPulse"``, ``"DragPulse"``). + **params: Pulse constructor keyword arguments (``length``, ``amplitude``, + ``sigma``, etc.). + + Returns: + Override dict in the format expected by pulse wiring. + """ + return {"type": pulse_type, **params} + + +@dataclass(frozen=True) +class ComponentOverrides: + """Typed container grouping macro and pulse overrides for one component.""" + + macros: dict[str, dict[str, Any]] = field(default_factory=dict) + pulses: dict[str, dict[str, Any]] = field(default_factory=dict) + + def _to_dict(self) -> dict[str, Any]: + """Convert to the internal dict format.""" + result: dict[str, Any] = {} + if self.macros: + result["macros"] = dict(self.macros) + if self.pulses: + result["pulses"] = dict(self.pulses) + return result + + +def overrides( + macros: dict[str, dict[str, Any]] | None = None, + pulses: dict[str, dict[str, Any]] | None = None, +) -> ComponentOverrides: + """Create a :class:`ComponentOverrides` for a component type or instance. + + Args: + macros: Macro name -> override entry mapping (use :func:`macro` / + :func:`disabled` to build entries). + pulses: Pulse name -> override entry mapping (use :func:`pulse` / + :func:`disabled` to build entries). + """ + return ComponentOverrides( + macros=macros or {}, + pulses=pulses or {}, + ) + + +def _convert_typed_overrides( + component_overrides: Mapping[type | str, ComponentOverrides | Mapping] | None, + instance_overrides: Mapping[str, ComponentOverrides | Mapping] | None, +) -> dict[str, Any]: + """Convert typed override kwargs to the internal dict format. + + Accepts class objects or strings as component type keys, and + ``ComponentOverrides`` or raw dicts as values. + """ + result: dict[str, Any] = {} + + if component_overrides: + types_dict: dict[str, Any] = {} + for key, value in component_overrides.items(): + type_name = key.__name__ if isinstance(key, type) else str(key) + if isinstance(value, ComponentOverrides): + types_dict[type_name] = value._to_dict() + elif isinstance(value, Mapping): + types_dict[type_name] = dict(value) + else: + raise TypeError( + f"component_overrides[{type_name}] must be a ComponentOverrides " + f"or mapping, got {type(value).__name__}." + ) + result["component_types"] = types_dict + + if instance_overrides: + instances_dict: dict[str, Any] = {} + for path, value in instance_overrides.items(): + if not isinstance(path, str): + raise TypeError( + f"Instance override keys must be strings, got {type(path).__name__}." + ) + if isinstance(value, ComponentOverrides): + instances_dict[path] = value._to_dict() + elif isinstance(value, Mapping): + instances_dict[path] = dict(value) + else: + raise TypeError( + f"instance_overrides[{path!r}] must be a ComponentOverrides " + f"or mapping, got {type(value).__name__}." + ) + result["instances"] = instances_dict + + return result diff --git a/quam_builder/architecture/quantum_dots/macro_engine/wiring.py b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py new file mode 100644 index 00000000..d753d92e --- /dev/null +++ b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py @@ -0,0 +1,541 @@ +"""Macro and pulse wiring with user override utilities for quantum-dot machines. + +This module is the runtime entry point for: +1. Materializing component defaults from the component-type registry. +2. Applying user overrides from a TOML profile and/or Python mapping. +3. Supporting partial overrides while retaining all untouched defaults. +4. Wiring default pulses onto qubit XY drives and sensor dot resonators. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import importlib +import inspect +from pathlib import Path +from typing import Any +import tomllib + +from quam.core.macro import QuamMacro + +from quam_builder.architecture.quantum_dots.operations.macro_registry import ( + get_default_macro_factories, +) +from quam_builder.architecture.quantum_dots.operations.component_macro_catalog import ( + register_default_component_macro_factories, +) +from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( + register_default_component_pulse_factories, + _make_xy_pulse_factories, + _make_readout_pulse, +) +from quam_builder.architecture.quantum_dots.macro_engine.overrides import ( + ComponentOverrides, + _convert_typed_overrides, +) + +__all__ = [ + "wire_machine_macros", + "load_macro_profile", +] + + +def load_macro_profile(profile_path: str | Path | None) -> dict[str, Any]: + """Load an optional TOML macro profile. + + Args: + profile_path: Path to a TOML file, or ``None`` to skip profile loading. + + Returns: + Decoded profile data as a mapping. + + Raises: + FileNotFoundError: If the path is provided and does not exist. + ValueError: If the parsed file is not a dictionary-like structure. + """ + if profile_path is None: + return {} + path = Path(profile_path) + if not path.exists(): + raise FileNotFoundError(f"Macro profile not found: {path}") + with path.open("rb") as f: + data = tomllib.load(f) + if not isinstance(data, dict): + raise ValueError("Macro profile must decode to a dictionary.") + return data + + +def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + """Recursively merge ``override`` onto ``base`` and return a new mapping.""" + merged = dict(base) + for key, value in override.items(): + if isinstance(value, Mapping) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) # type: ignore[arg-type] + else: + merged[key] = value + return merged + + +def _iter_macro_components(machine: Any): + """Iterate components on a machine that expose a ``macros`` mapping. + + Yields: + Tuples of ``(, )`` where + ``component_path`` is suitable for ``instances.`` overrides. + """ + collection_names = ( + "quantum_dots", + "sensor_dots", + "barrier_gates", + "global_gates", + "quantum_dot_pairs", + "qubits", + "qubit_pairs", + ) + for collection_name in collection_names: + collection = getattr(machine, collection_name, None) + if isinstance(collection, Mapping): + for name, component in collection.items(): + if hasattr(component, "macros"): + yield f"{collection_name}.{name}", component + + qpu = getattr(machine, "qpu", None) + if qpu is not None and hasattr(qpu, "macros"): + yield "qpu", qpu + + +def _resolve_macro_factory(factory_spec: Any): + """Resolve macro factory spec to a concrete ``QuamMacro`` subclass. + + Args: + factory_spec: Either a class object or a ``module.path:Symbol`` string. + + Returns: + A ``QuamMacro`` subclass. + """ + if isinstance(factory_spec, str): + if ":" not in factory_spec: + raise ValueError( + f"Macro factory '{factory_spec}' must use 'module.path:SymbolName' format." + ) + module_name, symbol_name = factory_spec.split(":", 1) + module = importlib.import_module(module_name) + factory = getattr(module, symbol_name) + else: + factory = factory_spec + + if not isinstance(factory, type) or not issubclass(factory, QuamMacro): + raise TypeError( + f"Resolved macro factory '{factory}' must be a QuamMacro subclass, " + f"got '{type(factory).__name__}'." + ) + return factory + + +def _set_component_macro(component: Any, name: str, macro: QuamMacro) -> None: + """Set or replace a macro on a component while keeping parent links valid.""" + set_macro = getattr(component, "set_macro", None) + if callable(set_macro): + set_macro(name, macro) + return + + if not hasattr(component, "macros") or component.macros is None: + component.macros = {} + component.macros[name] = macro + if getattr(macro, "parent", None) is None: + macro.parent = component + + +def _remove_component_macro(component: Any, name: str, strict: bool) -> None: + """Remove a macro from a component and invalidate dispatch cache if needed.""" + macros = getattr(component, "macros", None) + if not isinstance(macros, Mapping): + if strict: + raise KeyError(f"Component '{component}' has no macros container.") + return + if name not in macros: + if strict: + raise KeyError(f"Macro '{name}' not found on component '{component.id}'.") + return + del macros[name] + + +def _normalize_macro_override(entry: Any) -> tuple[type[QuamMacro] | None, dict[str, Any], bool]: + """Normalize one macro override entry into factory/params/enabled tuple.""" + if isinstance(entry, str) or (isinstance(entry, type) and issubclass(entry, QuamMacro)): + return _resolve_macro_factory(entry), {}, True + + if not isinstance(entry, Mapping): + raise TypeError( + "Macro override entry must be a mapping, a QuamMacro class, or import path string." + ) + + enabled = bool(entry.get("enabled", True)) + if not enabled: + return None, {}, False + + factory_spec = entry.get("factory") + if factory_spec is None: + raise ValueError("Enabled macro override must provide 'factory'.") + params = entry.get("params", {}) + if not isinstance(params, Mapping): + raise TypeError("Macro override 'params' must be a mapping.") + return _resolve_macro_factory(factory_spec), dict(params), True + + +def _apply_macros_to_component( + component: Any, + macros_config: Mapping[str, Any], + *, + strict: bool, + context: str, +) -> None: + """Apply normalized macro override entries to one component.""" + known_macros = set(get_default_macro_factories(component).keys()) + known_macros.update(getattr(component, "macros", {}).keys()) + + for macro_name, entry in macros_config.items(): + if strict and macro_name not in known_macros: + raise KeyError( + f"[{context}] Unknown macro '{macro_name}' for component " + f"{type(component).__name__}({getattr(component, 'id', '?')}). " + f"Known macros: {sorted(known_macros)}" + ) + + factory, params, enabled = _normalize_macro_override(entry) + if not enabled: + _remove_component_macro(component, macro_name, strict=strict) + continue + + init_param_names = set(inspect.signature(factory).parameters) + init_params = {key: value for key, value in params.items() if key in init_param_names} + runtime_params = { + key: value for key, value in params.items() if key not in init_param_names + } + + macro = factory(**init_params) # type: ignore[misc] + for key, value in runtime_params.items(): + if strict and not hasattr(macro, key): + raise TypeError( + f"[{context}] Macro '{macro_name}' on component " + f"{type(component).__name__}({getattr(component, 'id', '?')}) " + f"does not expose attribute '{key}'." + ) + setattr(macro, key, value) + _set_component_macro(component, macro_name, macro) + + +def _ensure_default_pulses(machine: Any) -> None: + """Materialize default pulses onto qubit XY drives and sensor dot resonators. + + Called automatically at the end of :func:`wire_machine_macros`, after macro + wiring is complete. Pulse wiring is additive: only pulse names not already + present in a channel's ``operations`` dict are added. User-supplied or + override-supplied pulses always take precedence. + + Qubit XY drives: + For each qubit in ``machine.qubits`` that has an ``xy`` drive with an + ``operations`` dict, adds a single ``GaussianPulse`` reference pulse + named ``"gaussian"``. The ``XYDriveMacro`` scales amplitude for + rotation angle and applies virtual-Z for rotation axis, so only one + calibrated pulse is needed. The pulse is drive-type aware: IQ/MW + channels get ``axis_angle=0.0``; ``SingleChannel`` uses ``axis_angle=None``. + + Sensor dot readout resonators: + For each sensor dot in ``machine.sensor_dots`` that has a + ``readout_resonator`` with an ``operations`` dict, adds a default + ``SquareReadoutPulse`` named ``"readout"``. + + Args: + machine: Target machine object with ``qubits`` and/or ``sensor_dots`` + collections. + """ + register_default_component_pulse_factories() + + qubits = getattr(machine, "qubits", None) + if isinstance(qubits, Mapping): + for qubit in qubits.values(): + xy = getattr(qubit, "xy", None) + if xy is None: + continue + operations = getattr(xy, "operations", None) + if operations is None: + continue + default_pulses = _make_xy_pulse_factories(xy) + for pulse_name, pulse in default_pulses.items(): + if pulse_name not in operations: + operations[pulse_name] = pulse + + sensor_dots = getattr(machine, "sensor_dots", None) + if isinstance(sensor_dots, Mapping): + for sensor_dot in sensor_dots.values(): + resonator = getattr(sensor_dot, "readout_resonator", None) + if resonator is None: + continue + operations = getattr(resonator, "operations", None) + if operations is None: + continue + if "readout" not in operations: + operations["readout"] = _make_readout_pulse() + + +def _apply_pulse_overrides( + machine: Any, + merged_overrides: Mapping[str, Any], +) -> None: + """Apply pulse overrides from a TOML profile or runtime mapping. + + Called automatically at the end of :func:`wire_machine_macros`, after + ``_ensure_default_pulses`` has materialized defaults. + + Override schema (inside both ``component_types`` and ``instances`` scopes):: + + [component_types.LDQubit.pulses] + x180 = {type = "GaussianPulse", length = 500, amplitude = 0.3, sigma = 83} + + [instances."qubits.q1".pulses] + x180 = {type = "GaussianPulse", length = 800, amplitude = 0.15, sigma = 133} + + Supported pulse types: ``GaussianPulse``, ``SquarePulse``, + ``SquareReadoutPulse``, ``DragPulse`` (if available in the quam version). + + To remove a pulse, set ``enabled = false``:: + + [instances."qubits.q1".pulses] + "-y90" = {enabled = false} + + Precedence (last wins): + 1. Default pulses from ``_ensure_default_pulses`` + 2. Type-level overrides (``component_types..pulses``) + 3. Instance-level overrides (``instances..pulses``) + + Args: + machine: Target machine whose component pulses should be overridden. + merged_overrides: Combined TOML profile + runtime override mapping, + as produced by ``_deep_merge(profile_data, macro_overrides)``. + """ + from quam.components import pulses as quam_pulses + + _PULSE_TYPE_MAP = { + "GaussianPulse": quam_pulses.GaussianPulse, + "SquarePulse": quam_pulses.SquarePulse, + "SquareReadoutPulse": quam_pulses.SquareReadoutPulse, + } + if hasattr(quam_pulses, "DragPulse"): + _PULSE_TYPE_MAP["DragPulse"] = quam_pulses.DragPulse + + def _apply_pulse_config_to_operations(operations: dict, pulses_config: Mapping, context: str): + for pulse_name, entry in pulses_config.items(): + if not isinstance(entry, Mapping): + continue + enabled = entry.get("enabled", True) + if not enabled: + operations.pop(pulse_name, None) + continue + pulse_type_name = entry.get("type") + if pulse_type_name is None: + continue + pulse_cls = _PULSE_TYPE_MAP.get(pulse_type_name) + if pulse_cls is None: + raise ValueError( + f"[{context}] Unknown pulse type '{pulse_type_name}'. " + f"Known types: {sorted(_PULSE_TYPE_MAP)}" + ) + params = {k: v for k, v in entry.items() if k not in ("type", "enabled")} + operations[pulse_name] = pulse_cls(**params) + + def _get_pulse_target_operations(component: Any) -> dict | None: + """Find operations dict on a component's drive or resonator.""" + xy = getattr(component, "xy", None) + if xy is not None: + return getattr(xy, "operations", None) + rr = getattr(component, "readout_resonator", None) + if rr is not None: + return getattr(rr, "operations", None) + return getattr(component, "operations", None) + + components_by_path = dict(_iter_macro_components(machine)) + + type_overrides = merged_overrides.get("component_types", {}) + if isinstance(type_overrides, Mapping): + for _, component in components_by_path.items(): + for type_key in ( + type(component).__name__, + f"{type(component).__module__}.{type(component).__qualname__}", + ): + type_config = type_overrides.get(type_key) + if type_config is None: + continue + pulses_config = type_config.get("pulses", {}) + if not isinstance(pulses_config, Mapping) or not pulses_config: + continue + operations = _get_pulse_target_operations(component) + if operations is not None: + _apply_pulse_config_to_operations( + operations, pulses_config, f"component_types.{type_key}" + ) + + instance_overrides = merged_overrides.get("instances", {}) + if isinstance(instance_overrides, Mapping): + for component_path, component_config in instance_overrides.items(): + if component_path not in components_by_path: + continue + pulses_config = component_config.get("pulses", {}) + if not isinstance(pulses_config, Mapping) or not pulses_config: + continue + component = components_by_path[component_path] + operations = _get_pulse_target_operations(component) + if operations is not None: + _apply_pulse_config_to_operations( + operations, pulses_config, f"instances.{component_path}" + ) + + +def wire_machine_macros( + machine: Any, + *, + macro_profile_path: str | Path | None = None, + component_overrides: Mapping[type | str, ComponentOverrides | Mapping] | None = None, + instance_overrides: Mapping[str, ComponentOverrides | Mapping] | None = None, + strict: bool = True, +) -> None: + """Wire defaults and user-configured macro/pulse overrides onto machine components. + + Two override sources are merged in this order (last wins): + + 1. TOML profile from *macro_profile_path*. + 2. Typed *component_overrides* / *instance_overrides* kwargs. + + The typed kwargs accept class objects as component-type keys (e.g. + ``LDQubit``) and validate macro factories at construction time via + :func:`macro` / :func:`disabled` helpers from + :mod:`~quam_builder.architecture.quantum_dots.macro_engine.overrides`. + + Args: + machine: Target machine whose components should be wired. + macro_profile_path: Optional TOML file path. + component_overrides: Overrides keyed by component class or class name. + Values are :class:`ComponentOverrides` (from :func:`overrides`) + or raw dicts. Example:: + + from quam_builder.architecture.quantum_dots.macro_engine import ( + macro, overrides, + ) + + component_overrides={ + LDQubit: overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), + }), + } + + instance_overrides: Overrides keyed by component path string + (e.g. ``"qubits.q2"``). Values are :class:`ComponentOverrides` + or raw dicts. Example:: + + instance_overrides={ + "qubits.q2": overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), + }), + } + + strict: If True, unknown paths/macros raise explicit errors. + + Example: + Wire defaults only (most common):: + + wire_machine_macros(machine) + + Override initialize on all LDQubits:: + + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, macro, overrides, + ) + from quam_builder.architecture.quantum_dots.qubit import LDQubit + from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + ) + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro( + InitializeStateMacro, ramp_duration=64, + ), + }), + }, + ) + + Override one qubit, keep all other defaults:: + + wire_machine_macros( + machine, + instance_overrides={ + "qubits.q1": overrides(macros={ + SingleQubitMacroName.X_180: macro(TunedX180Macro), + }), + }, + ) + """ + + register_default_component_macro_factories() + profile_data = load_macro_profile(macro_profile_path) + + typed_dict = _convert_typed_overrides(component_overrides, instance_overrides) + merged_overrides = _deep_merge(profile_data, typed_dict) + + components_by_path = dict(_iter_macro_components(machine)) + + # Ensure defaults are materialized before applying overrides. + for component in components_by_path.values(): + ensure_defaults = getattr(component, "ensure_default_macros", None) + if callable(ensure_defaults): + ensure_defaults() + + type_overrides = merged_overrides.get("component_types", {}) + if type_overrides: + if not isinstance(type_overrides, Mapping): + raise TypeError("'component_types' overrides must be a mapping.") + for _, component in components_by_path.items(): + for type_key in ( + type(component).__name__, + f"{type(component).__module__}.{type(component).__qualname__}", + ): + type_config = type_overrides.get(type_key) + if type_config is None: + continue + macros_config = type_config.get("macros", {}) + if not isinstance(macros_config, Mapping): + raise TypeError(f"component_types.{type_key}.macros must be a mapping.") + _apply_macros_to_component( + component, + macros_config, + strict=strict, + context=f"component_types.{type_key}", + ) + + instance_overrides = merged_overrides.get("instances", {}) + if instance_overrides: + if not isinstance(instance_overrides, Mapping): + raise TypeError("'instances' overrides must be a mapping.") + for component_path, component_config in instance_overrides.items(): + if component_path not in components_by_path: + if strict: + raise KeyError( + f"Unknown component path '{component_path}' in macro overrides. " + f"Known paths: {sorted(components_by_path)}" + ) + continue + macros_config = component_config.get("macros", {}) + if not isinstance(macros_config, Mapping): + raise TypeError(f"instances.{component_path}.macros must be a mapping.") + _apply_macros_to_component( + components_by_path[component_path], + macros_config, + strict=strict, + context=f"instances.{component_path}", + ) + + # Wire default pulses and apply pulse overrides. + _ensure_default_pulses(machine) + _apply_pulse_overrides(machine, merged_overrides) diff --git a/quam_builder/architecture/quantum_dots/operations/README.md b/quam_builder/architecture/quantum_dots/operations/README.md new file mode 100644 index 00000000..6adcc7a8 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/README.md @@ -0,0 +1,692 @@ +# Quantum Dots Operations and Macro Defaults + +This folder contains the default operations + macro wiring system for quantum-dot QuAM components. +The main goal is to keep macro behavior decoupled from component classes while making defaults and user overrides explicit, composable, and serializable. + +## Architecture Overview + +Core modules: + +- [`names.py`](./names.py): canonical string names (voltage points and macro names). +- [`default_macros/`](./default_macros): built-in macro classes and default per-component macro maps. +- [`macro_registry.py`](./macro_registry.py): component-type -> default macro factory registration/resolution. +- [`component_macro_catalog.py`](./component_macro_catalog.py): idempotent registration of architecture defaults (`QPU`, `LDQubit`, `LDQubitPair`). +- [`pulse_registry.py`](./pulse_registry.py): component-type -> default pulse factory registration/resolution (parallel to macro registry). +- [`component_pulse_catalog.py`](./component_pulse_catalog.py): idempotent registration of default pulse factories (`LDQubit` XY pulses, `SensorDot` readout). +- [`../macro_engine/wiring.py`](../macro_engine/wiring.py): runtime wiring API (`wire_machine_macros`) that materializes macro and pulse defaults and applies overrides. +- [`default_operations.py`](./default_operations.py): operation signatures exposed through `OperationsRegistry`. + +## Canonical Voltage Point Enums + +Voltage-point names are centralized in [`names.py`](./names.py): + +- `initialize` +- `measure` +- `empty` +- `exchange` + +These are represented by `VoltagePointName` (`StrEnum`) and reused by default state macros. +Default state macros assume these points exist in each relevant voltage sequence (for example via `add_point(...)`). +In Python examples below, prefer the enum members directly. +These are `StrEnum`s, so `TwoQubitMacroName.CZ` already behaves like `"cz"`. +In TOML profiles, use the serialized enum values (`initialize`, `x180`, `gaussian`, ...). + +Canonical macro names are also centralized as enums in the same module: + +- `SingleQubitMacroName` for built-in 1Q defaults — state macros (`initialize`, `measure`, `empty`, `exchange`) and gate macros (`xy_drive`, `x`, `y`, `z`, `x180`, ...) +- `TwoQubitMacroName` for built-in 2Q defaults — state macros (`initialize`, `measure`, `empty`, `exchange`) and gate macros (`cnot`, `cz`, `swap`, `iswap`) + +Alias spellings (for example `-x90`, `-y90`) remain explicit strings via +`SINGLE_QUBIT_MACRO_ALIASES` and `SINGLE_QUBIT_MACRO_ALIAS_MAP`. + +## Default Macro Logic by Component Type + +### Utility macros (all macro-dispatch components) + +From [`../../../tools/macros/default_macros.py`](../../../tools/macros/default_macros.py): + +- `align` +- `wait` + +### `QPU` + +From [`default_macros/state_macros.py`](./default_macros/state_macros.py): + +- `initialize` +- `measure` +- `empty` + +QPU state macros dispatch to active qubits/pairs when configured; otherwise they broadcast to all registered qubits/pairs (with stage-1 fallbacks). + +### `LDQubit` + +From [`default_macros/single_qubit_macros.py`](./default_macros/single_qubit_macros.py): + +- State macros: `initialize`, `measure`, `empty`, `exchange` +- Canonical 1Q macros: `xy_drive`, `x`, `y`, `z` +- Fixed-angle wrappers: `x180`, `x90`, `x_neg90` (and `-x90` alias), `y180`, `y90`, `y_neg90` (and `-y90` alias), `z180`, `z90` +- Identity: `I` + +Canonical chain: + +- `x` and `y` delegate to `xy_drive` with phase offsets. +- `x90`/`x180`/`x_neg90` and `y*` wrappers delegate to canonical `x`/`y`. +- `z90`/`z180` wrappers delegate to canonical `z`. +- Negative XY angles are encoded as positive-angle drives with an additional `+pi` + phase shift (on top of the axis phase), so amplitude scaling is based on + `abs(angle)`. + +Practical consequence: overriding one canonical macro (for example `xy_drive` or `x`) automatically affects all wrappers above it. + +### `LDQubitPair` + +From [`default_macros/two_qubit_macros.py`](./default_macros/two_qubit_macros.py): + +- State macros: `initialize`, `measure`, `empty`, `exchange` +- Two-qubit gates: `cnot`, `cz`, `swap`, `iswap` + +The `exchange` macro ramps to a configurable exchange voltage point, holds for a wait duration, then ramps back to the initialize point. Ramp duration, wait duration, and both voltage targets are configurable via class fields and runtime kwargs. + +Default two-qubit gate macros (`cnot`, `cz`, `swap`, `iswap`) are explicit placeholders (`NotImplementedError`) until user calibration logic is supplied through overrides. + +## Invocation Paths: Registry vs Direct vs Macro + +All invocation paths ultimately execute `macro.apply()`. Choose based on your use case: + +| Invocation | When to use | Applicable component types | +|------------|-------------|----------------------------| +| `from ...default_operations import x180; x180(q)` | Generic algorithms, type-safe protocol code, IDE completion | LDQubit (1Q gates); LDQubitPair (2Q gates); QuantumDot, QuantumDotPair, SensorDot (state macros) | +| `q.x180()` | Component-specific code; natural direct call via `__getattr__` dispatch | Same as registry | +| `q.macros["x180"].apply()` | Direct access; use when you need the macro object itself (introspection, custom dispatch) | Any component with `macros` dict | + +`q.x180()` and `q.macros["x180"].apply()` are equivalent — `__getattr__` returns `macro.apply` directly. + +See [`default_operations.py`](./default_operations.py) module docstring for the registry vs direct comparison in prose. + +## When and Where Macros Are Wired + +Wiring happens in three places: + +1. Component construction (`MacroDispatchMixin.__post_init__`): materializes missing defaults. +2. Build flow (`build_base_quam`, `build_loss_divincenzo_quam`, `build_quam`): calls `wire_machine_macros(...)`. +3. Load flow (`BaseQuamQD.load`, `LossDiVincenzoQuam.load`): calls `wire_machine_macros(instance)` after load. + +Important behavior: + +- Default materialization is additive: only missing default macro names are inserted. +- Existing macro entries are not reset unless an override explicitly targets that name. + +## Override Model (Detailed) + +`wire_machine_macros` supports two override scopes via typed kwargs: + +- `component_overrides={LDQubit: overrides(macros={...})}` — all instances of a type +- `instance_overrides={"qubits.q1": overrides(macros={...})}` — one specific instance + +Use the helpers from `quam_builder.architecture.quantum_dots.macro_engine`: + +| Helper | Purpose | +|--------|---------| +| `macro(Factory, **params)` | Create a macro override entry (validates factory is QuamMacro) | +| `pulse("GaussianPulse", **params)` | Create a pulse override entry | +| `disabled()` | Remove a macro or pulse | +| `overrides(macros={...}, pulses={...})` | Group macro and pulse overrides for one component | + +### Precedence and Merge Order + +1. Architecture defaults (registry/catalog). +2. TOML profile from `macro_profile_path`. +3. `component_overrides` applied to each matching instance. +4. `instance_overrides` applied last. + +So, effective precedence for a specific macro name on one component is: + +`instance override` > `type override` > `TOML profile` > `default`. + +### Targeting Component Types + +Type override keys use the actual Python class (recommended) or a string: + +- class reference: `LDQubit` (import-time validation, IDE autocomplete) +- short class name string: `"LDQubit"` (for TOML profiles) +- fully qualified string: `"quam_builder.architecture.quantum_dots.qubit.LDQubit"` + +### Targeting Component Instances + +Instance override keys are component paths, for example: + +- `qpu` +- `qubits.q1` +- `qubit_pairs.q1_q2` +- `quantum_dots.dot_1` +- `quantum_dot_pairs.dot_1_dot_2` +- `sensor_dots.s1` +- `barrier_gates.b12` +- `global_gates.g1` + +Only components exposing a `macros` mapping are eligible. + +### Macro Entry Forms (Python) + +Use the `macro()` helper to build entries: + +```python +from quam_builder.architecture.quantum_dots.macro_engine import macro, disabled + +# With parameters: +macro(InitializeStateMacro, ramp_duration=64) + +# Class only (no extra params): +macro(TunedX180Macro) + +# Import-path string (for TOML compatibility): +macro("my_pkg.macros:TunedX180Macro") +``` + +`macro()` validates at call time that the factory is a `QuamMacro` subclass. + +### Macro Entry Forms (TOML) + +TOML profiles use string keys and the `factory` / `params` / `enabled` format: + +```toml +[component_types.LDQubit.macros.initialize] +factory = "my_pkg.macros:CustomInitializeMacro" +enabled = true +[component_types.LDQubit.macros.initialize.params] +ramp_duration = 64 + +[component_types.LDQubitPair.macros] +cz = "my_pkg.macros:CalibratedCZMacro" +``` + +### Disable/Remove a Macro + +Python: + +```python +instance_overrides={ + "qubit_pairs.q1_q2": overrides(macros={ + TwoQubitMacroName.CZ: disabled(), + }), +} +``` + +TOML: + +```toml +[instances."qubit_pairs.q1_q2".macros.cz] +enabled = false +``` + +### Strict vs Non-Strict Mode + +- `strict=True` (default): + - unknown component path -> error + - unknown macro name for that component -> error +- `strict=False`: + - unknown paths are ignored + - unknown macro names can be added (or ignored on removal) + +Use `strict=True` in production profiles to catch typos early. + +## How Users Override Only One Macro and Keep Defaults + +This is the common case and is fully supported. + +Example: override only `x180` on one qubit; all other macros remain default. + +```python +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, macro, overrides, +) +from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName + +wire_machine_macros( + machine, + instance_overrides={ + "qubits.q1": overrides(macros={ + SingleQubitMacroName.X_180: macro(TunedX180Macro), + }), + }, + strict=True, +) +``` + +Result: + +- `qubits.q1.macros["x180"]` is replaced with `TunedX180Macro`. +- All other macros on `q1` are untouched. +- All macros on other qubits are untouched. +- Persistent single-qubit amplitude calibration should live on the reference + pulse object, not on wrapper macros. + +## Type-Level Override + Instance-Level Specialization + +Pattern: set lab-wide default for a component type, then specialize one device instance. + +```python +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, macro, overrides, +) +from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName +from quam_builder.architecture.quantum_dots.qubit import LDQubit + +wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), + }), + }, + instance_overrides={ + "qubits.q2": overrides(macros={ + SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), + }), + }, +) +``` + +`qubits.q2` wins over type-level settings because instance overrides are applied last. + +## Profile + Runtime Combination + +Typical workflow: + +1. Keep stable, shared calibration defaults in TOML (`macro_profile_path`). +2. Apply session-specific tweaks with `component_overrides` / `instance_overrides` in Python. + +Because runtime overrides are deep-merged over profile data, users can patch only one leaf value without duplicating the full profile map. + +## Importing a Full Macro Catalog From Another Package + +For users who want a custom default set that survives upstream pulls, keep macro logic in a separate package/repo and import it as `component_overrides`. + +### Where This Should Sit + +Use this layout: + +1. External package (stable lab-owned code): `my_lab_qd_macros/` +2. Experiment/build entrypoint (in your experiment repo): where `wire_machine_macros(...)` is called +3. Optional local TOML for small per-run tweaks + +### External package example + +`my_lab_qd_macros/catalog.py`: + +```python +from __future__ import annotations + +from quam_builder.architecture.quantum_dots.macro_engine import macro, pulse, overrides +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair + +from .single_qubit import LabInitialize1Q, LabMeasure1Q, LabEmpty1Q, LabX, LabY, LabZ +from .two_qubit import LabCZ, LabISWAP, LabCNOT, LabSWAP +from .qpu import LabInitializeQPU, LabMeasureQPU, LabEmptyQPU + + +def build_component_overrides() -> dict: + """Return component_overrides dict for wire_machine_macros.""" + return { + QPU: overrides(macros={ + VoltagePointName.INITIALIZE: macro(LabInitializeQPU), + VoltagePointName.MEASURE: macro(LabMeasureQPU), + VoltagePointName.EMPTY: macro(LabEmptyQPU), + }), + LDQubit: overrides( + macros={ + SingleQubitMacroName.INITIALIZE: macro(LabInitialize1Q), + SingleQubitMacroName.MEASURE: macro(LabMeasure1Q), + SingleQubitMacroName.EMPTY: macro(LabEmpty1Q), + SingleQubitMacroName.X: macro(LabX), + SingleQubitMacroName.Y: macro(LabY), + SingleQubitMacroName.Z: macro(LabZ), + }, + pulses={ + DrivePulseName.GAUSSIAN: pulse( + "GaussianPulse", length=1000, amplitude=0.17, sigma=167, + ), + }, + ), + LDQubitPair: overrides(macros={ + TwoQubitMacroName.CNOT: macro(LabCNOT), + TwoQubitMacroName.CZ: macro(LabCZ), + TwoQubitMacroName.SWAP: macro(LabSWAP), + TwoQubitMacroName.ISWAP: macro(LabISWAP), + }), + } +``` + +### Experiment call site example + +In your experiment repo, at the machine-build call site: + +```python +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from my_lab_qd_macros.catalog import build_component_overrides + +wire_machine_macros( + machine, + component_overrides=build_component_overrides(), + strict=True, +) +``` + +This keeps custom defaults out of `quam-builder` itself, so pulling upstream changes does not overwrite your catalog. + +### Optional pattern: catalog defaults + local one-off tweak + +```python +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, macro, overrides, +) +from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName +from my_lab_qd_macros.catalog import build_component_overrides + +wire_machine_macros( + machine, + component_overrides=build_component_overrides(), + instance_overrides={ + "qubits.q3": overrides(macros={ + SingleQubitMacroName.X_180: macro( + "my_lab_qd_macros.single_qubit:Q3TunedX180" + ), + }), + }, + strict=True, +) +``` + +This is the recommended model for common lab usage: central catalog + selective per-device override. + +## Calibrated Parameters: Storage and Persistence + +There are two storage layers: + +1. Source-of-truth config (optional): + - TOML profile in your repository/lab config. +2. Runtime QuAM object: + - instantiated macro objects in `component.macros` + - pulse objects in channel `operations` mappings + +Parameter examples: + +- `machine.qubits["q1"].macros[SingleQubitMacroName.INITIALIZE].ramp_duration` +- `machine.qubits["q1"].xy.operations[DrivePulseName.GAUSSIAN].amplitude` + +Serialization behavior: + +- Macro objects/fields in `component.macros` are part of QuAM state. +- Pulse objects/fields in `channel.operations` are part of QuAM state. + +## Recommended Override Strategy + +1. Override the reference pulse first if you want broad single-qubit amplitude or envelope changes. +2. Use canonical macros (`xy_drive`, `x`, `y`, `z`) for behavior changes such as phase logic or dispatch behavior. +3. Avoid persistent amplitude defaults on wrapper macros; keep amplitude calibration on the pulse object itself. +4. Keep two-qubit calibrated logic in profile/type-level overrides and specialize only exceptional instances. +5. Use `strict=True` for CI and release configs. + +## Default Pulse Wiring + +`wire_machine_macros()` also wires default pulses onto component channels. Pulse wiring is additive — only pulse names not already present are added. + +### Qubit XY Drive Pulse + +A single reference pulse is registered per qubit. `XYDriveMacro` scales amplitude for rotation angle and applies virtual-Z for rotation axis (X/Y), so all single-qubit gates derive from this one pulse. + +Composition rules: +- `x`/`y` add their canonical axis phase to any runtime `phase=...`. +- `xy_drive` runtime `amplitude_scale=...` multiplies the angle-derived scale from the reference pulse instead of replacing it. + +### Single-Qubit Gate Composition Model + +#### Delegation chain + +All single-qubit XY gate calls flow through a strict delegation chain: + +``` +q.x90() # fixed-angle wrapper + └─ q.macros["x"].apply() # canonical axis macro (adds phase=0 for X) + └─ q.macros["xy_drive"].apply() # core XY drive + ├─ q.virtual_z(phase) # frame rotation (if phase != 0) + ├─ q.voltage_sequence.step_to_voltages({}, duration) # hold voltages + ├─ q.xy.play(pulse_name, amplitude_scale, duration) # hardware play + └─ q.virtual_z(-phase) # restore frame +``` + +Overriding a single canonical macro automatically affects all wrappers above it. For example, replacing `xy_drive` changes the behavior of every XY gate. + +#### Source of truth + +The reference pulse is the single source of truth for all single-qubit XY rotations: + +```python +qubit.xy.operations[qubit.macros["xy_drive"].reference_pulse_name] +``` + +- Pulse-envelope parameters (`amplitude`, `length`, `sigma`, `axis_angle`, `alpha`, `anharmonicity`) live on that pulse object, not in the wrapper macros. +- Replacing the pulse object, or switching `reference_pulse_name`, updates the whole single-qubit gate family. +- All gates derive their amplitude scale from the reference pulse using: `abs(angle) / reference_angle`. + +#### What to calibrate + +| Parameter | Where it lives | Affects | +|-----------|---------------|---------| +| Pi-pulse amplitude | `qubit.xy.operations["gaussian"].amplitude` | All XY gates (single source of truth) | +| Pulse envelope shape | `qubit.xy.operations["gaussian"]` (length, sigma, etc.) | All XY gates | +| Drive frequency | `qubit.xy.intermediate_frequency` / `qubit.xy.LO_frequency` | All XY gates | +| Reference angle | `qubit.macros["xy_drive"].reference_angle` | Scale factor mapping (default: pi) | +| Voltage points | `qubit.add_point("initialize", {...})` etc. | State macros | + +#### Modulation order + +- `xy_drive` converts `angle` into an angle-derived amplitude scale: `abs(angle) / reference_angle`. Duration is always the reference pulse length (no stretching). +- `x` or `y` adds its axis phase (`0` for X, `pi/2` for Y) to any runtime `phase`. +- A fixed-angle wrapper such as `x90` contributes its default angle only. +- Runtime `amplitude_scale` multiplies the angle-derived scale (compositional, not replacing). +- Runtime `duration` / `pulse_duration` overrides the reference pulse duration. + +#### Negative angle handling + +Negative angles are encoded as positive-angle drives with a `+pi` phase shift on top of the axis phase. This means amplitude scaling is always computed from `abs(angle)`, and the sign information is carried in the virtual-Z frame rotation. + +#### Effective play parameters + +- Effective phase = `sign-normalization phase + axis phase + wrapper phase + runtime phase` +- Effective amplitude scale = `(abs(angle) / reference_angle) * runtime amplitude_scale` +- Effective duration = `runtime duration` if provided, otherwise reference pulse length + +#### Representative cases + +1. Base calibration only + - Reference pulse: gaussian with `amplitude=1.0`, `length=1000` + - Call: `q.x180()` + - Result: plays with scale `1.0`, phase `0`, duration `1000 ns` + +2. Calibrated amplitude + - `q.xy.operations["gaussian"].amplitude = 0.17` + - Call: `q.x180()` → effective amplitude `0.17 * 1.0` + - Call: `q.x90()` → effective amplitude `0.17 * 0.5` + - Consistency: both derive from the same pulse object + +3. Runtime modulation + - Call: `q.y90(amplitude_scale=0.5, phase=0.1)` + - Effective phase: `pi/2 + 0.1` + - Effective amplitude scale: `0.5 * 0.5 = 0.25` (runtime multiplies angle-derived) + +4. Negative rotation + - Call: `q.x(angle=-pi/2)` + - Effective phase: `0 + pi` (sign-flip phase) + - Effective amplitude scale: `0.5` (from `abs(-pi/2) / pi`) + - Frame is restored after the play + +#### Default reference pulse + +| Pulse Name | Type | Amplitude | Length | Sigma | Axis Angle (IQ/MW) | Axis Angle (SingleChannel) | +|------------|------|-----------|--------|-------|---------------------|---------------------------| +| `gaussian` | `GaussianPulse` | 1.0 | 1000 ns | 167 ns | 0.0 | `None` | + +Pulse names are centralized in `DrivePulseName` (`StrEnum`) in `names.py`: +- `DrivePulseName.GAUSSIAN` = `"gaussian"` (default) +- `DrivePulseName.DRAG` = `"drag"` (user-registered) + +To switch pulse type (e.g. Gaussian → DRAG): +1. Register the new pulse: `qubit.xy.operations[DrivePulseName.DRAG] = DragPulse(...)` +2. Update the macro: `qubit.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name = DrivePulseName.DRAG` +3. All gate macros (x90, x180, y90, etc.) automatically use the new pulse. + +### Sensor Dot Readout Pulses + +For each sensor dot in `machine.sensor_dots` with a `readout_resonator`, a default `SquareReadoutPulse` named `"readout"` is added (length 2000 ns, amplitude 0.1). + +### Pulse Override Schema + +Pulse overrides use the same scoping as macro overrides, under a `pulses` key: + +```toml +# Type-level: all LDQubits get a shorter gaussian pulse +[component_types.LDQubit.pulses] +gaussian = {type = "GaussianPulse", length = 500, amplitude = 0.3, sigma = 83} + +# Instance-level: qubit q1 gets a custom gaussian +[instances."qubits.q1".pulses] +gaussian = {type = "GaussianPulse", length = 800, amplitude = 0.15, sigma = 133} + +# Remove a pulse +[instances."qubits.q2".pulses] +gaussian = {enabled = false} +``` + +Supported pulse types: `GaussianPulse`, `SquarePulse`, `SquareReadoutPulse`, `DragPulse`. + +Precedence (last wins): default → type-level override → instance-level override. + +Python runtime overrides use the typed helpers: + +```python +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, pulse, overrides, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit + +wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides(pulses={ + "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), + }), + }, + instance_overrides={ + "qubits.q1": overrides(pulses={ + "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), + }), + }, +) +``` + +### Pulse Registry (Advanced) + +For custom component types that need default pulses, use the pulse registry directly: + +```python +from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( + register_component_pulse_factories, +) +from quam.components.pulses import SquarePulse + +register_component_pulse_factories( + MyCustomComponent, + {"drive": lambda: SquarePulse(length=200, amplitude=0.5)}, +) +``` + +The registry follows MRO resolution: derived classes can override individual pulse names registered on a base class. + +## Public APIs + +Register macro defaults for a custom component type: + +```python +from quam_builder.architecture.quantum_dots.operations.macro_registry import ( + register_component_macro_factories, +) + +register_component_macro_factories(MyComponent, {"my_macro": MyMacroClass}) +``` + +Register pulse defaults for a custom component type: + +```python +from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( + register_component_pulse_factories, +) + +register_component_pulse_factories(MyComponent, {"drive": lambda: SquarePulse(length=200, amplitude=0.5)}) +``` + +Wire defaults + overrides (macros and pulses): + +```python +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, macro, pulse, overrides, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit + +wire_machine_macros( + machine, + macro_profile_path="macros.toml", # optional TOML profile + component_overrides={ # all instances of a type + LDQubit: overrides(macros={...}, pulses={...}), + }, + instance_overrides={ # one specific instance + "qubits.q1": overrides(macros={...}), + }, + strict=True, +) +``` + +Builder integration: + +- `build_base_quam(...)` +- `build_loss_divincenzo_quam(...)` +- `build_quam(...)` + +all accept `macro_profile_path`, `component_overrides`, and `instance_overrides`. + +## End-to-End Example + +See [`../examples/default_macro_overrides_example.py`](../examples/default_macro_overrides_example.py) for: + +1. default macro wiring +2. one-macro instance override +3. type-level override +4. program build using default + overridden macros + +See [`../examples/default_macro_defaults_example.py`](../examples/default_macro_defaults_example.py) for: + +1. default-only wiring (no profile and no runtime overrides) +2. parameterizing built-in default macro instances on components +3. program build using parameterized default macros only + +See [`../examples/pulse_overrides_example.py`](../examples/pulse_overrides_example.py) for: + +1. default pulse wiring via `wire_machine_macros` +2. type-level pulse overrides (all qubits of a type) +3. instance-level pulse overrides (one specific qubit) + +See [`../examples/full_workflow_example.py`](../examples/full_workflow_example.py) for: + +1. wiring a Loss-DiVincenzo qubit machine (combined single-stage workflow) +2. wiring default macros and pulses +3. updating operation parameters +4. swapping drive pulse type (Gaussian → DRAG) +5. replacing macros (instance-level and type-level overrides) diff --git a/quam_builder/architecture/quantum_dots/operations/__init__.py b/quam_builder/architecture/quantum_dots/operations/__init__.py index f79fc4e7..31c408f0 100644 --- a/quam_builder/architecture/quantum_dots/operations/__init__.py +++ b/quam_builder/architecture/quantum_dots/operations/__init__.py @@ -4,13 +4,26 @@ This module provides: - Default operations registry with gate-level operations - Default macros for state preparation, single-qubit gates, and two-qubit gates +- Canonical name constants for voltage points and supported macros +- A component-type macro registry for decoupled macro-default wiring +- A component-type pulse registry for decoupled pulse-default wiring """ # Operations registry and operations +from .component_macro_catalog import * +from .component_pulse_catalog import * +from .macro_registry import * +from .pulse_registry import * +from .names import * from .default_macros import * from .default_operations import * __all__ = [ + *component_macro_catalog.__all__, + *component_pulse_catalog.__all__, + *macro_registry.__all__, + *pulse_registry.__all__, + *names.__all__, *default_macros.__all__, *default_operations.__all__, -] \ No newline at end of file +] diff --git a/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py b/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py new file mode 100644 index 00000000..c718f446 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py @@ -0,0 +1,83 @@ +"""Default component->macro catalog registration for quantum-dot architecture.""" + +from __future__ import annotations + +from quam_builder.architecture.quantum_dots.operations.default_macros import ( + QPU_STATE_MACROS, + SINGLE_QUBIT_MACROS, + STATE_POINT_MACROS, + TWO_QUBIT_MACROS, +) +from quam_builder.architecture.quantum_dots.operations.macro_registry import ( + register_component_macro_factories, +) + +_REGISTERED = False + +__all__ = [ + "register_default_component_macro_factories", +] + + +def register_default_component_macro_factories() -> None: + """Register built-in macro factories for core quantum-dot component types. + + Registration is idempotent and intentionally centralized to keep default + behavior decoupled from component class definitions. + """ + global _REGISTERED + if _REGISTERED: + return + + # Import lazily to avoid import-cycle side effects during module initialization. + from quam_builder.architecture.quantum_dots.components import QPU + from quam_builder.architecture.quantum_dots.qubit import LDQubit + from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair + + register_component_macro_factories(QPU, QPU_STATE_MACROS) + register_component_macro_factories(LDQubit, SINGLE_QUBIT_MACROS) + register_component_macro_factories(LDQubitPair, TWO_QUBIT_MACROS) + + # Phase 1 additions: QuantumDot voltage-only components + from quam_builder.architecture.quantum_dots.components.quantum_dot import ( + QuantumDot, + ) + from quam_builder.architecture.quantum_dots.components.quantum_dot_pair import ( + QuantumDotPair, + ) + from quam_builder.architecture.quantum_dots.components.sensor_dot import ( + SensorDot, + ) + from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + MeasurePSBPairMacro, + SensorDotMeasureMacro, + ) + from quam_builder.architecture.quantum_dots.operations.names import ( + VoltagePointName, + ) + + register_component_macro_factories(QuantumDot, STATE_POINT_MACROS) + qdpair_macros = { + **STATE_POINT_MACROS, + VoltagePointName.MEASURE.value: MeasurePSBPairMacro, + } + register_component_macro_factories(QuantumDotPair, qdpair_macros) + # SensorDot inherits from QuantumDot — replace=True prevents initialize/empty + # from flowing down via MRO resolution. CAT-03: measure only. + register_component_macro_factories( + SensorDot, + {VoltagePointName.MEASURE.value: SensorDotMeasureMacro}, + replace=True, + ) + + _REGISTERED = True + + +def _reset_registration() -> None: + """Reset global registration state. FOR TESTING ONLY. + + Called by the reset_catalog pytest fixture to ensure each test that + explicitly verifies registration behavior starts from a clean slate. + """ + global _REGISTERED + _REGISTERED = False diff --git a/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py b/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py new file mode 100644 index 00000000..915b6e73 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py @@ -0,0 +1,163 @@ +"""Default pulse catalog for quantum-dot component types. + +Parallel to :mod:`~quam_builder.architecture.quantum_dots.operations.component_macro_catalog`, +this module provides idempotent registration of default pulse factories for +core quantum-dot component types (``LDQubit``, ``SensorDot``). + +Default pulse parameters +------------------------ +Single-qubit XY drive pulse (on ``qubit.xy``): + A single ``GaussianPulse`` named ``"gaussian"`` — length 1000 ns, + amplitude 1.0 (π rotation reference), sigma 167 ns. + This is the **single source of truth** for all XY rotations. + The ``XYDriveMacro`` scales amplitude for different rotation angles + and applies virtual-Z frame rotations for the rotation axis (X/Y), + so only one reference pulse is needed. + + Drive-type aware: IQ/MW channels get ``axis_angle=0.0`` for hardware + mixing; ``SingleChannel`` (``XYDriveSingle``) uses ``axis_angle=None``. + +Readout pulse (on ``sensor_dot.readout_resonator``): + ``SquareReadoutPulse`` — length 2000 ns, amplitude 0.1. + +Usage:: + + from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( + register_default_component_pulse_factories, + _make_xy_pulse_factories, + _make_readout_pulse, + ) + + # Register all built-in defaults (idempotent): + register_default_component_pulse_factories() + + # Or build pulse dicts directly for a specific drive channel: + pulses = _make_xy_pulse_factories(qubit.xy) + readout = _make_readout_pulse() +""" + +from __future__ import annotations + +from quam.components.channels import SingleChannel +from quam.components.pulses import SquareReadoutPulse + +from quam_builder.architecture.quantum_dots.components.pulses import ScalableGaussianPulse +from quam_builder.architecture.quantum_dots.operations.names import DrivePulseName +from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( + register_component_pulse_factories, +) + +_REGISTERED = False + +__all__ = [ + "register_default_component_pulse_factories", +] + +# Default single-qubit XY pulse parameters +_PULSE_LENGTH = 1000 # ns +_PULSE_AMP = 1.0 # normalized (macro stores calibrated scaling) +_SIGMA_RATIO = 1 / 6 + +# Default readout pulse parameters +_READOUT_LENGTH = 2000 # ns +_READOUT_AMP = 1.0 + + +def _make_xy_pulse_factories(drive_channel): + """Build default XY drive reference pulse for an XY drive channel. + + Returns a dict with a single ``"gaussian"`` pulse — the reference + envelope used by ``XYDriveMacro`` for all single-qubit rotations. The macro + handles amplitude scaling (for rotation angle) and virtual-Z frame rotation + (for rotation axis), so only one calibrated pulse is needed. + + Users can register additional pulse types (e.g. ``"drag"``) and + point ``XYDriveMacro.reference_pulse_name`` at them to switch the + envelope used for all gates. + + Drive-type awareness: + - ``SingleChannel`` (``XYDriveSingle``): ``axis_angle=None`` — real-valued + waveforms only, rotation axis encoded via virtual-Z. + - IQ/MW channels: ``axis_angle=0.0`` — hardware IQ mixing; rotation + axis is set by virtual-Z frame rotation in the macro. + + Args: + drive_channel: The XY drive channel instance. Checked with + ``isinstance(drive_channel, SingleChannel)`` to determine pulse type. + + Returns: + Dict with one entry: ``{"gaussian": GaussianPulse(...)}``. + + Example:: + + pulses = _make_xy_pulse_factories(qubit.xy) + qubit.xy.operations.update(pulses) + """ + is_single = isinstance(drive_channel, SingleChannel) + axis_angle = None if is_single else 0.0 + + return { + DrivePulseName.GAUSSIAN.value: ScalableGaussianPulse( + id=DrivePulseName.GAUSSIAN.value, + length=_PULSE_LENGTH, + amplitude=_PULSE_AMP, + sigma_ratio=_SIGMA_RATIO, + axis_angle=axis_angle, + ), + } + + +def _make_readout_pulse(): + """Build default readout pulse for sensor dot resonators. + + Returns: + ``SquareReadoutPulse`` with id ``"readout"``, length 2000 ns, + amplitude 0.1. + """ + return SquareReadoutPulse( + id="readout", + length=_READOUT_LENGTH, + amplitude=_READOUT_AMP, + ) + + +def register_default_component_pulse_factories() -> None: + """Register built-in pulse factories for core quantum-dot component types. + + This function is idempotent — calling it multiple times has no effect after + the first registration. It is called automatically by + :func:`~quam_builder.architecture.quantum_dots.macro_engine.wiring._ensure_default_pulses` + during ``wire_machine_macros()``. + + Registration is intentionally centralized here (rather than in component + ``__post_init__``) to keep default behavior decoupled from component class + definitions. Actual pulse instances are created at wiring time by + ``_ensure_default_pulses``, which inspects each drive channel's type to + select the correct ``axis_angle``. + + Registered component types: + - ``LDQubit``: placeholder (actual XY pulses created at wiring time + based on drive channel type). + - ``SensorDot``: placeholder (readout pulse created at wiring time). + """ + global _REGISTERED + if _REGISTERED: + return + + from quam_builder.architecture.quantum_dots.qubit import LDQubit + from quam_builder.architecture.quantum_dots.components.sensor_dot import SensorDot + + # LDQubit: XY drive pulses (actual instances created at wiring time + # since drive type must be inspected) + register_component_pulse_factories(LDQubit, {}) + + # SensorDot: readout pulse + register_component_pulse_factories(SensorDot, {}) + + _REGISTERED = True + + +def _reset_registration() -> None: + """Reset global registration state. FOR TESTING ONLY.""" + global _REGISTERED + _REGISTERED = False diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/__init__.py b/quam_builder/architecture/quantum_dots/operations/default_macros/__init__.py index 90a151a0..937906b0 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/__init__.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/__init__.py @@ -3,18 +3,21 @@ This module provides a collection of default macro implementations for quantum dot components, organized by operation type: -- State macros: initialization, measurement, operation, and idle +- State macros: initialize, measure, and empty voltage transitions - Single-qubit macros: rotations around X, Y, Z axes and identity - Two-qubit macros: CNOT, CZ, SWAP, and iSWAP gates - -The default_macros dictionary provides a unified collection of all -available macros for easy registration with quantum dot components. """ + +from . import state_macros +from . import single_qubit_macros +from . import two_qubit_macros +from .state_macros import * from .single_qubit_macros import * from .two_qubit_macros import * __all__ = [ + *state_macros.__all__, *single_qubit_macros.__all__, *two_qubit_macros.__all__, ] diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py index 460b1f5c..9d64e3c7 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py @@ -1,281 +1,641 @@ +"""Single-qubit default macros for quantum-dot qubits. + +Rescaling philosophy +-------------------- +All single-qubit rotations derive from a **single reference pulse** (by +default a ``GaussianPulse`` named ``"gaussian"``). The ``XYDriveMacro`` +rescales only **amplitude** and **phase** at the macro level: + +* **Amplitude** is scaled proportionally to the requested rotation angle + relative to ``reference_angle`` (default π). +* **Phase** selects the rotation axis via a virtual-Z frame rotation + (0 → X, π/2 → Y, arbitrary → any XY axis). + +The pulse is **never time-stretched** via QUA's ``play(duration=…)`` +parameter, because arbitrary waveforms (Gaussian, DRAG) have internal +shape parameters (e.g. ``sigma``) defined in absolute samples. Stretching +the waveform without scaling ``sigma`` distorts the envelope. By always +playing at the pulse's native ``length``, the macro guarantees the +waveform shape is self-consistent. + +For experiments that require sweeping pulse duration (e.g. time-Rabi), +users should define a custom macro that explicitly accepts the +sigma/length trade-off, or register multiple pulses with different +(length, sigma) pairs. """ -# pylint: disable=unused-argument -Single-qubit gate macros for quantum dot qubits. +# Framework macro base classes introduce deep inheritance chains by design. +# pylint: disable=too-many-ancestors -These macros implement single-qubit rotations around X, Y, and Z axes, -as well as the identity operation. -""" +from __future__ import annotations + +import math +import numpy as np +from qm.qua import wait from quam.components.macro import QubitMacro +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + VoltagePointName, + X_NEG_90_ALIAS, + Y_NEG_90_ALIAS, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + EmptyStateMacro, + InitializeStateMacro, +) __all__ = [ "SINGLE_QUBIT_MACROS", - # state macros "Initialize1QMacro", "Measure1QMacro", - "Operate1QMacro", - "Idle1QMacro", - # X rotations + "Empty1QMacro", + "XYDriveMacro", + "XMacro", + "YMacro", + "ZMacro", "X180Macro", "X90Macro", "XNeg90Macro", - # Y rotations "Y180Macro", "Y90Macro", "YNeg90Macro", - # Z rotations "Z180Macro", "Z90Macro", "ZNeg90Macro", - # Identity "IdentityMacro", ] -# ============================================================================ -# X Rotation Macros -# ============================================================================ +def _quantize_ns(duration_ns: float) -> int: + """Quantize nanoseconds to OPX 4 ns clock boundaries.""" + return max(int(round(duration_ns / 4.0)) * 4, 0) -class X180Macro(QubitMacro): - """Apply 180-degree rotation around X axis (π pulse).""" +def _compose_phase(base_phase: float, extra_phase: float | None) -> float: + """Combine phase offsets across macro layers.""" + if extra_phase is None: + return base_phase + return base_phase + extra_phase - def apply(self, **kwargs): - """ - Apply X180 gate. - Args: - **kwargs: Optional parameter overrides - """ - pass +def _compose_amplitude_scale( + base_scale: float, + extra_scale: float | None, +) -> float | None: + """Combine amplitude scales across macro layers. + Returns ``None`` only when the composition is an identity scaling. + """ + scale = base_scale if extra_scale is None else base_scale * extra_scale + if extra_scale is None and math.isclose(scale, 1.0): + return None + return scale -class X90Macro(QubitMacro): - """Apply 90-degree rotation around X axis (π/2 pulse).""" - def apply(self, **kwargs): - """ - Apply X90 gate. - - Args: - **kwargs: Optional parameter overrides - """ - pass +class Initialize1QMacro(InitializeStateMacro, QubitMacro): + """Initialize qubit by ramping to the `initialize` voltage point.""" + point: str = VoltagePointName.INITIALIZE.value -class XNeg90Macro(QubitMacro): - """Apply -90-degree rotation around X axis (-π/2 pulse).""" - def apply(self, **kwargs): - """ - Apply X-90 gate. +class Measure1QMacro(QubitMacro): + """PSB measure macro for a single qubit. - Args: - **kwargs: Optional parameter overrides - """ - pass + Navigates from the qubit to its preferred readout quantum dot, + finds the corresponding QuantumDotPair, and delegates to the + pair's measure macro which performs the full PSB readout chain. + """ + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) -# ============================================================================ -# Y Rotation Macros -# ============================================================================ + def apply(self, **kwargs): + """Perform PSB readout via the qubit's preferred readout dot pair. + Returns: + QUA boolean expression from the PSB state discrimination. + """ + qubit = self.qubit + preferred_dot_id = getattr(qubit, "preferred_readout_quantum_dot", None) + if preferred_dot_id is None: + raise ValueError(f"Qubit '{qubit.id}' has no preferred_readout_quantum_dot set.") + + own_dot = qubit.quantum_dot + machine = qubit.machine + pair_name = machine.find_quantum_dot_pair(own_dot.id, preferred_dot_id) + if pair_name is None: + raise ValueError( + f"No QuantumDotPair found for dots '{own_dot.id}' and " f"'{preferred_dot_id}'." + ) + + qd_pair = machine.quantum_dot_pairs[pair_name] + return qd_pair.macros[SingleQubitMacroName.MEASURE].apply(**kwargs) + + +class Empty1QMacro(EmptyStateMacro, QubitMacro): + """Move qubit to the `empty` voltage point.""" + + point: str = VoltagePointName.EMPTY.value + + +class XYDriveMacro(QubitMacro): + """Canonical XY-drive macro with angle-to-amplitude conversion. + + The macro converts rotation angle to drive amplitude using a reference + pulse (``"gaussian"`` by default). The pulse is **always played at its + native length** — only amplitude and phase are rescaled at the macro + level. This guarantees the waveform shape (e.g. Gaussian sigma) + remains self-consistent, avoiding distortion from QUA's + ``play(duration=…)`` waveform stretching. + + Calibrated amplitude is stored as ``reference_amplitude`` on the macro + (not on the pulse), keeping pulse definitions as normalised shape + templates. The pulse amplitude should remain at 1.0. + + Negative angles are represented as a +π phase shift on the requested + axis, so amplitude scaling is always computed from ``abs(angle)``. + + The optional ``phase`` rotates the drive axis in the XY plane by + applying a temporary virtual-Z frame rotation before the pulse and + restoring it afterwards. + """ + + reference_pulse_name: str = DrivePulseName.GAUSSIAN.value + reference_angle: float = float(np.pi) + default_angle: float = float(np.pi) + reference_amplitude: float = 1.0 + + @property + def reference_pulse(self): + """Return the pulse object backing this macro's XY rotations.""" + name = self._resolve_pulse_name(None) + return self.qubit.xy.operations[name] + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def _resolve_pulse_name(self, pulse_name: str | None) -> str: + if self.qubit.xy is None: + raise ValueError(f"Qubit '{self.qubit.id}' has no XY drive configured.") + + if pulse_name is not None: + if pulse_name not in self.qubit.xy.operations: + raise KeyError( + f"Pulse operation '{pulse_name}' is not defined on qubit '{self.qubit.id}'." + ) + return pulse_name + + for candidate in ( + self.reference_pulse_name, + DrivePulseName.GAUSSIAN, + DrivePulseName.DRAG, + SingleQubitMacroName.X_180, + SingleQubitMacroName.X_90, + ): + if candidate in self.qubit.xy.operations: + return candidate + + raise KeyError( + f"No reference pulse found for qubit '{self.qubit.id}'. " + "Expected one of: " + f"'{self.reference_pulse_name}', " + f"'{SingleQubitMacroName.X_180}', " + f"'{SingleQubitMacroName.X_90}'." + ) + + def _reference_pulse_length_ns(self, pulse_name: str) -> int | None: + """Reference pulse native length in nanoseconds. + + Used for voltage-sequence hold timing so the hold matches the + pulse's actual waveform length. + """ + pulse = self.qubit.xy.operations[pulse_name] + length = getattr(pulse, "length", None) + return length if isinstance(length, int) else None -class Y180Macro(QubitMacro): - """Apply 180-degree rotation around Y axis (π pulse).""" + def _angle_to_amplitude_scale(self, angle: float) -> float: + """Convert rotation angle to amplitude scale relative to reference. - def apply(self, **kwargs): + Returns a multiplicative factor: ``abs(angle) / reference_angle``. + """ + if self.reference_angle <= 0: + raise ValueError("reference_angle must be positive.") + return abs(angle) / self.reference_angle + + @staticmethod + def _normalize_angle_sign_to_phase(angle: float, phase: float) -> tuple[float, float]: + """Encode negative-angle rotations as positive angle + pi phase offset.""" + if angle < 0: + return abs(angle), phase + float(np.pi) + return angle, phase + + def inferred_duration_for_angle(self, angle: float) -> float | None: + """Infer runtime duration (seconds) for a given rotation angle. + + Duration is always the reference pulse's native length regardless + of the angle — only amplitude changes. """ - Apply Y180 gate. + try: + pulse_name = self._resolve_pulse_name(None) + length_ns = self._reference_pulse_length_ns(pulse_name) + except Exception: # pragma: no cover - defensive + return None + + return length_ns * 1e-9 if isinstance(length_ns, int) else None + + @property + def inferred_duration(self) -> float | None: + """Infer runtime duration (seconds) for ``default_angle``.""" + return self.inferred_duration_for_angle(self.default_angle) + + def update( + self, + *, + amplitude: float | None = None, + amplitude_scale: float | None = None, + duration: int | None = None, + frequency: float | None = None, + frequency_offset: float | None = None, + ) -> None: + """Persistently update calibrated pulse parameters. + + Changes are applied to the QuAM state objects directly and are + captured by subsequent serialisation (``machine.save``). Args: - **kwargs: Optional parameter overrides + amplitude: Set ``reference_amplitude`` to this absolute value. + amplitude_scale: Multiply current ``reference_amplitude`` by + this factor. Mutually exclusive with *amplitude*. + duration: Set the reference pulse length in **nanoseconds** + (quantised to 4 ns). For ``ScalableGaussianPulse`` the + sigma auto-scales via ``sigma_ratio``; for plain + ``GaussianPulse`` sigma is rescaled proportionally. + frequency: Set ``qubit.xy.intermediate_frequency`` to this + absolute value. Mutually exclusive with *frequency_offset*. + frequency_offset: Add this offset to the current + ``qubit.xy.intermediate_frequency``. """ - pass + if amplitude is not None and amplitude_scale is not None: + raise ValueError("Provide either amplitude or amplitude_scale, not both.") + if frequency is not None and frequency_offset is not None: + raise ValueError("Provide either frequency or frequency_offset, not both.") + + if amplitude is not None: + self.reference_amplitude = float(amplitude) + elif amplitude_scale is not None: + self.reference_amplitude *= float(amplitude_scale) + + if duration is not None: + pulse = self.reference_pulse + new_length = _quantize_ns(duration) + old_length = int(pulse.length) + + if hasattr(pulse, "sigma_ratio"): + # ScalableGaussianPulse: sigma auto-follows via ratio + pulse.length = new_length + pulse.sigma = pulse.length * pulse.sigma_ratio + else: + # Legacy GaussianPulse: proportionally rescale sigma + sigma = getattr(pulse, "sigma", None) + pulse.length = new_length + if sigma is not None and old_length > 0 and hasattr(pulse, "sigma"): + pulse.sigma = sigma * new_length / old_length + + if frequency is not None: + self.qubit.xy.intermediate_frequency = float(frequency) + elif frequency_offset is not None: + self.qubit.xy.intermediate_frequency += float(frequency_offset) + + def apply( + self, + angle: float | None = None, + phase: float = 0.0, + pulse_name: str | None = None, + amplitude_scale: float | None = None, + frequency_offset=None, + duration=None, + restore_frame: bool = True, + **kwargs, + ): + """Play a phase-rotated XY drive pulse with compositional scaling. + + The pulse is always played at its native waveform length unless + ``duration`` is provided (in **clock cycles**, 4 ns each). When + a custom duration is given, both the voltage-sequence hold and + the QUA ``play(duration=…)`` use the same value so they stay in + sync. + + Runtime ``amplitude_scale`` multiplies the angle-derived scale + from the reference pulse instead of replacing it. + + Runtime ``frequency_offset`` temporarily shifts the drive + intermediate frequency for this pulse and restores it afterwards. + """ + target_angle = self.default_angle if angle is None else float(angle) + if math.isclose(target_angle, 0.0): + return None + + target_angle, phase = self._normalize_angle_sign_to_phase(target_angle, phase) + resolved_pulse_name = self._resolve_pulse_name(pulse_name) + + auto_amplitude_scale = self._angle_to_amplitude_scale(target_angle) + if math.isclose(auto_amplitude_scale, 0.0): + return None + + drive_scale = _compose_amplitude_scale( + self.reference_amplitude * auto_amplitude_scale, + amplitude_scale, + ) + + # Runtime frequency shift (before pulse) + has_freq_offset = frequency_offset is not None + if has_freq_offset: + self.qubit.xy.update_frequency( + self.qubit.xy.intermediate_frequency + frequency_offset, + ) + + if not math.isclose(phase, 0.0): + self.qubit.virtual_z(phase) + + if duration is not None: + hold_duration_ns = duration * 4 + else: + hold_duration_ns = self._reference_pulse_length_ns(resolved_pulse_name) + self.qubit.voltage_sequence.step_to_voltages( + {}, + duration=hold_duration_ns, + ) + + play_kwargs = dict(kwargs) + if duration is not None: + play_kwargs["duration"] = duration + self.qubit.xy.play( + pulse_name=resolved_pulse_name, + amplitude_scale=drive_scale, + **play_kwargs, + ) + + if restore_frame and not math.isclose(phase, 0.0): + self.qubit.virtual_z(-phase) + + # Restore frequency (after pulse) + if has_freq_offset: + self.qubit.xy.update_frequency( + self.qubit.xy.intermediate_frequency, + ) + + +class _AxisRotationMacro(QubitMacro): + """Canonical axis-rotation macro delegating to `xy_drive`.""" + + default_angle: float = float(np.pi) + phase: float = 0.0 + + def _xy_drive_macro(self): + macro = self.qubit.macros.get(SingleQubitMacroName.XY_DRIVE) + if macro is None: + raise KeyError(f"Missing canonical macro '{SingleQubitMacroName.XY_DRIVE}' on qubit.") + return macro + + @property + def reference_pulse(self): + """Delegate to the XY-drive macro's reference pulse.""" + return self._xy_drive_macro().reference_pulse + + @property + def inferred_duration(self) -> float | None: + """Infer runtime duration (seconds) for `default_angle`.""" + return self.inferred_duration_for_angle(self.default_angle) + + def inferred_duration_for_angle(self, angle: float) -> float | None: + """Infer runtime duration (seconds) for a given angle via `xy_drive`.""" + macro = self._xy_drive_macro() + infer_fn = getattr(macro, "inferred_duration_for_angle", None) + return infer_fn(angle) if callable(infer_fn) else None + + def update(self, **kwargs) -> None: + """Delegate persistent parameter updates to the XY-drive macro.""" + self._xy_drive_macro().update(**kwargs) + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, angle: float | None = None, **kwargs): + """Apply rotation around fixed XY axis by delegating to `xy_drive`.""" + target_angle = self.default_angle if angle is None else float(angle) + phase = _compose_phase(self.phase, kwargs.pop("phase", None)) + runtime_amplitude_scale = kwargs.pop("amplitude_scale", None) + call_kwargs = dict(kwargs) + call_kwargs.update( + angle=target_angle, + phase=phase, + ) + if runtime_amplitude_scale is not None: + call_kwargs["amplitude_scale"] = runtime_amplitude_scale + return self.qubit.macros[SingleQubitMacroName.XY_DRIVE].apply( + **call_kwargs, + ) + + +class XMacro(_AxisRotationMacro): + """Canonical X-axis rotation macro.""" + + default_angle: float = float(np.pi) + phase: float = 0.0 + + +class YMacro(_AxisRotationMacro): + """Canonical Y-axis rotation macro.""" + + default_angle: float = float(np.pi) + phase: float = float(np.pi / 2) + +class ZMacro(QubitMacro): + """Canonical virtual-Z rotation macro.""" -class Y90Macro(QubitMacro): - """Apply 90-degree rotation around Y axis (π/2 pulse).""" + default_angle: float = float(np.pi) - def apply(self, **kwargs): - """ - Apply Y90 gate. + @property + def inferred_duration(self) -> float: + """Virtual-Z is frame-only and therefore has zero duration.""" + return 0.0 - Args: - **kwargs: Optional parameter overrides - """ - pass + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + def apply(self, angle: float | None = None, **kwargs): + """Apply virtual-Z rotation for requested angle.""" + target_angle = self.default_angle if angle is None else float(angle) + self.qubit.virtual_z(target_angle) -class YNeg90Macro(QubitMacro): - """Apply -90-degree rotation around Y axis (-π/2 pulse).""" - def apply(self, **kwargs): - """ - Apply Y-90 gate. +class _FixedAxisAngleMacro(QubitMacro): + """Fixed-angle wrapper that delegates to canonical `x`, `y`, or `z` macro.""" - Args: - **kwargs: Optional parameter overrides - """ - pass + axis_macro_name: str + default_angle: float + phase: float = 0.0 + @property + def reference_pulse(self): + """Delegate to the axis macro's reference pulse.""" + axis_macro = self.qubit.macros.get(self.axis_macro_name) + if axis_macro is None: + raise KeyError(f"Missing macro '{self.axis_macro_name}' on qubit.") + return axis_macro.reference_pulse -# ============================================================================ -# Z Rotation Macros -# ============================================================================ + @property + def inferred_duration(self) -> float | None: + axis_macro = self.qubit.macros.get(self.axis_macro_name) + if axis_macro is None: + return None + infer_fn = getattr(axis_macro, "inferred_duration_for_angle", None) + if callable(infer_fn): + return infer_fn(self.default_angle) -class Z180Macro(QubitMacro): - """Apply 180-degree rotation around Z axis (π pulse).""" + duration = getattr(axis_macro, "inferred_duration", None) + return float(duration) if isinstance(duration, (int, float)) else None - def apply(self, **kwargs): - """ - Apply Z180 gate. - - Args: - **kwargs: Optional parameter overrides - """ - pass + def update(self, **kwargs) -> None: + """Delegate persistent parameter updates to the XY-drive macro.""" + xy_drive = self.qubit.macros.get(SingleQubitMacroName.XY_DRIVE) + if xy_drive is None: + raise KeyError(f"Missing canonical macro '{SingleQubitMacroName.XY_DRIVE}' on qubit.") + xy_drive.update(**kwargs) + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) -class Z90Macro(QubitMacro): - """Apply 90-degree rotation around Z axis (π/2 pulse).""" + def apply(self, angle: float | None = None, **kwargs): + target_angle = self.default_angle if angle is None else float(angle) + extra_phase = kwargs.pop("phase", None) + runtime_amplitude_scale = kwargs.pop("amplitude_scale", None) + phase = _compose_phase(self.phase, extra_phase) + call_kwargs = dict(kwargs) + if extra_phase is not None or not math.isclose(self.phase, 0.0): + call_kwargs["phase"] = phase + if runtime_amplitude_scale is not None: + call_kwargs["amplitude_scale"] = runtime_amplitude_scale + return self.qubit.macros[self.axis_macro_name].apply( + angle=target_angle, + **call_kwargs, + ) - def apply(self, **kwargs): - """ - Apply Z90 gate. - Args: - **kwargs: Optional parameter overrides - """ - pass +class X180Macro(_FixedAxisAngleMacro): + """Apply 180-degree rotation around X axis via canonical `x` macro.""" + axis_macro_name: str = SingleQubitMacroName.X.value + default_angle: float = float(np.pi) -class ZNeg90Macro(QubitMacro): - """Apply -90-degree rotation around Z axis (-π/2 pulse).""" - def apply(self, **kwargs): - """ - Apply Z-90 gate. +class X90Macro(_FixedAxisAngleMacro): + """Apply 90-degree rotation around X axis via canonical `x` macro.""" - Args: - **kwargs: Optional parameter overrides - """ - pass + axis_macro_name: str = SingleQubitMacroName.X.value + default_angle: float = float(np.pi / 2) -# ============================================================================ -# Identity Macro -# ============================================================================ +class XNeg90Macro(_FixedAxisAngleMacro): + """Apply -90-degree rotation around X axis via canonical `x` macro.""" + axis_macro_name: str = SingleQubitMacroName.X.value + default_angle: float = float(-np.pi / 2) -class IdentityMacro(QubitMacro): - """Apply identity operation (no-op or wait).""" - def apply(self, **kwargs): - """ - Apply identity operation. +class Y180Macro(_FixedAxisAngleMacro): + """Apply 180-degree rotation around Y axis via canonical `y` macro.""" - Args: - **kwargs: Optional parameter overrides (e.g., duration) - """ - pass + axis_macro_name: str = SingleQubitMacroName.Y.value + default_angle: float = float(np.pi) -# ============================================================================ -# State Macros -# ============================================================================ +class Y90Macro(_FixedAxisAngleMacro): + """Apply 90-degree rotation around Y axis via canonical `y` macro.""" + axis_macro_name: str = SingleQubitMacroName.Y.value + default_angle: float = float(np.pi / 2) -class Initialize1QMacro(QubitMacro): - """Initialize component to its ground state.""" - ramp_duration: float = 1.0 - hold_duration: float = 1.0 +class YNeg90Macro(_FixedAxisAngleMacro): + """Apply -90-degree rotation around Y axis via canonical `y` macro.""" - def apply(self, ramp_duration=None, hold_duration=None, **kwargs): - """ - Apply initialization sequence. + axis_macro_name: str = SingleQubitMacroName.Y.value + default_angle: float = float(-np.pi / 2) - Args: - **kwargs: Optional parameter overrides - """ - ramp_duration = self.ramp_duration if ramp_duration is None else ramp_duration - hold_duration = self.hold_duration if hold_duration is None else hold_duration - # self.qubit.ramp_to_point('initialize', ramp_duration, hold_duration) - pass +class Z180Macro(_FixedAxisAngleMacro): + """Apply virtual 180-degree Z rotation via canonical `z` macro.""" + axis_macro_name: str = SingleQubitMacroName.Z.value + default_angle: float = float(np.pi) -class Measure1QMacro(QubitMacro): - """Perform measurement on component.""" - def apply(self, **kwargs): - """ - Apply measurement sequence. +class Z90Macro(_FixedAxisAngleMacro): + """Apply virtual 90-degree Z rotation via canonical `z` macro.""" - Args: - **kwargs: Optional parameter overrides - """ - from qm.qua import assign, declare, fixed + axis_macro_name: str = SingleQubitMacroName.Z.value + default_angle: float = float(np.pi / 2) - v1 = declare(fixed) - assign(v1, 1.3) - v2 = declare(fixed) - assign(v2, 1.3) - return 1.0, 1.0 +class ZNeg90Macro(_FixedAxisAngleMacro): + """Apply virtual -90-degree Z rotation via canonical `z` macro.""" -class Operate1QMacro(QubitMacro): - """Move component to operation voltage point.""" + axis_macro_name: str = SingleQubitMacroName.Z.value + default_angle: float = float(-np.pi / 2) - def apply(self, **kwargs): - """ - Apply operation point transition. - Args: - **kwargs: Optional parameter overrides - """ - pass +class IdentityMacro(QubitMacro): + """Identity operation implemented as wait.""" + duration: int = 16 -class Idle1QMacro(QubitMacro): - """Move component to idle voltage point.""" + @property + def inferred_duration(self) -> float: + """Return configured wait duration in seconds.""" + return self.duration * 1e-9 - def apply(self, **kwargs): - """ - Apply idle point transition. + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) - Args: - **kwargs: Optional parameter overrides (e.g., hold_duration) - """ - pass + def apply(self, duration: int | None = None, **kwargs): + """Implement identity as a quantized wait on qubit channels.""" + duration_ns = self.duration if duration is None else duration + if duration_ns < 0: + raise ValueError("Identity duration must be non-negative.") + duration_ns = max(0, int(round(duration_ns / 4.0)) * 4) + # Qubit.wait also issues qua.wait but expects clock cycles. Use it when available. + if hasattr(self.qubit, "wait"): + self.qubit.wait(duration_ns // 4) + else: + wait(duration_ns // 4) -# ============================================================================ -# Single Qubit Macros Dictionary -# ============================================================================ SINGLE_QUBIT_MACROS = { - # State macros - "initialize": Initialize1QMacro, - "measure": Measure1QMacro, - "operate": Operate1QMacro, - "idle": Idle1QMacro, - # X rotations - "x180": X180Macro, - "x90": X90Macro, - "x_neg90": XNeg90Macro, - # Y rotations - "y180": Y180Macro, - "y90": Y90Macro, - "y_neg90": YNeg90Macro, - # Z rotations - "z180": Z180Macro, - "z90": Z90Macro, - "z_neg90": ZNeg90Macro, - # Identity - "I": IdentityMacro, + VoltagePointName.INITIALIZE.value: Initialize1QMacro, + VoltagePointName.MEASURE.value: Measure1QMacro, + VoltagePointName.EMPTY.value: Empty1QMacro, + SingleQubitMacroName.XY_DRIVE.value: XYDriveMacro, + SingleQubitMacroName.X.value: XMacro, + SingleQubitMacroName.Y.value: YMacro, + SingleQubitMacroName.Z.value: ZMacro, + SingleQubitMacroName.X_180.value: X180Macro, + SingleQubitMacroName.X_90.value: X90Macro, + SingleQubitMacroName.X_NEG_90.value: XNeg90Macro, + X_NEG_90_ALIAS: XNeg90Macro, + SingleQubitMacroName.Y_180.value: Y180Macro, + SingleQubitMacroName.Y_90.value: Y90Macro, + SingleQubitMacroName.Y_NEG_90.value: YNeg90Macro, + Y_NEG_90_ALIAS: YNeg90Macro, + SingleQubitMacroName.Z_180.value: Z180Macro, + SingleQubitMacroName.Z_90.value: Z90Macro, + SingleQubitMacroName.Z_NEG_90.value: ZNeg90Macro, + SingleQubitMacroName.IDENTITY.value: IdentityMacro, } +# Default single-qubit macro factories for ``LDQubit`` components. diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py new file mode 100644 index 00000000..9cce9428 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py @@ -0,0 +1,350 @@ +"""State macros shared across quantum-dot component types.""" + +from __future__ import annotations + +from typing import Any, Optional + +from quam.core import quam_dataclass +from quam.core.macro import QuamMacro +from quam_builder.architecture.quantum_dots.operations.names import ( + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.tools.qua_tools import VoltageLevelType + +__all__ = [ + "InitializeStateMacro", + "EmptyStateMacro", + "ExchangeStateMacro", + "SensorDotMeasureMacro", + "MeasurePSBPairMacro", + "QPUInitializeMacro", + "QPUMeasureMacro", + "QPUEmptyMacro", + "STATE_POINT_MACROS", + "QPU_STATE_MACROS", +] + +PointType = str | dict[str, VoltageLevelType] + + +def _owner_component(macro: QuamMacro) -> Any: + """Resolve the component that owns a macro instance. + + The parent may be either the component itself or an intermediate mapping + object (for example, a QuAM dict wrapper around ``component.macros``). + """ + direct_parent = getattr(macro, "parent", None) + if direct_parent is None: + raise ValueError("Macro is not attached to a component.") + + # Macro parent can be either the owning component or an intermediate dict-like object. + if hasattr(direct_parent, "step_to_point") or hasattr(direct_parent, "macros"): + return direct_parent + + owner = getattr(direct_parent, "parent", None) + if owner is None: + raise ValueError("Could not resolve macro owner component.") + return owner + + +def _resolve_default_point_duration_ns(owner: Any, point: PointType) -> Optional[int]: + """Best-effort lookup of a point's default hold duration in nanoseconds.""" + if not isinstance(point, str): + return None + try: + full_name = owner._create_point_name(point) + point = owner.voltage_sequence.gate_set.macros[full_name] + duration = getattr(point, "duration", None) + if isinstance(duration, int): + return duration + except Exception: # pragma: no cover - defensive + return None + return None + + +def _step_to_target(owner: Any, point: PointType, duration: int | None = None) -> None: + """Step to either a named point or an explicit voltage dictionary.""" + if isinstance(point, str): + owner.step_to_point(point, duration=duration) + return + owner.step_to_voltages(point, duration=duration) + + +def _ramp_to_target( + owner: Any, + point: PointType, + ramp_duration: int, + duration: int | None = None, +) -> None: + """Ramp to either a named point or an explicit voltage dictionary.""" + if isinstance(point, str): + owner.ramp_to_point(point, ramp_duration=ramp_duration, duration=duration) + return + owner.ramp_to_voltages(point, duration=duration, ramp_duration=ramp_duration) + + +@quam_dataclass +class InitializeStateMacro(QuamMacro): + """Move component to initialize voltages using a ramp transition.""" + + point: PointType = VoltagePointName.INITIALIZE.value + ramp_duration: int = 16 + hold_duration: int | None = None + + @property + def inferred_duration(self) -> float | None: + """Return inferred runtime duration in seconds, if available.""" + owner = _owner_component(self) + hold = self.hold_duration + if hold is None: + hold = _resolve_default_point_duration_ns(owner, self.point) + if hold is None: + return None + return (self.ramp_duration + hold) * 1e-9 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + ramp_duration: int | None = None, + hold_duration: int | None = None, + **kwargs, + ): + """Ramp to the initialize target with optional runtime overrides.""" + owner = _owner_component(self) + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + hold = self.hold_duration if hold_duration is None else hold_duration + _ramp_to_target(owner, self.point, ramp_duration=ramp, duration=hold) + + +@quam_dataclass +class EmptyStateMacro(QuamMacro): + """Move component to empty voltages.""" + + point: PointType = VoltagePointName.EMPTY.value + hold_duration: int | None = None + + @property + def inferred_duration(self) -> float | None: + """Return inferred runtime duration in seconds, if available.""" + owner = _owner_component(self) + hold = self.hold_duration + if hold is None: + hold = _resolve_default_point_duration_ns(owner, self.point) + return hold * 1e-9 if hold is not None else None + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, hold_duration: int | None = None, **kwargs): + """Step to the empty target with optional hold-duration override.""" + owner = _owner_component(self) + hold = self.hold_duration if hold_duration is None else hold_duration + _step_to_target(owner, self.point, duration=hold) + + +@quam_dataclass +class ExchangeStateMacro(QuamMacro): + """Ramp to exchange target, hold, then ramp back to initialize. + + The sequence is: + 1. Ramp to the exchange target over ``ramp_duration`` ns, then hold at that + voltage for ``wait_duration`` ns (post-ramp plateau on + ``ramp_to_voltages`` — equivalent to the former separate sticky + ``step_to_voltages`` wait). + 2. Ramp back to the initialize target over ``ramp_duration`` ns (no extra + post-ramp hold; ``duration=0`` avoids ``None`` in integrated-voltage + tracking when QUA types are present). + """ + + point: PointType = VoltagePointName.EXCHANGE.value + return_point: PointType = VoltagePointName.INITIALIZE.value + ramp_duration: int = 16 + wait_duration: int = 16 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + ramp_duration: int | None = None, + wait_duration: int | None = None, + point: PointType | None = None, + return_point: PointType | None = None, + **kwargs, + ): + """Execute the exchange pulse sequence with optional runtime overrides.""" + owner = _owner_component(self) + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + wait = self.wait_duration if wait_duration is None else wait_duration + exchange_point = self.point if point is None else point + exchange_return_point = self.return_point if return_point is None else return_point + + # Ramp to exchange; hold at plateau for wait_duration (ns after ramp completes) + _ramp_to_target(owner, exchange_point, ramp_duration=ramp, duration=wait) + # Ramp back to initialize / return point + _ramp_to_target(owner, exchange_return_point, ramp_duration=ramp, duration=0) + + +@quam_dataclass +class SensorDotMeasureMacro(QuamMacro): + """PSB readout via the SensorDot readout resonator with state assignment. + + When called with a ``quantum_dot_pair_id``, applies the stored + projector and threshold for that pair to perform state discrimination, + returning a QUA boolean suitable for ``Cast.to_int()``. + + Without a pair ID, falls back to a raw resonator measurement. + """ + + pulse_name: str = "readout" + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, *args, quantum_dot_pair_id: Optional[str] = None, **kwargs): + """Measure the readout resonator and optionally perform state assignment. + + Args: + quantum_dot_pair_id: If provided, apply the projector and threshold + stored on this sensor dot for the given pair, returning a QUA + boolean (projected_value > threshold). + *args, **kwargs: Forwarded to ``readout_resonator.measure()`` when + no pair ID is given. + + Returns: + QUA boolean expression when ``quantum_dot_pair_id`` is set, + otherwise ``None`` (raw measurement stored in qua_vars). + """ + from qm.qua import declare, fixed, assign + + owner = _owner_component(self) + + I = declare(fixed) + Q = declare(fixed) + owner.readout_resonator.measure(self.pulse_name, qua_vars=(I, Q)) + + if quantum_dot_pair_id is None: + return (I, Q) + + threshold, projector = owner._readout_params(quantum_dot_pair_id) + wI = projector.get("wI", 1.0) + wQ = projector.get("wQ", 0.0) + offset = projector.get("offset", 0.0) + + x = declare(fixed) + assign(x, I * wI + Q * wQ + offset) + return x > threshold + + +@quam_dataclass +class MeasurePSBPairMacro(QuamMacro): + """PSB measure macro for QuantumDotPair. + + Steps to the measure target, then dispatches readout to the + first coupled sensor dot with the pair ID for threshold lookup. + Returns a QUA boolean for state discrimination. + """ + + point: PointType = VoltagePointName.MEASURE.value + hold_duration: int | None = None + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, hold_duration: int | None = None, **kwargs): + """Step to measure target, then perform PSB readout via sensor dot.""" + owner = _owner_component(self) + hold = self.hold_duration if hold_duration is None else hold_duration + _step_to_target(owner, self.point, duration=hold) + + if not owner.sensor_dots: + raise ValueError(f"QuantumDotPair '{owner.id}' has no sensor dots for readout.") + sensor_dot = owner.sensor_dots[0] + return sensor_dot.macros[TwoQubitMacroName.MEASURE].apply( + quantum_dot_pair_id=owner.id, + ) + + +def _iter_qpu_targets(machine: Any): + """Yield components targeted by QPU-level state macros. + + Priority order: + 1. Active qubits/pairs if explicitly set. + 2. All registered qubits/pairs. + 3. Fallback to quantum dots/pairs for stage-1 machines. + """ + if getattr(machine, "active_qubit_names", None): + for name in machine.active_qubit_names: + yield machine.qubits[name] + elif getattr(machine, "qubits", None): + yield from machine.qubits.values() + elif getattr(machine, "quantum_dots", None): + yield from machine.quantum_dots.values() + + if getattr(machine, "active_qubit_pair_names", None): + for name in machine.active_qubit_pair_names: + yield machine.qubit_pairs[name] + elif getattr(machine, "qubit_pairs", None): + yield from machine.qubit_pairs.values() + elif getattr(machine, "quantum_dot_pairs", None): + yield from machine.quantum_dot_pairs.values() + + +@quam_dataclass +class _QPUStateDispatchMacro(QuamMacro): + """Dispatch a state macro to active machine components.""" + + macro_name: str + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, **kwargs): + """Dispatch configured state macro to each selected machine target.""" + owner = _owner_component(self) + machine = getattr(owner, "machine", None) + if machine is None: + raise ValueError("QPU macro owner has no machine.") + + results = {} + for component in _iter_qpu_targets(machine): + results[component.id] = component.macros[self.macro_name].apply(**kwargs) + return results + + +@quam_dataclass +class QPUInitializeMacro(_QPUStateDispatchMacro): + """QPU-level dispatch macro for ``initialize`` state transition.""" + + macro_name: str = VoltagePointName.INITIALIZE.value + + +@quam_dataclass +class QPUMeasureMacro(_QPUStateDispatchMacro): + """QPU-level dispatch macro for ``measure`` state transition.""" + + macro_name: str = VoltagePointName.MEASURE.value + + +@quam_dataclass +class QPUEmptyMacro(_QPUStateDispatchMacro): + """QPU-level dispatch macro for ``empty`` state transition.""" + + macro_name: str = VoltagePointName.EMPTY.value + + +STATE_POINT_MACROS = { + VoltagePointName.INITIALIZE.value: InitializeStateMacro, + VoltagePointName.EMPTY.value: EmptyStateMacro, +} +# Default state-macro factories for point-capable components. + +QPU_STATE_MACROS = { + VoltagePointName.INITIALIZE.value: QPUInitializeMacro, + VoltagePointName.MEASURE.value: QPUMeasureMacro, + VoltagePointName.EMPTY.value: QPUEmptyMacro, +} +# Default state-macro factories for the machine-level QPU component. diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py index 5a1def1a..9c98249d 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py @@ -1,20 +1,27 @@ -""" -# pylint: disable=unused-argument +"""Two-qubit default macros for quantum-dot qubit pairs.""" -Two-qubit gate macros for quantum dot qubit pairs. - -These macros implement two-qubit gates like CNOT, CZ, SWAP, and iSWAP. -""" +# Framework macro base classes introduce deep inheritance chains by design. +# pylint: disable=too-many-ancestors from quam.components.macro import QubitPairMacro +from quam_builder.architecture.quantum_dots.operations.names import ( + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + EmptyStateMacro, + ExchangeStateMacro, + InitializeStateMacro, + _owner_component, +) __all__ = [ "TWO_QUBIT_MACROS", "Initialize2QMacro", "Measure2QMacro", - "Operate2QMacro", - "Idle2QMacro", + "Empty2QMacro", + "Exchange2QMacro", "CNOTMacro", "CZMacro", "SwapMacro", @@ -22,139 +29,92 @@ ] -# ============================================================================ -# State Macros -# ============================================================================ - - -class Initialize2QMacro(QubitPairMacro): - """Initialize component to its ground state.""" - - ramp_duration: float = 1.0 - hold_duration: float = 1.0 +class Initialize2QMacro(InitializeStateMacro, QubitPairMacro): + """Initialize qubit pair by ramping to the `initialize` voltage point.""" - def apply(self, ramp_duration=None, hold_duration=None, **kwargs): - """ - Apply initialization sequence. - - Args: - **kwargs: Optional parameter overrides - """ - ramp_duration = self.ramp_duration if ramp_duration is None else ramp_duration - hold_duration = self.hold_duration if hold_duration is None else hold_duration - - self.qubit.ramp_to_point("initialize", ramp_duration, hold_duration) + point: str = VoltagePointName.INITIALIZE.value class Measure2QMacro(QubitPairMacro): - """Perform measurement on component.""" - - def apply(self, **kwargs): - """ - Apply measurement sequence. + """PSB measure macro for LDQubitPair. - Args: - **kwargs: Optional parameter overrides - """ - pass + Delegates to the underlying QuantumDotPair's measure macro, + which performs the full PSB readout chain (voltage step -> sensor + dot readout -> threshold -> QUA boolean). + """ - -class Operate2QMacro(QubitPairMacro): - """Move component to operation voltage point.""" + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) def apply(self, **kwargs): - """ - Apply operation point transition. - - Args: - **kwargs: Optional parameter overrides - """ - pass + """Delegate measurement to the underlying quantum_dot_pair.""" + owner = _owner_component(self) + qd_pair = getattr(owner, "quantum_dot_pair", None) + if qd_pair is None: + raise ValueError(f"LDQubitPair '{owner.id}' has no quantum_dot_pair for readout.") + return qd_pair.macros[TwoQubitMacroName.MEASURE].apply(**kwargs) -class Idle2QMacro(QubitPairMacro): - """Move component to idle voltage point.""" +class Empty2QMacro(EmptyStateMacro, QubitPairMacro): + """Move qubit pair to the `empty` voltage point.""" - def apply(self, **kwargs): - """ - Apply idle point transition. + point: str = VoltagePointName.EMPTY.value - Args: - **kwargs: Optional parameter overrides (e.g., hold_duration) - """ - pass +class Exchange2QMacro(ExchangeStateMacro, QubitPairMacro): + """Exchange macro for LDQubitPair — ramp to exchange, wait, ramp back.""" -# ============================================================================ -# Two-Qubit Gate Macros -# ============================================================================ + point: str = VoltagePointName.EXCHANGE.value -class CNOTMacro(QubitPairMacro): - """Apply controlled-NOT gate on qubit pair.""" +class _Unsupported2QGateMacro(QubitPairMacro): + """Default placeholder for two-qubit gates requiring calibration-specific logic.""" - def apply(self, **kwargs): - """ - Apply CNOT gate. + gate_name: str - Args: - **kwargs: Optional parameter overrides - """ - pass + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + def apply(self, **kwargs): + """Raise explicit guidance to register a calibration-specific override.""" + raise NotImplementedError( + f"Default macro for '{self.gate_name}' is not yet implemented for " + f"component '{self.qubit_pair.id}'. Register a calibrated macro override." + ) -class CZMacro(QubitPairMacro): - """Apply controlled-Z gate on qubit pair.""" - def apply(self, **kwargs): - """ - Apply CZ gate. +class CNOTMacro(_Unsupported2QGateMacro): + """Default placeholder for CNOT (override required).""" - Args: - **kwargs: Optional parameter overrides - """ - pass + gate_name: str = TwoQubitMacroName.CNOT.value -class SwapMacro(QubitPairMacro): - """Apply SWAP gate on qubit pair.""" +class CZMacro(_Unsupported2QGateMacro): + """Default placeholder for CZ (override required).""" - def apply(self, **kwargs): - """ - Apply SWAP gate. + gate_name: str = TwoQubitMacroName.CZ.value - Args: - **kwargs: Optional parameter overrides - """ - pass +class SwapMacro(_Unsupported2QGateMacro): + """Default placeholder for SWAP (override required).""" -class ISwapMacro(QubitPairMacro): - """Apply iSWAP gate on qubit pair.""" + gate_name: str = TwoQubitMacroName.SWAP.value - def apply(self, **kwargs): - """ - Apply iSWAP gate. - Args: - **kwargs: Optional parameter overrides - """ - pass +class ISwapMacro(_Unsupported2QGateMacro): + """Default placeholder for iSWAP (override required).""" + gate_name: str = TwoQubitMacroName.ISWAP.value -# ============================================================================ -# Two Qubit Macros Dictionary -# ============================================================================ TWO_QUBIT_MACROS = { - # State macros - "initialize": Initialize2QMacro, - "measure": Measure2QMacro, - "operate": Operate2QMacro, - "idle": Idle2QMacro, - # Two qubit macros - "cnot": CNOTMacro, - "cz": CZMacro, - "swap": SwapMacro, - "iswap": ISwapMacro, + VoltagePointName.INITIALIZE.value: Initialize2QMacro, + VoltagePointName.MEASURE.value: Measure2QMacro, + VoltagePointName.EMPTY.value: Empty2QMacro, + TwoQubitMacroName.CNOT.value: CNOTMacro, + TwoQubitMacroName.CZ.value: CZMacro, + TwoQubitMacroName.SWAP.value: SwapMacro, + TwoQubitMacroName.ISWAP.value: ISwapMacro, + TwoQubitMacroName.EXCHANGE.value: Exchange2QMacro, } +# Default two-qubit macro factories for ``LDQubitPair`` components. diff --git a/quam_builder/architecture/quantum_dots/operations/default_operations.py b/quam_builder/architecture/quantum_dots/operations/default_operations.py index ee50f176..5259fdc4 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_operations.py +++ b/quam_builder/architecture/quantum_dots/operations/default_operations.py @@ -1,16 +1,14 @@ -""" -# pylint: disable=unused-argument - -Gate-level operations for quantum dot components using QuAM's OperationsRegistry. - -This module demonstrates how to register voltage point operations and pulse operations -as gate-level operations that can be called directly in QUA code. - -The OperationsRegistry allows you to: -1. Define operations at a high level with type hints -2. Automatically dispatch to the correct macro implementation -3. Get type checking and IDE autocomplete support -4. Write cleaner QUA code +"""Gate-level operations for quantum-dot components using QuAM OperationsRegistry. + +OperationsRegistry is a typed facade that provides operation names (e.g. `x180`, +`measure`) as callables; each call dispatches to the component's macro at runtime. +Use `operations_registry.x180(q)` when writing generic algorithms that work across +component types; use `q.x180()` for component-specific code where the component +type is known. OperationsRegistry is not required for most users — `q.x180()` is +the natural direct call; the registry is a convenience for protocol-style code. +Each registered function here is intentionally empty because the registry uses +the function signature and name as operation metadata and dispatches to +`component.macros[operation_name]` at runtime. """ from quam.core import OperationsRegistry @@ -18,26 +16,24 @@ __all__ = [ "operations_registry", - # State operations "initialize", "measure", - "operate", - "idle", - # Single-qubit rotations (X) + "empty", + "xy_drive", + "x", + "y", + "z", "x180", "x90", "x_neg90", - # Single-qubit rotations (Y) "y180", "y90", "y_neg90", - # Single-qubit rotations (Z) "z180", "z90", "z_neg90", - # Identity "I", - # Two-qubit gates + "exchange", "cnot", "cz", "swap", @@ -45,290 +41,114 @@ ] -# ============================================================================ -# Operations Registry -# ============================================================================ - -# Main registry for all quantum dot operations operations_registry = OperationsRegistry() -# ============================================================================ -# State Preparation and Measurement Operations -# ============================================================================ - @operations_registry.register_operation def initialize(component: QuantumComponent, **kwargs): - """ - Initialize component to its ground state. - - This will trigger component.macros["initialize"].apply(**kwargs) - - Args: - component: QuantumDot, SensorDot, LDQubit, or any VoltagePointMacroMixin component - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['initialize'].""" @operations_registry.register_operation def measure(component: QuantumComponent, **kwargs): - """ - Perform measurement on component. - - This will trigger component.macros["measure"].apply(**kwargs) - - Args: - component: QuantumDot, SensorDot, LDQubit, or any VoltagePointMacroMixin component - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['measure'].""" @operations_registry.register_operation -def operate(component: QuantumComponent, **kwargs): - """ - Move component to operation voltage point. +def empty(component: QuantumComponent, **kwargs): + """Dispatch to component.macros['empty'].""" - This will trigger component.macros["operate"].apply(**kwargs) - Args: - component: QuantumDot, SensorDot, LDQubit, or any VoltagePointMacroMixin component - **kwargs: Optional parameter overrides - """ - pass +@operations_registry.register_operation +def xy_drive(component: Qubit, **kwargs): + """Dispatch to component.macros['xy_drive'].""" @operations_registry.register_operation -def idle(component: QuantumComponent, **kwargs): - """ - Move component to idle voltage point. +def x(component: Qubit, **kwargs): + """Dispatch to component.macros['x'].""" - This will trigger component.macros["idle"].apply(**kwargs) - Args: - component: QuantumDot, SensorDot, LDQubit, or any VoltagePointMacroMixin component - **kwargs: Optional parameter overrides (e.g., hold_duration=200) - """ - pass +@operations_registry.register_operation +def y(component: Qubit, **kwargs): + """Dispatch to component.macros['y'].""" -# ============================================================================ -# Single-Qubit X Rotations -# ============================================================================ +@operations_registry.register_operation +def z(component: Qubit, **kwargs): + """Dispatch to component.macros['z'].""" @operations_registry.register_operation def x180(component: Qubit, **kwargs): - """ - Apply 180-degree rotation around X axis (π pulse). - - This will trigger component.macros["x180"].apply(**kwargs) - - Args: - component: LDQubit or any component with x180 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['x180'].""" @operations_registry.register_operation def x90(component: Qubit, **kwargs): - """ - Apply 90-degree rotation around X axis (π/2 pulse). - - This will trigger component.macros["x90"].apply(**kwargs) - - Args: - component: LDQubit or any component with x90 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['x90'].""" @operations_registry.register_operation def x_neg90(component: Qubit, **kwargs): - """ - Apply -90-degree rotation around X axis (-π/2 pulse). - - This will trigger component.macros["x_neg90"].apply(**kwargs) - - Args: - component: LDQubit or any component with x_neg90 pulse operation - **kwargs: Optional parameter overrides - """ - pass - - -# ============================================================================ -# Single-Qubit Y Rotations -# ============================================================================ + """Dispatch to component.macros['x_neg90'].""" @operations_registry.register_operation def y180(component: Qubit, **kwargs): - """ - Apply 180-degree rotation around Y axis (π pulse). - - This will trigger component.macros["y180"].apply(**kwargs) - - Args: - component: LDQubit or any component with y180 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['y180'].""" @operations_registry.register_operation def y90(component: Qubit, **kwargs): - """ - Apply 90-degree rotation around Y axis (π/2 pulse). - - This will trigger component.macros["y90"].apply(**kwargs) - - Args: - component: LDQubit or any component with y90 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['y90'].""" @operations_registry.register_operation def y_neg90(component: Qubit, **kwargs): - """ - Apply -90-degree rotation around Y axis (-π/2 pulse). - - This will trigger component.macros["y_neg90"].apply(**kwargs) - - Args: - component: LDQubit or any component with y_neg90 pulse operation - **kwargs: Optional parameter overrides - """ - pass - - -# ============================================================================ -# Single-Qubit Z Rotations -# ============================================================================ + """Dispatch to component.macros['y_neg90'].""" @operations_registry.register_operation def z180(component: Qubit, **kwargs): - """ - Apply 180-degree rotation around Z axis (π pulse). - - This will trigger component.macros["z180"].apply(**kwargs) - - Args: - component: LDQubit or any component with z180 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['z180'].""" @operations_registry.register_operation def z90(component: Qubit, **kwargs): - """ - Apply 90-degree rotation around Z axis (π/2 pulse). - - This will trigger component.macros["z90"].apply(**kwargs) - - Args: - component: LDQubit or any component with z90 pulse operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['z90'].""" @operations_registry.register_operation def z_neg90(component: Qubit, **kwargs): - """ - Apply -90-degree rotation around Z axis (-π/2 pulse). - - This will trigger component.macros["z_neg90"].apply(**kwargs) - - Args: - component: LDQubit or any component with z_neg90 pulse operation - **kwargs: Optional parameter overrides - """ - pass - - -# ============================================================================ -# Identity Operation -# ============================================================================ + """Dispatch to component.macros['z_neg90'].""" @operations_registry.register_operation def I(component: Qubit, **kwargs): - """ - Apply identity operation (no-op or wait). - - This will trigger component.macros["I"].apply(**kwargs) - - Args: - component: Any component with identity operation - **kwargs: Optional parameter overrides (e.g., duration) - """ - pass - - -# ============================================================================ -# Two-Qubit Gates -# ============================================================================ + """Dispatch to component.macros['I'].""" @operations_registry.register_operation def cnot(component: QubitPair, **kwargs): - """ - Apply controlled-NOT gate on qubit pair. - - This will trigger component.macros["cnot"].apply(**kwargs) - - Args: - component: LDQubitPair or any two-qubit component with cnot operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['cnot'].""" @operations_registry.register_operation def cz(component: QubitPair, **kwargs): - """ - Apply controlled-Z gate on qubit pair. - - This will trigger component.macros["cz"].apply(**kwargs) - - Args: - component: LDQubitPair or any two-qubit component with cz operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['cz'].""" @operations_registry.register_operation def swap(component: QubitPair, **kwargs): - """ - Apply SWAP gate on qubit pair. + """Dispatch to component.macros['swap'].""" - This will trigger component.macros["swap"].apply(**kwargs) - Args: - component: LDQubitPair or any two-qubit component with swap operation - **kwargs: Optional parameter overrides - """ - pass +@operations_registry.register_operation +def exchange(component: QubitPair, **kwargs): + """Dispatch to component.macros['exchange'].""" @operations_registry.register_operation def iswap(component: QubitPair, **kwargs): - """ - Apply iSWAP gate on qubit pair. - - This will trigger component.macros["iswap"].apply(**kwargs) - - Args: - component: LDQubitPair or any two-qubit component with iswap operation - **kwargs: Optional parameter overrides - """ - pass + """Dispatch to component.macros['iswap'].""" diff --git a/quam_builder/architecture/quantum_dots/operations/macro_registry.py b/quam_builder/architecture/quantum_dots/operations/macro_registry.py new file mode 100644 index 00000000..20c5225b --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/macro_registry.py @@ -0,0 +1,95 @@ +"""Registry for attaching default macro factories to component types. + +This decouples macro-default wiring from component/base classes while keeping +registration explicit and local to the architecture package. +""" + +from __future__ import annotations + +from typing import Dict, Mapping, Type + +from quam.core.macro import QuamMacro + +from quam_builder.tools.macros.default_macros import UTILITY_MACRO_FACTORIES + +__all__ = [ + "MacroFactoryMap", + "register_component_macro_factories", + "get_default_macro_factories", +] + +MacroFactoryMap = Dict[str, Type[QuamMacro]] +# Type alias for a macro-name to macro-class factory mapping. + +_COMPONENT_MACRO_FACTORIES: Dict[str, MacroFactoryMap] = {} +# Internal registry keyed by fully-qualified component class name. + +_REPLACE_KEYS: set = set() +# Keys registered with replace=True — do not inherit from bases. + + +def _component_key(component_type: type) -> str: + """Create a stable key for component-type registrations.""" + return f"{component_type.__module__}.{component_type.__qualname__}" + + +def register_component_macro_factories( + component_type: type, + macro_factories: Mapping[str, Type[QuamMacro]], + *, + replace: bool = False, +) -> None: + """Register macro factories for a component type. + + Args: + component_type: Target component class. + macro_factories: Macro name -> macro class mapping. + replace: If True, replace any existing mapping for this component type. + If False (default), merge into the existing mapping (new keys win). + """ + key = _component_key(component_type) + if replace: + _REPLACE_KEYS.add(key) + _COMPONENT_MACRO_FACTORIES[key] = dict(macro_factories) + return + if key not in _COMPONENT_MACRO_FACTORIES: + _COMPONENT_MACRO_FACTORIES[key] = dict(macro_factories) + return + + merged = dict(_COMPONENT_MACRO_FACTORIES[key]) + merged.update(macro_factories) + _COMPONENT_MACRO_FACTORIES[key] = merged + + +def get_default_macro_factories(component: object) -> MacroFactoryMap: + """Resolve default macro factories for a component instance. + + Resolution order: + 1. Utility factories (available on all macro-dispatch components) + 2. Registered factories for each type in the component MRO (base -> derived) + + Args: + component: Component instance for which to resolve default macro classes. + + Returns: + MacroFactoryMap with the effective defaults for this component. + """ + resolved: MacroFactoryMap = dict(UTILITY_MACRO_FACTORIES) + + for component_type in reversed(type(component).mro()): + key = _component_key(component_type) + if key in _COMPONENT_MACRO_FACTORIES: + if key in _REPLACE_KEYS: + # Do not inherit from bases — use only this type's factories. + resolved = dict(UTILITY_MACRO_FACTORIES) + resolved.update(_COMPONENT_MACRO_FACTORIES[key]) + else: + resolved.update(_COMPONENT_MACRO_FACTORIES[key]) + + return resolved + + +def _reset_registry() -> None: + """Clear all registered macro factories. FOR TESTING ONLY.""" + _COMPONENT_MACRO_FACTORIES.clear() + _REPLACE_KEYS.clear() diff --git a/quam_builder/architecture/quantum_dots/operations/names.py b/quam_builder/architecture/quantum_dots/operations/names.py new file mode 100644 index 00000000..7094e691 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/names.py @@ -0,0 +1,141 @@ +"""Canonical names for quantum-dot voltage points, macros, and operations. + +This module centralizes string identifiers used by default macro registration +and by user-facing override configuration. +""" + +from enum import StrEnum + +__all__ = [ + "VoltagePointName", + "SingleQubitMacroName", + "TwoQubitMacroName", + "DrivePulseName", + "X_NEG_90_ALIAS", + "Y_NEG_90_ALIAS", + "SINGLE_QUBIT_MACRO_ALIASES", + "SINGLE_QUBIT_MACRO_ALIAS_MAP", + "SINGLE_QUBIT_MACRO_NAMES", + "TWO_QUBIT_MACRO_NAMES", +] + + +class VoltagePointName(StrEnum): + """Canonical voltage-point names used by default state macros.""" + + INITIALIZE = "initialize" + MEASURE = "measure" + EMPTY = "empty" + EXCHANGE = "exchange" + + +class SingleQubitMacroName(StrEnum): + """Canonical single-qubit macro names for built-in defaults.""" + + INITIALIZE = "initialize" + MEASURE = "measure" + EMPTY = "empty" + EXCHANGE = "exchange" + + XY_DRIVE = "xy_drive" + X = "x" + Y = "y" + Z = "z" + + X_180 = "x180" + X_90 = "x90" + X_NEG_90 = "x_neg90" + + Y_180 = "y180" + Y_90 = "y90" + Y_NEG_90 = "y_neg90" + + Z_180 = "z180" + Z_90 = "z90" + Z_NEG_90 = "z_neg90" + + IDENTITY = "I" + + +class TwoQubitMacroName(StrEnum): + """Canonical two-qubit macro names for built-in defaults.""" + + INITIALIZE = "initialize" + MEASURE = "measure" + EMPTY = "empty" + EXCHANGE = "exchange" + + CNOT = "cnot" + CZ = "cz" + SWAP = "swap" + ISWAP = "iswap" + + +class DrivePulseName(StrEnum): + """Canonical drive pulse operation names for XY drive channels. + + Each entry identifies a pulse envelope type registered in + ``qubit.xy.operations``. The ``XYDriveMacro.reference_pulse_name`` + field selects which pulse is used as the single source of truth + for all single-qubit rotations. + + Users can register multiple pulse types (e.g. both ``gaussian`` + and ``drag``) and switch between them by updating + ``reference_pulse_name`` on the macro. + """ + + GAUSSIAN = "gaussian" + DRAG = "drag" + + +X_NEG_90_ALIAS = "-x90" +Y_NEG_90_ALIAS = "-y90" +# Canonical supported alias spellings for fixed-angle rotations. + +SINGLE_QUBIT_MACRO_ALIASES = ( + X_NEG_90_ALIAS, + Y_NEG_90_ALIAS, +) +# Supported alias names that map onto canonical single-qubit defaults. + +SINGLE_QUBIT_MACRO_ALIAS_MAP = { + X_NEG_90_ALIAS: SingleQubitMacroName.X_NEG_90.value, + Y_NEG_90_ALIAS: SingleQubitMacroName.Y_NEG_90.value, +} +# Alias -> canonical-name mapping used for default compatibility keys. + +SINGLE_QUBIT_MACRO_NAMES = ( + SingleQubitMacroName.INITIALIZE.value, + SingleQubitMacroName.MEASURE.value, + SingleQubitMacroName.EMPTY.value, + SingleQubitMacroName.EXCHANGE.value, + SingleQubitMacroName.XY_DRIVE.value, + SingleQubitMacroName.X.value, + SingleQubitMacroName.Y.value, + SingleQubitMacroName.Z.value, + SingleQubitMacroName.X_180.value, + SingleQubitMacroName.X_90.value, + SingleQubitMacroName.X_NEG_90.value, + X_NEG_90_ALIAS, + SingleQubitMacroName.Y_180.value, + SingleQubitMacroName.Y_90.value, + SingleQubitMacroName.Y_NEG_90.value, + Y_NEG_90_ALIAS, + SingleQubitMacroName.Z_180.value, + SingleQubitMacroName.Z_90.value, + SingleQubitMacroName.Z_NEG_90.value, + SingleQubitMacroName.IDENTITY.value, +) +# Supported single-qubit macro names for default LD qubits. + +TWO_QUBIT_MACRO_NAMES = ( + TwoQubitMacroName.INITIALIZE.value, + TwoQubitMacroName.MEASURE.value, + TwoQubitMacroName.EMPTY.value, + TwoQubitMacroName.CNOT.value, + TwoQubitMacroName.CZ.value, + TwoQubitMacroName.SWAP.value, + TwoQubitMacroName.ISWAP.value, + TwoQubitMacroName.EXCHANGE.value, +) +# Supported two-qubit macro names for default LD qubit pairs. diff --git a/quam_builder/architecture/quantum_dots/operations/pulse_registry.py b/quam_builder/architecture/quantum_dots/operations/pulse_registry.py new file mode 100644 index 00000000..26b6db9a --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/pulse_registry.py @@ -0,0 +1,126 @@ +"""Registry for attaching default pulse factories to component types. + +Mirrors the macro registry pattern +(:mod:`~quam_builder.architecture.quantum_dots.operations.macro_registry`): +pulse-default wiring is decoupled from component/base classes while keeping +registration explicit and local to the architecture package. + +How it works +------------ +1. At import time, :func:`register_component_pulse_factories` is called for + each component type that should carry default pulses (e.g. ``LDQubit``, + ``SensorDot``). The registry stores a mapping of + ``pulse_name -> factory_callable`` keyed by fully-qualified class name. + +2. At wiring time, :func:`get_default_pulse_factories` walks the component's + MRO and merges registered factories (base -> derived, later wins). + +3. The wiring layer (:func:`~quam_builder.architecture.quantum_dots.macro_engine.wiring._ensure_default_pulses`) + calls the factories and writes the resulting ``Pulse`` instances into the + component's ``operations`` dict — but only for pulse names that are not + already present (user-supplied pulses always take precedence). + +Example:: + + from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( + register_component_pulse_factories, + get_default_pulse_factories, + ) + from quam.components.pulses import SquarePulse + + register_component_pulse_factories( + MyCustomQubit, + {"pi": lambda: SquarePulse(length=100, amplitude=0.3)}, + ) + + # Later, at wiring time: + factories = get_default_pulse_factories(my_qubit_instance) + # factories == {"pi": } +""" + +from __future__ import annotations + +from typing import Callable, Dict, Mapping + +from quam.components.pulses import Pulse + +__all__ = [ + "PulseFactoryMap", + "register_component_pulse_factories", + "get_default_pulse_factories", +] + +PulseFactoryMap = Dict[str, Callable[[], Pulse]] +"""Type alias: maps pulse names to no-arg factory callables returning Pulse.""" + +_COMPONENT_PULSE_FACTORIES: Dict[str, PulseFactoryMap] = {} +# Internal registry keyed by fully-qualified component class name. + + +def _component_key(component_type: type) -> str: + """Create a stable key for component-type registrations. + + Uses the fully-qualified module + qualname so that two classes with the + same short name in different modules never collide. + """ + return f"{component_type.__module__}.{component_type.__qualname__}" + + +def register_component_pulse_factories( + component_type: type, + pulse_factories: Mapping[str, Callable[[], Pulse]], +) -> None: + """Register pulse factories for a component type. + + If the component type is already registered, new entries are merged + on top (new keys win, existing keys are preserved). + + Args: + component_type: Target component class (e.g. ``LDQubit``). + pulse_factories: Mapping of pulse name to factory callable. + Each factory should be a no-arg callable returning a + :class:`~quam.components.pulses.Pulse` instance. + + Example:: + + register_component_pulse_factories( + LDQubit, + {"x180": lambda: GaussianPulse(length=1000, amplitude=0.2, sigma=167)}, + ) + """ + key = _component_key(component_type) + if key not in _COMPONENT_PULSE_FACTORIES: + _COMPONENT_PULSE_FACTORIES[key] = dict(pulse_factories) + return + + merged = dict(_COMPONENT_PULSE_FACTORIES[key]) + merged.update(pulse_factories) + _COMPONENT_PULSE_FACTORIES[key] = merged + + +def get_default_pulse_factories(component: object) -> PulseFactoryMap: + """Resolve default pulse factories for a component instance. + + Resolution follows the MRO (base -> derived); later entries win. + This means a derived class can override individual pulse names + registered on a base class. + + Args: + component: Component instance for which to resolve defaults. + + Returns: + Merged ``PulseFactoryMap`` with the effective pulse defaults. + """ + resolved: PulseFactoryMap = {} + + for component_type in reversed(type(component).mro()): + key = _component_key(component_type) + if key in _COMPONENT_PULSE_FACTORIES: + resolved.update(_COMPONENT_PULSE_FACTORIES[key]) + + return resolved + + +def _reset_registry() -> None: + """Clear all registered pulse factories. **FOR TESTING ONLY.**""" + _COMPONENT_PULSE_FACTORIES.clear() diff --git a/quam_builder/architecture/quantum_dots/qpu/__init__.py b/quam_builder/architecture/quantum_dots/qpu/__init__.py index 0ae2250f..0d62927a 100644 --- a/quam_builder/architecture/quantum_dots/qpu/__init__.py +++ b/quam_builder/architecture/quantum_dots/qpu/__init__.py @@ -2,8 +2,11 @@ from .base_quam_qd import * from . import loss_divincenzo_quam from .loss_divincenzo_quam import * +from typing import Union __all__ = [ *base_quam_qd.__all__, *loss_divincenzo_quam.__all__, ] + +AnyQuamQD = Union[BaseQuamQD, LossDiVincenzoQuam] diff --git a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py index 99017843..72e05d7c 100644 --- a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py +++ b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py @@ -1,16 +1,28 @@ from pathlib import Path -from typing import List, Dict, Union, ClassVar, Optional, Literal, Tuple, Callable +from typing import ( + List, + Dict, + Union, + ClassVar, + Optional, + Literal, + Sequence, + Mapping, + Any, +) from dataclasses import field import numpy as np -from collections import defaultdict -from qm import QuantumMachinesManager, QuantumMachine +import importlib +import json + +from qm import QuantumMachinesManager from qm.octave import QmOctaveConfig from qm.qua.type_hints import QuaVariable, StreamType from qm.qua import declare, fixed, declare_stream from quam.serialisation import JSONSerialiser from quam.components import Octave, FrequencyConverter -from quam.components import Channel +from quam.components import Channel, pulses from quam.components.ports import FEMPortsContainer, OPXPlusPortsContainer from quam.core import quam_dataclass, QuamRoot, QuamBase @@ -30,6 +42,7 @@ ) from quam_builder.architecture.quantum_dots.components.global_gate import GlobalGate +from quam_builder.architecture.quantum_dots.components.dac_spec import QdacSpec from quam_builder.tools.voltage_sequence import VoltageSequence from quam_builder.architecture.quantum_dots.qubit import AnySpinQubit @@ -68,7 +81,7 @@ class BaseQuamQD(QuamRoot): register_sensor_dots: Internally create SensorDot objects from output physical channels, and their associated ReadoutResonator objects. register_barrier_gates: Internally create BarrierGate objects from the output physical channels. register_channel_elements: Shortcut to run register_quantum_dots, register_sensor_dots, and register_barrier_gates, i.e. a shortcut to register all the HW channel outputs. - add_point: Adds a point macro to a VirtualGateSet instance held internally. + add_point: Adds a named voltage point to a VirtualGateSet instance held internally. update_cross_compensation_submatrix: Input a list of virtual gates and a list of HW channels, as well as the associated correction submatrix. Internally it edits the VirtualGateSet matrix stored. update_full_cross_compensation: Update the full compensation matrix of the first VirtualGateSet layer. step_to_voltage: Steps the associated VoltageSequence to a dict of voltages. @@ -83,6 +96,7 @@ class BaseQuamQD(QuamRoot): default_factory=dict, metadata={"exclude": True} ) reservoirs: Dict[str, ReservoirBase] = field(default_factory=dict) + dacs: Dict[str, Dict] = field(default_factory=dict, metadata={"exclude": True}) quantum_dots: Dict[str, QuantumDot] = field(default_factory=dict) quantum_dot_pairs: Dict[str, QuantumDotPair] = field(default_factory=dict) @@ -143,71 +157,56 @@ def get_component(self, name: str) -> Union[AnySpinQubit, QuantumDot, SensorDot, def connect_to_external_source( self, - channel_source_mapping: Dict[Channel, Callable] = None, reset_voltages: bool = False, - external_qdac: bool = False, ) -> None: """ - Binds the channels to the correct external voltage source functions. If the external voltage souce is a QDAC, then set the bool external_qdac = True. + Binds the channels to the correct external voltage source functions. Args: - if external_qdac = True, then it will connect to the qdac IP saved in self.network, and it will use the qdac_ports stored in each VoltageGate. - - if external_qdac = False, then you must provide a channel mapping. - channel_source_mapping: Dict[Channel, Callable]: A dictionary mapping the channel objects to the correct external voltage source ports. - Example for an external source: - >>> - >>> channel_source_mapping = {} - ... channel_object_1: voltage_source.channel_1.current_voltage, - ... channel_object_2: voltage_source.channel_2.current_voltage - ... } - >>> + reset_voltages (bool): Whether to reset the voltages of each of the channels to the last-applied voltage, saved in the Quam state. + + The dac configuration must be saved in the same directory as the quam state.json file, as a dac_api.json. The format must be as follows: + { + "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", + "driver_class": "QDac2", + "connection": {"visalib": "@py", "address": "TCPIP::172.16.33.101::5025::SOCKET"}, + "channel_method": "channel", + "accessor": "dc_constant_V" + } """ - if external_qdac: - name = "QDAC" - from qcodes import Instrument - from qcodes_contrib_drivers.drivers.QDevil import QDAC2 - - try: - self.qdac = Instrument.find_instrument(name) - except KeyError: - self.qdac = QDAC2.QDac2( - name, visalib="@py", address=f'TCPIP::{self.network["qdac_ip"]}::5025::SOCKET' - ) + if self._dac_config is None: + raise ValueError( + "No DAC configurations found. Please save a directory of DACs with the Quam state." + ) - for channel in self.physical_channels.values(): - if hasattr(channel, "qdac_spec"): - qdac_port = channel.qdac_spec.qdac_output_port - channel.offset_parameter = self.qdac.channel(qdac_port).dc_constant_V - else: - print(f"Channel {channel.id} has no Qdac channel associated. Skipping") - if reset_voltages: - if ( - hasattr(channel, "current_external_voltage") - and channel.offset_parameter is not None - ): - channel.offset_parameter(channel.current_external_voltage) + dac_instances = self.dacs + for dac_name, config in self._dac_config.items(): + module = importlib.import_module(config["driver_module"]) + dac_class = getattr(module, config["driver_class"]) + dac_instances[dac_name] = { + "driver": dac_class(dac_name, **config["connection"]), + "channel_method": config["channel_method"], + "accessor": config["accessor"], + "is_qdac": config.get("is_qdac", False), + } - else: - for channel, fn in channel_source_mapping.items(): - # Ensure that the channel actually exists in the Quam. - chan = None - for ch in self.physical_channels.values(): - if ch is channel: - chan = ch - break - - if chan is None: - raise ValueError(f"Channel {channel.id} not found in Quam") - - chan.offset_parameter = fn - - if reset_voltages: - if ( - hasattr(chan, "current_external_voltage") - and chan.offset_parameter is not None - ): - chan.offset_parameter(chan.current_external_voltage) + for ch in self.physical_channels.values(): + dac_name = getattr(ch.dac_spec, "dac_name", "main") + if dac_name not in dac_instances: + print(f"WARNING: {ch.id} references {dac_name}, but no config found. Skipping") + continue + dac_info = dac_instances[dac_name] + dac_channel = getattr(dac_info["driver"], dac_info["channel_method"])( + ch.dac_spec.output_port + ) + ch.offset_parameter = getattr(dac_channel, dac_info["accessor"]) + + if ( + reset_voltages + and hasattr(ch, "current_external_voltage") + and ch.offset_parameter is not None + ): + ch.offset_parameter(ch.current_external_voltage) def _get_virtual_gate_set(self, channel: Channel) -> VirtualGateSet: """Find the internal VirtualGateSet associated with a particular output channel""" @@ -318,8 +317,8 @@ def register_sensor_dots( id=virtual_name, physical_channel=ch.get_reference(), ) - sensor_dot.physical_channel.readout = res self.sensor_dots[virtual_name] = sensor_dot + sensor_dot.physical_channel.readout = res if sensor_drain_mappings is not None: for ch, drain in sensor_drain_mappings.items(): @@ -345,6 +344,110 @@ def register_barrier_gates(self, barrier_channels: List[Channel]) -> None: ) self.barrier_gates[virtual_name] = barrier_gate + def wire_voltage_gate_qdac( + self, + voltage_gate: VoltageGate, + *, + qdac_output_port: int, + dac_name: str = "qdac", + with_trigger_channel: bool = False, + digital_output_key: str = "qdac_trig_0", + qdac_trigger_in: Optional[int] = None, + trigger_pulse_length_ns: int = 100, + ) -> None: + """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. + + Setting ``digital_outputs[...].parent = None`` before the line is registered under + the Quam tree causes QUAM to resolve references on an orphan + ``DigitalOutputChannel`` and emit ``get_root()`` warnings. This method registers + ``QdacSpec`` (and the optional OPX trigger ``Channel``) **first**, then moves the + existing digital line in a minimal second step. + + Args: + voltage_gate: Physical gate (e.g. from ``virtual_gate_sets[id].channels``). + qdac_output_port: QDAC channel index. + dac_name: Key under ``machine.dacs`` for the driver. + with_trigger_channel: If True, move ``digital_output_key`` into a wrapper + ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. + digital_output_key: Name of the digital output on the gate (default wiring + uses ``qdac_trig_0``). + qdac_trigger_in: Optional QDAC external trigger port. + trigger_pulse_length_ns: Length of the default ``trigger`` pulse on the + wrapper channel when ``with_trigger_channel`` is True. + """ + if with_trigger_channel: + if digital_output_key not in voltage_gate.digital_outputs: + raise KeyError( + f"{digital_output_key!r} missing from digital_outputs of " + f"{getattr(voltage_gate, 'name', voltage_gate)!r}" + ) + dig = voltage_gate.digital_outputs[digital_output_key] + digital_ch = Channel( + id=f"qdac_trig_{qdac_output_port}", + digital_outputs={}, + operations={ + "trigger": pulses.Pulse( + length=trigger_pulse_length_ns, digital_marker="ON" + ) + }, + ) + voltage_gate.dac_spec = QdacSpec( + dac_name=dac_name, + qdac_output_port=qdac_output_port, + opx_trigger_out=digital_ch, + qdac_trigger_in=qdac_trigger_in, + ) + del voltage_gate.digital_outputs[digital_output_key] + dig.parent = None + digital_ch.digital_outputs["trigger"] = dig + else: + voltage_gate.dac_spec = QdacSpec( + dac_name=dac_name, + qdac_output_port=qdac_output_port, + opx_trigger_out=None, + qdac_trigger_in=qdac_trigger_in, + ) + + def apply_qdac_channel_mapping( + self, + gate_set_id: str, + qdac_mapping: Mapping[str, Mapping[str, Any]], + *, + digital_output_key: str = "qdac_trig_0", + dac_name: str = "qdac", + qdac_trigger_in: Optional[int] = None, + trigger_pulse_length_ns: int = 100, + ) -> None: + """Apply :meth:`wire_voltage_gate_qdac` to every ``VoltageGate`` listed in ``qdac_mapping``. + + Keys of ``qdac_mapping`` must match :attr:`VoltageGate.name` (same as the prior + ``quam_config`` loop). Values are dicts with at least ``"ch"`` (QDAC port index, + or ``None`` to skip) and optional ``"trigger"`` (bool, default ``False``). + + Channel entries are resolved via ``virtual_gate_sets[gate_set_id].channels[key]`` + so ``#/`` references are followed while the gate set is already under this root. + """ + vgs = self.virtual_gate_sets[gate_set_id] + for channel_name in list(vgs.channels.keys()): + gate = vgs.channels[channel_name] + if not isinstance(gate, VoltageGate): + continue + if gate.name not in qdac_mapping: + continue + entry = qdac_mapping[gate.name] + ch_nb = entry.get("ch") + if ch_nb is None: + continue + self.wire_voltage_gate_qdac( + gate, + qdac_output_port=ch_nb, + dac_name=dac_name, + with_trigger_channel=bool(entry.get("trigger", False)), + digital_output_key=digital_output_key, + qdac_trigger_in=qdac_trigger_in, + trigger_pulse_length_ns=trigger_pulse_length_ns, + ) + def register_quantum_dot_pair( self, quantum_dot_ids: List[str], @@ -478,23 +581,29 @@ def create_virtual_gate_set( # Add to the channel mapping, which (for the VirtualGateSet) maps the physical channel names to the physical channel objects channel_mapping[physical_name] = ch.get_reference() + # Register an empty gate set first, then fill ``channels`` while ``parent`` is set. + # Otherwise QUAM may resolve ``#/`` references during construction while this + # VirtualGateSet is still detached from the QuamRoot, triggering get_root() warnings. self.virtual_gate_sets[gate_set_id] = VirtualGateSet( id=gate_set_id, - channels=channel_mapping, + channels={}, allow_rectangular_matrices=allow_rectangular_matrices, adjust_for_attenuation=adjust_for_attenuation, ) + vgs = self.virtual_gate_sets[gate_set_id] + for physical_name, ref in channel_mapping.items(): + vgs.channels[physical_name] = ref if compensation_matrix is None: compensation_matrix = np.eye(len(virtual_gate_names)).tolist() - self.virtual_gate_sets[gate_set_id].add_to_layer( + vgs.add_to_layer( layer_id="compensation_layer", source_gates=virtual_gate_names, target_gates=physical_gate_names, matrix=compensation_matrix, ) - self.voltage_sequences[gate_set_id] = self.virtual_gate_sets[gate_set_id].new_sequence( + self.voltage_sequences[gate_set_id] = vgs.new_sequence( track_integrated_voltage=True ) @@ -648,11 +757,11 @@ def step_to_voltage( actual_voltages[name] = value if not default_to_zero: - for qubit in self.qubits.keys(): + for qubit, qubit_obj in self.qubits.items(): if qubit in voltages: continue else: - voltages[qubit] = self.qubits[qubit].current_voltage + voltages[qubit] = qubit_obj.current_voltage new_sequence.step_to_voltages(actual_voltages) @@ -738,8 +847,19 @@ def to_dict(self, follow_references=False, include_defaults=False): # We treat the voltage_sequences as a runtime helper, and not as a Quam component. That way, it does not get serialised. # All the relevant information about the sequence (points, macros) are stored on the QuantumDot/Qubit level and/or the VirtualGateSet level. d.pop("voltage_sequences", None) + d.pop("dacs", None) return d + def set_dac_config(self, config: Dict) -> None: + """Set the DAC configuration(s). This will not be serialised as a Quam field""" + if config is None: + object.__setattr__(self, "_dac_config", None) + return + + if "driver_module" in config: + config = {"main": config} + object.__setattr__(self, "_dac_config", config) + @classmethod def load( cls, @@ -747,7 +867,7 @@ def load( validate_type: bool = True, fix_attrs: bool = True, ): - """Load machine from file and recreate voltage sequences""" + """Load machine, recreate runtime voltage sequences, and wire macros.""" instance = super().load( filepath_or_dict=filepath_or_dict, validate_type=validate_type, @@ -761,6 +881,60 @@ def load( track_integrated_voltage=True ) + # load the dac_api from the same directory too + if not isinstance(filepath_or_dict, dict): + if filepath_or_dict is not None: + state_dir = Path(filepath_or_dict) + if state_dir.is_file(): + state_dir = state_dir.parent + else: + state_path = Path(instance.serialiser._get_state_path()) + state_dir = state_path.parent if state_path.is_file() else state_path + dac_dir = state_dir / "dac" + if dac_dir.is_dir(): + dac_configs = {} + for f in sorted(dac_dir.glob("*.jsonc")): + with open(f) as fh: + dac_configs[f.stem] = json.load(fh) + instance.set_dac_config(dac_configs if dac_configs else None) + else: + instance.set_dac_config(None) + else: + instance.set_dac_config(None) + # We can also update the state_tracker here to hold the value held by QuantumDot.current_voltage. + from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + + wire_machine_macros(instance) + return instance + + def save( + self, + path: Optional[Union[Path, str]] = None, + content_mapping: Optional[Dict[str, str]] = None, + include_defaults: bool = False, + ignore: Optional[Sequence[str]] = None, + ): + super().save( + path, + content_mapping, + include_defaults, + ignore, + ) + + # Save the DAC driver API as a separate JSON file + if getattr(self, "_dac_config", None) is not None: + if path is not None: + state_dir = Path(path) + if state_dir.is_file(): + state_dir = state_dir.parent + else: + state_path = Path(self.serialiser._get_state_path()) + state_dir = state_path.parent if state_path.is_file() else state_path + dac_dir = state_dir / "dac" + dac_dir.mkdir(exist_ok=True) + for name, config in self._dac_config.items(): + with open(dac_dir / f"{name}.jsonc", "w") as f: + json.dump(config, f, indent=2) diff --git a/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py b/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py index ea63d17f..75e1f98e 100644 --- a/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py +++ b/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py @@ -75,13 +75,12 @@ def load( validate_type: bool = True, fix_attrs: bool = True, ): - """Load machine from file and recreate voltage sequences""" + """Load machine, upgrade to LD schema, and wire runtime macro defaults.""" instance = super().load( filepath_or_dict=filepath_or_dict, validate_type=validate_type, fix_attrs=fix_attrs, ) - instance.voltage_sequences = {} if type(instance) is BaseQuamQD: # pylint: disable=unidiomatic-typecheck instance.__class__ = cls @@ -98,6 +97,10 @@ def load( if not hasattr(instance, "active_qubit_pair_names"): instance.active_qubit_pair_names = [] + from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + + wire_machine_macros(instance) + return instance def get_component(self, name: str) -> Union[AnySpinQubit, QuantumDot, SensorDot, BarrierGate]: @@ -134,9 +137,12 @@ def register_qubit( d = quantum_dot_id dot = self.quantum_dots[d] # Assume a single quantum dot for a LD Qubit - qubit = LDQubit(id=d, quantum_dot=dot.get_reference(), xy=xy) - if readout_quantum_dot is not None: - qubit.preferred_readout_quantum_dot = readout_quantum_dot + qubit = LDQubit( + id=d, + quantum_dot=dot.get_reference(), + xy=xy, + preferred_readout_quantum_dot=readout_quantum_dot, + ) self.qubits[qubit_name] = qubit diff --git a/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py b/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py index 0417f70a..72d382bb 100644 --- a/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py +++ b/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py @@ -1,14 +1,15 @@ -from typing import Dict, Tuple, Union, Literal, TYPE_CHECKING, Optional, Type, ClassVar +# Quantum component inheritance depth is framework-driven for QuAM dataclasses. +# pylint: disable=too-many-ancestors + +from typing import Dict, Tuple, Union, Literal, TYPE_CHECKING, Optional from dataclasses import field import numpy as np from quam.components.quantum_components import Qubit from quam.components import Channel from quam.core import quam_dataclass -from quam.core.macro import QuamMacro from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin -from quam_builder.architecture.quantum_dots.operations import SINGLE_QUBIT_MACROS from qm.octave.octave_mixer_calibration import MixerCalibrationResults from qm import logger from qm import QuantumMachine @@ -47,7 +48,7 @@ class LDQubit(VoltageMacroMixin, Qubit): # pylint: disable=too-many-ancestors calibrate_octave: Calibrates the Octave channels (xy and resonator) linked to this transmon. thermalization_time: Returns the Loss DiVincenzo Qubit thermalization time in ns. reset: Reset the qubit state with a specified reset type. Default is thermal (wait thermalization time). - add_point: Adds a point macro to the associated VirtualGateSet. Also registers said point in the internal points attribute. Can accept qubit names + add_point: Adds a named voltage point to the associated VirtualGateSet. Can accept qubit names step_to_point: Steps to a pre-defined point in the internal points dict. ramp_to_point: Ramps to a pre-defined point in the internal points dict. """ @@ -57,6 +58,7 @@ class LDQubit(VoltageMacroMixin, Qubit): # pylint: disable=too-many-ancestors quantum_dot: QuantumDot xy: XYDriveBase = None + preferred_readout_quantum_dot: Optional[str] = None larmor_frequency: float = None @@ -66,17 +68,8 @@ class LDQubit(VoltageMacroMixin, Qubit): # pylint: disable=too-many-ancestors T2echo: float = None thermalization_time_factor: int = 5 - DEFAULT_MACROS: ClassVar[Dict[str, Type[QuamMacro]]] = SINGLE_QUBIT_MACROS - points: Dict[str, Dict[str, float]] = field(default_factory=dict) - def __post_init__(self): - super().__post_init__() - if isinstance(self.quantum_dot, str): - return - if self.id is None: - self.id = self.quantum_dot.id - @property def physical_channel(self) -> Channel: return self.quantum_dot.physical_channel @@ -191,27 +184,6 @@ def set_xy_frequency(self, frequency: float, recenter_LO: bool = True): else: self.xy.intermediate_frequency = intermediate_frequency - def play_xy_pulse( - self, - pulse_name: str, - pulse_duration: Optional[int] = None, - amplitude_scale: float = None, - **kwargs, - ) -> None: - """Play a pulse from the XY associated with the Qubit""" - if self.xy is None: - raise ValueError(f"No XY configured on Qubit {self.id}") - - if pulse_name not in self.xy.operations: - raise ValueError(f"Pulse {pulse_name} not in XY operations") - - self.xy.play( - pulse_name=pulse_name, - amplitude_scale=amplitude_scale, - duration=pulse_duration, - **kwargs, - ) - def virtual_z(self, phase: float) -> None: """Apply a virtual Z rotation""" frame_rotation_2pi(phase / (2 * np.pi), self.xy.name) @@ -226,12 +198,8 @@ def _validate_readout_quantum_dot(self, qd_name): f"Quantum dots {self.quantum_dot.id} and {qd_name} are not a registered Quantum Dot Pair. Please register first" ) - @property - def preferred_readout_quantum_dot(self) -> str: - return self._preferred_readout_quantum_dot - - @preferred_readout_quantum_dot.setter - def preferred_readout_quantum_dot(self, value: str): - if value is not None and not isinstance(self.quantum_dot, str): - self._validate_readout_quantum_dot(value) - self._preferred_readout_quantum_dot = value + def __setattr__(self, name, value): + if name == "preferred_readout_quantum_dot" and value is not None: + if hasattr(self, "quantum_dot") and not isinstance(self.quantum_dot, str): + self._validate_readout_quantum_dot(value) + super().__setattr__(name, value) diff --git a/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py b/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py index e0f9e336..31e8eb93 100644 --- a/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py +++ b/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py @@ -1,15 +1,16 @@ -from typing import Union, Dict, TYPE_CHECKING, Type, ClassVar +# Quantum component inheritance depth is framework-driven for QuAM dataclasses. +# pylint: disable=too-many-ancestors + +from typing import Union, TYPE_CHECKING from quam.core import quam_dataclass from quam.components import QubitPair -from quam.core.macro import QuamMacro from quam_builder.architecture.quantum_dots.components import ( QuantumDotPair, ) from quam_builder.architecture.quantum_dots.components import VoltageGate from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin -from quam_builder.architecture.quantum_dots.operations import TWO_QUBIT_MACROS from quam_builder.architecture.quantum_dots.qubit import LDQubit if TYPE_CHECKING: @@ -31,7 +32,7 @@ class LDQubitPair(VoltageMacroMixin, QubitPair): Methods: add_quantum_dot_pair: Adds the QuantumDotPair associated with the Qubit instances. - add_point: Adds a point macro to the associated VirtualGateSet. Also registers said point in the internal points attribute. Can accept qubit names + add_point: Adds a named voltage point to the associated VirtualGateSet. Can accept qubit names step_to_point: Steps to a pre-defined point in the internal points dict. ramp_to_point: Ramps to a pre-defined point in the internal points dict. """ @@ -43,8 +44,6 @@ class LDQubitPair(VoltageMacroMixin, QubitPair): quantum_dot_pair: QuantumDotPair = None - DEFAULT_MACROS: ClassVar[Dict[str, Type[QuamMacro]]] = TWO_QUBIT_MACROS - def __post_init__(self): super().__post_init__() if self.id is None: diff --git a/quam_builder/architecture/superconducting/components/__init__.py b/quam_builder/architecture/superconducting/components/__init__.py index 03302275..2fd4272a 100644 --- a/quam_builder/architecture/superconducting/components/__init__.py +++ b/quam_builder/architecture/superconducting/components/__init__.py @@ -4,4 +4,3 @@ from .tunable_coupler import * from .cross_resonance import * from .zz_drive import * -from .twpa import * diff --git a/quam_builder/architecture/superconducting/components/cross_resonance.py b/quam_builder/architecture/superconducting/components/cross_resonance.py index 6db47ace..9cfdeb96 100644 --- a/quam_builder/architecture/superconducting/components/cross_resonance.py +++ b/quam_builder/architecture/superconducting/components/cross_resonance.py @@ -29,14 +29,22 @@ def upconverter_frequency(self): @property def inferred_intermediate_frequency(self): - return self.target_qubit_LO_frequency + self.target_qubit_IF_frequency - self.LO_frequency + return ( + self.target_qubit_LO_frequency + + self.target_qubit_IF_frequency + - self.LO_frequency + ) @quam_dataclass class CrossResonanceMW(MWChannel, CrossResonanceBase): @property def inferred_intermediate_frequency(self): - return self.target_qubit_LO_frequency + self.target_qubit_IF_frequency - self.LO_frequency + return ( + self.target_qubit_LO_frequency + + self.target_qubit_IF_frequency + - self.LO_frequency + ) @property def upconverter_frequency(self): diff --git a/quam_builder/architecture/superconducting/components/flux_line.py b/quam_builder/architecture/superconducting/components/flux_line.py index 25c0d157..c513c7db 100644 --- a/quam_builder/architecture/superconducting/components/flux_line.py +++ b/quam_builder/architecture/superconducting/components/flux_line.py @@ -29,7 +29,9 @@ class FluxLine(SingleChannel): joint_offset: float = 0.0 min_offset: float = 0.0 arbitrary_offset: float = 0.0 - flux_point: Literal["joint", "independent", "min", "arbitrary", "zero"] = "independent" + flux_point: Literal["joint", "independent", "min", "arbitrary", "zero"] = ( + "independent" + ) settle_time: float = None def settle(self): diff --git a/quam_builder/architecture/superconducting/components/readout_resonator.py b/quam_builder/architecture/superconducting/components/readout_resonator.py index 0a7e2a19..2124a099 100644 --- a/quam_builder/architecture/superconducting/components/readout_resonator.py +++ b/quam_builder/architecture/superconducting/components/readout_resonator.py @@ -1,5 +1,4 @@ -from typing import Optional, Dict, Any -from dataclasses import field +from typing import Optional from quam.core import quam_dataclass from quam.components.channels import InOutIQChannel, InOutMWChannel @@ -43,10 +42,10 @@ class ReadoutResonatorBase: gef_confusion_matrix: list = None GEF_frequency_shift: float = None - extras: Dict[str, Any] = field(default_factory=dict) - @staticmethod - def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): + def calculate_voltage_scaling_factor( + fixed_power_dBm: float, target_power_dBm: float + ): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -110,7 +109,9 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) + return set_output_power_iq_channel( + self, power_in_dbm, gain, max_amplitude, Z, operation + ) @quam_dataclass diff --git a/quam_builder/architecture/superconducting/components/twpa.py b/quam_builder/architecture/superconducting/components/twpa.py index 6fff7fd7..8402df1f 100644 --- a/quam_builder/architecture/superconducting/components/twpa.py +++ b/quam_builder/architecture/superconducting/components/twpa.py @@ -1,7 +1,9 @@ from quam.core import quam_dataclass +from quam.components.channels import IQChannel from quam import QuamComponent from typing import Union, ClassVar -from .xy_drive import XYDriveMW, XYDriveIQ +from qm.qua import align, wait, update_frequency +import numpy as np __all__ = ["TWPA"] @@ -9,127 +11,84 @@ @quam_dataclass class TWPA(QuamComponent): """ - QuAM component for a Traveling-Wave Parametric Amplifier (TWPA). + Example QuAM component for a TWPA. + + Args: + id (str, int): The id of the TWPA, used to generate the name. + Can be a string, or an integer in which case it will add`Channel._default_label`. + pump (IQChannel): The pump component(sticky element) used for continuous output. + pump_ (IQChannel): The pump component(non sticky element)used for TWPA calibration + spectroscopy (IQChannel): Probe tone used for calibrating the saturation power of the TWPA + + max_avg_gain (float): The maximum average gain around the readout resonators related to the TWPA + max_avg_snr_improvement (float): The maximum average SNR improvement around the readout resonators related to the TWPA + pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement + pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement + mltpx_pump_frequency (float): calibrated pump frequency at which twpa gives proper snr improvement for multiplexed readout + mltpx_pump_amplitude (float): calibrated pump amplitude at which twpa gives proper snr improvement for multiplexed readout + p_saturation (float): calibrated saturation power of the twpa + avg_std_gain (float): standard deviation of the average gain around the readout resonators related to the TWPA + avg_std_snr_improvement (float): standard deviation of the average snr improvement around the readout resonators related to the TWPA + + dispersive_feature (float): dispersive feature of the twpa defined from it's designed parameters + qubits (list): list of qubits of which the signals are amplified by the twpa + + initialization (bool): whether to use the twpa in the QUA program or not + _initialized_ids (ClassVar[set]): A class-level set to track initialized twpa object IDs externally. + This won't be serialized since it's not an instance attribute. - Each TWPA has a pump channel implemented as two elements on the same physical - output: ``pump`` (sticky, for continuous output) and ``pump_`` (non-sticky, for - calibration). Optionally, an isolation channel with ``isolation`` (sticky) and - ``isolation_`` (non-sticky) on one physical output. - - Attributes - ---------- - id : int or str - TWPA identifier. Used for the component name (string as-is, int gets a - default prefix). - pump : XYDriveIQ or XYDriveMW - Pump channel, sticky element, used for continuous pump output. - pump_ : XYDriveIQ or XYDriveMW - Pump channel, non-sticky element, used for TWPA calibration. - isolation : XYDriveIQ or XYDriveMW, optional - Isolation channel, sticky element. Present only if the TWPA has isolation. - isolation_ : XYDriveIQ or XYDriveMW, optional - Isolation channel, non-sticky element. Present only if the TWPA has isolation. - settling_time : int - Pump settling time in ns. Default 100. - pump_frequency : float, optional - Calibrated pump frequency for maximum average SNR improvement. - pump_amplitude : float - Calibrated pump amplitude for maximum average SNR improvement. Default 1.0. - isolation_frequency : float, optional - Calibrated isolation tone frequency in Hz. - isolation_amplitude : float - Calibrated isolation tone amplitude. Default 1.0. - pumpline_attenuation (float): - attenuation in dB of the pump line from the OPX to the input of the TWPA - signalline_attenuation (float): - attenuation in dB on the signal line from the OPX to the input of the TWPA - grid_location : str, optional - Grid location for layout/plotting (e.g. "0,1"). - qubits : list, optional - Qubits whose readout signals are amplified by this TWPA. - initialization : bool - If True, use this TWPA in the QUA program (e.g. call initialize). Default True. - _initialized_ids : ClassVar[set] - Class-level set of initialized instance ids to ensure pump is initialized - only once per program run. Not serialized. """ id: Union[int, str] - pump: Union[XYDriveIQ, XYDriveMW] = None - pump_: Union[XYDriveIQ, XYDriveMW] = None - isolation: Union[XYDriveIQ, XYDriveMW] = None - isolation_: Union[XYDriveIQ, XYDriveMW] = None - - settling_time: int = 100 - pump_frequency: float = None - pump_amplitude: float = 1 - isolation_frequency: float = None - isolation_amplitude: float = 1 - - pumpline_attenuation: float = None - signalline_attenuation: float = None - - grid_location: str = None + pump: IQChannel = None + pump_: IQChannel = None + spectroscopy: IQChannel = None + + max_avg_gain: float = None + max_avg_snr_improvement: float = None + pump_frequency : float = None + pump_amplitude : float = None + mltpx_pump_frequency : float = None + mltpx_pump_amplitude : float = None + p_saturation: float = None + avg_std_gain: float=None + avg_std_snr_improvement: float= None + + dispersive_feature: float = None qubits: list = None - + initialization: bool = True _initialized_ids: ClassVar[set] = set() + @property def name(self): """The name of the twpa""" return self.id if isinstance(self.id, str) else f"twpa{self.id}" - def initialize(self, isolation: bool = False): - """ - Set the TWPA pump (and optionally isolation) to the calibrated tones for the QUA program. - - Has no effect if :attr:`initialization` is False. Each instance is initialized - at most once per program run; further calls return immediately. Sets the pump - frequency from :attr:`pump_frequency` and :attr:`pump_amplitude`, then plays - the pump operation. If ``isolation`` is True and this TWPA has isolation - channels, also sets and plays the isolation tone from :attr:`isolation_frequency` - and :attr:`isolation_amplitude`. - Parameters - ---------- - isolation : bool, optional - If True, also configure and play the isolation tone. Use when the TWPA - has isolation and you want it active. Default False. - Notes - ----- - Initialization state is tracked in :attr:`_initialized_ids`, so the pump is - turned on onl - """ + def initialize(self): # dont use twpa for the QUA program if initialization is set to False if not self.initialization: - return - # Initialize TWPA pump only when it hasn't been initialized yet + return + # Check initialization state using object ID (memory address) + # Initialize TWPA pump only when it hasn't been initialized yet # This won't be serialized since it's stored in a class-level set obj_id = id(self) - # Check initialization state using object ID (memory address) if obj_id in self._initialized_ids: return - - if self.pump_frequency is not None: - self.pump.update_frequency(int(self.pump_frequency - self.pump.LO_frequency)) - if self.pump_amplitude is not None: - self.pump.play("pump", amplitude_scale=self.pump_amplitude) - else: - self.pump.play("pump") - - if isolation: - if self.isolation_frequency is not None: - self.isolation.update_frequency( - int(self.isolation_frequency - self.isolation.LO_frequency) - ) - if self.isolation_amplitude is not None: - self.isolation.play("pump", amplitude_scale=self.isolation_amplitude) - else: - self.isolation.play("pump") - + + f_p = self.pump_frequency + p_p = self.pump_amplitude + update_frequency( + self.pump.name, + f_p+ self.pump.intermediate_frequency, + ) + self.pump.play("pump", amplitude_scale=p_p) # Store object ID externally (won't be serialized) # guarantee initializing twpa pump only once per QUA program execution self._initialized_ids.add(obj_id) + + diff --git a/quam_builder/architecture/superconducting/components/xy_drive.py b/quam_builder/architecture/superconducting/components/xy_drive.py index 708b6d67..6af81284 100644 --- a/quam_builder/architecture/superconducting/components/xy_drive.py +++ b/quam_builder/architecture/superconducting/components/xy_drive.py @@ -1,5 +1,4 @@ -from typing import Optional, Dict, Any -from dataclasses import field +from typing import Optional from quam.core import quam_dataclass from quam.components.channels import IQChannel, MWChannel @@ -22,10 +21,10 @@ class XYDriveBase: QUAM component for a XY drive line. """ - extras: Dict[str, Any] = field(default_factory=dict) - @staticmethod - def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): + def calculate_voltage_scaling_factor( + fixed_power_dBm: float, target_power_dBm: float + ): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -93,7 +92,9 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) + return set_output_power_iq_channel( + self, power_in_dbm, gain, max_amplitude, Z, operation + ) @quam_dataclass diff --git a/quam_builder/architecture/superconducting/qpu/base_quam.py b/quam_builder/architecture/superconducting/qpu/base_quam.py index f2816384..3631cd44 100644 --- a/quam_builder/architecture/superconducting/qpu/base_quam.py +++ b/quam_builder/architecture/superconducting/qpu/base_quam.py @@ -39,7 +39,7 @@ class BaseQuam(QuamRoot): ports (Union[FEMPortsContainer, OPXPlusPortsContainer]): The ports container. # _data_handler (ClassVar[DataHandler]): The data handler. # Unused qmm (Optional[QuantumMachinesManager]): The Quantum Machines Manager. - extras (dict): Additional attributes for the QUAM. + Methods: get_serialiser: Get the serialiser for the QuamRoot class. get_octave_config: Return the Octave configuration. @@ -69,15 +69,15 @@ class BaseQuam(QuamRoot): qmm: ClassVar[Optional[QuantumMachinesManager]] = None - extras: dict = field(default_factory=dict) - @classmethod def get_serialiser(cls) -> JSONSerialiser: """Get the serialiser for the QuamRoot class, which is the JSONSerialiser. This method can be overridden by subclasses to provide a custom serialiser. """ - return JSONSerialiser(content_mapping={"wiring": "wiring.json", "network": "wiring.json"}) + return JSONSerialiser( + content_mapping={"wiring": "wiring.json", "network": "wiring.json"} + ) def get_octave_config(self) -> Optional[QmOctaveConfig]: """Return the Octave configuration.""" @@ -290,7 +290,9 @@ def connect(self) -> QuantumMachinesManager: f"Failed to initialize {qmm_class.__name__} with provided settings: {e}" ) from e except Exception as e: - raise ConnectionError(f"Failed to connect to Quantum Machines Manager: {e}") from e + raise ConnectionError( + f"Failed to connect to Quantum Machines Manager: {e}" + ) from e def calibrate_octave_ports(self, QM: QuantumMachine) -> None: """Calibrate the Octave ports for all the active qubits. @@ -304,7 +306,9 @@ def calibrate_octave_ports(self, QM: QuantumMachine) -> None: try: self.qubits[name].calibrate_octave(QM) except NoCalibrationElements: - print(f"No calibration elements found for {name}. Skipping calibration.") + print( + f"No calibration elements found for {name}. Skipping calibration." + ) @property def active_qubits(self) -> List[AnyTransmon]: @@ -357,13 +361,6 @@ def declare_qua_variables( Q_st = [declare_stream() for _ in range(num_IQ_pairs)] return I, I_st, Q, Q_st, n, n_st - def initialize_qpu(self, isolation: bool = False, **kwargs): - """Initialize the QPU with the calibrated TWPA pumping points. - - Args: - isolation : bool, optional - If True, also configure and play the isolation tone. Use when the TWPA - has isolation and you want it active. Default False. - """ - for twpa in self.twpas.values(): - twpa.initialize(isolation=isolation) + def initialize_qpu(self, **kwargs): + """Initialize the QPU with the specified settings.""" + pass diff --git a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py index 388a55ee..744d383e 100644 --- a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py +++ b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py @@ -28,7 +28,9 @@ class FixedFrequencyQuam(BaseQuam): """ qubit_type: ClassVar[Type[FixedFrequencyTransmon]] = FixedFrequencyTransmon - qubit_pair_type: ClassVar[Type[FixedFrequencyTransmonPair]] = FixedFrequencyTransmonPair + qubit_pair_type: ClassVar[Type[FixedFrequencyTransmonPair]] = ( + FixedFrequencyTransmonPair + ) qubits: Dict[str, FixedFrequencyTransmon] = field(default_factory=dict) qubit_pairs: Dict[str, FixedFrequencyTransmonPair] = field(default_factory=dict) diff --git a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py index 0b17e72a..fb94fd6e 100644 --- a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py +++ b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py @@ -55,13 +55,17 @@ def apply_all_flux_to_joint_idle(self) -> None: if q.z is not None: q.z.to_joint_idle() else: - warnings.warn(f"Didn't find z-element on qubit {q.name}, didn't set to joint-idle") + warnings.warn( + f"Didn't find z-element on qubit {q.name}, didn't set to joint-idle" + ) for q in self.qubits: if self.qubits[q] not in self.active_qubits: if self.qubits[q].z is not None: self.qubits[q].z.to_min() else: - warnings.warn(f"Didn't find z-element on qubit {q}, didn't set to min") + warnings.warn( + f"Didn't find z-element on qubit {q}, didn't set to min" + ) self.apply_all_couplers_to_min() def apply_all_flux_to_min(self) -> None: @@ -81,7 +85,7 @@ def apply_all_flux_to_zero(self) -> None: def set_all_fluxes( self, flux_point: str, - target: Union[FluxTunableTransmon, FluxTunableTransmonPair] | None = None, + target: Union[FluxTunableTransmon, FluxTunableTransmonPair], ): """Set the fluxes to the specified point for the target qubit or qubit pair. @@ -103,7 +107,7 @@ def set_all_fluxes( self.apply_all_flux_to_joint_idle() if isinstance(target, FluxTunableTransmonPair): target_bias = target.mutual_flux_bias - elif isinstance(target, FluxTunableTransmon): + else: target_bias = target.z.joint_offset else: self.apply_all_flux_to_min() @@ -116,29 +120,28 @@ def set_all_fluxes( target.to_mutual_idle() target_bias = target.mutual_flux_bias - if target is None: - for q in self.qubits: - self.qubits[q].z.settle() - self.qubits[q].align() - else: - target.z.settle() - target.align() - + target.z.settle() + target.align() return target_bias - def initialize_qpu(self, isolation: bool = False, **kwargs): + + def initialize_qpu(self, **kwargs): """Initialize the QPU with the calibrated TWPA pumping points and with the specified flux point and target Args: flux_point (str): The flux point to set. Default is 'joint'. target: The qubit under study. - isolation : bool, optional - If True, also configure and play the isolation tone. Use when the TWPA - has isolation and you want it active. Default False. """ for twpa in self.twpas.values(): - twpa.initialize(isolation=isolation) + twpa.initialize() flux_point = kwargs.get("flux_point", "joint") target = kwargs.get("target", None) self.set_all_fluxes(flux_point, target) + + + + + + + \ No newline at end of file diff --git a/quam_builder/builder/qop_connectivity/build_quam_wiring.py b/quam_builder/builder/qop_connectivity/build_quam_wiring.py index 12c7eb86..2b192b93 100644 --- a/quam_builder/builder/qop_connectivity/build_quam_wiring.py +++ b/quam_builder/builder/qop_connectivity/build_quam_wiring.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Optional, Union from qualang_tools.wirer import Connectivity @@ -5,10 +6,11 @@ from quam_builder.architecture.superconducting.qpu import AnyQuam as AnyQuamSC from quam_builder.architecture.nv_center.qpu import AnyQuamNV +from quam_builder.architecture.quantum_dots.qpu import AnyQuamQD from quam_builder.builder.qop_connectivity.create_wiring import create_wiring -AnyQuam = Union[AnyQuamSC, AnyQuamNV] +AnyQuam = Union[AnyQuamSC, AnyQuamNV, AnyQuamQD] def build_quam_wiring( @@ -17,7 +19,8 @@ def build_quam_wiring( cluster_name: str, quam_instance: AnyQuam, port: Optional[int] = None, -): + path: Optional[Union[str, Path]] = None, +) -> AnyQuam: """Builds the QUAM wiring configuration and saves the machine setup. Args: @@ -26,12 +29,18 @@ def build_quam_wiring( cluster_name (str): The name of the cluster as displayed in the admin panel. quam_instance (AnyQuam): The QUAM instance to be configured. port (Optional[int]): The port number. Defaults to None. + path (Optional[Union[str, Path]]): Directory to save the machine state. + Defaults to None (uses the machine's existing save path). + + Returns: + AnyQuam: The configured QUAM instance. """ machine = quam_instance add_ports_container(connectivity, machine) add_name_and_ip(machine, host_ip, cluster_name, port) machine.wiring = create_wiring(connectivity) - machine.save() + machine.save(path) + return machine def add_ports_container(connectivity: Connectivity, machine: AnyQuam): diff --git a/quam_builder/builder/qop_connectivity/create_wiring.py b/quam_builder/builder/qop_connectivity/create_wiring.py index 27f7cc98..e3dc1706 100644 --- a/quam_builder/builder/qop_connectivity/create_wiring.py +++ b/quam_builder/builder/qop_connectivity/create_wiring.py @@ -37,6 +37,7 @@ def create_wiring(connectivity: Connectivity) -> dict: WiringLineType.FLUX, WiringLineType.LASER, WiringLineType.SPCM, + WiringLineType.PLUNGER_GATE, ]: for k, v in qubit_wiring(channels, element_id, line_type).items(): set_nested_value_with_path( @@ -54,13 +55,26 @@ def create_wiring(connectivity: Connectivity) -> dict: ) elif line_type in [ - WiringLineType.TWPA_PUMP, - WiringLineType.TWPA_ISOLATION, + WiringLineType.SENSOR_GATE, + WiringLineType.RF_RESONATOR, ]: - for k, v in twpa_wiring(channels).items(): + for k, v in qubit_wiring(channels, element_id, line_type).items(): + set_nested_value_with_path( + wiring, f"readout/{element_id}/{line_type.value}/{k}", v + ) + + elif line_type == WiringLineType.BARRIER_GATE: + for k, v in qubit_wiring(channels, element_id, line_type).items(): + set_nested_value_with_path( + wiring, f"qubit_pairs/{element_id}/{line_type.value}/{k}", v + ) + + elif line_type == WiringLineType.GLOBAL_GATE: + for k, v in qubit_wiring(channels, element_id, line_type).items(): set_nested_value_with_path( - wiring, f"twpas/{element_id}/{line_type.value}/{k}", v + wiring, f"globals/{element_id}/{line_type.value}/{k}", v ) + else: raise ValueError(f"Unknown line type {line_type}") @@ -116,24 +130,6 @@ def qubit_pair_wiring(channels: List[AnyInstrumentChannel], element_id: QubitPai return qubit_pair_line_wiring -def twpa_wiring(channels: List[AnyInstrumentChannel]) -> dict: - """Generates a dictionary containing QUAM-compatible JSON references for a list of channels from a twpa and the same line type. - - Args: - channels (List[AnyInstrumentChannel]): The list of instrument channels. - - Returns: - dict: A dictionary containing QUAM-compatible JSON references. - """ - twpa_line_wiring = {} - for channel in channels: - if not (channel.signal_type == "digital" and channel.io_type == "input"): - key, reference = get_channel_port(channel, channels) - twpa_line_wiring[key] = reference - - return twpa_line_wiring - - def get_channel_port(channel: AnyInstrumentChannel, channels: List[AnyInstrumentChannel]) -> tuple: """Determines the key and JSON reference for a given channel. diff --git a/quam_builder/builder/quantum_dots/build_qpu.py b/quam_builder/builder/quantum_dots/build_qpu.py index ddf0666a..59d428e8 100644 --- a/quam_builder/builder/quantum_dots/build_qpu.py +++ b/quam_builder/builder/quantum_dots/build_qpu.py @@ -18,6 +18,9 @@ XYDriveIQ, XYDriveMW, ) +from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, +) from quam_builder.architecture.quantum_dots.components import ( ReadoutResonatorSingle, VoltageGate, @@ -355,6 +358,12 @@ def _register_qubits(self): id=f"{qubit_name}_xy", opx_output=f"{wiring_path}/opx_output", ) + elif xy_type == "Single": + xy = XYDriveSingle( + id=f"{qubit_name}_xy", + RF_frequency=int(DEFAULT_INTERMEDIATE_FREQUENCY), + opx_output=f"{wiring_path}/opx_output", + ) self.machine.register_qubit( quantum_dot_id=self.assembly.plunger_virtual_names[plunger_id], diff --git a/quam_builder/builder/quantum_dots/build_qpu_stage1.py b/quam_builder/builder/quantum_dots/build_qpu_stage1.py index bba0fc4b..dd7bc91c 100644 --- a/quam_builder/builder/quantum_dots/build_qpu_stage1.py +++ b/quam_builder/builder/quantum_dots/build_qpu_stage1.py @@ -105,33 +105,32 @@ def _collect_physical_channels(self): def _collect_global_gates(self, wiring_by_element: Mapping[str, Any]): """Collect global gate channels with QDAC support.""" - for global_id, line_types in wiring_by_element.items(): - for line_type, line_wiring in line_types.items(): + for global_id, wiring_by_line_type in wiring_by_element.items(): + for line_type, ports in wiring_by_line_type.items(): wiring_path = f"#/wiring/globals/{global_id}/{line_type}" # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(line_wiring) - - gate = _make_voltage_gate_with_qdac(global_id, wiring_path, qdac_channel) + qdac_channel = _extract_qdac_channel(ports) + gate = _make_voltage_gate_with_qdac(global_id, wiring_path, ports, qdac_channel) self.assembly.global_gates.append(gate) def _collect_sensor_dots(self, wiring_by_element: Mapping[str, Any], resonator_cls: Any): """Collect sensor gates and resonators with QDAC support.""" - for sensor_id, line_types in wiring_by_element.items(): + for sensor_id, wiring_by_line_type in wiring_by_element.items(): sensor_channel = None resonator = None sensor_number = _extract_qubit_number(sensor_id) sensor_gate_id = f"sensor_{sensor_number}" - for line_type, line_wiring in line_types.items(): + for line_type, ports in wiring_by_line_type.items(): _validate_line_type("readout", line_type) wiring_path = f"#/wiring/readout/{sensor_id}/{line_type}" if line_type == WiringLineType.SENSOR_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(line_wiring) + qdac_channel = _extract_qdac_channel(ports) sensor_channel = _make_voltage_gate_with_qdac( - sensor_gate_id, wiring_path, qdac_channel + sensor_gate_id, wiring_path, ports, qdac_channel ) elif line_type == WiringLineType.RF_RESONATOR.value: resonator = _make_resonator(sensor_gate_id, wiring_path, resonator_cls) @@ -148,19 +147,19 @@ def _collect_qubits(self, wiring_by_element: Mapping[str, Any]): Note: XY drives are NOT collected in Stage 1. """ - for qubit_id, line_types in wiring_by_element.items(): - for line_type, line_wiring in line_types.items(): + for qubit_id, wiring_by_line_type in wiring_by_element.items(): + for line_type, ports in wiring_by_line_type.items(): _validate_line_type("qubits", line_type) wiring_path = f"#/wiring/qubits/{qubit_id}/{line_type}" if line_type == WiringLineType.PLUNGER_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(line_wiring) + qdac_channel = _extract_qdac_channel(ports) plunger_number = _extract_qubit_number(qubit_id) plunger_id = f"plunger_{plunger_number}" - plunger = _make_voltage_gate_with_qdac(plunger_id, wiring_path, qdac_channel) + plunger = _make_voltage_gate_with_qdac(plunger_id, wiring_path, ports, qdac_channel) self.assembly.plunger_channels.append(plunger) self.assembly.plunger_id_to_channel[qubit_id] = plunger @@ -168,19 +167,19 @@ def _collect_qubits(self, wiring_by_element: Mapping[str, Any]): def _collect_qubit_pairs(self, wiring_by_element: Mapping[str, Any]): """Collect barrier gates with QDAC support.""" - for pair_id, line_types in wiring_by_element.items(): - for line_type, line_wiring in line_types.items(): + for pair_id, wiring_by_line_type in wiring_by_element.items(): + for line_type, ports in wiring_by_line_type.items(): _validate_line_type("qubit_pairs", line_type) wiring_path = f"#/wiring/qubit_pairs/{pair_id}/{line_type}" if line_type == WiringLineType.BARRIER_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(line_wiring) + qdac_channel = _extract_qdac_channel(ports) barrier_id = f"barrier_{self.assembly.barrier_counter}" self.assembly.barrier_counter += 1 - barrier = _make_voltage_gate_with_qdac(barrier_id, wiring_path, qdac_channel) + barrier = _make_voltage_gate_with_qdac(barrier_id, wiring_path, ports, qdac_channel) self.assembly.barrier_channels.append(barrier) self.assembly.barrier_id_to_channel[barrier_id] = barrier self.assembly.qubit_pair_id_to_barrier_id[pair_id] = barrier_id diff --git a/quam_builder/builder/quantum_dots/build_qpu_stage2.py b/quam_builder/builder/quantum_dots/build_qpu_stage2.py index 03f2b82c..8c1e15c5 100644 --- a/quam_builder/builder/quantum_dots/build_qpu_stage2.py +++ b/quam_builder/builder/quantum_dots/build_qpu_stage2.py @@ -54,8 +54,8 @@ def __init__( xy_drive_wiring: Optional dict mapping qubit_id → XY drive configuration. Format: { "q1": { - "type": "IQ" | "MW", # Drive type - "wiring_path": "#/wiring/qubits/q1/xy", # JSON path to ports + "type": "IQ" | "MW" | "Single", + "wiring_path": "#/wiring/qubits/q1/xy", "intermediate_frequency": 500e6 # Optional IF (Hz) }, ... @@ -116,6 +116,8 @@ def build(self) -> LossDiVincenzoQuam: # Register qubits and qubit pairs self._register_qubits() self._register_qubit_pairs() + self._wire_sensor_dots_to_pairs() + self._set_preferred_readout_quantum_dots() # Type cast: machine is guaranteed to be LossDiVincenzoQuam at this point return cast(LossDiVincenzoQuam, self.machine) @@ -124,13 +126,13 @@ def _extract_xy_drive_wiring(self) -> Dict[str, Dict]: """Extract XY drive wiring from machine.wiring if available. Scans machine.wiring for qubit drive lines and automatically determines - the drive type (IQ or MW) based on the port configuration. + the drive type (IQ, MW, or Single) based on the port configuration. Returns: Dict mapping qubit_id to XY drive configuration: { "q1": { - "type": "IQ" or "MW", + "type": "IQ" | "MW" | "Single", "wiring_path": "#/wiring/qubits/q1/xy", "intermediate_frequency": 500e6 }, @@ -151,15 +153,16 @@ def _extract_xy_drive_wiring(self) -> Dict[str, Dict]: for qubit_id, line_types in qubits_wiring.items(): drive_wiring = line_types.get(WiringLineType.DRIVE.value) if drive_wiring: - # Determine drive type (IQ or MW) by inspecting port configuration - # IQ drives have opx_output_I/Q + frequency_converter_up - # MW drives have single opx_output port + # Determine drive type by inspecting port configuration: + # IQ – opx_output_I/Q + frequency_converter_up + # MW – single opx_output pointing at mw_outputs + # Single – single opx_output pointing at analog_outputs drive_type = _validate_drive_ports(qubit_id, drive_wiring) wiring_path = f"#/wiring/qubits/{qubit_id}/{WiringLineType.DRIVE.value}" # Build XY drive configuration xy_wiring[qubit_id] = { - "type": drive_type, # "IQ" or "MW" + "type": drive_type, # "IQ", "MW", or "Single" "wiring_path": wiring_path, # JSON reference to ports "intermediate_frequency": drive_wiring.get( "intermediate_frequency", DEFAULT_INTERMEDIATE_FREQUENCY @@ -320,3 +323,29 @@ def _register_qubit_pairs(self): except Exception as e: logger.error(f"Failed to register qubit pair {pair_id}: {e}") continue + + def _wire_sensor_dots_to_pairs(self): + """Populate quantum_dot_pair.sensor_dots from qubit_pair_sensor_map.""" + for pair_id, sensor_names in self.qubit_pair_sensor_map.items(): + qp = self.machine.qubit_pairs.get(pair_id) + if qp is None: + continue + qdp = qp.quantum_dot_pair + for sname in sensor_names: + for sid in self.machine.sensor_dots: + if sid == sname or sid.endswith(f"_{sname.split('_')[-1]}"): + ref = f"#/sensor_dots/{sid}" + if ref not in qdp.sensor_dots: + qdp.sensor_dots.append(ref) + + def _set_preferred_readout_quantum_dots(self): + """Set preferred_readout_quantum_dot using cross-pair topology. + + For each qubit pair (control, target), the readout dot for the + control qubit is the target's quantum dot and vice-versa. + """ + for qp in self.machine.qubit_pairs.values(): + qc = qp.qubit_control + qt = qp.qubit_target + qc.preferred_readout_quantum_dot = qt.quantum_dot.id + qt.preferred_readout_quantum_dot = qc.quantum_dot.id diff --git a/quam_builder/builder/quantum_dots/build_quam.py b/quam_builder/builder/quantum_dots/build_quam.py index 74eafc82..2b1c78dc 100644 --- a/quam_builder/builder/quantum_dots/build_quam.py +++ b/quam_builder/builder/quantum_dots/build_quam.py @@ -9,33 +9,35 @@ - Default pulse assignment """ +# Public builder APIs intentionally expose explicit parameter sets. +# pylint: disable=too-many-arguments,too-many-positional-arguments + +import warnings from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, Mapping, Any from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam.components import FrequencyConverter, LocalOscillator, Octave from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer from quam_builder.builder.quantum_dots.build_qpu import ( - QpuAssembly, _QpuBuilder, _set_default_grid_location, ) from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam -from quam_builder.builder.quantum_dots.pulses import ( - add_default_ldv_qubit_pair_pulses, - add_default_ldv_qubit_pulses, - add_default_resonator_pulses, -) -from quam_builder.architecture.superconducting.qpu import AnyQuam - +from quam_builder.architecture.quantum_dots.components import QPU +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam, AnyQuamQD +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.components import VoltageGate, QdacSpec def build_base_quam( machine: BaseQuamQD, calibration_db_path: Optional[Union[Path, str]] = None, - qdac_ip: Optional[str] = None, + dac_mapping: Optional[dict] = None, connect_qdac: bool = False, + macro_profile_path: Optional[Union[Path, str]] = None, + component_overrides: Optional[dict] = None, + instance_overrides: Optional[dict] = None, save: bool = True, ) -> BaseQuamQD: """Build Stage 1: BaseQuamQD with physical quantum dots. @@ -57,9 +59,13 @@ def build_base_quam( machine: BaseQuamQD instance with wiring defined. calibration_db_path: Path to Octave calibration database. If None, uses the machine's state directory. - qdac_ip: IP address for QDAC connection. If provided with connect_qdac=True, - connects to QDAC for external voltage control. + dac_mapping: mapping between the voltage gate and the corresponding dac channel {"voltage_gate_name": {"ch": dac_channel_number, "trigger": bool}} connect_qdac: If True, connects to QDAC using qdac_ip or machine.network['qdac_ip']. + macro_profile_path: Optional TOML file with macro override definitions. + component_overrides: Typed overrides keyed by component class. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + instance_overrides: Typed overrides keyed by component path string. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. save: If True, saves the machine state after building. Returns: @@ -84,12 +90,21 @@ def build_base_quam( # Build Stage 1: Quantum dots only _BaseQpuBuilder(machine).build() + if getattr(machine, "qpu", None) is None: + machine.qpu = QPU() + # Map the connectivity between the DAC channels of the voltage gates + add_dacs(machine, dac_mapping) + + wire_machine_macros( + machine, + macro_profile_path=macro_profile_path, + component_overrides=component_overrides, + instance_overrides=instance_overrides, + ) # Optional QDAC connection if connect_qdac: - if qdac_ip: - machine.network["qdac_ip"] = qdac_ip - machine.connect_to_external_source(external_qdac=True) + machine.connect_to_external_source() if save: machine.save() @@ -118,6 +133,9 @@ def build_loss_divincenzo_quam( xy_drive_wiring: Optional[dict] = None, qubit_pair_sensor_map: Optional[dict] = None, implicit_mapping: bool = True, + macro_profile_path: Optional[Union[Path, str]] = None, + component_overrides: Optional[dict] = None, + instance_overrides: Optional[dict] = None, save: bool = True, ) -> LossDiVincenzoQuam: """Build Stage 2: Convert BaseQuamQD to LossDiVincenzoQuam with qubits. @@ -135,7 +153,7 @@ def build_loss_divincenzo_quam( xy_drive_wiring: Optional dict mapping qubit_id → XY drive configuration. Format: { "q1": { - "type": "IQ" or "MW", + "type": "IQ" | "MW" | "Single", "wiring_path": "#/wiring/qubits/q1/xy", "intermediate_frequency": 500e6 # optional }, @@ -149,6 +167,11 @@ def build_loss_divincenzo_quam( Format: {"q1_q2": ["sensor_1", "sensor_2"], ...} implicit_mapping: If True, uses q1→virtual_dot_1 mapping. If False, requires explicit mapping configuration. + macro_profile_path: Optional TOML file with macro override definitions. + component_overrides: Typed overrides keyed by component class. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + instance_overrides: Typed overrides keyed by component path string. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. save: If True, saves the machine state after building. Returns: @@ -165,7 +188,7 @@ def build_loss_divincenzo_quam( >>> # Load Stage 1 result from file (may not have wiring) >>> xy_wiring = { ... "q1": {"type": "IQ", "wiring_path": "#/wiring/qubits/q1/xy"}, - ... "q2": {"type": "MW", "wiring_path": "#/wiring/qubits/q2/xy"}, + ... "q2": {"type": "Single", "wiring_path": "#/wiring/qubits/q2/xy"}, ... } >>> ld_machine = build_loss_divincenzo_quam( ... "path/to/base_quam_state", @@ -184,9 +207,16 @@ def build_loss_divincenzo_quam( implicit_mapping=implicit_mapping, ) machine = builder.build() + if getattr(machine, "qpu", None) is None: + machine.qpu = QPU() - # Add default pulses - add_pulses(machine) + add_ports(machine) + wire_machine_macros( + machine, + macro_profile_path=macro_profile_path, + component_overrides=component_overrides, + instance_overrides=instance_overrides, + ) if save: machine.save() @@ -199,8 +229,11 @@ def build_quam( machine: Union[BaseQuamQD, LossDiVincenzoQuam], calibration_db_path: Optional[Union[Path, str]] = None, qubit_pair_sensor_map: Optional[dict] = None, - qdac_ip: Optional[str] = None, + dac_mapping: Optional[dict] = None, connect_qdac: bool = False, + macro_profile_path: Optional[Union[Path, str]] = None, + component_overrides: Optional[dict] = None, + instance_overrides: Optional[dict] = None, save: bool = True, ) -> LossDiVincenzoQuam: # pylint: disable=too-many-arguments,too-many-positional-arguments """Build complete QuAM configuration using two-stage process. @@ -215,8 +248,13 @@ def build_quam( machine: BaseQuamQD or LossDiVincenzoQuam with wiring defined. calibration_db_path: Path to Octave calibration database. qubit_pair_sensor_map: Sensor mapping for qubit pairs. - qdac_ip: IP address for QDAC connection. + dac_mapping: mapping between the voltage gate and the corresponding dac channel {"voltage_gate_name": {"ch": dac_channel_number, "trigger": bool}} connect_qdac: If True, connects to QDAC for external voltage control. + macro_profile_path: Optional TOML file with macro override definitions. + component_overrides: Typed overrides keyed by component class. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + instance_overrides: Typed overrides keyed by component path string. See + :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. save: If True, saves the machine state after building. Returns: @@ -243,8 +281,11 @@ def build_quam( machine = build_base_quam( machine, calibration_db_path=calibration_db_path, - qdac_ip=qdac_ip, + dac_mapping=dac_mapping, connect_qdac=connect_qdac, + macro_profile_path=macro_profile_path, + component_overrides=component_overrides, + instance_overrides=instance_overrides, save=False, # Don't save yet ) @@ -252,6 +293,9 @@ def build_quam( machine = build_loss_divincenzo_quam( machine, qubit_pair_sensor_map=qubit_pair_sensor_map, + macro_profile_path=macro_profile_path, + component_overrides=component_overrides, + instance_overrides=instance_overrides, save=save, ) @@ -272,7 +316,7 @@ class _OrchestratedQuamBuilder: def __init__( self, - machine: AnyQuam, + machine: AnyQuamQD, calibration_db_path: Optional[Union[Path, str]], qubit_pair_sensor_map: Optional[dict], ) -> None: @@ -296,12 +340,8 @@ def add_qpu(self) -> None: """Build and register QPU elements.""" add_qpu(self.machine, qubit_pair_sensor_map=self.qubit_pair_sensor_map) - def add_pulses(self) -> None: - """Add default pulse configurations.""" - add_pulses(self.machine) - -def add_ports(machine: AnyQuam) -> None: +def add_ports(machine: AnyQuamQD) -> None: """Register all I/O ports referenced in wiring specifications. Scans the wiring configuration and creates port objects for all @@ -320,7 +360,7 @@ def add_ports(machine: AnyQuam) -> None: ) -def add_qpu(machine: AnyQuam, qubit_pair_sensor_map: Optional[dict] = None) -> None: +def add_qpu(machine: AnyQuamQD, qubit_pair_sensor_map: Optional[dict] = None) -> None: """Build and register QPU elements from wiring specifications. Creates and registers: @@ -338,32 +378,8 @@ def add_qpu(machine: AnyQuam, qubit_pair_sensor_map: Optional[dict] = None) -> N _QpuBuilder(machine, qubit_pair_sensor_map=qubit_pair_sensor_map).build() -def add_pulses(machine: AnyQuam) -> None: - """Add default pulse configurations to qubits and resonators. - - Configures: - - Single-qubit rotation pulses (X, Y, ±90°, 180°) - - Readout pulses for sensor resonators - - Placeholder two-qubit gate pulses - - Args: - machine: QuAM instance with qubits and sensors registered. - """ - if hasattr(machine, "qubits"): - for ldv_qubit in machine.qubits.values(): - add_default_ldv_qubit_pulses(ldv_qubit) - - if hasattr(machine, "qubit_pairs"): - for qubit_pair in machine.qubit_pairs.values(): - add_default_ldv_qubit_pair_pulses(qubit_pair) - - if hasattr(machine, "sensor_dots"): - for sensor_dot in machine.sensor_dots.values(): - add_default_resonator_pulses(sensor_dot.readout_resonator) - - def _resolve_calibration_db_path( - machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] + machine: AnyQuamQD, calibration_db_path: Optional[Union[Path, str]] ) -> Path: """Resolve and normalize Octave calibration database path. @@ -385,8 +401,8 @@ def _resolve_calibration_db_path( def add_octaves( - machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None -) -> AnyQuam: + machine: AnyQuamQD, calibration_db_path: Optional[Union[Path, str]] = None +) -> AnyQuamQD: """Scan wiring for Octaves and initialize frequency converters. Creates Octave component instances for each Octave found in the wiring @@ -417,7 +433,7 @@ def add_octaves( return machine -def add_external_mixers(machine: AnyQuam) -> AnyQuam: +def add_external_mixers(machine: AnyQuamQD) -> AnyQuamQD: """Scan wiring for external mixers and create frequency converter components. Creates mixer components with local oscillators for each external mixer @@ -452,3 +468,128 @@ def add_external_mixers(machine: AnyQuam) -> AnyQuam: machine.mixers[mixer_name] = frequency_converter return machine + + +def add_pulses(machine: LossDiVincenzoQuam) -> None: + """Add default pulses to all qubits and sensor dots on a machine. + + .. deprecated:: + Use ``wire_machine_macros()`` from + ``quam_builder.architecture.quantum_dots.macro_engine`` instead. + Default pulses are now wired automatically during ``wire_machine_macros()``. + + Args: + machine: LossDiVincenzoQuam instance to add pulses to. + """ + warnings.warn( + "add_pulses() is deprecated. " + "Use wire_machine_macros() from quam_builder.architecture.quantum_dots.macro_engine instead. " + "Pulses are now wired automatically during wire_machine_macros().", + DeprecationWarning, + stacklevel=2, + ) + from quam_builder.architecture.quantum_dots.macro_engine.wiring import _ensure_default_pulses + + _ensure_default_pulses(machine) + + +def _wire_voltage_gate_qdac( + voltage_gate: VoltageGate, + *, + qdac_output_port: int, + dac_name: str = "qdac", + with_trigger_channel: bool = False, + digital_output_key: str = "qdac_trig_0", + qdac_trigger_in: Optional[int] = None, + trigger_pulse_length_ns: int = 100, +) -> None: + """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. + + Setting ``digital_outputs[...].parent = None`` before the line is registered under + the Quam tree causes QUAM to resolve references on an orphan + ``DigitalOutputChannel`` and emit ``get_root()`` warnings. This method registers + ``QdacSpec`` (and the optional OPX trigger ``Channel``) **first**, then moves the + existing digital line in a minimal second step. + + Args: + voltage_gate: Physical gate (e.g. from ``virtual_gate_sets[id].channels``). + qdac_output_port: QDAC channel index. + dac_name: Key under ``machine.dacs`` for the driver. + with_trigger_channel: If True, move ``digital_output_key`` into a wrapper + ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. + digital_output_key: Name of the digital output on the gate (default wiring + uses ``qdac_trig_0``). + qdac_trigger_in: Optional QDAC external trigger port. + trigger_pulse_length_ns: Length of the default ``trigger`` pulse on the + wrapper channel when ``with_trigger_channel`` is True. + """ + if with_trigger_channel: + if digital_output_key not in voltage_gate.digital_outputs: + raise KeyError( + f"{digital_output_key!r} missing from digital_outputs of " + f"{getattr(voltage_gate, 'name', voltage_gate)!r}" + ) + dig = voltage_gate.digital_outputs[digital_output_key] + # digital_ch = Channel( + # id=f"qdac_trig_{qdac_output_port}", + # digital_outputs={}, + # operations={ + # "trigger": pulses.Pulse( + # length=trigger_pulse_length_ns, digital_marker="ON" + # ) + # }, + # ) + voltage_gate.dac_spec = QdacSpec( + dac_name=dac_name, + qdac_output_port=qdac_output_port, + opx_trigger_out=dig.opx_output.get_reference(), + qdac_trigger_in=qdac_trigger_in, + ) + # del voltage_gate.digital_outputs[digital_output_key] + # dig.parent = None + # digital_ch.digital_outputs["trigger"] = dig + else: + voltage_gate.dac_spec = QdacSpec( + dac_name=dac_name, + qdac_output_port=qdac_output_port, + ) + +def add_dacs( + machine: BaseQuamQD, + dac_mapping: Mapping[str, Mapping[str, Any]], + *, + digital_output_key: str = "qdac_trig_0", + dac_name: str = "qdac", + qdac_trigger_in: Optional[int] = None, + trigger_pulse_length_ns: int = 100, +) -> None: + """Apply :meth:`wire_voltage_gate_qdac` to every ``VoltageGate`` listed in ``qdac_mapping``. + + Keys of ``qdac_mapping`` must match :attr:`VoltageGate.name` (same as the prior + ``quam_config`` loop). Values are dicts with at least ``"ch"`` (QDAC port index, + or ``None`` to skip) and optional ``"trigger"`` (bool, default ``False``). + + Channel entries are resolved via ``virtual_gate_sets[gate_set_id].channels[key]`` + so ``#/`` references are followed while the gate set is already under this root. + """ + for vgs_key in machine.virtual_gate_sets.keys(): + vgs = machine.virtual_gate_sets[vgs_key] + for channel_name in list(vgs.channels.keys()): + gate = vgs.channels[channel_name] + if not isinstance(gate, VoltageGate): + continue + if gate.name not in dac_mapping: + continue + entry = dac_mapping[gate.name] + ch_nb = entry.get("ch") + if ch_nb is None: + continue + _wire_voltage_gate_qdac( + gate, + qdac_output_port=ch_nb, + dac_name=dac_name, + with_trigger_channel=bool(entry.get("trigger", False)), + digital_output_key=digital_output_key, + qdac_trigger_in=qdac_trigger_in, + trigger_pulse_length_ns=trigger_pulse_length_ns, + ) diff --git a/quam_builder/builder/quantum_dots/build_utils.py b/quam_builder/builder/quantum_dots/build_utils.py index 82623f7d..01898ea5 100644 --- a/quam_builder/builder/quantum_dots/build_utils.py +++ b/quam_builder/builder/quantum_dots/build_utils.py @@ -21,8 +21,14 @@ ReadoutResonatorSingle, VoltageGate, ) -from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ, XYDriveMW -from quam_builder.architecture.superconducting.qpu import AnyQuam +from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveIQ, + XYDriveMW, + XYDriveSingle, +) +from quam_builder.builder.qop_connectivity.get_digital_outputs import ( + get_digital_outputs, +) from quam_builder.builder.qop_connectivity.channel_ports import ( iq_out_channel_ports, mw_out_channel_ports, @@ -32,6 +38,7 @@ DEFAULT_GATE_SET_ID = "main_qpu" DEFAULT_STICKY_DURATION = 16 DEFAULT_INTERMEDIATE_FREQUENCY = 500e6 +DEFAULT_RESONATOR_INTERMEDIATE_FREQUENCY = 0 DEFAULT_READOUT_LENGTH = 200 DEFAULT_READOUT_AMPLITUDE = 0.01 @@ -186,7 +193,7 @@ def _make_resonator(sensor_id: str, wiring_path: str, resonator_cls: Any) -> Rea return resonator_cls( id=f"readout_resonator_{sensor_number}", frequency_bare=0, - intermediate_frequency=DEFAULT_INTERMEDIATE_FREQUENCY, + intermediate_frequency=DEFAULT_RESONATOR_INTERMEDIATE_FREQUENCY, operations={ "readout": pulses.SquareReadoutPulse( length=DEFAULT_READOUT_LENGTH, id="readout", amplitude=DEFAULT_READOUT_AMPLITUDE @@ -194,19 +201,29 @@ def _make_resonator(sensor_id: str, wiring_path: str, resonator_cls: Any) -> Rea }, opx_output=f"{wiring_path}/opx_output", opx_input=f"{wiring_path}/opx_input", - sticky=_make_sticky_channel(), ) def _validate_drive_ports(qubit_id: str, ports: Mapping[str, Any]) -> str: """Validate qubit drive port configuration and determine drive type. + Distinguishes three configurations based on port keys *and* the port + reference path: + + * **IQ** -- ``opx_output_I``, ``opx_output_Q``, ``frequency_converter_up`` + present. Used for LF-FEM baseband I/Q with an Octave or external mixer. + * **MW** -- single ``opx_output`` whose reference points at ``mw_outputs``. + * **Single** -- single ``opx_output`` whose reference points at + ``analog_outputs`` (LF-FEM / OPX+). Used for baseband EDSR / ESR + driving without external upconversion. + Args: qubit_id: Identifier of the qubit being validated. - ports: Port configuration mapping. + ports: Port configuration mapping (keys are port names, values are + JSON reference strings such as ``#/ports/analog_outputs/...``). Returns: - Drive type string: "IQ" for IQ mixing or "MW" for microwave. + Drive type string: ``"IQ"``, ``"MW"``, or ``"Single"``. Raises: ValueError: If port configuration is ambiguous or incomplete. @@ -218,12 +235,32 @@ def _validate_drive_ports(qubit_id: str, ports: Mapping[str, Any]) -> str: raise ValueError( f"Qubit {qubit_id} wiring is ambiguous: matches both IQ and MW drive ports" ) - if not has_iq and not has_mw: - raise ValueError( - f"Qubit {qubit_id} wiring is incomplete: missing IQ ports {iq_out_channel_ports} " - f"and MW ports {mw_out_channel_ports}" - ) - return "IQ" if has_iq else "MW" + if has_iq: + return "IQ" + if has_mw: + ref = ports.get("opx_output", "") + # When accessed through QuAM reference resolution, ref may be an + # actual port object rather than a string reference. + ref_str = str(ref) + if "mw_outputs" in ref_str or "MWFEM" in type(ref).__name__: + return "MW" + # Check the raw/unresolved value if available (QuAM dict wrapper) + raw_ref = None + if hasattr(ports, "get_raw_value"): + raw_ref = str(ports.get_raw_value("opx_output")) + elif hasattr(ports, "get_unreferenced_value"): + raw_ref = str(ports.get_unreferenced_value("opx_output")) + if raw_ref and "mw_outputs" in raw_ref: + return "MW" + # TODO: When qualang_tools supports LF-FEM IQ allocation for drive + # lines (two outputs + frequency converter), the IQ path above will + # match automatically. The "Single" branch here covers the current + # DC-allocated single-port case only. + return "Single" + raise ValueError( + f"Qubit {qubit_id} wiring is incomplete: missing IQ ports {iq_out_channel_ports} " + f"and MW ports {mw_out_channel_ports}" + ) def _build_virtual_mapping( @@ -293,22 +330,27 @@ def _extract_qdac_channel(wiring_dict: Dict[str, Any]) -> int | None: def _make_voltage_gate_with_qdac( - gate_id: str, wiring_path: str, qdac_channel: int | None = None + gate_id: str, wiring_path: str, ports: Dict[str, str], qdac_channel: int | None = None ) -> VoltageGate: """Create a voltage gate component with sticky channel and optional QDAC mapping. Args: gate_id: Identifier for the gate. wiring_path: JSON path to wiring configuration. + ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. qdac_channel: Optional QDAC channel number for external voltage control. Returns: Configured VoltageGate instance with QDAC channel if provided. """ + + digital_outputs = get_digital_outputs(wiring_path, ports, "qdac_trig") + gate = VoltageGate( id=gate_id, opx_output=f"{wiring_path}/opx_output", sticky=_make_sticky_channel(), + digital_outputs=digital_outputs, ) if qdac_channel is not None: gate.qdac_channel = qdac_channel @@ -368,39 +410,51 @@ def _create_xy_drive_from_wiring( drive_type: str, wiring_path: str, intermediate_frequency: float = DEFAULT_INTERMEDIATE_FREQUENCY, -) -> Any: # Returns XYDriveIQ or XYDriveMW +) -> Any: # Returns XYDriveIQ, XYDriveMW, or XYDriveSingle """Create an XY drive channel from wiring specification. Args: qubit_id: Qubit identifier for naming the drive. - drive_type: Type of drive - 'IQ' or 'MW'. + drive_type: ``"IQ"``, ``"MW"``, or ``"Single"`` (see + :func:`_validate_drive_ports`). wiring_path: JSON path to wiring configuration for ports. intermediate_frequency: IF for the drive channel. Returns: - XYDriveIQ or XYDriveMW instance based on drive_type. + XYDriveIQ, XYDriveMW, or XYDriveSingle instance based on *drive_type*. Raises: - ValueError: If drive_type is not 'IQ' or 'MW'. + ValueError: If drive_type is not recognised. """ drive_id = f"{qubit_id}_xy" + # TODO: XYDriveIQ is the preferred type for LF-FEM when IQ ports are + # available (allocated via WiringFrequency.RF with Octave fallback). + # XYDriveSingle is the baseband-only fallback for DC-allocated + # single-port drives. Once qualang_tools exposes LF-FEM IQ allocation + # for drive lines, the "IQ" branch below will handle it automatically. if drive_type == "IQ": return XYDriveIQ( id=drive_id, - intermediate_frequency=intermediate_frequency, opx_output_I=f"{wiring_path}/opx_output_I", opx_output_Q=f"{wiring_path}/opx_output_Q", frequency_converter_up=f"{wiring_path}/frequency_converter_up", + RF_frequency=None, ) if drive_type == "MW": return XYDriveMW( id=drive_id, - intermediate_frequency=intermediate_frequency, + RF_frequency=None, + opx_output=f"{wiring_path}/opx_output", + ) + if drive_type == "Single": + return XYDriveSingle( + id=drive_id, + RF_frequency=int(intermediate_frequency), opx_output=f"{wiring_path}/opx_output", ) - raise ValueError(f"Unknown drive type: {drive_type}. Expected 'IQ' or 'MW'.") + raise ValueError(f"Unknown drive type: {drive_type}. Expected 'IQ', 'MW', or 'Single'.") # pylint: disable=undefined-all-variable diff --git a/quam_builder/builder/quantum_dots/pulses.py b/quam_builder/builder/quantum_dots/pulses.py index 0f2952cd..94183276 100644 --- a/quam_builder/builder/quantum_dots/pulses.py +++ b/quam_builder/builder/quantum_dots/pulses.py @@ -1,18 +1,22 @@ """Default pulse configurations for quantum dot qubits and qubit pairs. -This module provides functions to add standard pulse configurations to -Loss-DiVincenzo qubits, including: -- Single-qubit rotation pulses (X and Y rotations at 90° and 180°) -- Readout pulses for sensor resonators -- Placeholder two-qubit gate pulses for qubit pairs +.. deprecated:: + This module is superseded by the macro-engine pulse wiring system. + Use ``wire_machine_macros()`` from + ``quam_builder.architecture.quantum_dots.macro_engine`` instead. + Pulse defaults are now registered via ``component_pulse_catalog`` and + applied automatically during ``wire_machine_macros()``. """ -from typing import Any, Union +import warnings +from typing import Any, Literal import numpy as np -from quam.components.pulses import GaussianPulse, SquareReadoutPulse, DragPulse +from quam.components.pulses import GaussianPulse, SquareReadoutPulse, SquarePulse +from quam.components.channels import SingleChannel from qualang_tools.addons.calibration.calibrations import unit from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.components import ReadoutResonatorBase +from quam_builder.architecture.quantum_dots.components import ANY_READOUT_RESONATOR, VoltageGate +from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME, MIN_PULSE_DURATION_NS u = unit(coerce_to_integer=True) @@ -20,37 +24,38 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: """Add default Gaussian pulses for Loss-DiVincenzo qubit single-qubit gates. - Configures standard single-qubit rotation pulses using Gaussian envelopes: - - X and Y rotations at 180° (pi pulses) - - X and Y rotations at ±90° (pi/2 pulses) - - Default pulse parameters: - - Length: 1000 ns - - Amplitude: 0.2 (180°), 0.1 (90°) - - Sigma: length / 6 (for Gaussian envelope) - - Pulses are added to the qubit's xy (ESR/MW drive) if present. + .. deprecated:: + Use ``wire_machine_macros()`` instead. Pulses are now wired automatically. Args: qubit: Loss-DiVincenzo qubit instance to configure. - - Note: - These are placeholder values. Actual pulse parameters should be determined - through calibration for your specific quantum dot device. """ + warnings.warn( + "add_default_ldv_qubit_pulses() is deprecated. " + "Use wire_machine_macros() from quam_builder.architecture.quantum_dots.macro_engine instead.", + DeprecationWarning, + stacklevel=2, + ) # ESR/MW drive pulses (if xy exists) if hasattr(qubit, "xy") and qubit.xy is not None: pulse_length = 1000 # ns pulse_amp = 0.2 sigma = pulse_length / 6 - # X rotations + # SingleChannel (XYDriveSingle) uses real-valued waveforms only. + # IQ/MW channels use axis_angle for hardware IQ mixing. + # Y-axis rotations on SingleChannel are handled by the macro engine + # via virtual_z frame rotation, so no axis_angle is needed. + is_single = isinstance(qubit.xy, SingleChannel) + x_angle = None if is_single else 0.0 + y_angle = None if is_single else float(np.pi / 2) + qubit.xy.operations["x180"] = GaussianPulse( id="x180", length=pulse_length, amplitude=pulse_amp, sigma=sigma, - axis_angle=0.0, + axis_angle=x_angle, ) qubit.xy.operations["x90"] = GaussianPulse( @@ -58,16 +63,15 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: length=pulse_length, amplitude=pulse_amp / 2, sigma=sigma, - axis_angle=0.0, + axis_angle=x_angle, ) - # Y rotations qubit.xy.operations["y180"] = GaussianPulse( id="y180", length=pulse_length, amplitude=pulse_amp, sigma=sigma, - axis_angle=float(np.pi / 2), + axis_angle=y_angle, ) qubit.xy.operations["y90"] = GaussianPulse( @@ -75,16 +79,15 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: length=pulse_length, amplitude=pulse_amp / 2, sigma=sigma, - axis_angle=float(np.pi / 2), + axis_angle=y_angle, ) - # Minus rotations (useful for pulse sequences) qubit.xy.operations["-x90"] = GaussianPulse( id="-x90", length=pulse_length, amplitude=-pulse_amp / 2, sigma=sigma, - axis_angle=0.0, + axis_angle=x_angle, ) qubit.xy.operations["-y90"] = GaussianPulse( @@ -92,29 +95,28 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: length=pulse_length, amplitude=-pulse_amp / 2, sigma=sigma, - axis_angle=float(np.pi / 2), + axis_angle=y_angle, ) -def add_default_resonator_pulses(resonator: ReadoutResonatorBase) -> None: +def add_default_resonator_pulses(resonator: ANY_READOUT_RESONATOR) -> None: """Add default square readout pulse to sensor resonator. - Configures a square readout pulse for charge sensing via the readout resonator. - - Default pulse parameters: - - Length: 2000 ns - - Amplitude: 0.1 + .. deprecated:: + Use ``wire_machine_macros()`` instead. Pulses are now wired automatically. Args: resonator: Readout resonator instance to configure. - - Note: - These are placeholder values. Actual readout pulse parameters should be - optimized through calibration to maximize SNR for your sensor dot system. """ + warnings.warn( + "add_default_resonator_pulses() is deprecated. " + "Use wire_machine_macros() from quam_builder.architecture.quantum_dots.macro_engine instead.", + DeprecationWarning, + stacklevel=2, + ) readout_length = 2000 # ns readout_amp = 0.1 - if isinstance(resonator, ReadoutResonatorBase): + if isinstance(resonator, ANY_READOUT_RESONATOR): resonator.operations["readout"] = SquareReadoutPulse( id="readout", length=readout_length, @@ -125,27 +127,68 @@ def add_default_resonator_pulses(resonator: ReadoutResonatorBase) -> None: def add_default_ldv_qubit_pair_pulses(qubit_pair: Any) -> None: """Placeholder for adding two-qubit gate pulses to qubit pairs. - Two-qubit gates in quantum dot systems typically involve: - - Barrier gate voltage pulses to control exchange coupling - - Coordinated plunger gate adjustments - - Timing-critical pulse sequences - - This function is currently a placeholder. Implementations are highly - system-specific and depend on: - - Exchange coupling mechanism (direct exchange, virtual gates, etc.) - - Device geometry and materials - - Operating regime (singlet-triplet, loss-divincenzo, hybrid, etc.) + .. deprecated:: + Use ``wire_machine_macros()`` instead. Pulses are now wired automatically. Args: qubit_pair: Qubit pair instance to configure. - - Note: - Users should implement custom two-qubit gate calibrations based on - their specific quantum dot architecture and coupling mechanism. """ - # Placeholder implementation - # In production, this would configure exchange pulses, SWAP gates, CZ gates, etc. - # Example structure: - # if hasattr(qubit_pair, "barrier_channel"): - # qubit_pair.barrier_channel.operations["exchange"] = pulse_config + warnings.warn( + "add_default_ldv_qubit_pair_pulses() is deprecated. " + "Use wire_machine_macros() from quam_builder.architecture.quantum_dots.macro_engine instead.", + DeprecationWarning, + stacklevel=2, + ) pass + + +def add_default_baseband_pulse(voltage_gate: VoltageGate): + """Add default square pulse to the voltage gate. + Note that the amplitude of the default pulse is chosen based on the output mode: + - if OPX1000 & output_mode is "amplified", then the waveform amplitude is 1.25V + - if OPX1000 & output_mode is "direct", then the waveform amplitude is 0.25V + - if OPX+, then the waveform amplitude is 0.25V. + + Args: + voltage_gate: VoltageGate instance to configure. + """ + if not isinstance(voltage_gate, str): + if hasattr(voltage_gate.opx_output, "output_mode"): + if voltage_gate.opx_output.output_mode == "amplified": + voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( + amplitude=1.25, length=MIN_PULSE_DURATION_NS + ) + else: + voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( + amplitude=0.25, length=MIN_PULSE_DURATION_NS + ) + else: + voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( + amplitude=0.25, length=MIN_PULSE_DURATION_NS + ) + # Add trigger pulse for the external DAC + if len(voltage_gate.digital_outputs) > 0: + voltage_gate.operations["trigger"] = SquarePulse(amplitude=0.0, length=1000, digital_marker="ON") + +def update_output_mode_and_default_baseband_pulse(voltage_gate: VoltageGate, output_mode: Literal["amplified", "direct"]): + """Update default square pulse of the voltage gate. + Note that the amplitude of the default pulse is chosen based on the output mode: + - if OPX1000 & output_mode is "amplified", then the waveform amplitude is 1.25V + - if OPX1000 & output_mode is "direct", then the waveform amplitude is 0.25V + - if OPX+, then the waveform amplitude is 0.25V. + + Args: + voltage_gate: VoltageGate instance to configure. + output_mode: The OPX1000 LF-FEM output mode. Can be "amplified" or "direct". + """ + if not isinstance(voltage_gate, str): + if hasattr(voltage_gate.opx_output, "output_mode"): + voltage_gate.opx_output.output_mode = output_mode + if voltage_gate.opx_output.output_mode == "amplified": + voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( + amplitude=1.25, length=MIN_PULSE_DURATION_NS + ) + else: + voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( + amplitude=0.25, length=MIN_PULSE_DURATION_NS + ) diff --git a/quam_builder/builder/superconducting/add_twpa_component.py b/quam_builder/builder/superconducting/add_twpa_component.py deleted file mode 100644 index 9a63b18c..00000000 --- a/quam_builder/builder/superconducting/add_twpa_component.py +++ /dev/null @@ -1,132 +0,0 @@ -from typing import Dict, Union -from quam.components.channels import StickyChannelAddon -from quam_builder.builder.qop_connectivity.channel_ports import ( - iq_out_channel_ports, - mw_out_channel_ports, -) -from quam_builder.architecture.superconducting.components.twpa import TWPA, XYDriveMW, XYDriveIQ -from quam_builder.builder.qop_connectivity.get_digital_outputs import ( - get_digital_outputs, -) -from quam_builder.architecture.superconducting.qubit import ( - FixedFrequencyTransmon, - FluxTunableTransmon, -) -from qualang_tools.addons.calibration.calibrations import unit - - -u = unit(coerce_to_integer=True) - - -def add_twpa_pump_component( - twpa: TWPA, - wiring_path: str, - ports: Dict[str, str], -): - """Adds an amplification pump component to a TWPA based on the provided wiring path and ports. - - Args: - twpa (TWPA): The TWPA component to which the amplification pump component will be added. - wiring_path (str): The path to the wiring configuration. - ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. - - Raises: - ValueError: If the port keys do not match any implemented mapping. - """ - digital_outputs = get_digital_outputs(wiring_path, ports) - - if all(key in ports for key in iq_out_channel_ports): - # LF-FEM & Octave or OPX+ & Octave - twpa.pump = XYDriveIQ( - opx_output_I=f"{wiring_path}/opx_output_I", - opx_output_Q=f"{wiring_path}/opx_output_Q", - frequency_converter_up=f"{wiring_path}/frequency_converter_up", - sticky=StickyChannelAddon(duration=100, digital=False), - RF_frequency=None, - digital_outputs=digital_outputs, - ) - twpa.pump_ = XYDriveIQ( - opx_output_I=f"{wiring_path}/opx_output_I", - opx_output_Q=f"{wiring_path}/opx_output_Q", - frequency_converter_up=f"{wiring_path}/frequency_converter_up", - RF_frequency=None, - digital_outputs=digital_outputs, - ) - - RF_output = twpa.pump.frequency_converter_up - RF_output.channel = twpa.pump.get_reference() - RF_output.output_mode = "always_on" # "triggered" - - elif all(key in ports for key in mw_out_channel_ports): - # MW-FEM single channel - twpa.pump = XYDriveMW( - opx_output=f"{wiring_path}/opx_output", - sticky=StickyChannelAddon(duration=100, digital=False), - digital_outputs=digital_outputs, - RF_frequency=None, - ) - twpa.pump_ = XYDriveMW( - opx_output=f"{wiring_path}/opx_output", - digital_outputs=digital_outputs, - RF_frequency=None, - ) - - else: - raise ValueError(f"Unimplemented mapping of port keys to channel for ports: {ports}") - - -def add_twpa_isolation_component( - twpa: TWPA, - wiring_path: str, - ports: Dict[str, str], -): - """Adds an isolation pump component to a TWPA based on the provided wiring path and ports. - - Args: - twpa (TWPA): The TWPA component to which the isolation pump component will be added. - wiring_path (str): The path to the wiring configuration. - ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. - - Raises: - ValueError: If the port keys do not match any implemented mapping. - """ - digital_outputs = get_digital_outputs(wiring_path, ports) - - if all(key in ports for key in iq_out_channel_ports): - # LF-FEM & Octave or OPX+ & Octave - twpa.isolation = XYDriveIQ( - opx_output_I=f"{wiring_path}/opx_output_I", - opx_output_Q=f"{wiring_path}/opx_output_Q", - frequency_converter_up=f"{wiring_path}/frequency_converter_up", - sticky=StickyChannelAddon(duration=100, digital=False), - RF_frequency=None, - digital_outputs=digital_outputs, - ) - twpa.isolation_ = XYDriveIQ( - opx_output_I=f"{wiring_path}/opx_output_I", - opx_output_Q=f"{wiring_path}/opx_output_Q", - frequency_converter_up=f"{wiring_path}/frequency_converter_up", - RF_frequency=None, - digital_outputs=digital_outputs, - ) - - RF_output = twpa.pump.frequency_converter_up - RF_output.channel = twpa.pump.get_reference() - RF_output.output_mode = "always_on" # "triggered" - - elif all(key in ports for key in mw_out_channel_ports): - # MW-FEM single channel - twpa.isolation = XYDriveMW( - opx_output=f"{wiring_path}/opx_output", - sticky=StickyChannelAddon(duration=100, digital=False), - digital_outputs=digital_outputs, - RF_frequency=None, - ) - twpa.isolation_ = XYDriveMW( - opx_output=f"{wiring_path}/opx_output", - digital_outputs=digital_outputs, - RF_frequency=None, - ) - - else: - raise ValueError(f"Unimplemented mapping of port keys to channel for ports: {ports}") diff --git a/quam_builder/builder/superconducting/build_quam.py b/quam_builder/builder/superconducting/build_quam.py index 2fa80f4c..91e7936a 100644 --- a/quam_builder/builder/superconducting/build_quam.py +++ b/quam_builder/builder/superconducting/build_quam.py @@ -3,15 +3,9 @@ from numpy import sqrt, ceil from quam.components import Octave, LocalOscillator, FrequencyConverter from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer -from quam_builder.architecture.superconducting.components.twpa import TWPA from quam_builder.builder.superconducting.pulses import ( add_default_transmon_pulses, add_default_transmon_pair_pulses, - add_default_twpa_pulses, -) -from quam_builder.builder.superconducting.add_twpa_component import ( - add_twpa_pump_component, - add_twpa_isolation_component, ) from quam_builder.builder.superconducting.add_transmon_drive_component import ( add_transmon_drive_component, @@ -31,12 +25,17 @@ from quam_builder.architecture.superconducting.qpu import AnyQuam -def build_quam(machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None) -> AnyQuam: +def build_quam( + machine: AnyQuam, + calibration_db_path: Optional[Union[Path, str]] = None, + save: bool = True, +) -> AnyQuam: """Builds the QuAM by adding various components and saving the machine configuration. Args: machine (AnyQuam): The QuAM to be built. calibration_db_path (Optional[Union[Path, str]]): The path to the Octave calibration database. + save (bool): If True, saves the machine state after building. Returns: AnyQuam: The built QuAM. @@ -47,7 +46,8 @@ def build_quam(machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] add_transmons(machine) add_pulses(machine) - machine.save() + if save: + machine.save() return machine @@ -142,25 +142,6 @@ def add_transmons(machine: AnyQuam): machine.qubit_pairs[transmon_pair.name] = transmon_pair machine.active_qubit_pair_names.append(transmon_pair.name) - elif element_type == "twpas": - number_of_twpas = len(wiring_by_element.items()) - twpa_number = 0 - for twpa_id, wiring_by_line_type in wiring_by_element.items(): - twpa = TWPA(id=twpa_id) - machine.twpas[twpa_id] = twpa - machine.twpas[twpa_id].grid_location = _set_default_grid_location( - twpa_number, number_of_twpas - ) - twpa_number += 1 - for line_type, ports in wiring_by_line_type.items(): - wiring_path = f"#/wiring/{element_type}/{twpa_id}/{line_type}" - if line_type == WiringLineType.TWPA_PUMP.value: - add_twpa_pump_component(twpa, wiring_path, ports) - elif line_type == WiringLineType.TWPA_ISOLATION.value: - add_twpa_isolation_component(twpa, wiring_path, ports) - else: - raise ValueError(f"Unknown line type: {line_type}") - def add_pulses(machine: AnyQuam): """Adds default pulses to the transmon qubits and qubit pairs in the machine. @@ -176,10 +157,6 @@ def add_pulses(machine: AnyQuam): for qubit_pair in machine.qubit_pairs.values(): add_default_transmon_pair_pulses(qubit_pair) - if hasattr(machine, "twpas"): - for twpa in machine.twpas.values(): - add_default_twpa_pulses(twpa) - def add_octaves( machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None diff --git a/quam_builder/builder/superconducting/pulses.py b/quam_builder/builder/superconducting/pulses.py index efd61291..0f6817aa 100644 --- a/quam_builder/builder/superconducting/pulses.py +++ b/quam_builder/builder/superconducting/pulses.py @@ -8,7 +8,6 @@ FixedFrequencyTransmonPair, FluxTunableTransmonPair, ) -from quam_builder.architecture.superconducting.components.twpa import TWPA import numpy as np from typing import Union @@ -262,7 +261,9 @@ def add_Square_pulses( transmon.set_gate_shape("Square") -def add_default_transmon_pulses(transmon: Union[FixedFrequencyTransmon, FluxTunableTransmon]): +def add_default_transmon_pulses( + transmon: Union[FixedFrequencyTransmon, FluxTunableTransmon] +): """Adds default pulses to a transmon qubit: * transmon.xy.operations["saturation"] = pulses.SquarePulse(amplitude=0.25, length=20 * u.us, axis_angle=0) * transmon.z.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) @@ -279,7 +280,9 @@ def add_default_transmon_pulses(transmon: Union[FixedFrequencyTransmon, FluxTuna if hasattr(transmon, "z"): if transmon.z is not None: - transmon.z.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) + transmon.z.operations["const"] = pulses.SquarePulse( + amplitude=0.1, length=100 + ) if hasattr(transmon, "resonator"): if transmon.resonator is not None: @@ -289,7 +292,7 @@ def add_default_transmon_pulses(transmon: Union[FixedFrequencyTransmon, FluxTuna def add_default_transmon_pair_pulses( - transmon_pair: Union[FixedFrequencyTransmonPair, FluxTunableTransmonPair], + transmon_pair: Union[FixedFrequencyTransmonPair, FluxTunableTransmonPair] ): """Adds default pulses to a transmon qubit pair depending on its attributes: * transmon_pair.coupler.operations["const"] = pulses.SquarePulse(amplitude=0.1, length=100) @@ -314,37 +317,3 @@ def add_default_transmon_pair_pulses( transmon_pair.zz_drive.operations["square"] = pulses.SquarePulse( amplitude=0.1, length=100 ) - - -def add_default_twpa_pulses(twpa: TWPA): - """Adds default pulses to a TWPA: - * twpa.pump.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=16, axis_angle=0) - * twpa.pump_.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=2000, axis_angle=0) - * twpa.isolation.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=16, axis_angle=0) - * twpa.isolation_.operations["pump"] = pulses.SquarePulse(amplitude=0.5, length=2000, axis_angle=0) - - Args: - twpa (TWPA): The TWPA component to which the pulses will be added. - """ - if hasattr(twpa, "pump"): - if twpa.pump is not None: - twpa.pump.operations["pump"] = pulses.SquarePulse( - amplitude=0.5, length=16, axis_angle=0 - ) - if hasattr(twpa, "pump_"): - if twpa.pump_ is not None: - twpa.pump_.operations["pump"] = pulses.SquarePulse( - amplitude=0.5, length=2000, axis_angle=0 - ) - - if hasattr(twpa, "isolation"): - if twpa.isolation is not None: - twpa.isolation.operations["pump"] = pulses.SquarePulse( - amplitude=0.5, length=16, axis_angle=0 - ) - - if hasattr(twpa, "isolation_"): - if twpa.isolation_ is not None: - twpa.isolation_.operations["pump"] = pulses.SquarePulse( - amplitude=0.5, length=2000, axis_angle=0 - ) diff --git a/quam_builder/tools/macros/__init__.py b/quam_builder/tools/macros/__init__.py index a057b886..fa9de09c 100644 --- a/quam_builder/tools/macros/__init__.py +++ b/quam_builder/tools/macros/__init__.py @@ -1,11 +1,11 @@ -"""Quantum dot macros for QUAM.""" +"""Macro utilities shared across the quantum-dot architecture.""" -from .point_macros import * -from .composable_macros import * -from .measure_macros import * +from .default_macros import AlignMacro, WaitMacro, UTILITY_MACRO_FACTORIES +from .measure_macros import MeasureMacro __all__ = [ - *point_macros.__all__, - *composable_macros.__all__, - *measure_macros.__all__, -] \ No newline at end of file + "AlignMacro", + "WaitMacro", + "UTILITY_MACRO_FACTORIES", + "MeasureMacro", +] diff --git a/quam_builder/tools/macros/composable_macros.py b/quam_builder/tools/macros/composable_macros.py deleted file mode 100644 index 456a856b..00000000 --- a/quam_builder/tools/macros/composable_macros.py +++ /dev/null @@ -1,335 +0,0 @@ -"""Composable macros for building complex quantum operation sequences.""" - -from dataclasses import field -from typing import List, Optional - -from qm import qua -from quam import QuamComponent -from quam.components.quantum_components import Qubit, QubitPair -from quam.core import quam_dataclass -from quam.core.macro.quam_macro import QuamMacro -from quam.core.operation.operations_registry import OperationsRegistry -from quam.utils import string_reference -from quam.utils.exceptions import InvalidReferenceError - -__all__ = [ - "SequenceMacro", - "ConditionalMacro", -] - - -@quam_dataclass -class ConditionalMacro(QuamMacro): - """Macro for conditional execution based on measurement outcome. - - Performs: measurement → optional alignment → conditional operation. - Useful for active reset, state preparation, and conditional operations. - - Attributes: - measurement_macro: Reference to measurement macro (must return boolean QUA variable) - conditional_macro: Reference to macro applied conditionally - invert_condition: If False, apply when measurement is True; if True, apply when False - """ - - measurement_macro: str - conditional_macro: str - invert_condition: bool = False - - def __call__(self, **overrides): - """Invoke macro as callable (QUAM convention). - - Args: - **overrides: Optional parameter overrides - - Returns: - Measured state - """ - if not hasattr(self, "parent") or self.parent is None: - raise ValueError( - "Cannot execute macro: macro has no parent. " - "Ensure macro is attached via component.macros['name'] = macro" - ) - return self.apply(**overrides) - - def _resolve_macro(self, reference: str) -> QuamMacro: - """Resolve macro reference to actual macro object. - - Args: - reference: Reference string to the macro - - Returns: - Resolved macro - - Raises: - InvalidReferenceError: If reference cannot be resolved - """ - try: - if isinstance(reference, str): - macro = string_reference.get_referenced_value( - self.parent.parent, - reference, - root=self.parent.parent.get_root(), - ) - elif isinstance(reference, QuamMacro): - macro = reference - else: - raise InvalidReferenceError(f"Reference type '{reference}' not supported") - - except (InvalidReferenceError, AttributeError) as e: - raise InvalidReferenceError(f"Could not resolve macro reference '{reference}': {e}") - - if not isinstance(macro, QuamMacro): - raise TypeError( - f"Reference '{reference}' resolved to {type(macro).__name__}, expected QuamMacro" - ) - - return macro - - @property - def inferred_duration(self) -> Optional[float]: - """Calculate total duration (measurement + conditional macro) in seconds.""" - try: - measurement = self._resolve_macro(self.measurement_macro) - conditional = self._resolve_macro(self.conditional_macro) - - measurement_duration = getattr(measurement, "inferred_duration", None) - conditional_duration = getattr(conditional, "inferred_duration", None) - - if measurement_duration is None or conditional_duration is None: - return None - - return measurement_duration + conditional_duration - except (InvalidReferenceError, AttributeError): - return None - - def apply(self, invert_condition: Optional[bool] = None, **kwargs): - """Execute conditional operation. - - Args: - invert_condition: Optional override for condition inversion - **kwargs: Additional parameters passed to conditional macro - - Returns: - Measured state - """ - # Resolve macros - measurement = self._resolve_macro(self.measurement_macro) - conditional = self._resolve_macro(self.conditional_macro) - - # Execute measurement - state = measurement.apply() - - # Apply conditional macro based on condition - use_inverted = invert_condition if invert_condition is not None else self.invert_condition - - if use_inverted: - with qua.if_(~state): - conditional.apply(**kwargs) - else: - with qua.if_(state): - conditional.apply(**kwargs) - - return state - - -@quam_dataclass -class SequenceMacro(QuamMacro): - """Macro that executes an ordered list of other macros in sequence. - - Lightweight container preserving serialization by storing macro references. - Any QuamMacro (PointMacro, PulseMacro, etc.) can participate. - - Attributes: - name: Sequence name - macro_refs: Tuple of macro reference strings - description: Optional description - return_index: Optional index of macro result to return (default: None returns all) - """ - - name: str - macro_refs: tuple[str, ...] = field(default_factory=tuple) - description: Optional[str] = None - return_index: Optional[int] = None - align_elements = True - - def __call__(self, *args, **kwargs): - """Execute sequence as callable.""" - self.apply(*args, **kwargs) - - def with_reference(self, reference: str) -> "SequenceMacro": - """Return new SequenceMacro with reference appended. - - Args: - reference: Macro reference string to append - - Returns: - New SequenceMacro with updated references - """ - return SequenceMacro( - name=self.name, - macro_refs=self.macro_refs + (reference,), - description=self.description, - return_index=self.return_index, - ) - - def with_macro(self, owner: QuamComponent, macro_name: str) -> "SequenceMacro": - """Append macro by name, creating reference. - - Args: - owner: Component owning the macro - macro_name: Name of macro in owner.macros - - Returns: - New SequenceMacro with macro appended - """ - reference = self._reference_for(owner, macro_name) - return self.with_reference(reference) - - def with_macros(self, owner: QuamComponent, macro_names: List[str]) -> "SequenceMacro": - """Append multiple macros by name. - - Args: - owner: Component owning the macros - macro_names: List of macro names - - Returns: - New SequenceMacro with all macros appended - """ - sequence = self - for macro_name in macro_names: - sequence = sequence.with_macro(owner, macro_name) - return sequence - - @staticmethod - def _reference_for(owner: QuamComponent, macro_name: str) -> str: - """Create or reuse reference for macro name on owner. - - Args: - owner: Component owning the macro - macro_name: Macro name - - Returns: - Reference string - - Raises: - KeyError: If macro not found - """ - macros = getattr(owner, "macros", None) - if macros is None: - macros = {} - setattr(owner, "macros", macros) - - if macro_name not in macros: - raise KeyError(f"Macro '{macro_name}' not found on owner {owner}") - - return f"#./macros/{macro_name}" - - def resolved_macros(self, component: QuamComponent) -> List[QuamMacro]: - """Resolve stored references to concrete macros. - - Args: - component: Component from which to resolve references - - Returns: - List of resolved macros - - Raises: - InvalidReferenceError: If any reference cannot be resolved - """ - resolved: List[QuamMacro] = [] - for reference in self.macro_refs: - try: - resolved_macro = string_reference.get_referenced_value( - component, - reference, - root=component.get_root(), - ) - except (InvalidReferenceError, AttributeError): - raise InvalidReferenceError( - f"Could not resolve reference '{reference}' for sequence '{self.name}'" - ) - resolved.append(resolved_macro) - return resolved - - def apply(self, **kwargs): - """Execute each referenced macro sequentially. - - Args: - **kwargs: Parameters passed to each macro - - Returns: - Result(s) based on return_index setting - """ - res = [] - previous_element = None - for macro in self.resolved_macros(self.parent.parent): - r = macro.apply(**kwargs) - - # Get the component that owns this macro (macro.parent is 'macros' dict, parent.parent is the component) - new_element = macro.parent.parent - - if previous_element is not None and self.align_elements: - - if isinstance(previous_element, (Qubit, QubitPair)) and not isinstance(new_element, (Qubit, QubitPair)): - previous_element.align() - elif isinstance(previous_element, (Qubit, QubitPair)) and previous_element==new_element: - previous_element.align() - elif isinstance(previous_element, (Qubit)) and isinstance(new_element, (Qubit)): - previous_element.align(new_element) - elif isinstance(new_element, (QubitPair)): - new_element.align() - else: - raise TypeError( - f"Cannot align '{previous_element.id}' to '{new_element.id}' because previous_element is not Qubit, or QubitPair " - ) - - previous_element = new_element - res.append(r) - - if self.return_index is not None: - return res[self.return_index] - - def register_operation( - self, - registry: OperationsRegistry, - operation_name: Optional[str] = None, - description: Optional[str] = None, - ) -> None: - """Register this sequence as an operation. - - Args: - registry: Operations registry - operation_name: Optional operation name (defaults to self.name) - description: Optional description - """ - op_name = operation_name or self.name - - def operation_fn(component: QuamComponent): - """Execute the sequence via operations registry.""" - sequence = component.sequences[self.name] - sequence(component) - - operation_fn.__doc__ = description or self.description or f"Execute '{self.name}' sequence." - operation_fn.__name__ = op_name - registry.register_operation(op_name)(operation_fn) - - def total_duration_seconds(self, component: QuamComponent) -> Optional[float]: - """Calculate summed duration of all referenced macros. - - Args: - component: Component from which to resolve macros - - Returns: - Total duration in seconds, or None if any duration is unavailable - """ - durations: List[Optional[float]] = [] - for macro in self.resolved_macros(component): - duration = getattr(macro, "inferred_duration", None) - if duration is None: - duration = getattr(macro, "duration", None) - durations.append(duration) - - if any(duration is None for duration in durations): - return None - - return sum(d for d in durations if d is not None) \ No newline at end of file diff --git a/quam_builder/tools/macros/default_macros.py b/quam_builder/tools/macros/default_macros.py index 4401e5d1..d6f102a2 100644 --- a/quam_builder/tools/macros/default_macros.py +++ b/quam_builder/tools/macros/default_macros.py @@ -10,7 +10,7 @@ __all__ = [ "AlignMacro", "WaitMacro", - "DEFAULT_MACROS", + "UTILITY_MACRO_FACTORIES", ] @@ -64,7 +64,7 @@ def inferred_duration(self) -> Optional[float]: return self.duration * 1e-9 -DEFAULT_MACROS = { +UTILITY_MACRO_FACTORIES = { "align": AlignMacro, "wait": WaitMacro, } diff --git a/quam_builder/tools/macros/point_macros.py b/quam_builder/tools/macros/point_macros.py deleted file mode 100644 index b5d20935..00000000 --- a/quam_builder/tools/macros/point_macros.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Voltage point macros for quantum dot operations. - -This module provides macros for voltage point operations following QUAM's -Pulse → Macro → Operation pattern with reference-based serialization. -""" - -from typing import TYPE_CHECKING - -from quam import QuamComponent -from quam.core import quam_dataclass -from quam.core.macro.quam_macro import QuamMacro -from quam.utils import string_reference -from quam.utils.exceptions import InvalidReferenceError - -from quam_builder.tools.qua_tools import DurationType, VoltageLevelType -from .composable_macros import SequenceMacro - -if TYPE_CHECKING: - from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD - -from quam_builder.tools.voltage_sequence import VoltageSequence - -__all__ = [ - "BasePointMacro", - "StepPointMacro", - "RampPointMacro", - "SequenceMacro", -] - - -@quam_dataclass -class BasePointMacro(QuamMacro): - """Base class for voltage point macros using reference-based pattern. - - Encapsulates operations on pre-defined voltage points stored in gate_set.macros. - Stores references to points rather than concrete instances for serialization. - - Attributes: - point_ref: Reference string to VoltageTuningPoint - (e.g., "#./voltage_sequence/gate_set/macros/dot_0_idle") - macro_type: Type identifier ('step', 'ramp', etc.) - """ - - point_ref: str | None = None - macro_type: str = "base" - - @property - def voltage_sequence(self) -> "VoltageSequence": - """Voltage sequence for this macro.""" - return self.parent.parent.voltage_sequence - - def _resolve_point(self, component: QuamComponent): - """Resolve point reference to actual VoltageTuningPoint object. - - Args: - component: Component from which to resolve reference - - Returns: - Resolved voltage tuning point - - Raises: - ValueError: If point_ref is not set - TypeError: If reference doesn't resolve to VoltageTuningPoint - InvalidReferenceError: If reference cannot be resolved - """ - from quam_builder.architecture.quantum_dots.components.gate_set import ( - VoltageTuningPoint, - ) - - if self.point_ref is None: - raise ValueError("point_ref is not set on this macro") - - try: - point = string_reference.get_referenced_value( - component, - self.point_ref, - root=component.get_root(), - ) - except (InvalidReferenceError, AttributeError) as e: - raise InvalidReferenceError( - f"Could not resolve point reference '{self.point_ref}' from {component}: {e}" - ) from e - - if not isinstance(point, VoltageTuningPoint): - raise TypeError( - f"Reference '{self.point_ref}' resolved to {type(point).__name__}, " - "expected VoltageTuningPoint" - ) - - return point - - def _get_point_name(self) -> str: - """Extract point name from reference path. - - Returns: - Full point name (e.g., "quantum_dot_0_idle") - """ - point_ref_raw = object.__getattribute__(self, "__dict__").get("point_ref") - if point_ref_raw is None: - raise ValueError("point_ref is not set on this macro") - - parts = point_ref_raw.split("/") - if not parts or parts[-1] == "": - raise ValueError(f"Invalid point reference format: '{point_ref_raw}'") - return parts[-1] - - def _get_default_duration(self) -> int | None: - """Get default duration from the referenced VoltageTuningPoint. - - Returns: - Duration in nanoseconds, or None if point cannot be resolved - """ - try: - point = self._resolve_point(self) - return point.duration - except (ValueError, InvalidReferenceError, TypeError): - return None - - -@quam_dataclass -class StepPointMacro(BasePointMacro): - """Macro for instantaneous voltage transition to registered point. - - Steps voltage instantly (limited by hardware) and holds for specified duration. - - Attributes: - point_ref: Reference to VoltageTuningPoint - macro_type: Always "step" - """ - - macro_type: str = "step" - - @property - def inferred_duration(self) -> float | None: - """Total duration of step operation (seconds).""" - default_duration = self._get_default_duration() - return default_duration * 1e-9 if default_duration is not None else None - - def apply(self, *args, duration: int | None = None, **kwargs): - """Execute step operation. - - Args: - duration: Override for hold duration (nanoseconds). - If None, uses the VoltageTuningPoint's default duration. - """ - if duration is None: - duration = self._get_default_duration() - point_name = self._get_point_name() - self.voltage_sequence.step_to_point(point_name, duration=duration) - - -@quam_dataclass -class RampPointMacro(BasePointMacro): - """Macro for gradual voltage transition to registered point. - - Ramps voltage gradually over ramp_duration, then holds for specified duration. - Essential for adiabatic transitions. - - Attributes: - point_ref: Reference to VoltageTuningPoint - ramp_duration: Gradual transition time (nanoseconds, default: 16) - macro_type: Always "ramp" - """ - - macro_type: str = "ramp" - ramp_duration: int = 16 - - @property - def inferred_duration(self) -> float | None: - """Total duration of ramp + hold (seconds).""" - default_duration = self._get_default_duration() - if self.ramp_duration is None or default_duration is None: - return None - return (self.ramp_duration + default_duration) * 1e-9 - - def apply( - self, - *args, - duration: int | None = None, - ramp_duration: int | None = None, - **kwargs, - ): - """Execute ramp operation. - - Args: - duration: Override for hold duration (nanoseconds). - If None, uses the VoltageTuningPoint's default duration. - ramp_duration: Override for ramp duration (nanoseconds). - If None, uses the macro's default ramp_duration. - """ - if ramp_duration is None: - ramp_duration = self.ramp_duration - if duration is None: - duration = self._get_default_duration() - point_name = self._get_point_name() - self.voltage_sequence.ramp_to_point( - point_name, - ramp_duration=ramp_duration, - duration=duration, - ) diff --git a/quam_builder/tools/voltage_sequence/README.md b/quam_builder/tools/voltage_sequence/README.md index b15ce4bd..803f935a 100644 --- a/quam_builder/tools/voltage_sequence/README.md +++ b/quam_builder/tools/voltage_sequence/README.md @@ -1,6 +1,6 @@ # VoltageSequence: Orchestrating DC Voltage Control in QUA -NOTE: This document is a brief intro into `GateSet` and `VoltageSequence`, not a full guide. For a more thorough guide on `GateSet`, `VoltageSequence` and `VirtualGateSet`, please refer to the [quantum_dots README](../../architecture/quantum_dots/README.md). +NOTE: This document is a brief intro into `GateSet` and `VoltageSequence`, not a full guide. For a more thorough guide on `GateSet`, `VoltageSequence` and `VirtualGateSet`, please refer to the [quantum_dots README](../../architecture/quantum_dots/README.md). ## 1. Introduction @@ -20,9 +20,9 @@ This framework is specifically designed to work with channels that have **sticky #### 1. Define QUAM `VoltageGate` objects for physical gates -- A `VoltageGate` channel is a Quantum Dot specific channel inheriting from QuAM's `SingleChannel` object. It adds to the `SingleChannel` by containing an `offset_parameter` and an `attenuation` value. +- A `VoltageGate` channel is a Quantum Dot specific channel inheriting from QuAM's `SingleChannel` object. It adds to the `SingleChannel` by containing an `offset_parameter` and an `attenuation` value. -- Below is an example of how a `VoltageGate` is instantiated. +- Below is an example of how a `VoltageGate` is instantiated. ```python from quam_builder.architecture.quantum_dots.components import VoltageGate @@ -58,7 +58,7 @@ This framework is specifically designed to work with channels that have **sticky - It is important to ensure that the string names used here match the string names in your QuAM machine -- If your channel object are already parented by a QuAM machine (i.e. `machine.channel["channel_p1"] = VoltageGate(...)`), then the channels cannot be re-parented into your GateSet. In this case, it is important to use the channel reference as such: +- If your channel object are already parented by a QuAM machine (i.e. `machine.channel["channel_p1"] = VoltageGate(...)`), then the channels cannot be re-parented into your GateSet. In this case, it is important to use the channel reference as such: ```python channels = { @@ -69,29 +69,29 @@ This framework is specifically designed to work with channels that have **sticky #### 3. Instantiate your GateSet with your channel mapping - ```python + ```python from quam_builder.architecture.quantum_dots.components import GateSet my_gate_set = GateSet(id="dot_plungers", channels=channels) ``` #### 4. Optionally, add `VoltageTuningPoint` macros to the `GateSet` - + - This is useful for when you have set points in your charge-stability that must be re-used in the experiment. GateSet can hold VoltageTuningPoints which can easily be accessed by VoltageSequence ```python my_gate_set.add_point(name="idle", voltages={"channel_P1": 0.1, "channel_P2": -0.05}, duration=1000) ``` - + Internally this adds a **`VoltageTuningPoint` to GateSet.macros** #### 5. Create a `VoltageSequence` from the `GateSet` - ```python + ```python voltage_seq = my_gate_set.new_sequence() ``` -- `voltage_seq` can be used in QUA programs to easily step/ramp to points. +- `voltage_seq` can be used in QUA programs to easily step/ramp to points. #### 6. Use `VoltageSequence` methods within a QUA `program()` to define voltage changes @@ -100,15 +100,15 @@ This framework is specifically designed to work with channels that have **sticky ```python with qua.program() as prog: voltage_seq = my_gate_set.new_sequence() - voltage_seq.step_to_point("idle") # Step to point "idle". ramp_to_point also valid, with a ramp_duration argument. + voltage_seq.step_to_point("idle") # Step to point "idle". ramp_to_point also valid, with a ramp_duration argument. voltage_seq.step_to_voltages(voltages = {...}, duration = ...) # In-case you would like to step to a point not saved as a macro in the GateSet, you can just define it here ``` ## 3. `GateSet` -A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` or `SingleChannel` channels and treats them as a single, coordinated object for unified control. This is especially useful in Quantum Dot architectures, where one often has many physical gate electrodes that must be tuned together. Instead of controlling each `VoltageGate`/`SingleChannel` channel in isolation, the GateSet allows +A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` or `SingleChannel` channels and treats them as a single, coordinated object for unified control. This is especially useful in Quantum Dot architectures, where one often has many physical gate electrodes that must be tuned together. Instead of controlling each `VoltageGate`/`SingleChannel` channel in isolation, the GateSet allows -- Unified control: Iterate, configure, and programme multiple gates at once through a single object +- Unified control: Iterate, configure, and programme multiple gates at once through a single object - Pre-defined DC points: Store named DC working points using add_point(...) @@ -125,7 +125,7 @@ A `GateSet` is a higher-level abstraction that collects a group of `VoltageGate` ```python # Assume gate_set has channels: {"P1": channel_P1, "P2": channel_P2, "B1": channel_B1} - # Only specify the voltages of a partial subset of all the gates in the GateSet. + # Only specify the voltages of a partial subset of all the gates in the GateSet. partial_voltages = {"P1": 0.3, "B1": -0.1} # resolve_voltages fills in missing channels, creating a complete voltages dict internally by replacing all the un-named gate voltages with 0.0V @@ -164,26 +164,26 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir **Creating a `VoltageSequence`:** -- The sequence must be defined within a QUA program. +- The sequence must be defined within a QUA program. **Core Methods (used in `qua.program()` context):** -- `step_to_voltages(voltages: Dict[str, float], duration: int)` +- `step_to_voltages(voltages: Dict[str, float], duration: int)` Steps all specified channels directly to the given voltage levels and holds them for the specified duration (in nanoseconds). This creates immediate voltage changes without ramping. Both `voltages` values and `duration` can be QUA variables for dynamic control. ```python voltage_seq.step_to_voltages(voltages={"P1": 0.3, "P2": 0.1}, duration=1000) ``` -- `ramp_to_voltages(voltages: Dict[str, float], duration: int, ramp_duration: int)` +- `ramp_to_voltages(voltages: Dict[str, float], duration: int, ramp_duration: int)` Ramps all specified channels to the given voltage levels over the specified ramp duration, then holds them for the duration (both in nanoseconds). This provides smooth voltage transitions useful for avoiding voltage spikes that could affect sensitive quantum systems. All parameters can be QUA variables. ```python voltage_seq.ramp_to_voltages(voltages={"P1": 0.0}, duration=500, ramp_duration=40) ``` -- `step_to_point(name: str, duration: Optional[int] = None)` +- `step_to_point(name: str, duration: Optional[int] = None)` Steps all channels to the voltages defined in a predefined `VoltageTuningPoint` macro. If no duration is provided, uses the default duration from the tuning point definition. This enables quick transitions to well-defined system states. The `duration` parameter can be a QUA variable. ```python @@ -191,14 +191,14 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.step_to_point("readout", duration=2000) # Override default duration ``` -- `ramp_to_point(name: str, ramp_duration: int, duration: Optional[int] = None)` +- `ramp_to_point(name: str, ramp_duration: int, duration: Optional[int] = None)` Ramps all channels to the voltages defined in a predefined `VoltageTuningPoint` over the specified ramp duration, then holds them. Combines the smooth transitions of ramping with the convenience of predefined voltage states. Both `ramp_duration` and `duration` can be QUA variables. ```python voltage_seq.ramp_to_point("idle", ramp_duration=50, duration=1000) ``` -- `ramp_to_zero(ramp_duration: Optional[int] = None)` +- `ramp_to_zero(ramp_duration: Optional[int] = None)` Ramps the voltage on all channels in the GateSet to zero and resets the integrated voltage tracking for each channel. If no duration is specified, uses QUA's built-in `ramp_to_zero` command for immediate ramping. Essential for safely returning to a neutral state. The `ramp_duration` parameter can be a QUA variable. ```python @@ -206,7 +206,7 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.ramp_to_zero(ramp_duration=100) # Controlled ramp over 100ns ``` -- `apply_compensation_pulse(max_voltage: float = 0.49)` +- `apply_compensation_pulse(max_voltage: float = 0.49)` Applies a compensation pulse to each channel to counteract integrated voltage drift when tracking is enabled. The compensation amplitude is calculated based on the accumulated integrated voltage, with the pulse duration optimized to stay within the specified maximum voltage limit. Only available when `track_integrated_voltage=True`. ```python @@ -214,6 +214,17 @@ Implication: virtual gates do not maintain state across calls. To preserve a vir voltage_seq.apply_compensation_pulse(max_voltage=0.3) # Custom voltage limit ``` +### Custom Macro Duration Contract + +When channels are sticky, non-voltage macros can still contribute integrated +voltage while they execute. For accurate compensation: + +- Custom non-voltage macros should implement `inferred_duration` in **seconds**. +- Macros that already perform voltage-sequence operations and therefore update + tracking directly should define `updates_voltage_tracking = True`. +- Prefer calling macros through component dispatch (`component.macro_name(...)`) + to ensure sticky-duration tracking interception is applied. + ## 5. Foundation for Virtual Gates `GateSet` and `VoltageSequence` provide the physical voltage control layer necessary for `VirtualGateSet`. diff --git a/quam_builder/tools/voltage_sequence/voltage_sequence.py b/quam_builder/tools/voltage_sequence/voltage_sequence.py index bb50e70f..75a56018 100644 --- a/quam_builder/tools/voltage_sequence/voltage_sequence.py +++ b/quam_builder/tools/voltage_sequence/voltage_sequence.py @@ -46,6 +46,8 @@ __all__ = [ "VoltageTuningPoint", "VoltageSequence", + "DEFAULT_PULSE_NAME", + "MIN_PULSE_DURATION_NS", ] # --- Constants --- @@ -72,6 +74,7 @@ def round_amplitude(level): level = float(np.float16(level)) return level + class VoltageSequence: """ Manages the generation of a QUA sequence for setting and adjusting DC voltages @@ -133,14 +136,14 @@ def __init__( if self._keep_levels: self._keep_levels_tracker = KeepLevels(self.gate_set) - + self._batched_voltages = None self._prog_id = None - def _initialise_attenuation_qua_vars(self) -> None: + def _initialise_attenuation_qua_vars(self) -> None: """Lazy initiation of QUA variables that runs only at the start of the QUA program.""" current_program_scope = id(scopes_manager.program_scope) - if self._prog_id != current_program_scope: + if self._prog_id != current_program_scope: self._prog_id = current_program_scope self.attenuation_qua_variables = { @@ -156,11 +159,10 @@ def _initialise_attenuation_qua_vars(self) -> None: } if self.gate_set.adjust_for_attenuation: self._attenuated_delta_v_vars: Dict[str, QuaVariable] = { - ch_name: declare(fixed) - for ch_name in self.gate_set.channels.keys() - } + ch_name: declare(fixed) for ch_name in self.gate_set.channels.keys() + } - else: + else: return @contextmanager @@ -173,7 +175,7 @@ def simultaneous(self, duration: int = 16, ramp_duration: int = None): if self._batched_voltages: voltages_to_execute = self._batched_voltages.copy() self._batched_voltages = None - + # Cater for ramps if ramp_duration == 0 or ramp_duration is None: self.step_to_voltages(voltages_to_execute, duration) @@ -181,7 +183,7 @@ def simultaneous(self, duration: int = 16, ramp_duration: int = None): self.ramp_to_voltages(voltages_to_execute, ramp_duration, duration) else: self._batched_voltages = None - + def _get_temp_qua_var(self, name_suffix: str, var_type=fixed) -> QuaVariable: """Gets or declares a temporary QUA variable for internal calculations.""" # Use a prefix related to the VoltageSequence instance if multiple exist @@ -192,18 +194,14 @@ def _get_temp_qua_var(self, name_suffix: str, var_type=fixed) -> QuaVariable: return self._temp_qua_vars[internal_name] def _adjust_for_attenuation(self, channel, delta_v): - ch_name = next( - name for name, ch in self.gate_set.channels.items() if ch is channel - ) + ch_name = next(name for name, ch in self.gate_set.channels.items() if ch is channel) attenuation_scale = self.attenuation_qua_variables[ch_name] if is_qua_type(delta_v): unattenuated_delta_v = self._attenuated_delta_v_vars[ch_name] assign(unattenuated_delta_v, (delta_v * attenuation_scale) << ATTENUATION_BITSHIFT) else: unattenuated_delta_v = delta_v * ( - 10 ** (channel.attenuation / 20) - if hasattr(channel, "attenuation") - else 1 + 10 ** (channel.attenuation / 20) if hasattr(channel, "attenuation") else 1 ) return unattenuated_delta_v @@ -340,10 +338,8 @@ def _common_voltages_change( ) if self._keep_levels: - target_voltages_dict = ( - self._keep_levels_tracker.update_voltage_dict_with_current( - target_voltages_dict - ) + target_voltages_dict = self._keep_levels_tracker.update_voltage_dict_with_current( + target_voltages_dict ) full_target_voltages_dict = self.gate_set.resolve_voltages(target_voltages_dict) @@ -391,9 +387,7 @@ def _common_voltages_change( ) tracker.current_level = target_voltage - def step_to_voltages( - self, voltages: Dict[str, VoltageLevelType], duration: DurationType - ): + def step_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: DurationType): """ Steps all specified channels directly to the given voltage levels. @@ -474,6 +468,32 @@ def ramp_to_voltages( """ self._common_voltages_change(voltages, duration, ramp_duration=ramp_duration) + def track_sticky_duration(self, duration_ns: int) -> None: + """Track hold time at current channel levels without emitting pulses. + + This is used when non-voltage macros execute while voltage channels are + sticky at non-zero levels. It updates integrated-voltage trackers only. + """ + if not self._track_integrated_voltage: + return + if not isinstance(duration_ns, int): + raise TypeError("duration_ns must be an integer number of nanoseconds.") + if duration_ns < 0: + raise TypeError("duration_ns must be non-negative.") + if duration_ns % CLOCK_CYCLE_NS != 0: + raise TypeError( + f"duration_ns ({duration_ns}ns) must be a multiple of {CLOCK_CYCLE_NS}ns." + ) + if duration_ns == 0: + return + + for tracker in self.state_trackers.values(): + tracker.update_integrated_voltage( + level=tracker.current_level, + duration=duration_ns, + ramp_duration=None, + ) + def step_to_point(self, name: str, duration: Optional[DurationType] = None): """ Steps all channels to the voltages defined in a predefined tuning point. @@ -507,9 +527,7 @@ def step_to_point(self, name: str, duration: Optional[DurationType] = None): ) tuning_point: VoltageTuningPoint = tuning_point_macro effective_duration = duration if duration is not None else tuning_point.duration - self._common_voltages_change( - tuning_point.voltages, effective_duration, ramp_duration=None - ) + self._common_voltages_change(tuning_point.voltages, effective_duration, ramp_duration=None) def ramp_to_point( self, @@ -574,9 +592,7 @@ def _calculate_python_compensation_params( ideal_dur = abs(py_int_v * COMPENSATION_SCALING_FACTOR / max_voltage) py_comp_dur = max(ideal_dur, MIN_COMPENSATION_DURATION_NS) py_comp_dur = ( - (int(np.ceil(py_comp_dur)) + CLOCK_CYCLE_NS - 1) - // CLOCK_CYCLE_NS - * CLOCK_CYCLE_NS + (int(np.ceil(py_comp_dur)) + CLOCK_CYCLE_NS - 1) // CLOCK_CYCLE_NS * CLOCK_CYCLE_NS ) py_comp_dur = max(py_comp_dur, DEFAULT_QUA_COMPENSATION_DURATION_NS) @@ -689,9 +705,7 @@ def apply_compensation_pulse( raise ValueError("max_voltage must be positive.") if self._keep_levels: - zero_dict = { - name: 0.0 for name in self._keep_levels_tracker._keep_levels_dict - } + zero_dict = {name: 0.0 for name in self._keep_levels_tracker._keep_levels_dict} else: zero_dict = {} @@ -702,7 +716,10 @@ def apply_compensation_pulse( DEFAULT_WF_AMPLITUDE = channel_obj.operations[DEFAULT_PULSE_NAME].amplitude DEFAULT_AMPLITUDE_BITSHIFT = int(np.log2(1 / DEFAULT_WF_AMPLITUDE)) opx_voltage_limit = ( - 2.5 if hasattr(channel_obj.opx_output, "output_mode") and channel_obj.opx_output.output_mode == "amplified" else 0.5 + 2.5 + if hasattr(channel_obj.opx_output, "output_mode") + and channel_obj.opx_output.output_mode == "amplified" + else 0.5 ) if self.gate_set.adjust_for_attenuation and hasattr(channel_obj, "attenuation"): @@ -716,9 +733,7 @@ def apply_compensation_pulse( comp_amp_val: VoltageLevelType - if not is_qua_type(tracker.integrated_voltage) and not is_qua_type( - current_v - ): + if not is_qua_type(tracker.integrated_voltage) and not is_qua_type(current_v): py_comp_amp, py_comp_dur = self._calculate_python_compensation_params( tracker, max_voltage ) @@ -781,8 +796,7 @@ def _perform_ramp_to_zero_with_duration( with else_(): # Duration is 0, effectively a step channel_obj.play( DEFAULT_PULSE_NAME, - amplitude_scale=-current_v - * np.round(1.0 / DEFAULT_WF_AMPLITUDE, 10), + amplitude_scale=-current_v * np.round(1.0 / DEFAULT_WF_AMPLITUDE, 10), duration=ramp_duration >> 2, validate=False, # Do not validate as pulse may not exist yet ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/architecture/__init__.py b/tests/architecture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/architecture/quantum_dots/__init__.py b/tests/architecture/quantum_dots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/architecture/quantum_dots/components/__init__.py b/tests/architecture/quantum_dots/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/architecture/quantum_dots/components/test_barrier_gate.py b/tests/architecture/quantum_dots/components/test_barrier_gate.py new file mode 100644 index 00000000..6261b1ef --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_barrier_gate.py @@ -0,0 +1,64 @@ +"""Tests for BarrierGate. + +All objects are real — no mocks or stubs. +""" + +from qm import qua + +from quam_builder.architecture.quantum_dots.components import BarrierGate + + +class TestBarrierGateProperties: + def test_barrier_gates_exist(self, qd_machine): + assert len(qd_machine.barrier_gates) == 3 + + def test_barrier_gate_type(self, qd_machine): + for bg in qd_machine.barrier_gates.values(): + assert isinstance(bg, BarrierGate) + + def test_barrier_gate_has_physical_channel(self, qd_machine): + for bg in qd_machine.barrier_gates.values(): + assert bg.physical_channel is not None + + def test_name_equals_id(self, qd_machine): + for bg in qd_machine.barrier_gates.values(): + assert bg.name == bg.id + + def test_machine_reference(self, qd_machine): + bg = list(qd_machine.barrier_gates.values())[0] + assert bg.machine is qd_machine + + def test_current_voltage_default(self, qd_machine): + for bg in qd_machine.barrier_gates.values(): + assert bg.current_voltage == 0.0 + + def test_voltage_sequence_accessible(self, qd_machine): + bg = list(qd_machine.barrier_gates.values())[0] + assert bg.voltage_sequence is not None + + +class TestBarrierGateVoltageOps: + def test_update_current_voltage(self, qd_machine): + bg = list(qd_machine.barrier_gates.values())[0] + bg._update_current_voltage(0.35) + assert bg.current_voltage == 0.35 + + def test_add_point(self, qd_machine): + bg = list(qd_machine.barrier_gates.values())[0] + full_name = bg.add_point("open", {bg.id: 0.5}, duration=100) + macros = bg.voltage_sequence.gate_set.get_macros() + assert full_name in macros + + def test_step_to_point_in_qua(self, qd_machine): + bg = list(qd_machine.barrier_gates.values())[0] + bg.add_point("open", {bg.id: 0.5}, duration=100) + with qua.program() as prog: + bg.step_to_point("open") + assert prog is not None + + +class TestBarrierGateInPair: + def test_pair_barrier_gate(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert isinstance(pair.barrier_gate, BarrierGate) + assert pair.barrier_gate in qd_machine.barrier_gates.values() diff --git a/tests/architecture/quantum_dots/components/test_base_quam_qd.py b/tests/architecture/quantum_dots/components/test_base_quam_qd.py new file mode 100644 index 00000000..2180c8ef --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_base_quam_qd.py @@ -0,0 +1,324 @@ +"""Tests for BaseQuamQD and LossDiVincenzoQuam registration workflows. + +All objects are real — no mocks or stubs. +""" + +import pytest +from qm import qua +from quam.components import StickyChannelAddon, pulses +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + QuantumDot, + BarrierGate, + SensorDot, + QuantumDotPair, + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair + + +def _gate(port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", 6, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def _resonator() -> ReadoutResonatorSingle: + return ReadoutResonatorSingle( + id="rr", + frequency_bare=0, + intermediate_frequency=500e6, + operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def _two_dot_machine_with_readout() -> LossDiVincenzoQuam: + machine = LossDiVincenzoQuam() + p1 = _gate(1, "p1") + p2 = _gate(2, "p2") + b1 = _gate(3, "b1") + s1 = _gate(4, "s1") + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "vd1": p1, + "vd2": p2, + "vb1": b1, + "vs1": s1, + }, + gate_set_id="qpu", + ) + machine.register_channel_elements( + plunger_channels=[p1, p2], + barrier_channels=[b1], + sensor_resonator_mappings={s1: _resonator()}, + ) + machine.register_quantum_dot_pair( + id="pair_12", + quantum_dot_ids=["vd1", "vd2"], + sensor_dot_ids=["vs1"], + barrier_gate_id="vb1", + ) + return machine + + +class TestMachineCreation: + def test_empty_machine(self): + machine = LossDiVincenzoQuam() + assert machine.quantum_dots == {} + assert machine.sensor_dots == {} + assert machine.barrier_gates == {} + assert machine.qubits == {} + assert machine.qubit_pairs == {} + assert machine.virtual_gate_sets == {} + assert machine.quantum_dot_pairs == {} + + def test_default_fields(self): + machine = LossDiVincenzoQuam() + assert machine.b_field == 0 + assert machine.active_qubit_names == [] + assert machine.active_qubit_pair_names == [] + + +class TestVirtualGateSetCreation: + def test_create_virtual_gate_set(self): + machine = LossDiVincenzoQuam() + p1 = _gate(1, "p1") + p2 = _gate(2, "p2") + + machine.create_virtual_gate_set( + virtual_channel_mapping={"vd1": p1, "vd2": p2}, + gate_set_id="test_set", + ) + + assert "test_set" in machine.virtual_gate_sets + vgs = machine.virtual_gate_sets["test_set"] + assert "vd1" in vgs.valid_channel_names + assert "vd2" in vgs.valid_channel_names + + def test_physical_channels_registered(self): + machine = LossDiVincenzoQuam() + p1 = _gate(1, "p1") + machine.create_virtual_gate_set( + virtual_channel_mapping={"vd1": p1}, + gate_set_id="s", + ) + assert len(machine.physical_channels) >= 1 + + def test_compensation_matrix_defaults_to_identity(self): + machine = LossDiVincenzoQuam() + p1 = _gate(1, "p1") + p2 = _gate(2, "p2") + machine.create_virtual_gate_set( + virtual_channel_mapping={"vd1": p1, "vd2": p2}, + gate_set_id="s", + ) + vgs = machine.virtual_gate_sets["s"] + assert len(vgs.layers) == 1 + comp = vgs.layers[0].matrix + assert comp[0][0] == 1.0 + assert comp[1][1] == 1.0 + assert comp[0][1] == 0.0 + + +class TestRegistration: + @pytest.fixture + def machine_with_vgs(self): + machine = LossDiVincenzoQuam() + self.p1 = _gate(1, "p1") + self.p2 = _gate(2, "p2") + self.b1 = _gate(3, "b1") + self.s1 = _gate(4, "s1") + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "vd1": self.p1, + "vd2": self.p2, + "vb1": self.b1, + "vs1": self.s1, + }, + gate_set_id="qpu", + ) + return machine + + def test_register_quantum_dots(self, machine_with_vgs): + machine_with_vgs.register_quantum_dots([self.p1, self.p2]) + assert len(machine_with_vgs.quantum_dots) == 2 + for name, qd in machine_with_vgs.quantum_dots.items(): + assert isinstance(qd, QuantumDot) + + def test_register_barrier_gates(self, machine_with_vgs): + machine_with_vgs.register_barrier_gates([self.b1]) + assert len(machine_with_vgs.barrier_gates) == 1 + bg = list(machine_with_vgs.barrier_gates.values())[0] + assert isinstance(bg, BarrierGate) + + def test_register_sensor_dots(self, machine_with_vgs): + rr = _resonator() + machine_with_vgs.register_sensor_dots({self.s1: rr}) + assert len(machine_with_vgs.sensor_dots) == 1 + sd = list(machine_with_vgs.sensor_dots.values())[0] + assert isinstance(sd, SensorDot) + assert sd.readout_resonator is rr + + def test_register_channel_elements_all_at_once(self, machine_with_vgs): + rr = _resonator() + machine_with_vgs.register_channel_elements( + plunger_channels=[self.p1, self.p2], + barrier_channels=[self.b1], + sensor_resonator_mappings={self.s1: rr}, + ) + assert len(machine_with_vgs.quantum_dots) == 2 + assert len(machine_with_vgs.barrier_gates) == 1 + assert len(machine_with_vgs.sensor_dots) == 1 + + def test_register_quantum_dot_pair(self, machine_with_vgs): + rr = _resonator() + machine_with_vgs.register_channel_elements( + plunger_channels=[self.p1, self.p2], + barrier_channels=[self.b1], + sensor_resonator_mappings={self.s1: rr}, + ) + dot_ids = list(machine_with_vgs.quantum_dots.keys()) + sensor_ids = list(machine_with_vgs.sensor_dots.keys()) + barrier_id = list(machine_with_vgs.barrier_gates.keys())[0] + machine_with_vgs.register_quantum_dot_pair( + id="pair_12", + quantum_dot_ids=dot_ids[:2], + sensor_dot_ids=sensor_ids, + barrier_gate_id=barrier_id, + ) + assert "pair_12" in machine_with_vgs.quantum_dot_pairs + pair = machine_with_vgs.quantum_dot_pairs["pair_12"] + assert isinstance(pair, QuantumDotPair) + assert len(pair.quantum_dots) == 2 + assert len(pair.sensor_dots) == 1 + + +class TestComponentRetrieval: + def test_get_component_quantum_dot(self, qd_machine): + comp = qd_machine.get_component("virtual_dot_1") + assert isinstance(comp, QuantumDot) + + def test_get_component_barrier(self, qd_machine): + comp = qd_machine.get_component("virtual_barrier_1") + assert isinstance(comp, BarrierGate) + + def test_get_component_sensor(self, qd_machine): + comp = qd_machine.get_component("virtual_sensor_1") + assert isinstance(comp, SensorDot) + + def test_get_component_qubit(self, qd_machine): + comp = qd_machine.get_component("Q1") + assert isinstance(comp, LDQubit) + + def test_get_component_not_found(self, qd_machine): + with pytest.raises(ValueError): + qd_machine.get_component("nonexistent") + + def test_find_quantum_dot_pair(self, qd_machine): + pair_name = qd_machine.find_quantum_dot_pair("virtual_dot_1", "virtual_dot_2") + assert pair_name is not None + + def test_find_quantum_dot_pair_returns_none(self, qd_machine): + result = qd_machine.find_quantum_dot_pair("virtual_dot_1", "virtual_dot_4") + assert result is None + + +class TestVoltageSequence: + def test_get_voltage_sequence(self, qd_machine): + vs = qd_machine.get_voltage_sequence("main_qpu") + assert vs is not None + + def test_reset_voltage_sequence(self, qd_machine): + vs1 = qd_machine.get_voltage_sequence("main_qpu") + qd_machine.reset_voltage_sequence("main_qpu") + vs2 = qd_machine.get_voltage_sequence("main_qpu") + assert vs1 is not vs2 + + +class TestQubitRegistration: + def test_qubits_registered(self, qd_machine): + assert set(qd_machine.qubits.keys()) == {"Q1", "Q2", "Q3", "Q4"} + for q in qd_machine.qubits.values(): + assert isinstance(q, LDQubit) + + def test_register_qubit_stores_preferred_readout_quantum_dot(self): + machine = _two_dot_machine_with_readout() + machine.register_qubit( + quantum_dot_id="vd1", + qubit_name="Q1", + readout_quantum_dot="vd2", + ) + + assert machine.qubits["Q1"].preferred_readout_quantum_dot == "vd2" + + def test_qubit_pairs_registered(self, qd_machine): + assert set(qd_machine.qubit_pairs.keys()) == {"Q1_Q2", "Q3_Q4"} + for qp in qd_machine.qubit_pairs.values(): + assert isinstance(qp, LDQubitPair) + + def test_active_qubits(self, qd_machine): + qd_machine.active_qubit_names = ["Q1", "Q3"] + active = qd_machine.active_qubits + assert len(active) == 2 + assert all(isinstance(q, LDQubit) for q in active) + + def test_active_qubit_pairs(self, qd_machine): + qd_machine.active_qubit_pair_names = ["Q1_Q2"] + active = qd_machine.active_qubit_pairs + assert len(active) == 1 + assert isinstance(active[0], LDQubitPair) + + +class TestQuaVariables: + def test_declare_qua_variables(self, qd_machine): + with qua.program() as _prog: + iq_i, _i_st, iq_q, _q_st, _n, _n_st = qd_machine.declare_qua_variables() + + assert len(iq_i) == len(qd_machine.qubits) + assert len(iq_q) == len(qd_machine.qubits) + + def test_declare_qua_variables_custom_count(self, qd_machine): + with qua.program() as _prog: + iq_i, _i_st, iq_q, _q_st, _n, _n_st = qd_machine.declare_qua_variables(num_IQ_pairs=2) + + assert len(iq_i) == 2 + assert len(iq_q) == 2 + + +class TestSerialization: + def test_to_dict_excludes_voltage_sequences(self, qd_machine): + _ = qd_machine.get_voltage_sequence("main_qpu") + d = qd_machine.to_dict() + assert "voltage_sequences" not in d + + def test_to_dict_contains_qubits(self, qd_machine): + d = qd_machine.to_dict() + assert "qubits" in d + assert "Q1" in d["qubits"] + + def test_to_dict_contains_quantum_dots(self, qd_machine): + d = qd_machine.to_dict() + assert "quantum_dots" in d + + def test_round_trip_preserves_preferred_readout_quantum_dot(self, tmp_path): + machine = _two_dot_machine_with_readout() + machine.register_qubit( + quantum_dot_id="vd1", + qubit_name="Q1", + readout_quantum_dot="vd2", + ) + + machine.save(tmp_path, include_defaults=False) + loaded = LossDiVincenzoQuam.load(tmp_path) + + assert loaded.qubits["Q1"].preferred_readout_quantum_dot == "vd2" diff --git a/tests/architecture/quantum_dots/components/test_ld_qubit.py b/tests/architecture/quantum_dots/components/test_ld_qubit.py new file mode 100644 index 00000000..e26c7f10 --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_ld_qubit.py @@ -0,0 +1,79 @@ +"""Tests for LDQubit (Loss DiVincenzo spin qubit). + +All objects are real — no mocks or stubs. +""" + +from qm import qua + +from quam_builder.architecture.quantum_dots.components import QuantumDot + + +class TestLDQubitProperties: + def test_physical_channel(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert qubit.physical_channel is qubit.quantum_dot.physical_channel + + def test_machine_reference(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert qubit.machine is qd_machine + + def test_voltage_sequence(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert qubit.voltage_sequence is qubit.quantum_dot.voltage_sequence + + def test_quantum_dot_is_real(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert isinstance(qubit.quantum_dot, QuantumDot) + + def test_xy_none_by_default(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert qubit.xy is None + + +class TestThermalizationTime: + def test_default_when_T1_is_none(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + assert qubit.T1 is None + therm = qubit.thermalization_time + assert therm == 50_000 + + def test_calculated_from_T1(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + qubit.T1 = 1e-6 # 1 µs + expected = round(5 * 1e-6 * 1e9 / 4) * 4 + assert qubit.thermalization_time == expected + + +class TestVoltageOperations: + def test_add_point(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + full_name = qubit.add_point("idle", {"virtual_dot_1": 0.1}, duration=100) + macros = qubit.quantum_dot.voltage_sequence.gate_set.get_macros() + assert full_name in macros + + def test_step_to_point_in_qua(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + qubit.add_point("idle", {"virtual_dot_1": 0.1}, duration=100) + + with qua.program() as prog: + qubit.step_to_point("idle") + + assert prog is not None + + def test_ramp_to_point_in_qua(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + qubit.add_point("target", {"virtual_dot_1": 0.3}, duration=100) + + with qua.program() as prog: + qubit.ramp_to_point("target", ramp_duration=20) + + assert prog is not None + + +class TestReset: + def test_thermal_reset_in_qua(self, qd_machine): + qubit = qd_machine.qubits["Q1"] + with qua.program() as prog: + qubit.reset("thermal") + + assert prog is not None diff --git a/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py b/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py new file mode 100644 index 00000000..f6b397f2 --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py @@ -0,0 +1,73 @@ +"""Tests for LDQubitPair. + +All objects are real — no mocks or stubs. +""" + +from qm import qua + +from quam_builder.architecture.quantum_dots.components import QuantumDotPair +from quam_builder.architecture.quantum_dots.qubit import LDQubit + + +class TestLDQubitPairProperties: + def test_qubit_control_and_target(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert isinstance(pair.qubit_control, LDQubit) + assert isinstance(pair.qubit_target, LDQubit) + assert pair.qubit_control is qd_machine.qubits["Q1"] + assert pair.qubit_target is qd_machine.qubits["Q2"] + + def test_quantum_dot_pair_linked(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert isinstance(pair.quantum_dot_pair, QuantumDotPair) + + def test_detuning_axis_name(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert pair.detuning_axis_name == "dot1_dot2_epsilon" + + def test_voltage_sequence_accessible(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert pair.voltage_sequence is not None + + def test_machine_reference(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert pair.machine is qd_machine + + def test_physical_channel(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + assert pair.physical_channel is pair.quantum_dot_pair.physical_channel + + +class TestLDQubitPairVoltageOps: + def test_add_point(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + full_name = pair.add_point("exchange", {"dot1_dot2_epsilon": 0.5}, duration=100) + macros = pair.voltage_sequence.gate_set.get_macros() + assert full_name in macros + + def test_step_to_point_in_qua(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + pair.add_point("exchange", {"dot1_dot2_epsilon": 0.5}, duration=100) + + with qua.program() as prog: + pair.step_to_point("exchange") + + assert prog is not None + + def test_ramp_to_point_in_qua(self, qd_machine): + pair = qd_machine.qubit_pairs["Q1_Q2"] + pair.add_point("ramp_target", {"dot1_dot2_epsilon": 0.3}, duration=100) + + with qua.program() as prog: + pair.ramp_to_point("ramp_target", ramp_duration=20) + + assert prog is not None + + +class TestMultiplePairs: + def test_two_pairs_are_independent(self, qd_machine): + p1 = qd_machine.qubit_pairs["Q1_Q2"] + p2 = qd_machine.qubit_pairs["Q3_Q4"] + + assert p1.detuning_axis_name != p2.detuning_axis_name + assert p1.quantum_dot_pair is not p2.quantum_dot_pair diff --git a/tests/architecture/quantum_dots/components/test_quantum_dot.py b/tests/architecture/quantum_dots/components/test_quantum_dot.py new file mode 100644 index 00000000..30612d58 --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_quantum_dot.py @@ -0,0 +1,97 @@ +"""Tests for QuantumDot. + +All objects are real — no mocks or stubs. +""" + +from qm import qua + +from quam_builder.architecture.quantum_dots.components import QuantumDot +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + + +class TestQuantumDotProperties: + def test_quantum_dots_exist(self, qd_machine): + assert len(qd_machine.quantum_dots) == 4 + + def test_quantum_dot_type(self, qd_machine): + for qd in qd_machine.quantum_dots.values(): + assert isinstance(qd, QuantumDot) + + def test_has_physical_channel(self, qd_machine): + for qd in qd_machine.quantum_dots.values(): + assert qd.physical_channel is not None + + def test_name_property(self, qd_machine): + for qd in qd_machine.quantum_dots.values(): + assert isinstance(qd.name, str) + assert len(qd.name) > 0 + + def test_machine_reference(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + assert qd.machine is qd_machine + + def test_voltage_sequence_accessible(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + assert qd.voltage_sequence is not None + + def test_current_voltage_default(self, qd_machine): + for qd in qd_machine.quantum_dots.values(): + assert qd.current_voltage == 0.0 + + def test_charge_number_default(self, qd_machine): + for qd in qd_machine.quantum_dots.values(): + assert qd.charge_number == 0 + + +class TestQuantumDotVoltageOps: + def test_update_current_voltage(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + qd._update_current_voltage(0.42) + assert qd.current_voltage == 0.42 + + def test_add_point(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + full_name = qd.add_point("idle", {qd.id: 0.1}, duration=100) + macros = qd.voltage_sequence.gate_set.get_macros() + assert full_name in macros + + def test_step_to_point_in_qua(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + qd.add_point("test_pt", {qd.id: 0.2}, duration=100) + with qua.program() as prog: + qd.step_to_point("test_pt") + assert prog is not None + + def test_go_to_voltages_in_qua(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + with qua.program() as prog: + qd.go_to_voltages({qd.id: 0.15}, duration=100) + assert prog is not None + + +class TestQuantumDotPlay: + def test_play_in_qua(self, qd_machine): + qd = list(qd_machine.quantum_dots.values())[0] + with qua.program() as prog: + qd.play("half_max_square") + assert prog is not None + + +class TestQuantumDotCatalog: + """Verify QuantumDot receives state macros after wire_machine_macros().""" + + def test_has_initialize_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for qd in qd_machine.quantum_dots.values(): + assert "initialize" in qd.macros, f"{qd.id} missing 'initialize' macro" + + def test_no_generic_measure_macro_on_quantum_dot(self, qd_machine, reset_catalog): + """QuantumDot should not have a generic measure macro; measurement is component-specific.""" + wire_machine_macros(qd_machine) + for qd in qd_machine.quantum_dots.values(): + assert "measure" not in qd.macros, f"{qd.id} should not have generic 'measure' macro" + + def test_has_empty_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for qd in qd_machine.quantum_dots.values(): + assert "empty" in qd.macros, f"{qd.id} missing 'empty' macro" diff --git a/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py b/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py new file mode 100644 index 00000000..73686d82 --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py @@ -0,0 +1,119 @@ +"""Tests for QuantumDotPair. + +All objects are real — no mocks or stubs. +""" + +from qm import qua + +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.components import ( + QuantumDot, + SensorDot, + BarrierGate, +) + + +class TestQuantumDotPairProperties: + def test_quantum_dots_linked(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert len(pair.quantum_dots) == 2 + assert all(isinstance(qd, QuantumDot) for qd in pair.quantum_dots) + + def test_sensor_dots_linked(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert len(pair.sensor_dots) >= 1 + assert all(isinstance(sd, SensorDot) for sd in pair.sensor_dots) + + def test_barrier_gate_linked(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert isinstance(pair.barrier_gate, BarrierGate) + + def test_name_equals_id(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert pair.name == "dot1_dot2_pair" + + def test_voltage_sequence_accessible(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + vs = pair.voltage_sequence + assert vs is not None + + def test_machine_reference(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert pair.machine is qd_machine + + +class TestDetuningAxis: + def test_detuning_axis_name_set(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert pair.detuning_axis_name == "dot1_dot2_epsilon" + + def test_detuning_axis_in_virtual_gate_set(self, qd_machine): + vgs = qd_machine.virtual_gate_sets["main_qpu"] + assert "dot1_dot2_epsilon" in vgs.valid_channel_names + + def test_two_detuning_axes_coexist(self, qd_machine): + vgs = qd_machine.virtual_gate_sets["main_qpu"] + assert "dot1_dot2_epsilon" in vgs.valid_channel_names + assert "dot3_dot4_epsilon" in vgs.valid_channel_names + + +class TestDetuningControl: + def test_go_to_detuning_in_qua(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + with qua.program() as prog: + pair.go_to_detuning(0.3) + assert prog is not None + + def test_step_to_detuning_in_qua(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + with qua.program() as prog: + pair.step_to_detuning(0.5, duration=100) + assert prog is not None + + def test_ramp_to_detuning_in_qua(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + with qua.program() as prog: + pair.ramp_to_detuning(0.5, ramp_duration=20, duration=100) + assert prog is not None + + +class TestQuantumDotPairVoltagePoints: + def test_add_point(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + full_name = pair.add_point( + "symmetric", + voltages={"virtual_dot_1": 0.1, "virtual_dot_2": 0.1}, + duration=100, + ) + macros = pair.voltage_sequence.gate_set.get_macros() + assert full_name in macros + + def test_step_to_point_in_qua(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + pair.add_point( + "sym", + voltages={"virtual_dot_1": 0.1, "virtual_dot_2": 0.1}, + duration=100, + ) + with qua.program() as prog: + pair.step_to_point("sym") + assert prog is not None + + +class TestQuantumDotPairCatalog: + """Verify QuantumDotPair receives state macros after wire_machine_macros().""" + + def test_has_initialize_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for pair in qd_machine.quantum_dot_pairs.values(): + assert "initialize" in pair.macros, f"{pair.id} missing 'initialize' macro" + + def test_has_measure_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for pair in qd_machine.quantum_dot_pairs.values(): + assert "measure" in pair.macros, f"{pair.id} missing 'measure' macro" + + def test_has_empty_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for pair in qd_machine.quantum_dot_pairs.values(): + assert "empty" in pair.macros, f"{pair.id} missing 'empty' macro" diff --git a/tests/architecture/quantum_dots/components/test_readout_resonator.py b/tests/architecture/quantum_dots/components/test_readout_resonator.py new file mode 100644 index 00000000..7376fbfa --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_readout_resonator.py @@ -0,0 +1,75 @@ +"""Tests for ReadoutResonator components. + +All objects are real — no mocks or stubs. +""" + +from quam.components import StickyChannelAddon, pulses +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.components.readout_resonator import ( + ReadoutResonatorBase, +) + + +class TestReadoutResonatorSingleCreation: + def test_basic_creation(self): + rr = ReadoutResonatorSingle( + id="rr_test", + frequency_bare=5e9, + intermediate_frequency=100e6, + operations={ + "readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) + }, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + ) + assert rr.id == "rr_test" + assert rr.frequency_bare == 5e9 + assert "readout" in rr.operations + + def test_with_sticky_addon(self): + rr = ReadoutResonatorSingle( + id="rr_sticky", + frequency_bare=0, + intermediate_frequency=200e6, + operations={"readout": pulses.SquareReadoutPulse(length=100, id="ro", amplitude=0.05)}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + assert rr.sticky is not None + + +class TestVoltageScalingFactor: + def test_same_power_gives_factor_of_one(self): + factor = ReadoutResonatorBase.calculate_voltage_scaling_factor(0, 0) + assert abs(factor - 1.0) < 1e-9 + + def test_increase_power(self): + factor = ReadoutResonatorBase.calculate_voltage_scaling_factor(0, 6) + expected = 10 ** (6 / 20) + assert abs(factor - expected) < 1e-6 + + def test_decrease_power(self): + factor = ReadoutResonatorBase.calculate_voltage_scaling_factor(0, -6) + expected = 10 ** (-6 / 20) + assert abs(factor - expected) < 1e-6 + + def test_large_difference(self): + factor = ReadoutResonatorBase.calculate_voltage_scaling_factor(-40, 10) + expected = 10 ** (50 / 20) + assert abs(factor - expected) < 1e-3 + + +class TestResonatorInMachine: + def test_sensor_dot_has_resonator(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert sd.readout_resonator is not None + assert isinstance(sd.readout_resonator, ReadoutResonatorSingle) + + def test_resonator_operations(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert "readout" in sd.readout_resonator.operations diff --git a/tests/architecture/quantum_dots/components/test_sensor_dot.py b/tests/architecture/quantum_dots/components/test_sensor_dot.py new file mode 100644 index 00000000..b83b0b1a --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_sensor_dot.py @@ -0,0 +1,139 @@ +"""Tests for SensorDot and Projector. + +All objects are real — no mocks or stubs. +""" + +from unittest.mock import MagicMock + +from quam_builder.architecture.quantum_dots.components import SensorDot +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.components.sensor_dot import Projector + + +class TestSensorDotProperties: + def test_sensor_dot_exists(self, qd_machine): + assert len(qd_machine.sensor_dots) >= 1 + + def test_sensor_dot_type(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert isinstance(sd, SensorDot) + + def test_has_readout_resonator(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert sd.readout_resonator is not None + + def test_has_physical_channel(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert sd.physical_channel is not None + + def test_readout_thresholds_empty_by_default(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert sd.readout_thresholds == {} + + def test_readout_projectors_empty_by_default(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + assert sd.readout_projectors == {} + + +class TestProjector: + def test_default_projector(self): + p = Projector() + assert p.wI == 1.0 + assert p.wQ == 0.0 + assert p.offset == 0.0 + + def test_custom_projector(self): + p = Projector(wI=0.5, wQ=0.5, offset=-0.1) + assert p.wI == 0.5 + assert p.wQ == 0.5 + assert p.offset == -0.1 + + +class TestReadoutParams: + def test_add_readout_threshold(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + sd._add_readout_threshold("dot1_dot2_pair", 0.5) + assert "dot1_dot2_pair" in sd.readout_thresholds + assert sd.readout_thresholds["dot1_dot2_pair"] == 0.5 + + def test_add_readout_projector(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + proj = Projector(wI=0.7, wQ=0.3, offset=0.01) + sd._add_readout_projector("dot1_dot2_pair", proj) + assert "dot1_dot2_pair" in sd.readout_projectors + + def test_add_readout_params(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + proj = Projector(wI=0.8, wQ=0.2, offset=0.0) + sd._add_readout_params("dot1_dot2_pair", threshold=0.42, projector=proj) + assert "dot1_dot2_pair" in sd.readout_thresholds + assert "dot1_dot2_pair" in sd.readout_projectors + assert sd.readout_thresholds["dot1_dot2_pair"] == 0.42 + + def test_readout_params_retrieval(self, qd_machine): + sd = list(qd_machine.sensor_dots.values())[0] + proj = Projector(wI=0.6, wQ=0.4, offset=-0.05) + sd._add_readout_params("dot3_dot4_pair", threshold=0.55, projector=proj) + threshold, retrieved_proj = sd._readout_params("dot3_dot4_pair") + assert threshold == 0.55 + + +class TestSensorDotInQuantumDotPair: + def test_pair_has_sensor_dot(self, qd_machine): + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + assert len(pair.sensor_dots) >= 1 + assert isinstance(pair.sensor_dots[0], SensorDot) + + def test_sensor_shared_between_pairs(self, qd_machine): + p1 = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + p2 = qd_machine.quantum_dot_pairs["dot3_dot4_pair"] + assert p1.sensor_dots[0] is p2.sensor_dots[0] + + +class TestSensorDotMeasureMacro: + """Tests for SensorDotMeasureMacro (dispatch to readout resonator).""" + + def test_sensor_dot_measure_macro_importable(self): + """SensorDotMeasureMacro is importable from state_macros.""" + from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + SensorDotMeasureMacro, + ) + + assert SensorDotMeasureMacro is not None + + def test_sensor_dot_measure_macro_apply_calls_readout_resonator_measure(self): + """SensorDotMeasureMacro.apply() calls owner.readout_resonator.measure().""" + from quam.core.macro import QuamMacro + from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + SensorDotMeasureMacro, + ) + + assert issubclass(SensorDotMeasureMacro, QuamMacro) + mock_resonator = MagicMock() + mock_sd = MagicMock(spec=SensorDot) + mock_sd.readout_resonator = mock_resonator + macro = SensorDotMeasureMacro() + macro.parent = mock_sd + macro.apply(foo="bar") + mock_resonator.measure.assert_called_once_with(foo="bar") + + +class TestSensorDotCatalog: + """Verify SensorDot receives measure-only macro after wire_machine_macros().""" + + def test_has_measure_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for sd in qd_machine.sensor_dots.values(): + assert "measure" in sd.macros, f"{sd.id} missing 'measure' macro" + + def test_no_initialize_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for sd in qd_machine.sensor_dots.values(): + assert ( + "initialize" not in sd.macros + ), f"{sd.id} must not have 'initialize' macro (CAT-03)" + + def test_no_empty_macro(self, qd_machine, reset_catalog): + wire_machine_macros(qd_machine) + for sd in qd_machine.sensor_dots.values(): + assert "empty" not in sd.macros, f"{sd.id} must not have 'empty' macro (CAT-03)" diff --git a/tests/architecture/quantum_dots/components/test_xy_drive.py b/tests/architecture/quantum_dots/components/test_xy_drive.py new file mode 100644 index 00000000..684526ab --- /dev/null +++ b/tests/architecture/quantum_dots/components/test_xy_drive.py @@ -0,0 +1,139 @@ +"""Tests for XYDrive channel variants. + +Covers XYDriveSingle, XYDriveIQ, and XYDriveMW creation, default-pulse +generation, custom-pulse addition, and .play() inside a QUA program. +All objects are real — no mocks or stubs. +""" + +from qm import qua +from quam.components import pulses +from quam.components.ports import ( + LFFEMAnalogOutputPort, + MWFEMAnalogOutputPort, +) + +from quam_builder.architecture.quantum_dots.components import ( + XYDriveSingle, + XYDriveMW, +) + + +# --------------------------------------------------------------------------- +# XYDriveSingle +# --------------------------------------------------------------------------- + + +class TestXYDriveSingleCreation: + def test_basic_creation(self): + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + assert drive.id == "xy_single" + assert drive.RF_frequency == int(100e6) + assert drive.intermediate_frequency == int(100e6) + + def test_no_default_pulses_on_creation(self): + """XYDriveSingle no longer adds default pulses in __post_init__.""" + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + # No pulses added at construction time — they come from wire_machine_macros + assert "gaussian" not in drive.operations + assert "pi" not in drive.operations + + def test_add_custom_pulse(self): + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + drive.add_pulse("rabi", pulses.GaussianPulse(length=100, amplitude=0.2, sigma=20)) + assert "rabi" in drive.operations + assert drive.operations["rabi"].length == 100 + + +class TestXYDriveSingleInQUA: + def test_play_pulse_compiles(self): + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + with qua.program() as prog: + drive.play("gaussian") + assert prog is not None + + def test_play_with_amplitude_scale(self): + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + drive.operations["pi"] = pulses.SquarePulse(length=104, amplitude=0.2) + with qua.program() as prog: + drive.play("pi", amplitude_scale=0.5) + assert prog is not None + + def test_play_with_duration_override(self): + drive = XYDriveSingle( + id="xy_single", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + ) + drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + with qua.program() as prog: + t = qua.declare(int) + qua.assign(t, 100) + drive.play("gaussian", duration=t) + assert prog is not None + + +# --------------------------------------------------------------------------- +# XYDriveMW +# --------------------------------------------------------------------------- + + +class TestXYDriveMWCreation: + def test_basic_creation(self): + drive = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + intermediate_frequency=int(100e6), + ) + assert drive.id == "xy_mw" + assert drive.intermediate_frequency == int(100e6) + assert drive.upconverter_frequency == int(5e9) + + def test_add_pulse_and_play(self): + drive = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + intermediate_frequency=int(100e6), + ) + drive.operations["drive"] = pulses.GaussianPulse( + length=100, + amplitude=0.2, + sigma=20, + ) + with qua.program() as prog: + drive.play("drive") + assert prog is not None diff --git a/tests/architecture/quantum_dots/conftest.py b/tests/architecture/quantum_dots/conftest.py new file mode 100644 index 00000000..bc5cf7b1 --- /dev/null +++ b/tests/architecture/quantum_dots/conftest.py @@ -0,0 +1,104 @@ +"""Shared fixtures for quantum-dot architecture tests.""" + +import pytest +from quam.components import StickyChannelAddon, pulses +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam + + +def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +@pytest.fixture +def qd_machine(): + """Fully wired LossDiVincenzoQuam with 4 dots, 3 barriers, 1 sensor, + 4 qubits, 2 quantum-dot pairs, and 2 qubit pairs.""" + machine = LossDiVincenzoQuam() + lf = 6 + + p1 = _make_voltage_gate(lf, 1, "plunger_1") + p2 = _make_voltage_gate(lf, 2, "plunger_2") + p3 = _make_voltage_gate(lf, 3, "plunger_3") + p4 = _make_voltage_gate(lf, 4, "plunger_4") + b1 = _make_voltage_gate(lf, 5, "barrier_1") + b2 = _make_voltage_gate(lf, 6, "barrier_2") + b3 = _make_voltage_gate(lf, 7, "barrier_3") + s1 = _make_voltage_gate(lf, 8, "sensor_DC") + + resonator = ReadoutResonatorSingle( + id="readout_resonator", + frequency_bare=0, + intermediate_frequency=500e6, + operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "virtual_dot_1": p1, + "virtual_dot_2": p2, + "virtual_dot_3": p3, + "virtual_dot_4": p4, + "virtual_barrier_1": b1, + "virtual_barrier_2": b2, + "virtual_barrier_3": b3, + "virtual_sensor_1": s1, + }, + gate_set_id="main_qpu", + ) + + machine.register_channel_elements( + plunger_channels=[p1, p2, p3, p4], + barrier_channels=[b1, b2, b3], + sensor_resonator_mappings={s1: resonator}, + ) + + machine.register_qubit(quantum_dot_id="virtual_dot_1", qubit_name="Q1") + machine.register_qubit(quantum_dot_id="virtual_dot_2", qubit_name="Q2") + machine.register_qubit(quantum_dot_id="virtual_dot_3", qubit_name="Q3") + machine.register_qubit(quantum_dot_id="virtual_dot_4", qubit_name="Q4") + + machine.register_quantum_dot_pair( + id="dot1_dot2_pair", + quantum_dot_ids=["virtual_dot_1", "virtual_dot_2"], + sensor_dot_ids=["virtual_sensor_1"], + barrier_gate_id="virtual_barrier_2", + ) + machine.register_quantum_dot_pair( + id="dot3_dot4_pair", + quantum_dot_ids=["virtual_dot_3", "virtual_dot_4"], + sensor_dot_ids=["virtual_sensor_1"], + barrier_gate_id="virtual_barrier_3", + ) + + machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( + matrix=[[1, -1]], + detuning_axis_name="dot1_dot2_epsilon", + set_dc_virtual_axis=False, + ) + machine.quantum_dot_pairs["dot3_dot4_pair"].define_detuning_axis( + matrix=[[1, -1]], + detuning_axis_name="dot3_dot4_epsilon", + set_dc_virtual_axis=False, + ) + + machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") + machine.register_qubit_pair(qubit_control_name="Q3", qubit_target_name="Q4", id="Q3_Q4") + + # QuantumDotPair.__post_init__ eagerly creates the VoltageSequence (cached) + # before detuning axes are added, so the KeepLevels tracker is stale. + machine.reset_voltage_sequence("main_qpu") + + return machine diff --git a/tests/architecture/quantum_dots/operations/test_alignment_fixes.py b/tests/architecture/quantum_dots/operations/test_alignment_fixes.py new file mode 100644 index 00000000..43606837 --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_alignment_fixes.py @@ -0,0 +1,373 @@ +"""Tests for builder/architecture alignment fixes. + +Covers: +- SingleChannel pulse assignment (axis_angle=None for XYDriveSingle) +- ZNeg90 macro registration +- Measure macro chain (SensorDot -> QuantumDotPair -> Qubit) +""" + +from inspect import signature +from unittest.mock import MagicMock, call, patch + +import pytest +import numpy as np + +from quam.components import pulses as quam_pulses, StickyChannelAddon +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, + XYDriveMW, + XYDriveIQ, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + ZNeg90Macro, + SINGLE_QUBIT_MACROS, + Measure1QMacro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + ExchangeStateMacro, + InitializeStateMacro, + SensorDotMeasureMacro, + MeasurePSBPairMacro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + Measure2QMacro, +) +from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( + _make_xy_pulse_factories, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit + + +@pytest.fixture(autouse=True) +def _set_quam_state_path(tmp_path, monkeypatch): + monkeypatch.setenv("QUAM_STATE_PATH", str(tmp_path / "quam_state")) + + +# --------------------------------------------------------------------------- # +# Fix 1: SingleChannel pulse assignment +# --------------------------------------------------------------------------- # + + +class TestSingleChannelPulses: + """Verify that XYDriveSingle gets real-valued pulses (axis_angle=None).""" + + def test_single_channel_pulse_has_no_axis_angle(self): + xy = XYDriveSingle( + id="test_xy", + opx_output=LFFEMAnalogOutputPort("con1", 3, port_id=1), + RF_frequency=100_000_000, + ) + default_pulses = _make_xy_pulse_factories(xy) + for name, pulse in default_pulses.items(): + xy.operations[name] = pulse + + pulse = xy.operations["gaussian"] + assert ( + pulse.axis_angle is None + ), "Pulse 'gaussian' should have axis_angle=None for SingleChannel" + + def test_iq_channel_pulse_has_axis_angle(self): + """IQ channels should still get axis_angle=0.0.""" + xy = MagicMock(spec=XYDriveIQ) + xy.operations = {} + default_pulses = _make_xy_pulse_factories(xy) + for name, pulse in default_pulses.items(): + xy.operations[name] = pulse + + pulse = xy.operations["gaussian"] + assert pulse.axis_angle == 0.0 + + +# --------------------------------------------------------------------------- # +# Fix 3: ZNeg90 macro +# --------------------------------------------------------------------------- # + + +class TestZNeg90Macro: + """Verify ZNeg90Macro is registered and invokes z rotation.""" + + def test_z_neg90_in_macro_catalog(self): + assert SingleQubitMacroName.Z_NEG_90.value in SINGLE_QUBIT_MACROS + assert SINGLE_QUBIT_MACROS[SingleQubitMacroName.Z_NEG_90.value] is ZNeg90Macro + + def test_z_neg90_default_angle(self): + macro = ZNeg90Macro() + assert macro.default_angle == pytest.approx(-np.pi / 2) + + def test_z_neg90_wired_to_qubit(self, reset_catalog): + """After wiring, qubit should have z_neg90 macro.""" + machine = _make_wired_machine() + wire_machine_macros(machine) + + q = machine.qubits["Q1"] + assert "z_neg90" in q.macros + + +# --------------------------------------------------------------------------- # +# Fix 2: Measure macro chain +# --------------------------------------------------------------------------- # + + +class TestMeasureMacroRegistration: + """Verify macro types are correctly registered in the catalog.""" + + def test_measure1q_macro_type(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + q = machine.qubits["Q1"] + assert isinstance(q.macros["measure"], Measure1QMacro) + + def test_measure_psb_pair_macro_type(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + pair = machine.quantum_dot_pairs["dot1_dot2_pair"] + assert isinstance(pair.macros["measure"], MeasurePSBPairMacro) + + def test_sensor_dot_measure_macro_type(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + sd = machine.sensor_dots["virtual_sensor_1"] + assert isinstance(sd.macros["measure"], SensorDotMeasureMacro) + + def test_measure2q_delegates(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + qp = machine.qubit_pairs["Q1_Q2"] + assert isinstance(qp.macros["measure"], Measure2QMacro) + + +class TestMeasure1QNavigation: + """Test that Measure1QMacro navigates to the correct QuantumDotPair.""" + + def test_raises_without_preferred_readout(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + q = machine.qubits["Q1"] + # No preferred_readout_quantum_dot set + q.preferred_readout_quantum_dot = None + with pytest.raises(ValueError, match="preferred_readout_quantum_dot"): + q.macros["measure"].apply() + + def test_raises_with_invalid_pair(self, reset_catalog): + """Setting preferred_readout_quantum_dot to a non-existent dot should fail at the setter.""" + machine = _make_wired_machine() + wire_machine_macros(machine) + q = machine.qubits["Q1"] + with pytest.raises(ValueError, match="not a registered Quantum Dot"): + q.preferred_readout_quantum_dot = "nonexistent_dot" + + +class TestMeasurePSBPairNavigation: + """Test MeasurePSBPairMacro structure and error paths.""" + + def test_pair_macro_has_correct_point(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + pair = machine.quantum_dot_pairs["dot1_dot2_pair"] + macro = pair.macros["measure"] + assert macro.point == VoltagePointName.MEASURE.value + + def test_pair_has_sensor_dots(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + pair = machine.quantum_dot_pairs["dot1_dot2_pair"] + assert len(pair.sensor_dots) > 0 + + +class TestStateMacroPointDispatch: + """Verify state macros dispatch on the new `point` keyword type.""" + + def test_state_macro_signature_uses_point_keyword(self): + initialize_sig = signature(InitializeStateMacro) + exchange_sig = signature(ExchangeStateMacro) + measure_pair_sig = signature(MeasurePSBPairMacro) + + assert "point" in initialize_sig.parameters + assert "point_name" not in initialize_sig.parameters + assert "point" in exchange_sig.parameters + assert "return_point" in exchange_sig.parameters + assert "point_name" not in exchange_sig.parameters + assert "return_point_name" not in exchange_sig.parameters + assert "point" in measure_pair_sig.parameters + assert "point_name" not in measure_pair_sig.parameters + + def test_initialize_state_macro_ramps_to_named_point(self): + macro = InitializeStateMacro(point="custom_init", ramp_duration=48, hold_duration=64) + owner = MagicMock() + + with patch( + "quam_builder.architecture.quantum_dots.operations.default_macros.state_macros._owner_component", + return_value=owner, + ): + macro.apply() + + owner.ramp_to_point.assert_called_once_with("custom_init", ramp_duration=48, duration=64) + owner.ramp_to_voltages.assert_not_called() + + def test_initialize_state_macro_ramps_to_voltage_dict(self): + voltages = {"virtual_dot_1": 0.1, "virtual_barrier_1": -0.05} + macro = InitializeStateMacro(point=voltages, ramp_duration=48, hold_duration=64) + owner = MagicMock() + + with patch( + "quam_builder.architecture.quantum_dots.operations.default_macros.state_macros._owner_component", + return_value=owner, + ): + macro.apply() + + owner.ramp_to_voltages.assert_called_once_with(voltages, duration=64, ramp_duration=48) + owner.ramp_to_point.assert_not_called() + + def test_exchange_state_macro_accepts_voltage_dict_targets(self): + exchange_voltages = {"virtual_dot_1": 0.2} + return_voltages = {"virtual_dot_1": 0.0} + macro = ExchangeStateMacro( + point=exchange_voltages, + return_point=return_voltages, + ramp_duration=32, + wait_duration=80, + ) + owner = MagicMock() + + with patch( + "quam_builder.architecture.quantum_dots.operations.default_macros.state_macros._owner_component", + return_value=owner, + ): + macro.apply() + + owner.ramp_to_voltages.assert_has_calls( + [ + call(exchange_voltages, duration=80, ramp_duration=32), + call(return_voltages, duration=0, ramp_duration=32), + ] + ) + owner.ramp_to_point.assert_not_called() + owner.voltage_sequence.step_to_voltages.assert_not_called() + + def test_measure_psb_pair_macro_steps_to_voltage_dict(self): + voltages = {"virtual_dot_1": -0.1} + macro = MeasurePSBPairMacro(point=voltages, hold_duration=96) + sensor_dot = MagicMock() + owner = MagicMock() + owner.id = "dot1_dot2_pair" + owner.sensor_dots = [sensor_dot] + + with patch( + "quam_builder.architecture.quantum_dots.operations.default_macros.state_macros._owner_component", + return_value=owner, + ): + macro.apply() + + owner.step_to_voltages.assert_called_once_with(voltages, duration=96) + owner.step_to_point.assert_not_called() + sensor_dot.macros[VoltagePointName.MEASURE.value].apply.assert_called_once_with( + quantum_dot_pair_id="dot1_dot2_pair", + ) + + +class TestMeasure2QDelegation: + """Test Measure2QMacro delegates to quantum_dot_pair.""" + + def test_qubit_pair_has_quantum_dot_pair(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + qp = machine.qubit_pairs["Q1_Q2"] + assert qp.quantum_dot_pair is not None + + def test_measure2q_raises_without_quantum_dot_pair(self, reset_catalog): + """Directly test Measure2QMacro.apply with no quantum_dot_pair.""" + macro = Measure2QMacro() + owner = MagicMock() + owner.id = "test_pair" + owner.quantum_dot_pair = None + with patch( + "quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros._owner_component", + return_value=owner, + ): + with pytest.raises(ValueError, match="no quantum_dot_pair"): + macro.apply() + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def _make_wired_machine() -> LossDiVincenzoQuam: + """Build a machine similar to the conftest qd_machine but standalone.""" + machine = LossDiVincenzoQuam() + lf = 6 + + p1 = _make_voltage_gate(lf, 1, "plunger_1") + p2 = _make_voltage_gate(lf, 2, "plunger_2") + b1 = _make_voltage_gate(lf, 5, "barrier_1") + s1 = _make_voltage_gate(lf, 8, "sensor_DC") + + resonator = ReadoutResonatorSingle( + id="readout_resonator", + frequency_bare=0, + intermediate_frequency=500e6, + operations={ + "readout": quam_pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) + }, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "virtual_dot_1": p1, + "virtual_dot_2": p2, + "virtual_barrier_1": b1, + "virtual_sensor_1": s1, + }, + gate_set_id="main_qpu", + ) + + machine.register_channel_elements( + plunger_channels=[p1, p2], + barrier_channels=[b1], + sensor_resonator_mappings={s1: resonator}, + ) + + machine.register_qubit(quantum_dot_id="virtual_dot_1", qubit_name="Q1") + machine.register_qubit(quantum_dot_id="virtual_dot_2", qubit_name="Q2") + + machine.register_quantum_dot_pair( + id="dot1_dot2_pair", + quantum_dot_ids=["virtual_dot_1", "virtual_dot_2"], + sensor_dot_ids=["virtual_sensor_1"], + barrier_gate_id="virtual_barrier_1", + ) + + machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( + matrix=[[1, -1]], + detuning_axis_name="dot1_dot2_epsilon", + set_dc_virtual_axis=False, + ) + + machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") + machine.reset_voltage_sequence("main_qpu") + + return machine diff --git a/tests/architecture/quantum_dots/operations/test_catalog_reset.py b/tests/architecture/quantum_dots/operations/test_catalog_reset.py new file mode 100644 index 00000000..2986ce2b --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_catalog_reset.py @@ -0,0 +1,37 @@ +"""Tests for catalog/registry reset helpers (FOR TESTING ONLY).""" + +import pytest + +from quam_builder.architecture.quantum_dots.operations import component_macro_catalog +from quam_builder.architecture.quantum_dots.operations import macro_registry + + +def test_reset_registration_sets_registered_false(): + """_reset_registration() sets _REGISTERED = False so registration can re-run.""" + component_macro_catalog.register_default_component_macro_factories() + component_macro_catalog._reset_registration() + assert component_macro_catalog._REGISTERED is False + + +def test_reset_registry_clears_factories(): + """_reset_registry() empties _COMPONENT_MACRO_FACTORIES.""" + component_macro_catalog.register_default_component_macro_factories() + assert len(macro_registry._COMPONENT_MACRO_FACTORIES) > 0 + macro_registry._reset_registry() + assert len(macro_registry._COMPONENT_MACRO_FACTORIES) == 0 + + +def test_reset_allows_fresh_registration(): + """After both resets, register_default_component_macro_factories() can re-run.""" + component_macro_catalog.register_default_component_macro_factories() + component_macro_catalog._reset_registration() + macro_registry._reset_registry() + # Should not raise; idempotent re-registration + component_macro_catalog.register_default_component_macro_factories() + + +def test_reset_catalog_fixture_provides_fresh_state(reset_catalog): + """Tests using reset_catalog fixture see fresh registration state.""" + # After reset_catalog runs, registry should be empty and _REGISTERED False + assert component_macro_catalog._REGISTERED is False + assert len(macro_registry._COMPONENT_MACRO_FACTORIES) == 0 diff --git a/tests/architecture/quantum_dots/operations/test_pulse_catalog.py b/tests/architecture/quantum_dots/operations/test_pulse_catalog.py new file mode 100644 index 00000000..d59a0f20 --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_pulse_catalog.py @@ -0,0 +1,102 @@ +"""Tests for the pulse catalog and pulse registry.""" + +import pytest + +from quam.components.channels import SingleChannel +from quam.components.pulses import GaussianPulse, SquareReadoutPulse + +from quam_builder.architecture.quantum_dots.components.pulses import ScalableGaussianPulse + +from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( + _make_xy_pulse_factories, + _make_readout_pulse, + register_default_component_pulse_factories, + _reset_registration, +) +from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( + _reset_registry, +) + + +@pytest.fixture(autouse=True) +def reset_pulse_state(): + """Reset pulse registry and catalog state for each test.""" + _reset_registry() + _reset_registration() + yield + _reset_registry() + _reset_registration() + + +class TestMakeXYPulseFactories: + """Test pulse factory creation for XY drives.""" + + def test_single_channel_no_axis_angle(self): + """SingleChannel drives should produce pulse with axis_angle=None.""" + from quam.components.ports import LFFEMAnalogOutputPort + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveSingle + + xy = XYDriveSingle( + id="test_xy", + RF_frequency=100_000_000, + opx_output=LFFEMAnalogOutputPort("con1", 3, port_id=1), + ) + pulses = _make_xy_pulse_factories(xy) + + assert set(pulses.keys()) == {"gaussian"} + pulse = pulses["gaussian"] + assert isinstance(pulse, ScalableGaussianPulse) + assert pulse.axis_angle is None + + def test_iq_channel_has_axis_angle(self): + """IQ/MW channels should produce pulse with axis_angle=0.0.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + # Not a SingleChannel + pulses = _make_xy_pulse_factories(xy) + + assert pulses["gaussian"].axis_angle == 0.0 + + def test_pulse_parameters(self): + """Verify default pulse parameters.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = _make_xy_pulse_factories(xy) + + gaussian = pulses["gaussian"] + assert isinstance(gaussian, ScalableGaussianPulse) + assert gaussian.length == 1000 + assert gaussian.amplitude == 1.0 + assert gaussian.sigma == pytest.approx(1000 / 6) + assert gaussian.sigma_ratio == pytest.approx(1 / 6) + + +class TestMakeReadoutPulse: + """Test readout pulse factory.""" + + def test_readout_pulse_type(self): + pulse = _make_readout_pulse() + assert isinstance(pulse, SquareReadoutPulse) + + def test_readout_pulse_parameters(self): + pulse = _make_readout_pulse() + assert pulse.length == 2000 + assert pulse.amplitude == 1.0 + + +class TestRegistration: + """Test pulse catalog registration.""" + + def test_idempotent(self): + register_default_component_pulse_factories() + register_default_component_pulse_factories() # should not raise + + def test_reset(self): + register_default_component_pulse_factories() + _reset_registration() + # After reset, re-registration should work + register_default_component_pulse_factories() diff --git a/tests/architecture/quantum_dots/test_macro_persistence.py b/tests/architecture/quantum_dots/test_macro_persistence.py new file mode 100644 index 00000000..0780726e --- /dev/null +++ b/tests/architecture/quantum_dots/test_macro_persistence.py @@ -0,0 +1,28 @@ +"""Tests for macro persistence across save/load round-trips.""" + +import pytest + +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD + + +def test_macro_instances_survive_save_load_roundtrip(qd_machine, reset_catalog, tmp_path): + """QuantumDot, QuantumDotPair, SensorDot macros persist across save/load.""" + wire_machine_macros(qd_machine) + qd_machine.save(tmp_path) + + loaded = BaseQuamQD.load(tmp_path) + + for qd in loaded.quantum_dots.values(): + assert "initialize" in qd.macros + assert "empty" in qd.macros + + for pair in loaded.quantum_dot_pairs.values(): + assert "initialize" in pair.macros + assert "measure" in pair.macros + assert "empty" in pair.macros + + for sd in loaded.sensor_dots.values(): + assert "measure" in sd.macros + assert "initialize" not in sd.macros + assert "empty" not in sd.macros diff --git a/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py b/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py new file mode 100644 index 00000000..42268b08 --- /dev/null +++ b/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py @@ -0,0 +1,348 @@ +"""End-to-end test mirroring the rabi_chevron.py example workflow. + +Covers every pattern exercised by the example: + 1. Machine creation with physical channels and virtual gate set + 2. Channel element registration (quantum dots, sensor dot with resonator) + 3. Qubit registration with XY drive and readout quantum dot + 4. Fluent voltage-point definition (init → operate → readout) + 5. Custom QuamMacro subclasses (DriveMacro, MeasureMacro) + 6. Macro dispatch via qubit.__getattr__ (qubit.drive(), qubit.measure()) + 7. Full QUA program with multi-step voltage navigation + 8. Compensation pulse (apply_compensation_pulse) + 9. generate_config() for a quantum-dot machine + +All objects are real — no mocks or stubs. +""" + +from typing import Tuple + +import pytest +from qm import qua +from quam.components import pulses +from quam.components.channels import StickyChannelAddon +from quam.components.ports import ( + LFFEMAnalogInputPort, + LFFEMAnalogOutputPort, +) +from quam.core import quam_dataclass +from quam.core.macro.quam_macro import QuamMacro + +from quam_builder.architecture.quantum_dots.components import ( + ReadoutResonatorSingle, + VoltageGate, + XYDriveSingle, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam +from quam_builder.architecture.quantum_dots.qubit import LDQubit + + +# ----------------------------------------------------------------------- +# Helpers — mirror rabi_chevron.py sections 1-3 +# ----------------------------------------------------------------------- + + +def _make_gate(port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=port, output_mode="direct"), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def create_rabi_machine() -> Tuple[LossDiVincenzoQuam, XYDriveSingle, ReadoutResonatorSingle]: + """Set up a minimal machine that mirrors the rabi_chevron example.""" + machine = LossDiVincenzoQuam() + + plunger_1 = _make_gate(1, "plunger_1") + plunger_2 = _make_gate(2, "plunger_2") + sensor_dc = _make_gate(4, "sensor_DC") + + readout_resonator = ReadoutResonatorSingle( + id="sensor_resonator", + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=3, output_mode="direct"), + opx_input=LFFEMAnalogInputPort("con1", 2, port_id=1), + intermediate_frequency=int(50e6), + operations={ + "readout": pulses.SquareReadoutPulse(length=1000, amplitude=0.1), + }, + ) + + xy_drive = XYDriveSingle( + id="Q1_xy", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=5, output_mode="direct"), + ) + # Add the pulses that were previously added by XYDriveSingle.__post_init__ + xy_drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + xy_drive.operations["pi"] = pulses.SquarePulse(length=104, amplitude=0.2) + xy_drive.operations["pi_half"] = pulses.SquarePulse(length=52, amplitude=0.2) + xy_drive.operations["drive"] = pulses.GaussianPulse( + length=100, + amplitude=0.2, + sigma=100 / 6, + ) + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "virtual_dot_1": plunger_1, + "virtual_dot_2": plunger_2, + "virtual_sensor_1": sensor_dc, + }, + gate_set_id="main_qpu", + compensation_matrix=[ + [1.0, 0.1, 0.0], + [0.1, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + ) + + machine.register_channel_elements( + plunger_channels=[plunger_1, plunger_2], + sensor_resonator_mappings={sensor_dc: readout_resonator}, + barrier_channels=[], + ) + + machine.register_quantum_dot_pair( + quantum_dot_ids=["virtual_dot_1", "virtual_dot_2"], + sensor_dot_ids=["virtual_sensor_1"], + id="qd_pair_1_2", + ) + + return machine, xy_drive, readout_resonator + + +# ----------------------------------------------------------------------- +# Custom macros (mirror rabi_chevron.py DriveMacro / MeasureMacro) +# ----------------------------------------------------------------------- + + +@quam_dataclass +class DriveMacro(QuamMacro): + pulse_name: str = "drive" + amplitude_scale: float = None + + @property + def inferred_duration(self) -> float | None: + try: + parent_qubit = self.parent.parent + pulse = parent_qubit.xy.operations[self.pulse_name] + return pulse.length * 1e-9 + except Exception: + return None + + def apply(self, **kwargs): + duration = kwargs.get("duration", None) + amp_scale = kwargs.get("amplitude_scale", self.amplitude_scale) + parent_qubit = self.parent.parent + parent_qubit.xy.play( + self.pulse_name, + amplitude_scale=amp_scale, + duration=duration, + ) + + +@quam_dataclass +class MeasureMacro(QuamMacro): + pulse_name: str = "readout" + + @property + def inferred_duration(self) -> float | None: + try: + parent_qubit = self.parent.parent + resonator = parent_qubit.quantum_dot.machine.sensor_dots[ + "virtual_sensor_1" + ].readout_resonator + pulse = resonator.operations[self.pulse_name] + return (64 * 4 + pulse.length) * 1e-9 + except Exception: + return None + + def apply(self, **kwargs): + pulse = kwargs.get("pulse_name", self.pulse_name) + iq_i = qua.declare(qua.fixed) + iq_q = qua.declare(qua.fixed) + parent_qubit = self.parent.parent + resonator = parent_qubit.quantum_dot.machine.sensor_dots[ + "virtual_sensor_1" + ].readout_resonator + resonator.wait(64) + resonator.measure(pulse, qua_vars=(iq_i, iq_q)) + return iq_i, iq_q + + +# ----------------------------------------------------------------------- +# Fixture +# ----------------------------------------------------------------------- + + +@pytest.fixture +def rabi_setup(): + """Return (machine, qubit) with full rabi-chevron setup.""" + machine, xy_drive, _resonator = create_rabi_machine() + + machine.register_qubit( + quantum_dot_id="virtual_dot_1", + qubit_name="Q1", + xy=xy_drive, + readout_quantum_dot="virtual_dot_2", + ) + qubit = machine.qubits["Q1"] + + qubit.add_point(point_name="init", voltages={"virtual_dot_1": 0.05}, duration=500) + qubit.add_point(point_name="operate", voltages={"virtual_dot_1": 0.15}, duration=2000) + qubit.add_point(point_name="readout", voltages={"virtual_dot_1": -0.05}, duration=2000) + + qubit.macros["drive"] = DriveMacro(pulse_name="drive") + qubit.macros["measure"] = MeasureMacro(pulse_name="readout") + + return machine, qubit + + +# ======================================================================= +# Tests +# ======================================================================= + + +class TestMachineSetup: + """Section 1-2: machine, virtual gate set, channel elements.""" + + def test_virtual_gate_set_created(self, rabi_setup): + machine, _ = rabi_setup + assert "main_qpu" in machine.virtual_gate_sets + + def test_quantum_dots_registered(self, rabi_setup): + machine, _ = rabi_setup + assert "virtual_dot_1" in machine.quantum_dots + assert "virtual_dot_2" in machine.quantum_dots + + def test_sensor_dot_registered_with_resonator(self, rabi_setup): + machine, _ = rabi_setup + sd = machine.sensor_dots["virtual_sensor_1"] + assert sd.readout_resonator is not None + assert "readout" in sd.readout_resonator.operations + + def test_quantum_dot_pair_registered(self, rabi_setup): + machine, _ = rabi_setup + assert "qd_pair_1_2" in machine.quantum_dot_pairs + pair = machine.quantum_dot_pairs["qd_pair_1_2"] + assert len(pair.quantum_dots) == 2 + + +class TestQubitRegistration: + """Section 2: register_qubit with xy_channel and readout_quantum_dot.""" + + def test_qubit_exists(self, rabi_setup): + machine, qubit = rabi_setup + assert "Q1" in machine.qubits + assert qubit is machine.qubits["Q1"] + + def test_xy_set(self, rabi_setup): + _, qubit = rabi_setup + assert qubit.xy is not None + assert qubit.xy.id == "Q1_xy" + assert "drive" in qubit.xy.operations + assert "gaussian" in qubit.xy.operations + + def test_qubit_has_quantum_dot(self, rabi_setup): + _, qubit = rabi_setup + assert qubit.quantum_dot is not None + + +class TestVoltagePoints: + """Section 2: direct voltage-point definition.""" + + def test_three_points_defined(self, rabi_setup): + _, qubit = rabi_setup + gate_set_macros = qubit.voltage_sequence.gate_set.get_macros() + for name in ("init", "operate", "readout"): + full_name = f"{qubit.id}_{name}" + assert full_name in gate_set_macros, f"Missing voltage point '{full_name}'" + + +class TestCustomMacros: + """Section 3: custom QuamMacro subclasses and macro dispatch.""" + + def test_macros_registered(self, rabi_setup): + _, qubit = rabi_setup + assert "drive" in qubit.macros + assert "measure" in qubit.macros + + def test_drive_dispatches(self, rabi_setup): + _, qubit = rabi_setup + with qua.program() as prog: + qubit.drive(duration=100) + assert prog is not None + + def test_measure_dispatches_and_returns_iq(self, rabi_setup): + _, qubit = rabi_setup + with qua.program() as prog: + result = qubit.measure() + assert prog is not None + assert result is not None + assert len(result) == 2 + + +class TestQUAProgramFlow: + """Section 4: full init → operate → readout navigation + macros in QUA.""" + + def test_single_iteration_compiles(self, rabi_setup): + _, qubit = rabi_setup + with qua.program() as prog: + qubit.step_to_point("init") + qua.align() + qubit.step_to_point("operate") + qubit.drive(duration=100) + qua.align() + qubit.step_to_point("readout") + iq_i, iq_q = qubit.measure() + qua.save(iq_i, "I") + qua.save(iq_q, "Q") + assert prog is not None + + def test_loop_with_frequency_sweep(self, rabi_setup): + _, qubit = rabi_setup + with qua.program() as prog: + n = qua.declare(int) + f = qua.declare(int) + t = qua.declare(int) + with qua.for_(n, 0, n < 2, n + 1): + with qua.for_(t, 50, t < 200, t + 50): + with qua.for_(f, int(10e3), f < int(30e6), f + int(10e6)): + qua.update_frequency(qubit.xy.name, f) + qubit.step_to_point("init") + qua.align() + qubit.step_to_point("operate") + qubit.drive(duration=t) + qua.align() + qubit.step_to_point("readout") + qubit.measure() + assert prog is not None + + +class TestConfigGeneration: + """Section 5: generate_config() for a quantum-dot machine.""" + + def test_generates_valid_config(self, rabi_setup): + machine, _ = rabi_setup + config = machine.generate_config() + assert isinstance(config, dict) + assert "controllers" in config + assert "elements" in config + + def test_xy_drive_in_config(self, rabi_setup): + machine, qubit = rabi_setup + config = machine.generate_config() + assert qubit.xy.name in config["elements"] + + def test_readout_resonator_in_config(self, rabi_setup): + machine, _ = rabi_setup + config = machine.generate_config() + sd = machine.sensor_dots["virtual_sensor_1"] + assert sd.readout_resonator.name in config["elements"] + + def test_voltage_gates_in_config(self, rabi_setup): + machine, _ = rabi_setup + config = machine.generate_config() + for dot_name in ("virtual_dot_1", "virtual_dot_2"): + dot = machine.quantum_dots[dot_name] + assert dot.physical_channel.name in config["elements"] diff --git a/tests/architecture/quantum_dots/virtual_gates/__init__.py b/tests/architecture/quantum_dots/virtual_gates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/voltage_sequence/conftest.py b/tests/architecture/quantum_dots/virtual_gates/conftest.py similarity index 67% rename from tests/voltage_sequence/conftest.py rename to tests/architecture/quantum_dots/virtual_gates/conftest.py index 35564e51..63ffa70c 100644 --- a/tests/voltage_sequence/conftest.py +++ b/tests/architecture/quantum_dots/virtual_gates/conftest.py @@ -26,4 +26,16 @@ def machine(): }, ), ) - return machine \ No newline at end of file + return machine + + +@pytest.fixture +def virtual_gate_set(machine): + """A VirtualGateSet whose channels match the test machine (ch1, ch2).""" + return VirtualGateSet( + id="test_vgs", + channels={ + "ch1": "#../gate_set/channels/ch1", + "ch2": "#../gate_set/channels/ch2", + }, + ) diff --git a/tests/virtual_gates/test_virtual_gates.py b/tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py similarity index 100% rename from tests/virtual_gates/test_virtual_gates.py rename to tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py diff --git a/tests/virtual_gates/test_virtual_voltage_sequence.py b/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py similarity index 93% rename from tests/virtual_gates/test_virtual_voltage_sequence.py rename to tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py index ce43f3d5..61ff8f13 100644 --- a/tests/virtual_gates/test_virtual_voltage_sequence.py +++ b/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py @@ -127,8 +127,7 @@ def test_vgs_step_to_virtual_level_qua_voltage(virtual_gate_set: VirtualGateSet) duration=30, ) qua.play( - DEFAULT_PULSE_NAME - * qua.amp((((0.0 + ((0.0 * exp_qua_v_g1_level) + 0.6)) - 0.0) << 2)), + DEFAULT_PULSE_NAME * qua.amp((((0.0 + ((0.0 * exp_qua_v_g1_level) + 0.6)) - 0.0) << 2)), "ch2", duration=30, ) @@ -189,9 +188,7 @@ def test_ramp_to_voltages_simple(machine): def test_ramp_to_point_simple(machine): """Tests a simple ramp_to_point operation.""" - machine.gate_set.add_point( - "p_ramp", voltages={"ch1": 0.15, "ch2": 0.05}, duration=200 - ) + machine.gate_set.add_point("p_ramp", voltages={"ch1": 0.15, "ch2": 0.05}, duration=200) with qua.program() as prog: seq = machine.gate_set.new_sequence(track_integrated_voltage=False) seq.ramp_to_point("p_ramp", ramp_duration=80) @@ -210,15 +207,11 @@ def test_ramp_to_point_simple(machine): def test_step_then_ramp(machine): """Tests a step operation followed by a ramp operation.""" - machine.gate_set.add_point( - "p_step", voltages={"ch1": 0.1, "ch2": 0.1}, duration=100 - ) + machine.gate_set.add_point("p_step", voltages={"ch1": 0.1, "ch2": 0.1}, duration=100) with qua.program() as prog: seq = machine.gate_set.new_sequence(track_integrated_voltage=False) seq.step_to_point("p_step") # Current level: ch1=0.1, ch2=0.1 - seq.ramp_to_voltages( - voltages={"ch1": 0.3, "ch2": -0.1}, duration=160, ramp_duration=80 - ) + seq.ramp_to_voltages(voltages={"ch1": 0.3, "ch2": -0.1}, duration=160, ramp_duration=80) ast = ProgramTreeBuilder().build(prog) with qua.program() as expected_program: @@ -238,9 +231,7 @@ def test_step_then_ramp(machine): def test_ramp_then_step(machine): """Tests a ramp operation followed by a step operation.""" - machine.gate_set.add_point( - "p_final", voltages={"ch1": 0.05, "ch2": -0.05}, duration=500 - ) + machine.gate_set.add_point("p_final", voltages={"ch1": 0.05, "ch2": -0.05}, duration=500) with qua.program() as prog: seq = machine.gate_set.new_sequence(track_integrated_voltage=False) seq.ramp_to_voltages( @@ -300,9 +291,7 @@ def test_ramp_to_voltages_with_qua_ramp_duration(machine): seq = machine.gate_set.new_sequence(track_integrated_voltage=False) qua_ramp_dur = qua.declare(int) qua.assign(qua_ramp_dur, 80) # ns - seq.ramp_to_voltages( - voltages={"ch1": 0.2}, duration=160, ramp_duration=qua_ramp_dur - ) + seq.ramp_to_voltages(voltages={"ch1": 0.2}, duration=160, ramp_duration=qua_ramp_dur) ast = ProgramTreeBuilder().build(prog) with qua.program() as expected_program: @@ -312,18 +301,14 @@ def test_ramp_to_voltages_with_qua_ramp_duration(machine): qua.assign(expected_qua_ramp_dur, 80) # ch1: 0.0 -> 0.2 (delta=0.2), ramp=80(20), hold=160(40) -> 40 # ch2: 0.0 -> 0.0 (delta=0.0), ramp=80(20), hold=160(40) -> 40 - qua.assign( - _vseq_tmp_ch1_ramp_rate, 0.2 * qua.Math.div(1.0, expected_qua_ramp_dur) - ) + qua.assign(_vseq_tmp_ch1_ramp_rate, 0.2 * qua.Math.div(1.0, expected_qua_ramp_dur)) qua.play( qua.ramp(_vseq_tmp_ch1_ramp_rate), "ch1", duration=expected_qua_ramp_dur >> 2, ) qua.wait(40, "ch1") - qua.assign( - _vseq_tmp_ch2_ramp_rate, 0.0 * qua.Math.div(1.0, expected_qua_ramp_dur) - ) + qua.assign(_vseq_tmp_ch2_ramp_rate, 0.0 * qua.Math.div(1.0, expected_qua_ramp_dur)) qua.play( qua.ramp(_vseq_tmp_ch2_ramp_rate), "ch2", diff --git a/tests/virtual_gates/test_virtualisation_layer.py b/tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py similarity index 100% rename from tests/virtual_gates/test_virtualisation_layer.py rename to tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py diff --git a/tests/architecture/quantum_dots/voltage_sequence/__init__.py b/tests/architecture/quantum_dots/voltage_sequence/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/virtual_gates/conftest.py b/tests/architecture/quantum_dots/voltage_sequence/conftest.py similarity index 100% rename from tests/virtual_gates/conftest.py rename to tests/architecture/quantum_dots/voltage_sequence/conftest.py diff --git a/tests/voltage_sequence/test_gate_set.py b/tests/architecture/quantum_dots/voltage_sequence/test_gate_set.py similarity index 100% rename from tests/voltage_sequence/test_gate_set.py rename to tests/architecture/quantum_dots/voltage_sequence/test_gate_set.py diff --git a/tests/voltage_sequence/test_sequence_state_tracker.py b/tests/architecture/quantum_dots/voltage_sequence/test_sequence_state_tracker.py similarity index 100% rename from tests/voltage_sequence/test_sequence_state_tracker.py rename to tests/architecture/quantum_dots/voltage_sequence/test_sequence_state_tracker.py diff --git a/tests/voltage_sequence/test_voltage_sequence.py b/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py similarity index 98% rename from tests/voltage_sequence/test_voltage_sequence.py rename to tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py index 1b9119ee..767ed209 100644 --- a/tests/voltage_sequence/test_voltage_sequence.py +++ b/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py @@ -80,9 +80,7 @@ def test_go_to_multiple_points(machine): def test_step_to_point_with_custom_duration(machine): """Tests overriding the point's default duration in step_to_point.""" - machine.gate_set.add_point( - "p1", voltages={"ch1": 0.1}, duration=100 - ) # Default duration + machine.gate_set.add_point("p1", voltages={"ch1": 0.1}, duration=100) # Default duration with qua.program() as prog: seq = machine.gate_set.new_sequence() seq.step_to_point("p1", duration=60) @@ -129,9 +127,7 @@ def test_step_to_voltages_multiple_channels(machine): def test_step_to_voltages_then_step_to_point(machine): """Tests a step_to_voltages operation followed by a step_to_point.""" - machine.gate_set.add_point( - "p_after_step", voltages={"ch1": 0.2, "ch2": 0.2}, duration=80 - ) + machine.gate_set.add_point("p_after_step", voltages={"ch1": 0.2, "ch2": 0.2}, duration=80) with qua.program() as prog: seq = machine.gate_set.new_sequence() seq.step_to_voltages(voltages={"ch1": 0.1}, duration=100) diff --git a/tests/architecture/superconducting/__init__.py b/tests/architecture/superconducting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_components_flux_line.py b/tests/architecture/superconducting/test_components_flux_line.py similarity index 100% rename from tests/test_components_flux_line.py rename to tests/architecture/superconducting/test_components_flux_line.py diff --git a/tests/test_components_readout_resonator.py b/tests/architecture/superconducting/test_components_readout_resonator.py similarity index 100% rename from tests/test_components_readout_resonator.py rename to tests/architecture/superconducting/test_components_readout_resonator.py diff --git a/tests/test_components_tunable_coupler.py b/tests/architecture/superconducting/test_components_tunable_coupler.py similarity index 100% rename from tests/test_components_tunable_coupler.py rename to tests/architecture/superconducting/test_components_tunable_coupler.py diff --git a/tests/test_qubit_base_transmon.py b/tests/architecture/superconducting/test_qubit_base_transmon.py similarity index 100% rename from tests/test_qubit_base_transmon.py rename to tests/architecture/superconducting/test_qubit_base_transmon.py diff --git a/tests/builder/__init__.py b/tests/builder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/builder/quantum_dots/__init__.py b/tests/builder/quantum_dots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/quantum_dots/test_build_quam.py b/tests/builder/quantum_dots/test_build_quam.py similarity index 68% rename from tests/quantum_dots/test_build_quam.py rename to tests/builder/quantum_dots/test_build_quam.py index be6325fc..abea887f 100644 --- a/tests/quantum_dots/test_build_quam.py +++ b/tests/builder/quantum_dots/test_build_quam.py @@ -1,6 +1,22 @@ -"""Unit tests for the build_quam function and related helpers.""" - -# pylint: disable=missing-class-docstring,missing-function-docstring +"""Tests for the build_quam function and related helpers. + +Mock/patch policy: +- TestSetDefaultGridLocation, TestAddQPU: no mocks — pure functions or real objects. +- TestAddPorts: `machine.ports` is mocked because `add_ports` exercises + the quam framework's port-resolution protocol (`reference_to_port`). + Creating a real PortCollection with valid references requires the full + wiring pipeline, which is already covered by the E2E tests. +- TestBuildQuam: `build_base_quam` and `build_loss_divincenzo_quam` are + patched to test the *orchestration* logic of `build_quam` (correct + call order, save flag forwarding) without re-executing the sub-builders + that are independently tested. +- TestCalibrationPathResolver: the QuAM serializer is mocked because its + real implementation requires a persisted state file on disk, which is + irrelevant to the path-resolution logic under test. +- TestAddPulses: uses a real machine from the builder pipeline (no mocks). +""" + +# pylint: disable=missing-class-docstring,missing-function-docstring,no-member import shutil import tempfile @@ -8,19 +24,20 @@ from pathlib import Path import pytest +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD from quam_builder.architecture.quantum_dots.qpu.loss_divincenzo_quam import ( LossDiVincenzoQuam, ) +from quam_builder.builder.qop_connectivity import build_quam_wiring from quam_builder.builder.quantum_dots.build_quam import ( build_quam, build_base_quam, build_loss_divincenzo_quam, add_qpu, add_ports, - add_pulses, _resolve_calibration_db_path, _set_default_grid_location, ) @@ -30,24 +47,20 @@ class TestSetDefaultGridLocation: """Tests for the _set_default_grid_location helper function.""" def test_single_qubit_location(self): - """Test grid location for a single qubit.""" location = _set_default_grid_location(0, 1) assert location == "0,0" def test_two_qubits_locations(self): - """Test grid locations for two qubits.""" loc0 = _set_default_grid_location(0, 2) loc1 = _set_default_grid_location(1, 2) assert loc0 == "0,0" assert loc1 == "0,1" def test_four_qubits_grid(self): - """Test grid locations for four qubits (2x2 grid).""" locations = [_set_default_grid_location(i, 4) for i in range(4)] assert locations == ["0,0", "0,1", "1,0", "1,1"] def test_nine_qubits_grid(self): - """Test grid locations for nine qubits (3x3 grid).""" locations = [_set_default_grid_location(i, 9) for i in range(9)] assert locations == [ "0,0", @@ -63,10 +76,14 @@ def test_nine_qubits_grid(self): class TestAddPorts: - """Tests for the add_ports function.""" + """Tests for the add_ports function. + + Uses a mock for machine.ports because add_ports calls the quam framework's + reference_to_port protocol. The real PortCollection requires full wiring + from the pipeline, already covered by E2E tests. + """ def test_add_ports_with_valid_wiring(self): - """Test that ports are added from wiring configuration.""" machine = LossDiVincenzoQuam() machine.ports = MagicMock() @@ -74,26 +91,19 @@ class DummyRef(dict): def get_unreferenced_value(self, key): return self[key] - # Create mock wiring structure machine.wiring = { "qubits": { "q1": {WiringLineType.DRIVE.value: DummyRef({"opx_output": "#/ports/con1/1"})} } } - # Call add_ports add_ports(machine) - - # Verify ports.reference_to_port was called assert machine.ports.reference_to_port.called def test_add_ports_with_empty_wiring(self): - """Test that add_ports handles empty wiring gracefully.""" machine = LossDiVincenzoQuam() machine.ports = MagicMock() machine.wiring = {} - - # Should not raise an error add_ports(machine) @@ -102,46 +112,35 @@ class TestAddQPU: @pytest.fixture def machine_with_wiring(self): - """Create a machine with mock wiring for testing.""" machine = LossDiVincenzoQuam() - - # Mock wiring structure machine.wiring = { "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: { "opx_output": "#/wiring/qubits/q1/p/opx_output" }, - WiringLineType.DRIVE.value: {"opx_output": "#/wiring/qubits/q1/xy/opx_output"}, + WiringLineType.DRIVE.value: {"opx_output": "#/ports/mw_outputs/con1/1/1"}, }, "q2": { WiringLineType.PLUNGER_GATE.value: { "opx_output": "#/wiring/qubits/q2/p/opx_output" }, - WiringLineType.DRIVE.value: {"opx_output": "#/wiring/qubits/q2/xy/opx_output"}, + WiringLineType.DRIVE.value: {"opx_output": "#/ports/mw_outputs/con1/1/2"}, }, } } - return machine def test_add_qpu_creates_virtual_gate_set(self, machine_with_wiring): - """Test that add_qpu creates a virtual gate set.""" add_qpu(machine_with_wiring) - - # Verify virtual gate set was created assert "main_qpu" in machine_with_wiring.virtual_gate_sets def test_add_qpu_registers_qubits(self, machine_with_wiring): - """Test that add_qpu registers qubits using the qubit-capable machine.""" add_qpu(machine_with_wiring) - - # Verify qubits were registered assert len(machine_with_wiring.qubits) > 0 assert len(machine_with_wiring.quantum_dots) > 0 def test_add_qpu_handles_sensor_dots(self): - """Test that add_qpu correctly handles sensor dots.""" machine = BaseQuamQD() machine.wiring = { "sensor_dots": { @@ -156,76 +155,79 @@ def test_add_qpu_handles_sensor_dots(self): } } } - add_qpu(machine) - - # Verify sensor dots were processed assert len(machine.active_sensor_dot_names) > 0 class TestAddPulses: - """Tests for the add_pulses function.""" - - def test_add_pulses_to_qubits(self): - """Test that pulses are added to all qubits.""" - machine = LossDiVincenzoQuam() - - # Create mock qubits - qubit1 = MagicMock() - qubit1.xy = MagicMock() - qubit1.xy.operations = {} - - qubit2 = MagicMock() - qubit2.xy = MagicMock() - qubit2.xy.operations = {} - - machine.qubits = {"Q1": qubit1, "Q2": qubit2} - machine.qubit_pairs = {} - - # Add pulses - add_pulses(machine) - - # Verify the helper ran without error (mocks don't accumulate real pulses) - assert isinstance(qubit1.xy.operations, dict) - assert isinstance(qubit2.xy.operations, dict) - - def test_add_pulses_to_qubit_pairs(self): - """Test that pulses are added to qubit pairs.""" - machine = LossDiVincenzoQuam() - machine.qubits = {} - - # Create mock qubit pairs - pair1 = MagicMock() - machine.qubit_pairs = {"Q1_Q2": pair1} + """Tests for pulse wiring via wire_machine_macros using a real machine from the pipeline.""" - # Should not raise an error - add_pulses(machine) + @pytest.fixture + def built_machine(self): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + allocate_wiring(connectivity, instruments) + + tmp = tempfile.mkdtemp() + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=tmp, + ) + machine = build_base_quam(machine, calibration_db_path=tmp, connect_qdac=False, save=False) + machine = build_loss_divincenzo_quam( + machine, + implicit_mapping=True, + save=False, + ) + yield machine + shutil.rmtree(tmp) + + def test_wire_machine_macros_populates_xy_operations(self, built_machine): + """Pulses are wired onto qubit XY drives by wire_machine_macros.""" + for qubit in built_machine.qubits.values(): + if qubit.xy is not None: + assert len(qubit.xy.operations) > 0 + assert "gaussian" in qubit.xy.operations + + def test_wire_machine_macros_handles_empty_machine(self): + from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros - def test_add_pulses_handles_empty_machine(self): - """Test that add_pulses handles a machine with no qubits.""" machine = LossDiVincenzoQuam() machine.qubits = {} machine.qubit_pairs = {} - - # Should not raise an error - add_pulses(machine) + wire_machine_macros(machine, strict=False) class TestBuildQuam: - """Tests for the main build_quam function.""" + """Tests for the build_quam orchestration. + + Patches build_base_quam and build_loss_divincenzo_quam to verify + that build_quam delegates to them correctly and forwards the save flag. + The sub-builders are independently tested elsewhere. + """ @pytest.fixture def temp_dir(self): - """Create a temporary directory for test files.""" temp_dir = tempfile.mkdtemp() yield temp_dir shutil.rmtree(temp_dir) def test_build_quam_full_workflow(self, temp_dir): - """Test the complete build_quam workflow.""" machine = LossDiVincenzoQuam() - - # Create minimal wiring machine.wiring = { "qubits": { "q1": { @@ -235,7 +237,6 @@ def test_build_quam_full_workflow(self, temp_dir): } } } - machine.network = {"host": "127.0.0.1", "cluster_name": "test"} with ( @@ -246,15 +247,11 @@ def test_build_quam_full_workflow(self, temp_dir): ): mock_base.return_value = machine mock_ld.return_value = machine - result = build_quam(machine, calibration_db_path=temp_dir, save=False) - # Verify the function completed - assert result is not None - assert result == machine + assert result is machine def test_build_quam_calls_all_functions(self, temp_dir): - """Test that build_quam calls all necessary sub-functions.""" machine = BaseQuamQD() machine.wiring = {} machine.network = {"host": "127.0.0.1", "cluster_name": "test"} @@ -267,14 +264,11 @@ def test_build_quam_calls_all_functions(self, temp_dir): ): mock_base.return_value = machine mock_ld.return_value = machine - build_quam(machine, calibration_db_path=temp_dir) - mock_base.assert_called_once() mock_ld.assert_called_once() def test_build_quam_saves_machine(self, temp_dir): - """Test that build_quam saves the machine.""" machine = BaseQuamQD() machine.wiring = {} machine.network = {"host": "127.0.0.1", "cluster_name": "test"} @@ -287,13 +281,10 @@ def test_build_quam_saves_machine(self, temp_dir): ): mock_base.return_value = machine mock_ld.return_value = machine - build_quam(machine, calibration_db_path=temp_dir, save=True) - assert mock_ld.call_args.kwargs["save"] is True def test_build_quam_can_skip_save(self, temp_dir): - """Ensure build_quam respects save flag.""" machine = BaseQuamQD() machine.wiring = {} machine.network = {"host": "127.0.0.1", "cluster_name": "test"} @@ -306,21 +297,22 @@ def test_build_quam_can_skip_save(self, temp_dir): ): mock_base.return_value = machine mock_ld.return_value = machine - build_quam(machine, calibration_db_path=temp_dir, save=False) - assert mock_ld.call_args.kwargs["save"] is False class TestCalibrationPathResolver: - """Tests for calibration path normalization.""" + """Tests for calibration path normalization. + + Mocks the QuAM serializer because its real implementation requires a + persisted state file on disk, irrelevant to the path-resolution logic. + """ def test_resolves_none_to_state_parent(self, tmp_path): machine = LossDiVincenzoQuam() serializer = MagicMock() serializer._get_state_path.return_value = tmp_path / "state.json" machine.get_serialiser = lambda: serializer - resolved = _resolve_calibration_db_path(machine, None) assert resolved == tmp_path @@ -329,6 +321,5 @@ def test_resolves_string_to_path(self): serializer = MagicMock() serializer._get_state_path.return_value = Path("/tmp/state.json") machine.get_serialiser = lambda: serializer - resolved = _resolve_calibration_db_path(machine, "/tmp/calibration") assert resolved == Path("/tmp/calibration") diff --git a/tests/builder/quantum_dots/test_builder_pulses.py b/tests/builder/quantum_dots/test_builder_pulses.py new file mode 100644 index 00000000..e108f549 --- /dev/null +++ b/tests/builder/quantum_dots/test_builder_pulses.py @@ -0,0 +1,186 @@ +"""End-to-end tests for quantum dot pulse generation. + +Builds real machines from the wiring pipeline and verifies that default pulses +are correctly attached to qubits and resonators for each XY drive type. +""" + +# pylint: disable=no-member + +import shutil +import tempfile + +import pytest +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring + +from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ, XYDriveMW +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import build_base_quam, build_loss_divincenzo_quam +from quam_builder.builder.quantum_dots import build_quam as build_quam_qd + + +@pytest.fixture +def machine_with_iq_pulses(): + """Build a LossDiVincenzoQuam with XYDriveIQ channels via manual wiring injection. + + The standard QD pipeline never produces IQ drive wiring through allocate_wiring + (all QD drives produce single-output MW-type ports). XYDriveIQ channels require + explicit injection via xy_drive_wiring in build_loss_divincenzo_quam. + """ + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=False, # No drive lines allocated — injected manually below + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + allocate_wiring(connectivity, instruments) + + tmp = tempfile.mkdtemp() + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=tmp, + ) + machine = build_base_quam(machine, calibration_db_path=tmp, connect_qdac=False, save=False) + + # Inject IQ drive wiring explicitly — standard QD pipeline never produces IQ wiring + xy_drive_wiring = { + "q1": { + "type": "IQ", + "wiring_path": "#/wiring/qubits/q1/drive", + "intermediate_frequency": 500e6, + }, + "q2": { + "type": "IQ", + "wiring_path": "#/wiring/qubits/q2/drive", + "intermediate_frequency": 500e6, + }, + } + machine = build_loss_divincenzo_quam( + machine, + xy_drive_wiring=xy_drive_wiring, + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, + save=False, + ) + yield machine + shutil.rmtree(tmp) + + +@pytest.fixture +def machine_with_mw_pulses(): + """Build a LossDiVincenzoQuam with XYDriveMW channels via MW FEM.""" + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + allocate_wiring(connectivity, instruments) + + tmp = tempfile.mkdtemp() + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=tmp, + ) + machine = build_quam_qd( + machine, + calibration_db_path=tmp, + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, + connect_qdac=False, + save=False, + ) + yield machine + shutil.rmtree(tmp) + + +class TestAddDefaultLDVQubitPulsesIQ: + """E2E pulse tests using XYDriveIQ (OPX+/Octave pipeline).""" + + def test_xy_drive_type_is_iq(self, machine_with_iq_pulses): + for qubit in machine_with_iq_pulses.qubits.values(): + if qubit.xy is not None: + assert isinstance( + qubit.xy, XYDriveIQ + ), f"Expected XYDriveIQ, got {type(qubit.xy).__name__}" + + def test_gaussian_pulse_present_on_qubits(self, machine_with_iq_pulses): + for qubit_name, qubit in machine_with_iq_pulses.qubits.items(): + if qubit.xy is None: + continue + assert ( + "gaussian" in qubit.xy.operations + ), f"Pulse 'gaussian' missing from qubit {qubit_name}" + + def test_gaussian_pulse_properties(self, machine_with_iq_pulses): + qubit = next(q for q in machine_with_iq_pulses.qubits.values() if q.xy is not None) + + gaussian = qubit.xy.operations["gaussian"] + assert gaussian.length == 1000 + assert gaussian.amplitude == 1.0 + assert gaussian.axis_angle == pytest.approx(0.0) + + def test_readout_pulse_present_on_resonators(self, machine_with_iq_pulses): + for qubit in machine_with_iq_pulses.qubits.values(): + if hasattr(qubit, "resonator") and qubit.resonator is not None: + assert "readout" in qubit.resonator.operations + + def test_one_xy_operation_per_qubit(self, machine_with_iq_pulses): + for qubit in machine_with_iq_pulses.qubits.values(): + if qubit.xy is not None: + assert len(qubit.xy.operations) == 1 + + +class TestAddDefaultLDVQubitPulsesMW: + """E2E pulse tests using XYDriveMW (MW FEM pipeline).""" + + def test_xy_drive_type_is_mw(self, machine_with_mw_pulses): + for qubit in machine_with_mw_pulses.qubits.values(): + if qubit.xy is not None: + assert isinstance( + qubit.xy, XYDriveMW + ), f"Expected XYDriveMW, got {type(qubit.xy).__name__}" + + def test_gaussian_pulse_present_on_qubits(self, machine_with_mw_pulses): + for qubit_name, qubit in machine_with_mw_pulses.qubits.items(): + if qubit.xy is None: + continue + assert ( + "gaussian" in qubit.xy.operations + ), f"Pulse 'gaussian' missing from qubit {qubit_name}" + + def test_gaussian_pulse_properties(self, machine_with_mw_pulses): + qubit = next(q for q in machine_with_mw_pulses.qubits.values() if q.xy is not None) + + gaussian = qubit.xy.operations["gaussian"] + assert gaussian.length == 1000 + assert gaussian.amplitude == 1.0 + assert gaussian.axis_angle == pytest.approx(0.0) + + def test_readout_pulse_present_on_resonators(self, machine_with_mw_pulses): + for qubit in machine_with_mw_pulses.qubits.values(): + if hasattr(qubit, "resonator") and qubit.resonator is not None: + assert "readout" in qubit.resonator.operations + + def test_one_xy_operation_per_qubit(self, machine_with_mw_pulses): + for qubit in machine_with_mw_pulses.qubits.values(): + if qubit.xy is not None: + assert len(qubit.xy.operations) == 1 diff --git a/tests/builder/quantum_dots/test_e2e_quantum_dots.py b/tests/builder/quantum_dots/test_e2e_quantum_dots.py new file mode 100644 index 00000000..c4230a75 --- /dev/null +++ b/tests/builder/quantum_dots/test_e2e_quantum_dots.py @@ -0,0 +1,550 @@ +"""End-to-end tests for quantum dots QUAM construction using the wiring tools. + +Tests the full workflow from instrument definition → connectivity setup → +wiring allocation → QUAM construction for quantum dot architectures +(combined, two-stage, incremental drive-line workflows). + +These tests exercise the interoperability with py-qua-tools (qualang-tools) +wiring infrastructure. +""" + +# pylint: disable=no-member + +import shutil +import tempfile + +import pytest +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring + +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.architecture.quantum_dots.qpu.loss_divincenzo_quam import ( + LossDiVincenzoQuam, +) +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import ( + build_base_quam, + build_loss_divincenzo_quam, + build_quam as build_quam_qd, +) + + +@pytest.fixture +def temp_dir(): + """Create and clean up a temporary directory for test artifacts.""" + d = tempfile.mkdtemp() + yield d + shutil.rmtree(d) + + +class TestQuantumDotsCombinedWorkflow: + """Combined single-stage workflow for quantum dots (mirrors example_2).""" + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + return instruments + + def test_five_dot_combined_build(self, instruments, temp_dir): + """5 quantum dots, 2 sensors, 2 global gates — combined workflow.""" + global_gates = [1, 2] + sensor_dots = [1, 2] + quantum_dots = [1, 2, 3, 4, 5] + quantum_dot_pairs = [(1, 2), (2, 3), (3, 4), (4, 5)] + qubit_pair_sensor_map = { + "q1_q2": ["sensor_1"], + "q2_q3": ["sensor_1", "sensor_2"], + "q3_q4": ["sensor_2"], + } + + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_quam_qd( + machine, + calibration_db_path=temp_dir, + qubit_pair_sensor_map=qubit_pair_sensor_map, + connect_qdac=False, + save=False, + ) + + assert isinstance(machine, LossDiVincenzoQuam) + assert len(machine.quantum_dots) == 5 + assert len(machine.sensor_dots) == 2 + assert len(machine.qubits) == 5 + assert len(machine.quantum_dot_pairs) == 4 + assert len(machine.virtual_gate_sets) > 0 + + def test_two_dot_minimal(self, instruments, temp_dir): + """Minimal 2-dot system without global gates.""" + connectivity = Connectivity() + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_quam_qd( + machine, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + assert len(machine.quantum_dots) == 2 + assert len(machine.sensor_dots) == 1 + assert len(machine.qubits) == 2 + + +class TestQuantumDotsTwoStageWorkflow: + """Two-stage workflow: dot layer first, then qubits (mirrors example_1).""" + + GLOBAL_GATES = [1] + SENSOR_DOTS = [1, 2] + QUANTUM_DOTS = [1, 2, 3] + QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] + + @staticmethod + def _make_stage1_connectivity(global_gates, sensor_dots, quantum_dots, dot_pairs): + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + connectivity.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) + return connectivity + + @staticmethod + def _make_stage2_connectivity(global_gates, sensor_dots, quantum_dots, dot_pairs): + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + return connectivity + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + return instruments + + def test_two_stage_dot_then_qubit(self, instruments, temp_dir): + """Stage 1 builds dots, Stage 2 adds qubits with drive lines.""" + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} + + # Stage 1: dot layer only + connectivity_s1 = self._make_stage1_connectivity( + self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + ) + allocate_wiring(connectivity_s1, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_base_quam( + machine, calibration_db_path=temp_dir, connect_qdac=False, save=False + ) + + assert len(machine.quantum_dots) == 3 + assert len(machine.sensor_dots) == 2 + assert not hasattr(machine, "qubits") or len(getattr(machine, "qubits", {})) == 0 + + # Stage 2: add qubits with drive lines + instruments_s2 = Instruments() + instruments_s2.add_mw_fem(controller=1, slots=[1]) + instruments_s2.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_s2 = self._make_stage2_connectivity( + self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + ) + allocate_wiring(connectivity_s2, instruments_s2) + + machine = build_quam_wiring( + connectivity_s2, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_loss_divincenzo_quam( + machine, + qubit_pair_sensor_map=qubit_pair_sensor_map, + implicit_mapping=True, + save=False, + ) + + assert len(machine.quantum_dots) == 3 + assert len(machine.sensor_dots) == 2 + assert len(machine.qubits) == 3 + assert len(machine.quantum_dot_pairs) == 2 + + for qubit in machine.qubits.values(): + assert getattr(qubit, "xy", None) is not None + + def test_incremental_drive_lines(self, temp_dir): + """Stage 1 with shared instruments, Stage 2 adds only drive lines.""" + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"]} + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + # Stage 1 + connectivity_s1 = self._make_stage1_connectivity( + self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + ) + allocate_wiring(connectivity_s1, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_base_quam( + machine, calibration_db_path=temp_dir, connect_qdac=False, save=False + ) + + # Stage 2: only drive lines + connectivity_drive = Connectivity() + connectivity_drive.add_quantum_dot_drive_lines( + quantum_dots=self.QUANTUM_DOTS, use_mw_fem=True, shared_line=True + ) + allocate_wiring(connectivity_drive, instruments) + + machine = build_quam_wiring( + connectivity_drive, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_loss_divincenzo_quam( + machine, + qubit_pair_sensor_map=qubit_pair_sensor_map, + implicit_mapping=True, + save=False, + ) + + assert len(machine.qubits) == 3 + for qubit in machine.qubits.values(): + assert getattr(qubit, "xy", None) is not None + + +class TestQuantumDotsBaseQuamOnly: + """Stage 1 only: verify BaseQuamQD without qubit registration.""" + + def test_dots_without_drive_lines(self, temp_dir): + """Build a dot-only system with no drive lines or qubits.""" + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[1, 2]) + + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=[1], name="rb") + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3], add_drive_lines=False) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3)]) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_base_quam( + machine, calibration_db_path=temp_dir, connect_qdac=False, save=False + ) + + assert len(machine.quantum_dots) == 3 + assert len(machine.sensor_dots) == 1 + assert len(machine.virtual_gate_sets) > 0 + vgs = machine.virtual_gate_sets["main_qpu"] + assert len(vgs.channels) >= 3 + + +class TestQuantumDotsLargeSystem: + """Larger quantum dot systems to stress-test the wiring allocation.""" + + def test_eight_dots_four_sensors(self, temp_dir): + """8 quantum dots, 4 sensor dots, 7 pairs — larger-scale system.""" + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1, 2]) + instruments.add_lf_fem(controller=1, slots=[3, 4, 5, 6]) + + quantum_dots = list(range(1, 9)) + sensor_dots = [1, 2, 3, 4] + dot_pairs = [(i, i + 1) for i in range(1, 8)] + + connectivity = Connectivity() + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_quam_qd( + machine, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + assert len(machine.quantum_dots) == 8 + assert len(machine.sensor_dots) == 4 + assert len(machine.qubits) == 8 + assert len(machine.quantum_dot_pairs) == 7 + + +class TestLfFemSingleDriveWorkflow: + """E2E tests for LF-FEM-only systems using use_mw_fem=False. + + When no MW-FEM is present and use_mw_fem=False is specified, the wirer + allocates single LF-FEM outputs for drive lines. quam-builder should + create XYDriveSingle (not XYDriveMW) for these. + """ + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[3, 5]) + return instruments + + def _run_lf_only_two_stage(self, instruments, temp_dir): + """Two-stage build with LF-FEM only and use_mw_fem=False.""" + sensor_dots = [1, 2] + quantum_dots = [1, 2, 3, 4] + dot_pairs = [(1, 2), (3, 4)] + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q3_q4": ["sensor_2"]} + + # Stage 1 + conn_s1 = Connectivity() + conn_s1.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + conn_s1.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + conn_s1.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) + allocate_wiring(conn_s1, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring(conn_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = build_base_quam( + machine, calibration_db_path=temp_dir, connect_qdac=False, save=False + ) + + # Stage 2 with LF-FEM-only instruments and use_mw_fem=False + instruments_s2 = Instruments() + instruments_s2.add_lf_fem(controller=1, slots=[3, 5]) + + conn_s2 = Connectivity() + conn_s2.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) + conn_s2.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=False, + shared_drive_line=True, + ) + conn_s2.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) + allocate_wiring(conn_s2, instruments_s2) + + machine = build_quam_wiring(conn_s2, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = build_loss_divincenzo_quam( + machine, + qubit_pair_sensor_map=qubit_pair_sensor_map, + implicit_mapping=True, + save=False, + ) + return machine + + def test_lf_fem_drives_are_xy_drive_single(self, instruments, temp_dir): + """With use_mw_fem=False, all XY drives should be XYDriveSingle.""" + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, + ) + + machine = self._run_lf_only_two_stage(instruments, temp_dir) + + assert len(machine.qubits) == 4 + for qubit_id, qubit in machine.qubits.items(): + assert ( + getattr(qubit, "xy", None) is not None + ), f"Qubit {qubit_id} should have an XY drive" + assert isinstance(qubit.xy, XYDriveSingle), ( + f"Qubit {qubit_id} XY drive should be XYDriveSingle, " + f"got {type(qubit.xy).__name__}" + ) + + def test_lf_fem_drive_port_refs_are_analog(self, instruments, temp_dir): + """XY drive port references should point at analog_outputs, not mw_outputs.""" + machine = self._run_lf_only_two_stage(instruments, temp_dir) + + for qubit_id in machine.qubits: + wiring_xy = machine.wiring["qubits"][qubit_id].get("xy", {}) + raw_ref = wiring_xy.get_raw_value("opx_output") + ref = str(raw_ref) + assert "analog_outputs" in ref, ( + f"Qubit {qubit_id} drive wiring should reference analog_outputs, " f"got: {ref}" + ) + assert "mw_outputs" not in ref + + +class TestTwoStageWiringIntegration: + """E2E tests verifying the four wiring behaviours added to the two-stage pipeline.""" + + GLOBAL_GATES = [1] + SENSOR_DOTS = [1, 2] + QUANTUM_DOTS = [1, 2, 3] + QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] + QUBIT_PAIR_SENSOR_MAP = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + return instruments + + def _run_two_stage(self, instruments, temp_dir): + """Run the full two-stage workflow and return the final machine.""" + # Stage 1: dot layer only (no drive lines) + connectivity_s1 = Connectivity() + connectivity_s1.add_voltage_gate_lines(voltage_gates=self.GLOBAL_GATES, name="rb") + connectivity_s1.add_sensor_dots( + sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False, use_mw_fem=False + ) + connectivity_s1.add_quantum_dots(quantum_dots=self.QUANTUM_DOTS, add_drive_lines=False) + connectivity_s1.add_quantum_dot_pairs(quantum_dot_pairs=self.QUANTUM_DOT_PAIRS) + allocate_wiring(connectivity_s1, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_base_quam( + machine, calibration_db_path=temp_dir, connect_qdac=False, save=False + ) + + # Stage 2: add qubits with drive lines + instruments_s2 = Instruments() + instruments_s2.add_mw_fem(controller=1, slots=[1]) + instruments_s2.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_s2 = Connectivity() + connectivity_s2.add_voltage_gate_lines(voltage_gates=self.GLOBAL_GATES, name="rb") + connectivity_s2.add_sensor_dots( + sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False, use_mw_fem=False + ) + connectivity_s2.add_quantum_dot_pairs(quantum_dot_pairs=self.QUANTUM_DOT_PAIRS) + connectivity_s2.add_quantum_dots( + quantum_dots=self.QUANTUM_DOTS, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + allocate_wiring(connectivity_s2, instruments_s2) + + machine = build_quam_wiring( + connectivity_s2, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) + machine = build_loss_divincenzo_quam( + machine, + qubit_pair_sensor_map=self.QUBIT_PAIR_SENSOR_MAP, + implicit_mapping=True, + save=False, + ) + return machine + + def test_two_stage_ports_materialized(self, instruments, temp_dir): + """All wiring port references should have corresponding port objects.""" + machine = self._run_two_stage(instruments, temp_dir) + + def _collect_port_refs(obj, refs=None): + if refs is None: + refs = set() + if isinstance(obj, dict): + for v in obj.values(): + _collect_port_refs(v, refs) + elif isinstance(obj, str) and obj.startswith("#/ports/"): + refs.add(obj) + return refs + + wiring_refs = _collect_port_refs(machine.wiring) + for ref in wiring_refs: + parts = ref.lstrip("#/").split("/") + node = machine + for part in parts: + if isinstance(node, dict): + assert part in node, f"Port reference {ref} not materialized" + node = node[part] + else: + assert hasattr(node, part), f"Port reference {ref} not materialized" + node = getattr(node, part) + + def test_two_stage_sensor_dots_wired_to_pairs(self, instruments, temp_dir): + """Each quantum dot pair should have sensor dots populated per the map.""" + machine = self._run_two_stage(instruments, temp_dir) + + pair_q1_q2 = machine.qubit_pairs["q1_q2"] + assert len(pair_q1_q2.quantum_dot_pair.sensor_dots) > 0 + assert "#/sensor_dots/virtual_sensor_1" in pair_q1_q2.quantum_dot_pair.sensor_dots + + pair_q2_q3 = machine.qubit_pairs["q2_q3"] + assert len(pair_q2_q3.quantum_dot_pair.sensor_dots) > 0 + assert "#/sensor_dots/virtual_sensor_2" in pair_q2_q3.quantum_dot_pair.sensor_dots + + def test_two_stage_preferred_readout_dot(self, instruments, temp_dir): + """Each qubit should have preferred_readout_quantum_dot set. + + When a qubit belongs to multiple pairs, the last pair processed + wins (same last-write-wins semantics as the original quam_factory). + We verify the exact expected values for this 3-dot, 2-pair topology: + pairs processed in order q1_q2 then q2_q3. + """ + machine = self._run_two_stage(instruments, temp_dir) + + assert machine.qubits["q1"].preferred_readout_quantum_dot == "virtual_dot_2" + assert machine.qubits["q2"].preferred_readout_quantum_dot == "virtual_dot_3" + assert machine.qubits["q3"].preferred_readout_quantum_dot == "virtual_dot_2" + + def test_two_stage_resonator_not_sticky(self, instruments, temp_dir): + """Readout resonators should not have sticky enabled after two-stage build.""" + machine = self._run_two_stage(instruments, temp_dir) + + for sensor in machine.sensor_dots.values(): + rr = sensor.readout_resonator + assert rr.sticky is None, f"Readout resonator {rr.id} should not have sticky" diff --git a/tests/builder/quantum_dots/test_macro_names.py b/tests/builder/quantum_dots/test_macro_names.py new file mode 100644 index 00000000..0abadc07 --- /dev/null +++ b/tests/builder/quantum_dots/test_macro_names.py @@ -0,0 +1,93 @@ +"""Tests for canonical macro-name catalogs and default macro maps.""" + +from quam_builder.architecture.quantum_dots.operations import ( + default_operations as operations_module, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + SINGLE_QUBIT_MACROS, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + QPU_STATE_MACROS, + STATE_POINT_MACROS, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + TWO_QUBIT_MACROS, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + SINGLE_QUBIT_MACRO_ALIAS_MAP, + SINGLE_QUBIT_MACRO_ALIASES, + SINGLE_QUBIT_MACRO_NAMES, + TWO_QUBIT_MACRO_NAMES, + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) + + +def test_state_macro_enum_values_are_default_keys(): + """STATE_POINT_MACROS keys should be a subset of VoltagePointName values. + + Not every voltage point has a generic state macro — measure and exchange + are component-specific (handled by Measure1QMacro, MeasurePSBPairMacro, + ExchangeStateMacro, etc.) rather than generic STATE_POINT_MACROS entries. + """ + state_keys = {name.value for name in VoltagePointName} + + assert set(STATE_POINT_MACROS.keys()).issubset(state_keys) + assert set(QPU_STATE_MACROS.keys()).issubset(state_keys) + + +def test_single_qubit_enum_values_are_default_keys(): + """Canonical 1Q gate enum names should be present in default 1Q map. + + State-related names (initialize, measure, empty, exchange) are included + in SingleQubitMacroName for reference but may not all have entries in + SINGLE_QUBIT_MACROS since some are component-specific. + """ + gate_names = { + name.value + for name in SingleQubitMacroName + if name + not in ( + SingleQubitMacroName.INITIALIZE, + SingleQubitMacroName.MEASURE, + SingleQubitMacroName.EMPTY, + SingleQubitMacroName.EXCHANGE, + ) + } + + assert gate_names.issubset(SINGLE_QUBIT_MACROS.keys()) + assert gate_names.issubset(SINGLE_QUBIT_MACRO_NAMES) + assert set(SINGLE_QUBIT_MACRO_ALIASES).issubset(SINGLE_QUBIT_MACRO_NAMES) + + +def test_single_qubit_aliases_map_to_supported_canonical_names(): + """Each supported alias must map to a known canonical single-qubit name.""" + canonical_single_qubit_names = {name.value for name in SingleQubitMacroName} + + assert set(SINGLE_QUBIT_MACRO_ALIAS_MAP.keys()) == set(SINGLE_QUBIT_MACRO_ALIASES) + assert set(SINGLE_QUBIT_MACRO_ALIAS_MAP.values()).issubset(canonical_single_qubit_names) + + +def test_two_qubit_enum_values_are_default_keys(): + """Canonical 2Q enum names should be present in default 2Q map.""" + canonical_two_qubit_names = {name.value for name in TwoQubitMacroName} + + assert canonical_two_qubit_names.issubset(TWO_QUBIT_MACROS.keys()) + assert canonical_two_qubit_names.issubset(TWO_QUBIT_MACRO_NAMES) + + +def test_default_operations_match_canonical_enums(): + """Default operation registry names should match canonical enum names.""" + expected_operation_names = { + *(name.value for name in VoltagePointName), + *(name.value for name in SingleQubitMacroName), + *(name.value for name in TwoQubitMacroName), + } + registered_operation_names = set(operations_module.operations_registry.data.keys()) + + assert registered_operation_names == expected_operation_names + assert registered_operation_names.isdisjoint(SINGLE_QUBIT_MACRO_ALIASES) + assert expected_operation_names.issubset(operations_module.__all__) + for operation_name in expected_operation_names: + assert callable(getattr(operations_module, operation_name)) diff --git a/tests/builder/quantum_dots/test_macro_wiring.py b/tests/builder/quantum_dots/test_macro_wiring.py new file mode 100644 index 00000000..1afcba6d --- /dev/null +++ b/tests/builder/quantum_dots/test_macro_wiring.py @@ -0,0 +1,323 @@ +"""Tests for runtime macro wiring and override behavior.""" + +import numpy as np +import pytest +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from unittest.mock import patch +from qm import qua +from quam.components import pulses + +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + macro, + overrides, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + X180Macro, + XYDriveMacro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + InitializeStateMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder +from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder + + +def _plunger_ports(qubit_id: str) -> dict: + return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} + + +def _mw_drive_ports(qubit_id: str) -> dict: + return {"opx_output": f"#/ports/mw_outputs/con1/1/{qubit_id[-1]}"} + + +def _barrier_ports(pair_id: str) -> dict: + return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} + + +def _build_machine(): + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + }, + "q2": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), + WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + }, + }, + "qubit_pairs": { + "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, + }, + } + machine = _BaseQpuBuilder(machine).build() + machine = _LDQubitBuilder(machine).build() + return machine + + +def _seed_reference_pulses(machine): + for qubit in machine.qubits.values(): + if qubit.xy is None: + continue + qubit.xy.operations.setdefault( + "gaussian", pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16) + ) + + +class TunedX180Macro(X180Macro): + """Simple marker macro used for instance-level override testing.""" + + pass + + +def test_instance_override_path_supports_quam_mappings(): + """Instance overrides should work for collections stored as Quam mappings. + + Uses the typed API: instance_overrides with macro() helper. + Only q1 gets the override; q2 keeps the default X180Macro. + """ + machine = _build_machine() + + wire_machine_macros( + machine, + instance_overrides={ + "qubits.q1": overrides( + macros={ + SingleQubitMacroName.X_180: macro(TunedX180Macro), + } + ), + }, + strict=True, + ) + + assert isinstance(machine.qubits["q1"].macros["x180"], TunedX180Macro) + assert isinstance(machine.qubits["q2"].macros["x180"], X180Macro) + + +def test_component_type_override_applies_to_all_instances(): + """Component-type overrides should apply to each matching instance. + + Uses the typed API: component_overrides keyed by the LDQubit class. + The macro() helper validates the factory and passes params through. + """ + machine = _build_machine() + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides( + macros={ + SingleQubitMacroName.INITIALIZE: macro( + InitializeStateMacro, + ramp_duration=48, + ), + } + ), + }, + strict=True, + ) + + for qubit in machine.qubits.values(): + assert isinstance(qubit.macros["initialize"], InitializeStateMacro) + assert qubit.macros["initialize"].ramp_duration == 48 + + +def test_component_type_override_sets_xy_drive_runtime_params(): + """Override params should populate canonical xy_drive attributes after init. + + Uses the typed API with component_overrides to set reference_angle + on all LDQubits at once. + """ + machine = _build_machine() + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: overrides( + macros={ + SingleQubitMacroName.XY_DRIVE: macro( + XYDriveMacro, + reference_angle=1.5, + ), + } + ), + }, + strict=True, + ) + + for qubit in machine.qubits.values(): + assert isinstance(qubit.macros["xy_drive"], XYDriveMacro) + assert qubit.macros["xy_drive"].reference_angle == pytest.approx(1.5) + + +def test_canonical_x_and_y_delegate_to_xy_drive(): + """Canonical axis macros should delegate into `xy_drive` with proper phase.""" + machine = _build_machine() + q1 = machine.qubits["q1"] + + with patch.object(q1.macros["xy_drive"], "apply", return_value=None) as mock_apply: + q1.macros["x"].apply(angle=np.pi / 3) + mock_apply.assert_called_once_with(angle=np.pi / 3, phase=0.0) + + with patch.object(q1.macros["xy_drive"], "apply", return_value=None) as mock_apply: + q1.macros["y"].apply(angle=np.pi / 4) + mock_apply.assert_called_once_with(angle=np.pi / 4, phase=pytest.approx(np.pi / 2)) + + +def test_runtime_phase_is_added_to_axis_phase(): + """Runtime phase should compose additively with the canonical axis phase.""" + machine = _build_machine() + q1 = machine.qubits["q1"] + + with patch.object(q1.macros["xy_drive"], "apply", return_value=None) as mock_apply: + q1.macros["y"].apply(angle=np.pi / 4, phase=0.125) + mock_apply.assert_called_once_with( + angle=np.pi / 4, + phase=pytest.approx(np.pi / 2 + 0.125), + ) + + +def test_fixed_angle_macros_delegate_to_canonical_axes(): + """x90/y90/z90 wrappers should dispatch to canonical x/y/z with fixed angles.""" + machine = _build_machine() + q1 = machine.qubits["q1"] + + with patch.object(q1.macros["x"], "apply", return_value=None) as mock_apply: + q1.macros["x90"].apply() + mock_apply.assert_called_once_with(angle=pytest.approx(np.pi / 2)) + + with patch.object(q1.macros["y"], "apply", return_value=None) as mock_apply: + q1.macros["y90"].apply() + mock_apply.assert_called_once_with(angle=pytest.approx(np.pi / 2)) + + with patch.object(q1.macros["z"], "apply", return_value=None) as mock_apply: + q1.macros["z90"].apply() + mock_apply.assert_called_once_with(angle=pytest.approx(np.pi / 2)) + + +def test_x180_macro_produces_valid_qua_program(): + """X180Macro.apply() inside qua.program() produces a valid non-None QUA program.""" + machine = _build_machine() + wire_machine_macros(machine, strict=True) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + with qua.program() as prog: + q1.macros["x180"].apply() + + assert prog is not None + + +def test_x180_macro_triggers_play(): + """X180Macro.apply() triggers xy.play via delegation chain.""" + machine = _build_machine() + wire_machine_macros(machine, strict=True) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + with patch.object(q1.xy, "play", return_value=None) as mock_play: + with qua.program(): + q1.macros["x180"].apply() + + assert mock_play.call_count >= 1 + assert mock_play.call_args.kwargs["pulse_name"] == "gaussian" + + +def test_runtime_amplitude_scale_multiplies_angle_scale(): + """Runtime amplitude scaling should multiply the angle-derived pulse scaling.""" + machine = _build_machine() + wire_machine_macros(machine, strict=True) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + with ( + patch.object(q1.xy, "play", return_value=None) as mock_play, + patch.object(q1.voltage_sequence, "step_to_voltages", return_value=None), + ): + q1.x90(amplitude_scale=0.5) + + assert mock_play.call_args.kwargs["amplitude_scale"] == pytest.approx(0.25) + + +def test_reference_pulse_amplitude_is_shared_source_of_truth_for_x_family(): + """Updating the reference pulse amplitude should affect both x180 and x90.""" + machine = _build_machine() + wire_machine_macros(machine, strict=True) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + q1.xy.operations["gaussian"].amplitude = 0.15 + + with ( + patch.object(q1.xy, "play", return_value=None) as mock_play, + patch.object(q1.voltage_sequence, "step_to_voltages", return_value=None), + ): + q1.x180() + x180_scale = mock_play.call_args.kwargs["amplitude_scale"] + if x180_scale is None: + x180_scale = 1.0 + + with ( + patch.object(q1.xy, "play", return_value=None) as mock_play, + patch.object(q1.voltage_sequence, "step_to_voltages", return_value=None), + ): + q1.x90() + x90_scale = mock_play.call_args.kwargs["amplitude_scale"] + + assert q1.xy.operations["gaussian"].amplitude * x180_scale == pytest.approx(0.15) + assert q1.xy.operations["gaussian"].amplitude * x90_scale == pytest.approx(0.075) + + +def test_fixed_angle_inferred_duration_uses_reference_pulse_length(): + """Inferred duration should always equal the reference pulse length (no stretching).""" + machine = _build_machine() + wire_machine_macros(machine, strict=True) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + ref_duration = q1.xy.operations["gaussian"].length * 1e-9 + assert q1.macros["x"].inferred_duration == pytest.approx(ref_duration) + assert q1.macros["x90"].inferred_duration == pytest.approx(ref_duration) + assert q1.macros["y90"].inferred_duration == pytest.approx(ref_duration) + + +def test_negative_x_rotation_is_phase_shifted_positive_angle_drive(): + """Negative X should map to +pi phase shift with positive amplitude scale.""" + machine = _build_machine() + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + with ( + patch.object(q1, "virtual_z", return_value=None) as mock_vz, + patch.object(q1.xy, "play", return_value=None) as mock_play, + patch.object(q1.voltage_sequence, "step_to_voltages", return_value=None), + ): + q1.x(angle=-np.pi / 2) + + assert mock_vz.call_args_list[0].args[0] == pytest.approx(np.pi) + assert mock_vz.call_args_list[1].args[0] == pytest.approx(-np.pi) + assert mock_play.call_args.kwargs["amplitude_scale"] == pytest.approx(0.5) + + +def test_negative_y_rotation_is_phase_shifted_positive_angle_drive(): + """Negative Y should map to (pi/2 + pi) phase shift with positive amplitude scale.""" + machine = _build_machine() + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + + with ( + patch.object(q1, "virtual_z", return_value=None) as mock_vz, + patch.object(q1.xy, "play", return_value=None) as mock_play, + patch.object(q1.voltage_sequence, "step_to_voltages", return_value=None), + ): + q1.y(angle=-np.pi / 2) + + assert mock_vz.call_args_list[0].args[0] == pytest.approx(3 * np.pi / 2) + assert mock_vz.call_args_list[1].args[0] == pytest.approx(-3 * np.pi / 2) + assert mock_play.call_args.kwargs["amplitude_scale"] == pytest.approx(0.5) diff --git a/tests/builder/quantum_dots/test_pulse_wiring.py b/tests/builder/quantum_dots/test_pulse_wiring.py new file mode 100644 index 00000000..bbc8591c --- /dev/null +++ b/tests/builder/quantum_dots/test_pulse_wiring.py @@ -0,0 +1,203 @@ +"""Tests for pulse wiring via wire_machine_macros and TOML pulse overrides.""" + +import pytest +from unittest.mock import MagicMock + +from quam.components import pulses as quam_pulses, StickyChannelAddon +from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + ReadoutResonatorSingle, +) +from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveSingle +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + pulse, + disabled, + overrides as component_overrides_helper, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.macro_engine.wiring import ( + _ensure_default_pulses, +) + + +def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: + return VoltageGate( + id=gate_id, + opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=port), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + +def _make_wired_machine() -> LossDiVincenzoQuam: + """Build a minimal wired machine for pulse testing.""" + machine = LossDiVincenzoQuam() + lf = 6 + + p1 = _make_voltage_gate(lf, 1, "plunger_1") + s1 = _make_voltage_gate(lf, 8, "sensor_DC") + + resonator = ReadoutResonatorSingle( + id="readout_resonator", + frequency_bare=0, + intermediate_frequency=500e6, + operations={}, + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), + opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + + xy_drive = XYDriveSingle( + id="Q1_xy", + RF_frequency=int(100e6), + opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=3), + ) + + machine.create_virtual_gate_set( + virtual_channel_mapping={ + "virtual_dot_1": p1, + "virtual_sensor_1": s1, + }, + gate_set_id="main_qpu", + ) + + machine.register_channel_elements( + plunger_channels=[p1], + barrier_channels=[], + sensor_resonator_mappings={s1: resonator}, + ) + + machine.register_qubit( + quantum_dot_id="virtual_dot_1", + qubit_name="Q1", + xy=xy_drive, + ) + + return machine + + +class TestDefaultPulseWiring: + """Test that _ensure_default_pulses adds pulses to the right places.""" + + def test_xy_drive_gets_default_pulse(self): + machine = _make_wired_machine() + _ensure_default_pulses(machine) + + xy = machine.qubits["Q1"].xy + assert "gaussian" in xy.operations + + def test_single_channel_no_axis_angle(self): + machine = _make_wired_machine() + _ensure_default_pulses(machine) + + xy = machine.qubits["Q1"].xy + assert xy.operations["gaussian"].axis_angle is None + + def test_readout_resonator_gets_default_pulse(self): + machine = _make_wired_machine() + _ensure_default_pulses(machine) + + rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator + assert "readout" in rr.operations + assert isinstance(rr.operations["readout"], quam_pulses.SquareReadoutPulse) + + def test_existing_pulses_not_overwritten(self): + machine = _make_wired_machine() + custom_pulse = quam_pulses.GaussianPulse(length=500, amplitude=0.3, sigma=83) + machine.qubits["Q1"].xy.operations["gaussian"] = custom_pulse + + _ensure_default_pulses(machine) + + # Custom pulse should be preserved + assert machine.qubits["Q1"].xy.operations["gaussian"] is custom_pulse + + def test_existing_readout_not_overwritten(self): + machine = _make_wired_machine() + custom_readout = quam_pulses.SquareReadoutPulse(length=5000, amplitude=0.5) + machine.sensor_dots["virtual_sensor_1"].readout_resonator.operations[ + "readout" + ] = custom_readout + + _ensure_default_pulses(machine) + + rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator + assert rr.operations["readout"] is custom_readout + + +class TestWireMacrosPulseIntegration: + """Test that wire_machine_macros wires both macros and pulses.""" + + def test_full_wiring_adds_pulses(self, reset_catalog): + machine = _make_wired_machine() + wire_machine_macros(machine) + + xy = machine.qubits["Q1"].xy + assert "gaussian" in xy.operations + + rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator + assert "readout" in rr.operations + + def test_pulse_overrides_via_component_overrides(self, reset_catalog): + """Override pulse on all LDQubits using typed component_overrides API. + + Uses pulse() helper to define a shorter gaussian with different amplitude. + """ + machine = _make_wired_machine() + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: component_overrides_helper( + pulses={ + "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), + } + ), + }, + ) + + gaussian = machine.qubits["Q1"].xy.operations["gaussian"] + assert gaussian.length == 500 + assert gaussian.amplitude == 0.3 + + def test_pulse_disable_via_component_overrides(self, reset_catalog): + """Remove a pulse from all LDQubits using disabled() helper.""" + machine = _make_wired_machine() + + wire_machine_macros( + machine, + component_overrides={ + LDQubit: component_overrides_helper( + pulses={ + "gaussian": disabled(), + } + ), + }, + ) + + xy = machine.qubits["Q1"].xy + assert "gaussian" not in xy.operations + + def test_instance_pulse_override(self, reset_catalog): + """Override pulse on one specific qubit using instance_overrides API. + + Only qubits.Q1 gets the custom gaussian; others keep the default. + """ + machine = _make_wired_machine() + + wire_machine_macros( + machine, + instance_overrides={ + "qubits.Q1": component_overrides_helper( + pulses={ + "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), + } + ), + }, + ) + + gaussian = machine.qubits["Q1"].xy.operations["gaussian"] + assert gaussian.length == 800 + assert gaussian.amplitude == 0.15 diff --git a/tests/builder/quantum_dots/test_stage_workflow_persistence.py b/tests/builder/quantum_dots/test_stage_workflow_persistence.py new file mode 100644 index 00000000..b4f03edd --- /dev/null +++ b/tests/builder/quantum_dots/test_stage_workflow_persistence.py @@ -0,0 +1,130 @@ +"""Tests for persistence of calibration data across two-stage wiring.""" + +from pathlib import Path + +import pytest + +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import build_base_quam, build_loss_divincenzo_quam + + +EXAMPLE_GLOBAL_GATES = [1] +EXAMPLE_SENSOR_DOTS = [1, 2] +EXAMPLE_QUANTUM_DOTS = [1, 2, 3] +EXAMPLE_QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] + + +def _make_stage1_connectivity(): + connectivity = Connectivity() + if EXAMPLE_GLOBAL_GATES: + connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") + connectivity.add_sensor_dots( + sensor_dots=EXAMPLE_SENSOR_DOTS, + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=EXAMPLE_QUANTUM_DOTS, + add_drive_lines=False, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) + return connectivity + + +def _make_stage2_connectivity(): + connectivity = Connectivity() + if EXAMPLE_GLOBAL_GATES: + connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") + connectivity.add_sensor_dots( + sensor_dots=EXAMPLE_SENSOR_DOTS, + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) + connectivity.add_quantum_dots( + quantum_dots=EXAMPLE_QUANTUM_DOTS, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + return connectivity + + +def _make_instruments(): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + return instruments + + +def _add_calibration_data(machine: BaseQuamQD) -> None: + gate_set = machine.virtual_gate_sets["main_qpu"] + virtual_targets = [ + name for name in gate_set.layers[0].source_gates if name.startswith("virtual_dot_") + ][:2] + gate_set.add_to_layer( + layer_id="calibration_layer", + source_gates=["virtual_calibration_axis"], + target_gates=virtual_targets, + matrix=[[1.0, -1.0]], + ) + + quantum_dot = machine.quantum_dots["virtual_dot_1"] + quantum_dot.add_point("calibration_idle", {"virtual_dot_1": 0.12}, duration=120) + + +def test_calibration_data_persists_across_two_stage_build(tmp_path): + """Ensure virtual gates and voltage points survive stage 2 wiring/build.""" + stage1_dir = Path(tmp_path) / "stage1" + stage2_dir = Path(tmp_path) / "stage2" + + connectivity_stage1 = _make_stage1_connectivity() + instruments_stage1 = _make_instruments() + allocate_wiring(connectivity_stage1, instruments_stage1) + + machine_stage1 = BaseQuamQD() + machine_stage1 = build_quam_wiring( + connectivity_stage1, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_stage1, + path=stage1_dir, + ) + machine_stage1 = build_base_quam( + machine_stage1, + calibration_db_path=stage1_dir, + connect_qdac=False, + save=False, + ) + + _add_calibration_data(machine_stage1) + machine_stage1.save(stage1_dir) + + machine_loaded = BaseQuamQD.load(stage1_dir) + connectivity_stage2 = _make_stage2_connectivity() + instruments_stage2 = _make_instruments() + allocate_wiring(connectivity_stage2, instruments_stage2) + + machine_stage2 = build_quam_wiring( + connectivity_stage2, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_loaded, + path=stage2_dir, + ) + machine_stage2 = build_loss_divincenzo_quam( + machine_stage2, + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, + implicit_mapping=True, + save=False, + ) + + gate_set = machine_stage2.virtual_gate_sets["main_qpu"] + calibration_layer = next(layer for layer in gate_set.layers if layer.id == "calibration_layer") + assert calibration_layer.source_gates == ["virtual_calibration_axis"] + + quantum_dot = machine_stage2.quantum_dots["virtual_dot_1"] + point_name = f"{quantum_dot.id}_calibration_idle" + assert point_name in gate_set.macros diff --git a/tests/builder/quantum_dots/test_two_stage_build.py b/tests/builder/quantum_dots/test_two_stage_build.py index 335cdf83..c4dc9317 100644 --- a/tests/builder/quantum_dots/test_two_stage_build.py +++ b/tests/builder/quantum_dots/test_two_stage_build.py @@ -47,9 +47,13 @@ def _resonator_ports(sensor_id: str) -> dict: } -def _mw_drive_ports(qubit_id: str) -> dict: - """Helper to create MW drive wiring.""" - return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} +def _drive_ports(qubit_id: str) -> dict: + """Helper to create LF-FEM single-channel drive wiring. + + Uses a port-style reference containing 'analog_outputs' so + _validate_drive_ports classifies it as "Single" -> XYDriveSingle. + """ + return {"opx_output": f"#/ports/analog_outputs/con1/3/{qubit_id[-1]}"} def _iq_drive_ports(qubit_id: str) -> dict: @@ -184,7 +188,7 @@ def test_does_not_create_xy_drives(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, } } @@ -209,7 +213,7 @@ def test_converts_base_to_ld_quam(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, } } @@ -233,11 +237,11 @@ def test_implicit_mapping(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, "q2": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), - WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + WiringLineType.DRIVE.value: _drive_ports("q2"), }, } } @@ -265,7 +269,7 @@ def test_loads_from_file(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, } } @@ -292,7 +296,7 @@ def test_creates_xy_drives(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, } } @@ -317,11 +321,11 @@ def test_creates_qubit_pairs(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, "q2": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), - WiringLineType.DRIVE.value: _mw_drive_ports("q2"), + WiringLineType.DRIVE.value: _drive_ports("q2"), }, }, "qubit_pairs": { @@ -387,7 +391,7 @@ def test_full_two_stage_flow(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, "q2": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), @@ -437,7 +441,7 @@ def test_save_and_load_between_stages(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), }, } } @@ -458,6 +462,208 @@ def test_save_and_load_between_stages(self): assert "virtual_dot_1" in result.quantum_dots +class TestStage2WiringBehaviours: + """Tests for Stage 2 wiring behaviours added by the builder.""" + + def _build_two_dot_one_sensor_one_pair(self, qubit_pair_sensor_map=None): + """Build a 2-dot, 1-sensor, 1-pair machine through both stages.""" + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: _drive_ports("q1"), + }, + "q2": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), + WiringLineType.DRIVE.value: _drive_ports("q2"), + }, + }, + "qubit_pairs": { + "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, + }, + "readout": { + "s1": { + WiringLineType.SENSOR_GATE.value: _sensor_ports("s1"), + WiringLineType.RF_RESONATOR.value: _resonator_ports("s1"), + }, + }, + } + builder1 = _BaseQpuBuilder(machine) + machine = builder1.build() + + builder2 = _LDQubitBuilder( + machine, + qubit_pair_sensor_map=qubit_pair_sensor_map or {"q1_q2": ["sensor_1"]}, + ) + return builder2.build() + + def test_sensor_dots_wired_to_quantum_dot_pairs(self): + """Sensor dots should be wired to quantum dot pairs via qubit_pair_sensor_map.""" + machine = self._build_two_dot_one_sensor_one_pair( + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]} + ) + pair = machine.qubit_pairs["q1_q2"] + assert "#/sensor_dots/virtual_sensor_1" in pair.quantum_dot_pair.sensor_dots + + def test_preferred_readout_quantum_dot_set_on_qubits(self): + """Each qubit's preferred_readout_quantum_dot should be the partner's dot.""" + machine = self._build_two_dot_one_sensor_one_pair() + assert machine.qubits["q1"].preferred_readout_quantum_dot == "virtual_dot_2" + assert machine.qubits["q2"].preferred_readout_quantum_dot == "virtual_dot_1" + + def test_readout_resonator_not_sticky(self): + """Readout resonators should not have sticky enabled.""" + machine = BaseQuamQD() + machine.wiring = { + "readout": { + "s1": { + WiringLineType.SENSOR_GATE.value: _sensor_ports("s1"), + WiringLineType.RF_RESONATOR.value: _resonator_ports("s1"), + }, + }, + "qubits": { + "q1": {WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1")}, + }, + } + builder = _BaseQpuBuilder(machine) + machine = builder.build() + + sensor = machine.sensor_dots["virtual_sensor_1"] + assert sensor.readout_resonator.sticky is None + + def test_readout_resonator_intermediate_frequency_zero(self): + """Readout resonator intermediate_frequency should default to 0.""" + machine = BaseQuamQD() + machine.wiring = { + "readout": { + "s1": { + WiringLineType.SENSOR_GATE.value: _sensor_ports("s1"), + WiringLineType.RF_RESONATOR.value: _resonator_ports("s1"), + }, + }, + "qubits": { + "q1": {WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1")}, + }, + } + builder = _BaseQpuBuilder(machine) + machine = builder.build() + + sensor = machine.sensor_dots["virtual_sensor_1"] + assert sensor.readout_resonator.intermediate_frequency == 0 + + +class TestDriveTypeDetection: + """Tests for drive type detection distinguishing IQ, MW, and Single.""" + + def test_validate_drive_ports_returns_mw_for_mw_fem_ref(self): + """opx_output referencing mw_outputs should be classified as MW.""" + from quam_builder.builder.quantum_dots.build_utils import _validate_drive_ports + + ports = {"opx_output": "#/ports/mw_outputs/con1/1/1"} + assert _validate_drive_ports("q1", ports) == "MW" + + def test_validate_drive_ports_returns_single_for_analog_ref(self): + """opx_output referencing analog_outputs should be classified as Single.""" + from quam_builder.builder.quantum_dots.build_utils import _validate_drive_ports + + ports = {"opx_output": "#/ports/analog_outputs/con1/3/1"} + assert _validate_drive_ports("q1", ports) == "Single" + + def test_validate_drive_ports_returns_iq(self): + """IQ ports should still be classified as IQ.""" + from quam_builder.builder.quantum_dots.build_utils import _validate_drive_ports + + ports = _iq_drive_ports("q1") + assert _validate_drive_ports("q1", ports) == "IQ" + + def test_create_xy_drive_single(self): + """_create_xy_drive_from_wiring should create XYDriveSingle for Single type.""" + from quam_builder.builder.quantum_dots.build_utils import ( + _create_xy_drive_from_wiring, + ) + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, + ) + + drive = _create_xy_drive_from_wiring( + qubit_id="q1", + drive_type="Single", + wiring_path="#/wiring/qubits/q1/xy", + intermediate_frequency=100e6, + ) + assert isinstance(drive, XYDriveSingle) + assert drive.id == "q1_xy" + assert drive.RF_frequency == 100_000_000 + + def test_create_xy_drive_mw(self): + """_create_xy_drive_from_wiring should create XYDriveMW for MW type.""" + from quam_builder.builder.quantum_dots.build_utils import ( + _create_xy_drive_from_wiring, + ) + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveMW, + ) + + drive = _create_xy_drive_from_wiring( + qubit_id="q1", + drive_type="MW", + wiring_path="#/wiring/qubits/q1/xy", + ) + assert isinstance(drive, XYDriveMW) + + def test_stage2_single_drive_creates_xy_drive_single(self): + """Stage 2 with LF-FEM single drive should create XYDriveSingle.""" + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, + ) + + lf_drive = {"opx_output": "#/ports/analog_outputs/con1/3/1"} + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: lf_drive, + }, + } + } + builder1 = _BaseQpuBuilder(machine) + machine = builder1.build() + + builder2 = _LDQubitBuilder(machine) + result = builder2.build() + + assert "q1" in result.qubits + assert isinstance(result.qubits["q1"].xy, XYDriveSingle) + assert result.qubits["q1"].xy.id == "q1_xy" + + def test_stage2_mw_drive_creates_xy_drive_mw(self): + """Stage 2 with MW-FEM drive should create XYDriveMW.""" + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveMW, + ) + + mw_drive = {"opx_output": "#/ports/mw_outputs/con1/1/1"} + machine = BaseQuamQD() + machine.wiring = { + "qubits": { + "q1": { + WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), + WiringLineType.DRIVE.value: mw_drive, + }, + } + } + builder1 = _BaseQpuBuilder(machine) + machine = builder1.build() + + builder2 = _LDQubitBuilder(machine) + result = builder2.build() + + assert "q1" in result.qubits + assert isinstance(result.qubits["q1"].xy, XYDriveMW) + + class TestHighLevelAPI: """Tests for high-level build functions.""" @@ -478,19 +684,16 @@ def test_build_base_quam_function(self): def test_build_loss_divincenzo_quam_function(self): """Test build_loss_divincenzo_quam() convenience function.""" - # First create BaseQuamQD machine = BaseQuamQD() machine.wiring = { "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), }, } } machine = build_base_quam(machine, save=False) - # Then convert to LossDiVincenzoQuam result = build_loss_divincenzo_quam(machine, save=False) assert isinstance(result, LossDiVincenzoQuam) @@ -503,14 +706,12 @@ def test_build_quam_convenience_wrapper(self): "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), }, } } result = build_quam(machine, save=False) - # Should execute both stages assert isinstance(result, LossDiVincenzoQuam) assert "q1" in result.qubits assert "virtual_dot_1" in result.quantum_dots diff --git a/tests/builder/quantum_dots/test_wirer_builder_integration.py b/tests/builder/quantum_dots/test_wirer_builder_integration.py new file mode 100644 index 00000000..aeb1ad05 --- /dev/null +++ b/tests/builder/quantum_dots/test_wirer_builder_integration.py @@ -0,0 +1,560 @@ +"""Integration tests for the wirer and builder working together.""" + +# pylint: disable=too-few-public-methods + +import shutil +import tempfile +from pathlib import Path + +import pytest + +from qualang_tools.wirer import Instruments, Connectivity, allocate_wiring +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import ( + build_quam, + build_base_quam, + build_loss_divincenzo_quam, +) +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.architecture.quantum_dots.qpu.loss_divincenzo_quam import ( + LossDiVincenzoQuam, +) + + +class TestWirerBuilderIntegration: + """Integration tests for the complete wiring and building workflow.""" + + EXAMPLE_GLOBAL_GATES = [1] + EXAMPLE_SENSOR_DOTS = [1, 2] + EXAMPLE_QUANTUM_DOTS = [1, 2, 3] + EXAMPLE_QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] + + @staticmethod + def _make_stage1_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=False, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) + return connectivity + + @staticmethod + def _make_stage2_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") + connectivity.add_sensor_dots( + sensor_dots=sensor_dots, + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) + connectivity.add_quantum_dots( + quantum_dots=quantum_dots, + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + return connectivity + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory for test files.""" + temp_dir = tempfile.mkdtemp() + yield temp_dir + shutil.rmtree(temp_dir) + + @pytest.fixture + def instruments(self): + """Create instruments configuration.""" + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1, 2]) + instruments.add_lf_fem(controller=1, slots=[3, 4, 5]) + return instruments + + def test_complete_workflow_basic_setup(self, instruments, temp_dir): + """Test the complete workflow from wiring to build for a basic setup.""" + # Setup connectivity + connectivity = Connectivity() + connectivity.add_voltage_gate_lines(voltage_gates=[1], name="rb") + connectivity.add_sensor_dots( + sensor_dots=[1], + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + + # Allocate wiring + allocate_wiring(connectivity, instruments) + + # Build wiring + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + # Verify wiring was created + assert machine.wiring is not None + assert "readout" in machine.wiring + assert "qubits" in machine.wiring + assert "qubit_pairs" in machine.wiring + + # Build QuAM without save/load round-trip + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify QPU elements were created + assert len(machine.sensor_dots) > 0 + assert len(machine.quantum_dots) > 0 + + def test_example_two_stage_workflow(self, temp_dir): + """Exercise the two-stage flow used in wiring_example.""" + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} + + instruments_stage1 = Instruments() + instruments_stage1.add_mw_fem(controller=1, slots=[1]) + instruments_stage1.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_stage1 = self._make_stage1_connectivity( + self.EXAMPLE_GLOBAL_GATES, + self.EXAMPLE_SENSOR_DOTS, + self.EXAMPLE_QUANTUM_DOTS, + self.EXAMPLE_QUANTUM_DOT_PAIRS, + ) + allocate_wiring(connectivity_stage1, instruments_stage1) + + machine_stage1 = BaseQuamQD() + machine_stage1 = build_quam_wiring( + connectivity_stage1, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_stage1, + path=temp_dir, + ) + machine_stage1 = build_base_quam( + machine_stage1, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + instruments_stage2 = Instruments() + instruments_stage2.add_mw_fem(controller=1, slots=[1]) + instruments_stage2.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_stage2 = self._make_stage2_connectivity( + self.EXAMPLE_GLOBAL_GATES, + self.EXAMPLE_SENSOR_DOTS, + self.EXAMPLE_QUANTUM_DOTS, + self.EXAMPLE_QUANTUM_DOT_PAIRS, + ) + allocate_wiring(connectivity_stage2, instruments_stage2) + + machine_stage2 = build_quam_wiring( + connectivity_stage2, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_stage1, + path=temp_dir, + ) + machine_stage2 = build_loss_divincenzo_quam( + machine_stage2, + qubit_pair_sensor_map=qubit_pair_sensor_map, + implicit_mapping=True, + save=False, + ) + + assert len(machine_stage2.quantum_dots) == len(self.EXAMPLE_QUANTUM_DOTS) + assert len(machine_stage2.sensor_dots) == len(self.EXAMPLE_SENSOR_DOTS) + assert len(machine_stage2.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) + assert len(machine_stage2.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) + + def test_example_combined_workflow(self, temp_dir): + """Exercise the combined flow used in wiring_example.""" + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"]} + + instruments_combined = Instruments() + instruments_combined.add_mw_fem(controller=1, slots=[1]) + instruments_combined.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_combined = self._make_stage2_connectivity( + self.EXAMPLE_GLOBAL_GATES, + self.EXAMPLE_SENSOR_DOTS, + self.EXAMPLE_QUANTUM_DOTS, + self.EXAMPLE_QUANTUM_DOT_PAIRS, + ) + allocate_wiring(connectivity_combined, instruments_combined) + + machine_combined = BaseQuamQD() + machine_combined = build_quam_wiring( + connectivity_combined, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_combined, + path=temp_dir, + ) + machine_combined = build_quam( + machine_combined, + calibration_db_path=temp_dir, + qubit_pair_sensor_map=qubit_pair_sensor_map, + connect_qdac=False, + save=False, + ) + + assert len(machine_combined.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) + assert len(machine_combined.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) + + def test_example_incremental_drive_lines(self, temp_dir): + """Exercise incremental drive-line flow from wiring_example.""" + qubit_pair_sensor_map = {"q1_q2": ["sensor_1"]} + + instruments_stage1 = Instruments() + instruments_stage1.add_mw_fem(controller=1, slots=[1]) + instruments_stage1.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity_stage1 = self._make_stage1_connectivity( + self.EXAMPLE_GLOBAL_GATES, + self.EXAMPLE_SENSOR_DOTS, + self.EXAMPLE_QUANTUM_DOTS, + self.EXAMPLE_QUANTUM_DOT_PAIRS, + ) + allocate_wiring(connectivity_stage1, instruments_stage1) + + machine_stage1 = BaseQuamQD() + machine_stage1 = build_quam_wiring( + connectivity_stage1, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_stage1, + path=temp_dir, + ) + machine_stage1 = build_base_quam( + machine_stage1, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + connectivity_drive_lines = Connectivity() + connectivity_drive_lines.add_quantum_dot_drive_lines( + quantum_dots=self.EXAMPLE_QUANTUM_DOTS, + use_mw_fem=True, + shared_line=True, + ) + allocate_wiring(connectivity_drive_lines, instruments_stage1) + + machine_stage2 = build_quam_wiring( + connectivity_drive_lines, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine_stage1, + path=temp_dir, + ) + machine_stage2 = build_loss_divincenzo_quam( + machine_stage2, + qubit_pair_sensor_map=qubit_pair_sensor_map, + implicit_mapping=True, + save=False, + ) + + assert len(machine_stage2.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) + for qubit in machine_stage2.qubits.values(): + assert getattr(qubit, "xy", None) is not None + + def test_workflow_with_multiple_qubits(self, instruments, temp_dir): + """Test workflow with multiple qubits and qubit pairs.""" + # Setup connectivity with more qubits + connectivity = Connectivity() + connectivity.add_sensor_dots( + sensor_dots=[1, 2], + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=[1, 2, 3, 4], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3), (3, 4)]) + + # Allocate wiring + allocate_wiring(connectivity, instruments) + + # Build wiring + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + # Build QuAM without save/load round-trip + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify correct number of elements + assert len(machine.quantum_dots) == 4 + assert len(machine.quantum_dot_pairs) == 3 + assert len(machine.sensor_dots) == 2 + + def test_virtual_gate_set_creation(self, instruments, temp_dir): + """Test that virtual gate set is correctly created.""" + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1, 2, 3], + add_drive_lines=False, + ) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify virtual gate set was created + assert len(machine.virtual_gate_sets) > 0 + assert "main_qpu" in machine.virtual_gate_sets + + # Verify virtual gate set has correct channels + vgs = machine.virtual_gate_sets["main_qpu"] + assert len(vgs.channels) >= 3 # At least 3 plunger gates + + def test_qubit_registration_with_xy_drives(self, instruments, temp_dir): + """Test that qubits are registered with their XY drives in Stage 2.""" + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + # build_quam does both Stage 1 and Stage 2, creating qubits with XY drives + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify qubits (Stage 2) have XY drives + assert hasattr(machine, "qubits"), "Machine should have qubits after build_quam" + for qubit_name, qubit in machine.qubits.items(): + assert hasattr(qubit, "xy"), f"Qubit {qubit_name} should have xy attribute" + + def test_sensor_dots_with_resonators(self, instruments, temp_dir): + """Test that sensor dots are registered with resonators.""" + connectivity = Connectivity() + connectivity.add_sensor_dots( + sensor_dots=[1, 2], + shared_resonator_line=False, + use_mw_fem=False, + ) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + machine_loaded = BaseQuamQD.load(temp_dir) + build_base_quam(machine_loaded, calibration_db_path=temp_dir, save=False) + + # Verify sensor dots have resonators + assert len(machine_loaded.sensor_dots) == 2 + # Note: Resonator attachment depends on wiring allocation + + def test_pulses_are_added(self, instruments, temp_dir): + """Test that default pulses are added to qubits in Stage 2.""" + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + # build_quam does both Stage 1 and Stage 2, creating qubits with XY drives + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify qubits (Stage 2) have pulses (if they have xy channels) + assert hasattr(machine, "qubits"), "Machine should have qubits after build_quam" + for qubit_name, qubit in machine.qubits.items(): + if hasattr(qubit, "xy") and qubit.xy is not None: + assert len(qubit.xy.operations) > 0 + + def test_network_configuration_is_set(self, instruments, temp_dir): + """Test that network configuration is properly set.""" + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1], + add_drive_lines=False, + ) + + allocate_wiring(connectivity, instruments) + + machine = LossDiVincenzoQuam() + machine = build_quam_wiring( + connectivity, + host_ip="192.168.1.100", + cluster_name="my_cluster", + quam_instance=machine, + port=9510, + path=temp_dir, + ) + + # Verify network configuration + assert machine.network["host"] == "192.168.1.100" + assert machine.network["cluster_name"] == "my_cluster" + assert machine.network["port"] == 9510 + + def test_active_element_names_are_set(self, instruments, temp_dir): + """Test that active element names lists are populated.""" + connectivity = Connectivity() + connectivity.add_sensor_dots( + sensor_dots=[1], + shared_resonator_line=False, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + build_quam(machine, calibration_db_path=temp_dir, save=False) + + # Verify elements are populated + assert len(machine.sensor_dots) > 0 + assert len(machine.quantum_dots) > 0 + assert len(machine.quantum_dot_pairs) > 0 + + +class TestWirerOnly: + """Tests specifically for the wirer connectivity setup.""" + + def test_connectivity_quantum_dot_interface(self): + """Test that the quantum dot connectivity interface works correctly.""" + connectivity = Connectivity() + + # Add various element types + connectivity.add_voltage_gate_lines(voltage_gates=[1, 2], name="g") + connectivity.add_sensor_dots( + sensor_dots=[1, 2], + shared_resonator_line=True, + use_mw_fem=False, + ) + connectivity.add_quantum_dots( + quantum_dots=[1, 2, 3], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3)]) + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + allocate_wiring(connectivity, instruments) + + # Verify elements were added + assert len(connectivity.elements) > 0 + + # Check element types + element_types = set() + for element in connectivity.elements.values(): + for line_type in element.channels: + element_types.add(line_type.value) + + expected_types = { + WiringLineType.GLOBAL_GATE.value, + WiringLineType.SENSOR_GATE.value, + WiringLineType.RF_RESONATOR.value, + WiringLineType.PLUNGER_GATE.value, + WiringLineType.DRIVE.value, + WiringLineType.BARRIER_GATE.value, + } + assert element_types.intersection(expected_types) + + def test_allocate_wiring_creates_channels(self): + """Test that allocate_wiring creates proper channel allocations.""" + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_quantum_dots( + quantum_dots=[1, 2], + add_drive_lines=True, + use_mw_fem=True, + shared_drive_line=True, + ) + + # Allocate wiring + allocate_wiring(connectivity, instruments) + + # Verify channels were allocated + for element in connectivity.elements.values(): + assert len(element.channels) > 0 + for channel_list in element.channels.values(): + assert len(channel_list) > 0 diff --git a/tests/builder/superconducting/__init__.py b/tests/builder/superconducting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/builder/superconducting/test_e2e_superconducting.py b/tests/builder/superconducting/test_e2e_superconducting.py new file mode 100644 index 00000000..9f4df6e9 --- /dev/null +++ b/tests/builder/superconducting/test_e2e_superconducting.py @@ -0,0 +1,278 @@ +"""End-to-end tests for superconducting QUAM construction using the wiring tools. + +Tests the full workflow from instrument definition → connectivity setup → +wiring allocation → QUAM construction for superconducting qubit architectures +(OPX+/Octave, LF-FEM/MW-FEM, LF-FEM/Octave). + +These tests exercise the interoperability with py-qua-tools (qualang-tools) +wiring infrastructure. +""" + +import shutil +import tempfile + +import pytest +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring +from qualang_tools.wirer.wirer.channel_specs import mw_fem_spec, octave_spec + +from quam_builder.architecture.superconducting.qpu import ( + FixedFrequencyQuam, + FluxTunableQuam, +) +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.superconducting import build_quam as build_quam_sc + + +@pytest.fixture +def temp_dir(): + """Create and clean up a temporary directory for test artifacts.""" + d = tempfile.mkdtemp() + yield d + shutil.rmtree(d) + + +class TestSuperconductingOPXPlusOctave: + """OPX+ with Octave: flux-tunable transmons (mirrors wiring_opxp_octave.py).""" + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_opx_plus(controllers=[1, 2]) + instruments.add_octave(indices=1) + return instruments + + @staticmethod + def _make_connectivity(qubits, qubit_pairs, instruments): + connectivity = Connectivity() + connectivity.add_resonator_line( + qubits=qubits, + constraints=octave_spec(index=1, rf_out=1, rf_in=1), + ) + connectivity.add_qubit_drive_lines(qubits=qubits) + connectivity.add_qubit_flux_lines(qubits=qubits) + connectivity.add_qubit_pair_flux_lines(qubit_pairs=qubit_pairs) + allocate_wiring(connectivity, instruments) + return connectivity + + def test_four_qubit_full_workflow(self, instruments, temp_dir): + """Build a 4-qubit flux-tunable system with OPX+/Octave from scratch.""" + qubits = [1, 2, 3, 4] + qubit_pairs = [(1, 2), (2, 3), (3, 4)] + connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 4 + assert len(machine.qubit_pairs) == 3 + assert len(machine.active_qubit_names) == 4 + assert len(machine.active_qubit_pair_names) == 3 + + for qid, qubit in machine.qubits.items(): + assert qubit.xy is not None, f"{qid} missing xy drive" + assert qubit.resonator is not None, f"{qid} missing resonator" + assert qubit.z is not None, f"{qid} missing flux line" + assert len(qubit.xy.operations) > 0, f"{qid} xy has no pulses" + assert len(qubit.resonator.operations) > 0, f"{qid} resonator has no pulses" + + def test_two_qubit_minimal(self, instruments, temp_dir): + """Minimal 2-qubit setup to verify basic wiring + build.""" + qubits = [1, 2] + qubit_pairs = [(1, 2)] + connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 2 + assert len(machine.qubit_pairs) == 1 + assert machine.network["host"] == "127.0.0.1" + assert machine.network["cluster_name"] == "test_cluster" + + def test_drive_only_no_flux_no_pairs(self, temp_dir): + """Qubits with drive and resonator but no flux lines (fixed-frequency).""" + instruments = Instruments() + instruments.add_opx_plus(controllers=[1]) + instruments.add_octave(indices=1) + + qubits = [1, 2, 3] + connectivity = Connectivity() + connectivity.add_resonator_line( + qubits=qubits, + constraints=octave_spec(index=1, rf_out=1, rf_in=1), + ) + connectivity.add_qubit_drive_lines(qubits=qubits) + allocate_wiring(connectivity, instruments) + + machine = FixedFrequencyQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + + machine = FixedFrequencyQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 3 + assert len(machine.qubit_pairs) == 0 + for qid, qubit in machine.qubits.items(): + assert qubit.xy is not None, f"{qid} missing xy drive" + assert qubit.resonator is not None, f"{qid} missing resonator" + + def test_config_generation_mwfem(self, temp_dir): + """The built QUAM with MW-FEM must produce a valid QUA config dict. + + MW-FEM does not use Octave frequency converters (which require LO_frequency + to be populated), so generate_config succeeds without extra calibration data. + """ + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2]) + + qubits = [1, 2] + qubit_pairs = [(1, 2)] + connectivity = Connectivity() + connectivity.add_resonator_line( + qubits=qubits, + constraints=mw_fem_spec(con=1, slot=1, in_port=1, out_port=1), + ) + connectivity.add_qubit_drive_lines(qubits=qubits) + connectivity.add_qubit_flux_lines(qubits=qubits) + connectivity.add_qubit_pair_flux_lines(qubit_pairs=qubit_pairs) + allocate_wiring(connectivity, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + config = machine.generate_config() + assert isinstance(config, dict) + assert "controllers" in config + assert "elements" in config + assert "pulses" in config + + def test_wiring_structure_opxp_octave(self, instruments, temp_dir): + """Verify wiring dict structure after OPX+/Octave allocation.""" + qubits = [1, 2] + qubit_pairs = [(1, 2)] + connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + + assert "qubits" in machine.wiring + assert "qubit_pairs" in machine.wiring + assert len(machine.wiring["qubits"]) == 2 + + +class TestSuperconductingLFFEMMWFEM: + """LF-FEM + MW-FEM: flux-tunable transmons (mirrors wiring_lffem_mwfem.py).""" + + @pytest.fixture + def instruments(self): + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1, 2]) + instruments.add_lf_fem(controller=1, slots=[3, 5]) + return instruments + + def test_eight_qubit_two_feedlines(self, instruments, temp_dir): + """8-qubit setup with two MW-FEM feedlines and individual drives.""" + qubits = [1, 2, 3, 4, 5, 6, 7, 8] + qubit_pairs = [(qubits[i], qubits[i + 1]) for i in range(len(qubits) - 1)] + + q1to4_res_ch = mw_fem_spec(con=1, slot=1, in_port=1, out_port=1) + q5to8_res_ch = mw_fem_spec(con=1, slot=2, in_port=1, out_port=1) + q1to4_drive_ch = mw_fem_spec(con=1, slot=1, in_port=None, out_port=None) + q5to8_drive_ch = mw_fem_spec(con=1, slot=2, in_port=None, out_port=4) + + connectivity = Connectivity() + connectivity.add_resonator_line(qubits=qubits[:4], constraints=q1to4_res_ch) + connectivity.add_resonator_line(qubits=qubits[4:], constraints=q5to8_res_ch) + connectivity.add_qubit_drive_lines(qubits=qubits[:4], constraints=q1to4_drive_ch) + for qubit in qubits[4:]: + connectivity.add_qubit_drive_lines(qubits=qubit, constraints=q5to8_drive_ch) + allocate_wiring(connectivity, instruments, block_used_channels=False) + connectivity.add_qubit_flux_lines(qubits=qubits) + connectivity.add_qubit_pair_flux_lines(qubit_pairs=qubit_pairs) + allocate_wiring(connectivity, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 8 + assert len(machine.qubit_pairs) == 7 + + for qid, qubit in machine.qubits.items(): + assert qubit.xy is not None, f"{qid} missing xy drive" + assert qubit.resonator is not None, f"{qid} missing resonator" + assert qubit.z is not None, f"{qid} missing flux line" + + def test_four_qubit_mwfem(self, instruments, temp_dir): + """4-qubit setup with single MW-FEM feedline.""" + qubits = [1, 2, 3, 4] + qubit_pairs = [(1, 2), (2, 3), (3, 4)] + + connectivity = Connectivity() + connectivity.add_resonator_line( + qubits=qubits, + constraints=mw_fem_spec(con=1, slot=1, in_port=1, out_port=1), + ) + connectivity.add_qubit_drive_lines(qubits=qubits) + connectivity.add_qubit_flux_lines(qubits=qubits) + connectivity.add_qubit_pair_flux_lines(qubit_pairs=qubit_pairs) + allocate_wiring(connectivity, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 4 + assert len(machine.qubit_pairs) == 3 + + config = machine.generate_config() + assert isinstance(config, dict) + assert "elements" in config + + +class TestSuperconductingLFFEMOctave: + """LF-FEM + Octave (mirrors wiring_lffem_octave.py).""" + + def test_three_qubit_lffem_octave(self, temp_dir): + """3-qubit LF-FEM + Octave with flux lines and tunable couplers.""" + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[1, 2]) + instruments.add_octave(indices=1) + + qubits = [1, 2, 3] + qubit_pairs = [(1, 2), (2, 3)] + + connectivity = Connectivity() + connectivity.add_resonator_line( + qubits=qubits, + constraints=octave_spec(index=1, rf_out=1, rf_in=1), + ) + connectivity.add_qubit_drive_lines(qubits=qubits) + connectivity.add_qubit_flux_lines(qubits=qubits) + connectivity.add_qubit_pair_flux_lines(qubit_pairs=qubit_pairs) + allocate_wiring(connectivity, instruments) + + machine = FluxTunableQuam() + build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = FluxTunableQuam.load(temp_dir) + build_quam_sc(machine, calibration_db_path=temp_dir, save=False) + + assert len(machine.qubits) == 3 + assert len(machine.qubit_pairs) == 2 + + for qid, qubit in machine.qubits.items(): + assert qubit.xy is not None + assert qubit.resonator is not None + assert qubit.z is not None diff --git a/tests/conftest.py b/tests/conftest.py index af1b2bcb..2891b5c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,47 @@ """Test-wide fixtures and helpers.""" -from enum import Enum - -from qualang_tools.wirer.connectivity import wiring_spec - - -# Extend WiringLineType with quantum-dot specific entries when missing in the installed -# qualang_tools version. This keeps the tests compatible with older releases. -_existing_members = {member.name: member.value for member in wiring_spec.WiringLineType} -_extra_members = { - "PLUNGER_GATE": "plunger_gate", - "PLUNGER": "plunger_gate", - "BARRIER_GATE": "barrier_gate", - "BARRIER": "barrier_gate", - "GLOBAL_GATE": "global_gate", - "SENSOR_GATE": "sensor_gate", - "RF_RESONATOR": "rf_resonator", -} - -if any(name not in _existing_members for name in _extra_members): - merged = { - **_existing_members, - **{k: v for k, v in _extra_members.items() if k not in _existing_members}, - } - ExtendedWiringLineType = Enum("WiringLineType", merged) - - wiring_spec.WiringLineType = ExtendedWiringLineType - # Ensure any future imports see the extended enum - import sys - - sys.modules["qualang_tools.wirer.connectivity.wiring_spec"].WiringLineType = ( - ExtendedWiringLineType - ) +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from quam_builder.architecture.quantum_dots.operations import component_macro_catalog +from quam_builder.architecture.quantum_dots.operations import component_pulse_catalog +from quam_builder.architecture.quantum_dots.operations import macro_registry +from quam_builder.architecture.quantum_dots.operations import pulse_registry + +# Make test utilities (test_utils.py) importable from any sub-directory +sys.path.insert(0, str(Path(__file__).parent)) + + +@pytest.fixture(autouse=True) +def bypass_quam_config_version_check(): + """Bypass the QUAM global config version check. + + The QUAM 0.5.x load() path runs quam_version_validator against the user's + ~/.quam/config.json. If that file is from an older QUAM version the check + raises InvalidQuamConfigVersion and aborts. Patching the validator to a + no-op lets tests call QuamBase.load() without requiring a migrated user + config on every developer machine. + """ + with patch("quam.config.resolvers.quam_version_validator"): + yield + + +@pytest.fixture +def reset_catalog(): + """Reset catalog and registry before each test that uses it. + + Use this fixture in any test that directly verifies registration behavior + (e.g., tests that call wire_machine_macros() and then assert macro presence). + + Do NOT use autouse=True — only tests that care about registration state + should pull this in explicitly. Using autouse=True would break tests that + rely on registration completing during component construction. + """ + component_macro_catalog._reset_registration() + component_pulse_catalog._reset_registration() + macro_registry._reset_registry() + pulse_registry._reset_registry() + yield diff --git a/tests/macros/__init__.py b/tests/macros/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/quantum_dots/conftest.py b/tests/macros/conftest.py similarity index 100% rename from tests/quantum_dots/conftest.py rename to tests/macros/conftest.py diff --git a/tests/pylint_plugin/test_pylint_qua_plugin.py b/tests/pylint_plugin/test_pylint_qua_plugin.py index 1e133327..4161bbe3 100644 --- a/tests/pylint_plugin/test_pylint_qua_plugin.py +++ b/tests/pylint_plugin/test_pylint_qua_plugin.py @@ -19,7 +19,6 @@ # Get paths REPO_ROOT = Path(__file__).parent.parent.parent SAMPLE_FILE = Path(__file__).parent / "sample_qua_file.py" -PYLINTRC = REPO_ROOT / ".pylintrc" def run_pylint(file_path: Path, use_plugin: bool = True) -> tuple[int, str, str]: @@ -35,24 +34,30 @@ def run_pylint(file_path: Path, use_plugin: bool = True) -> tuple[int, str, str] """ env = {"PYTHONPATH": str(REPO_ROOT)} + # Both modes use the same base args; the project .pylintrc is NOT used + # because its ignore-patterns would skip files under tests/. + base_args = [ + "--disable=W,I,invalid-name,import-error,no-name-in-module,too-many-locals", + "--enable=E,R,F,C", + "--reports=no", + ] + if use_plugin: - # Run with the plugin via .pylintrc cmd = [ sys.executable, "-m", "pylint", - f"--rcfile={PYLINTRC}", + f"--init-hook=import sys; sys.path.insert(0, r'{REPO_ROOT}')", + "--load-plugins=pylint_qua_plugin", + *base_args, str(file_path), ] else: - # Run without the plugin (skip init-hook and load-plugins) cmd = [ sys.executable, "-m", "pylint", - "--disable=W,I,invalid-name,import-error,no-name-in-module,too-many-locals", - "--enable=E,R,F,C", - "--reports=no", + *base_args, str(file_path), ] @@ -154,6 +159,22 @@ def test_c1805_suppressed_in_qua_helper(self): class TestRegularPythonNotSuppressed: """Test that warnings are NOT suppressed for regular Python code.""" + def test_c1805_not_suppressed_in_regular_python(self): + """ + C1805 should still appear for regular Python code outside QUA contexts. + """ + return_code, stdout, stderr = run_pylint(SAMPLE_FILE, use_plugin=True) + + lines_with_c1805 = get_lines_with_message(stdout, "C1805") + + # regular_python_function is around lines 36-48 + # mixed_function is around lines 145-159 + # These SHOULD have C1805 warnings + + # At minimum, there should be SOME C1805 warnings for regular Python code + # The exact lines may vary, but we should see warnings outside QUA contexts + assert len(lines_with_c1805) > 0, "C1805 should still appear for regular Python code" + def test_c0121_not_suppressed_in_regular_python(self): """ C0121 should still appear for regular Python code outside QUA contexts. diff --git a/tests/quantum_dots/__init__.py b/tests/quantum_dots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/quantum_dots/test_builder_pulses.py b/tests/quantum_dots/test_builder_pulses.py deleted file mode 100644 index 19895fd1..00000000 --- a/tests/quantum_dots/test_builder_pulses.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Unit tests for quantum dot pulse generation.""" - -# pylint: disable=too-few-public-methods - -from unittest.mock import MagicMock - -import pytest -from quam.components import StickyChannelAddon -from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort - -from quam_builder.architecture.quantum_dots.components import ReadoutResonatorSingle, XYDriveSingle -from quam_builder.builder.quantum_dots.pulses import ( - add_default_ldv_qubit_pulses, - add_default_ldv_qubit_pair_pulses, - add_default_resonator_pulses, -) - - -class TestAddDefaultLDVQubitPulses: - """Tests for add_default_ldv_qubit_pulses function.""" - - def test_add_xy_pulses_to_qubit_with_xy(self): - """Test that XY pulses are added when qubit has xy.""" - # Create a mock qubit with xy - qubit = MagicMock() - qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) - qubit.xy.operations = {} - - # Add default pulses - add_default_ldv_qubit_pulses(qubit) - - # Verify that all expected XY pulses were added - expected_pulses = ["x180", "x90", "y180", "y90", "-x90", "-y90"] - for pulse_name in expected_pulses: - assert pulse_name in qubit.xy.operations, f"Pulse {pulse_name} not found" - assert qubit.xy.operations[pulse_name] is not None - - def test_add_readout_pulses_to_qubit_with_resonator(self): - """Test that readout pulses are added when qubit has resonator.""" - qubit = MagicMock() - qubit.resonator = ReadoutResonatorSingle( - id="readout_resonator", - frequency_bare=0.0, - opx_output=LFFEMAnalogOutputPort("con1", 1, port_id=1), - opx_input=LFFEMAnalogInputPort("con1", 1, port_id=2), - sticky=StickyChannelAddon(duration=16, digital=False), - operations={}, - ) - - # Add default pulses - add_default_resonator_pulses(qubit.resonator) - - # Verify readout pulse was added - assert "readout" in qubit.resonator.operations - assert qubit.resonator.operations["readout"] is not None - - def test_add_pulses_to_qubit_with_both_xy_and_resonator(self): - """Test adding pulses when qubit has both xy and resonator.""" - qubit = MagicMock() - qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) - qubit.xy.operations = {} - qubit.resonator = ReadoutResonatorSingle( - id="readout_resonator", - frequency_bare=0.0, - opx_output=LFFEMAnalogOutputPort("con1", 1, port_id=1), - opx_input=LFFEMAnalogInputPort("con1", 1, port_id=2), - sticky=StickyChannelAddon(duration=16, digital=False), - operations={}, - ) - - add_default_ldv_qubit_pulses(qubit) - add_default_resonator_pulses(qubit.resonator) - - # Verify both XY and readout pulses were added - assert len(qubit.xy.operations) == 6 # 6 XY pulses - assert "readout" in qubit.resonator.operations - - def test_no_pulses_added_when_no_channels(self): - """Test that no pulses are added when qubit has no xy or resonator.""" - qubit = MagicMock() - qubit.xy = None - qubit.resonator = None - - # Should not raise an error - add_default_ldv_qubit_pulses(qubit) - - def test_xy_pulse_properties(self): - """Test that XY pulses have correct properties.""" - qubit = MagicMock() - qubit.xy = XYDriveSingle(opx_output="/tmp/opx", id="xy_drive", RF_frequency=100000000) - qubit.xy.operations = {} - - add_default_ldv_qubit_pulses(qubit) - - # Check x180 pulse properties - x180 = qubit.xy.operations["x180"] - assert x180.length == 1000 - assert x180.amplitude == 0.2 - assert x180.axis_angle == pytest.approx(0.0) - - # Check y90 pulse properties - y90 = qubit.xy.operations["y90"] - assert y90.amplitude == 0.1 # Half of x180 - assert y90.axis_angle == pytest.approx(1.5707963, rel=1e-5) # pi/2 - - -class TestAddDefaultLDVQubitPairPulses: - """Tests for add_default_ldv_qubit_pair_pulses function.""" - - def test_qubit_pair_pulse_function_runs_without_error(self): - """Test that the qubit pair pulse function runs (placeholder implementation).""" - qubit_pair = MagicMock() - qubit_pair.z = MagicMock() - - # Should not raise an error even though it's a placeholder - add_default_ldv_qubit_pair_pulses(qubit_pair) From 1d4732f5205218297d48f5c4ac56c111d587d04d Mon Sep 17 00:00:00 2001 From: KU-QM Date: Tue, 19 May 2026 11:24:28 +0100 Subject: [PATCH 6/9] DAC changes --- .../components/quantum_dot_pair.py | 7 +- .../quantum_dots/components/voltage_gate.py | 31 ++- .../quantum_dots/macro_engine/wiring.py | 21 +- .../operations/component_pulse_catalog.py | 17 +- .../quantum_dots/qpu/base_quam_qd.py | 122 +++++------- .../qop_connectivity/build_quam_wiring.py | 15 +- .../builder/qop_connectivity/create_wiring.py | 23 ++- .../qop_connectivity/get_digital_outputs.py | 16 +- .../builder/qop_connectivity/qdac_wiring.py | 112 +++++++++++ .../builder/quantum_dots/build_quam.py | 178 ++++++++++++----- .../builder/quantum_dots/build_utils.py | 65 +++++-- .../voltage_sequence/voltage_sequence.py | 19 ++ .../test_wirer_builder_integration.py | 179 +++++++++++++++++- 13 files changed, 662 insertions(+), 143 deletions(-) create mode 100644 quam_builder/builder/qop_connectivity/qdac_wiring.py diff --git a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py index 2f43395b..aee2c31d 100644 --- a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py +++ b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py @@ -9,6 +9,7 @@ from .barrier_gate import BarrierGate from .mixins import VoltageMacroMixin from .voltage_gate import VoltageGate +from .virtual_gate_set import VirtualGateSet if TYPE_CHECKING: from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD @@ -68,6 +69,10 @@ def physical_channel(self) -> VoltageGate: @property def voltage_sequence(self) -> VoltageSequence: return self.quantum_dots[0].voltage_sequence + + @property + def gate_set(self) -> VirtualGateSet: + return self.voltage_sequence.gate_set @property def machine(self) -> "BaseQuamQD": @@ -91,7 +96,7 @@ def define_detuning_axis( # Ensure that the detuning axis name held in object is consistent self.detuning_axis_name = detuning_axis_name - virtual_gate_set = self.voltage_sequence.gate_set + virtual_gate_set = self.gate_set # Should be the correct virtual axes in the first layer of the VirtualGateSet target_gates = [qd.id for qd in self.quantum_dots] diff --git a/quam_builder/architecture/quantum_dots/components/voltage_gate.py b/quam_builder/architecture/quantum_dots/components/voltage_gate.py index 7235373a..9363e3df 100644 --- a/quam_builder/architecture/quantum_dots/components/voltage_gate.py +++ b/quam_builder/architecture/quantum_dots/components/voltage_gate.py @@ -1,7 +1,8 @@ -from typing import Any, Callable, Optional, Union +from typing import Any, Dict, Generator, Optional, Sequence, Union from quam.components import SingleChannel from quam.core import quam_dataclass +from quam.core.quam_classes import QuamBase from .readout_transport import ANY_READOUT_TRANSPORT from .readout_resonator import ANY_READOUT_RESONATOR @@ -22,6 +23,10 @@ class VoltageGate(SingleChannel): automatically. Default is None. offset_parameter: The optional DC offset of the voltage gate Can be e.g. a QDAC channel. + opx_output: When ``None``, the gate is **QDAC-only** (no OPX/LF analog port). It is omitted from + :meth:`~quam.core.quam_classes.QuamRoot.generate_config` (no ``elements`` entry, no sticky); + use :attr:`dac_spec` / external drivers for DC. + Example: >>> @@ -46,6 +51,7 @@ class VoltageGate(SingleChannel): current_external_voltage: Optional[float] = None dac_spec: DacSpec = None readout: Union[ANY_READOUT_RESONATOR, ANY_READOUT_TRANSPORT] = None + opx_output: Any = None def __post_init__(self): super().__post_init__() @@ -75,3 +81,26 @@ def settle(self): """Wait for the voltage bias to settle""" if self.settling_time is not None: self.wait(int(self.settling_time) // 4 * 4) + + def iterate_components( + self, skip_elems: Optional[Sequence[QuamBase]] = None + ) -> Generator[QuamBase, None, None]: + """QDAC-only gates are excluded from QUA config generation (no OPX element conflicts).""" + if self.opx_output is None: + return + yield from super().iterate_components(skip_elems=skip_elems) + + def apply_to_config(self, config: Dict[str, dict]) -> None: + """Only OPX-backed gates contribute to ``elements``; QDAC-only gates skip via :meth:`iterate_components`.""" + if self.opx_output is None: + return + super().apply_to_config(config) + + def set_dc_offset(self, offset): # noqa: ANN001 — match SingleChannel API + """Raise for QDAC-only gates; OPX has no DC line to offset.""" + if self.opx_output is None: + raise RuntimeError( + "VoltageGate has no OPX analog output (QDAC-only wiring); " + "cannot use set_dc_offset on the OPX. Control DC via dac_spec / external DAC." + ) + super().set_dc_offset(offset) diff --git a/quam_builder/architecture/quantum_dots/macro_engine/wiring.py b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py index d753d92e..ccf67b64 100644 --- a/quam_builder/architecture/quantum_dots/macro_engine/wiring.py +++ b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py @@ -16,6 +16,7 @@ from typing import Any import tomllib +from qm.qua._dsl import amplitude from quam.core.macro import QuamMacro from quam_builder.architecture.quantum_dots.operations.macro_registry import ( @@ -28,11 +29,13 @@ register_default_component_pulse_factories, _make_xy_pulse_factories, _make_readout_pulse, + _make_baseband_pulse, ) from quam_builder.architecture.quantum_dots.macro_engine.overrides import ( ComponentOverrides, _convert_typed_overrides, ) +from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME __all__ = [ "wire_machine_macros", @@ -278,7 +281,23 @@ def _ensure_default_pulses(machine: Any) -> None: if "readout" not in operations: operations["readout"] = _make_readout_pulse() - + physical_channels = getattr(machine, "physical_channels", None) + if isinstance(physical_channels, Mapping): + for physical_channel in physical_channels.values(): + if physical_channel.opx_output is not None: + operations = getattr(physical_channel, "operations", None) + if operations is None: + continue + if DEFAULT_PULSE_NAME not in operations: + output_mode = getattr(physical_channel.opx_output, "output_mode", None) + if output_mode is None: + operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(0.25) + elif output_mode == "direct": + operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(0.25) + elif output_mode == "amplified": + operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(1.25) + else: + raise ValueError("Unknown output mode '{}'".format(output_mode)) def _apply_pulse_overrides( machine: Any, merged_overrides: Mapping[str, Any], diff --git a/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py b/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py index 915b6e73..460d27bd 100644 --- a/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py +++ b/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py @@ -39,13 +39,14 @@ from __future__ import annotations from quam.components.channels import SingleChannel -from quam.components.pulses import SquareReadoutPulse +from quam.components.pulses import SquareReadoutPulse, SquarePulse from quam_builder.architecture.quantum_dots.components.pulses import ScalableGaussianPulse from quam_builder.architecture.quantum_dots.operations.names import DrivePulseName from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( register_component_pulse_factories, ) +from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME, MIN_PULSE_DURATION_NS _REGISTERED = False @@ -120,6 +121,20 @@ def _make_readout_pulse(): amplitude=_READOUT_AMP, ) +def _make_baseband_pulse(amplitude: float): + """Build default baseband pulse for all voltage gates connected to the OPX. + + Args: + amplitude: The baseband pulse amplitude in Volt. + + Returns: + ``SquarePulse`` with id ``DEFAULT_PULSE_NAME``, length MIN_PULSE_DURATION_NS ns, and specified amplitude. + """ + return SquarePulse( + id=DEFAULT_PULSE_NAME, + length=MIN_PULSE_DURATION_NS, + amplitude=amplitude, + ) def register_default_component_pulse_factories() -> None: """Register built-in pulse factories for core quantum-dot component types. diff --git a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py index 72e05d7c..d5a624f3 100644 --- a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py +++ b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py @@ -13,7 +13,6 @@ from dataclasses import field import numpy as np import importlib -import json from qm import QuantumMachinesManager from qm.octave import QmOctaveConfig @@ -65,6 +64,8 @@ class BaseQuamQD(QuamRoot): global_gates (Dict[str, GlobalGate]): Global gate components associated with back gate, reservoirs, or splitter gates. wiring (dict): The wiring configuration. network (dict): The network configuration. + dac_config (dict): Per-DAC driver settings (same structure as passed to :meth:`set_dac_config`), + serialised alongside ``wiring`` and ``network`` in ``wiring.json``. ports (Union[FEMPortsContainer, OPXPlusPortsContainer]): The ports container. _data_handler (ClassVar[DataHandler]): The data handler. qmm (ClassVar[Optional[QuantumMachinesManager]]): The Quantum Machines Manager. @@ -107,7 +108,7 @@ class BaseQuamQD(QuamRoot): mixers: Dict[str, FrequencyConverter] = field(default_factory=dict) wiring: dict = field(default_factory=dict) network: dict = field(default_factory=dict) - + dac_config: Dict[str, Any] = field(default_factory=dict) ports: Union[FEMPortsContainer, OPXPlusPortsContainer] = None qmm: ClassVar[Optional[QuantumMachinesManager]] = None @@ -118,7 +119,13 @@ def get_serialiser(cls) -> JSONSerialiser: This method can be overridden by subclasses to provide a custom serialiser. """ - return JSONSerialiser(content_mapping={"wiring": "wiring.json", "network": "wiring.json"}) + return JSONSerialiser( + content_mapping={ + "wiring": "wiring.json", + "network": "wiring.json", + "dac_config": "wiring.json", + } + ) def get_voltage_sequence(self, gate_set_id: str) -> VoltageSequence: if gate_set_id not in self.voltage_sequences: @@ -165,24 +172,29 @@ def connect_to_external_source( Args: reset_voltages (bool): Whether to reset the voltages of each of the channels to the last-applied voltage, saved in the Quam state. - The dac configuration must be saved in the same directory as the quam state.json file, as a dac_api.json. The format must be as follows: + DAC entries must be set on :attr:`dac_config` (e.g. via :meth:`set_dac_config`) and are + stored in ``wiring.json`` when saving. Example entry: + { "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", "driver_class": "QDac2", "connection": {"visalib": "@py", "address": "TCPIP::172.16.33.101::5025::SOCKET"}, "channel_method": "channel", - "accessor": "dc_constant_V" + "accessor": "dc_constant_V", + "is_qdac": true } """ - if self._dac_config is None: + if not self.dac_config: raise ValueError( - "No DAC configurations found. Please save a directory of DACs with the Quam state." + "No DAC configurations found. Set them with set_dac_config(...) or load from wiring.json." ) dac_instances = self.dacs - for dac_name, config in self._dac_config.items(): + for dac_name, config in self.dac_config.items(): module = importlib.import_module(config["driver_module"]) dac_class = getattr(module, config["driver_class"]) + if dac_name in dac_instances: + dac_instances[dac_name]["driver"].close() dac_instances[dac_name] = { "driver": dac_class(dac_name, **config["connection"]), "channel_method": config["channel_method"], @@ -346,14 +358,13 @@ def register_barrier_gates(self, barrier_channels: List[Channel]) -> None: def wire_voltage_gate_qdac( self, - voltage_gate: VoltageGate, + voltage_gate: Union[VoltageGate, Channel], *, qdac_output_port: int, dac_name: str = "qdac", with_trigger_channel: bool = False, - digital_output_key: str = "qdac_trig_0", + digital_output_key: str = "qdac_trig", qdac_trigger_in: Optional[int] = None, - trigger_pulse_length_ns: int = 100, ) -> None: """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. @@ -370,10 +381,8 @@ def wire_voltage_gate_qdac( with_trigger_channel: If True, move ``digital_output_key`` into a wrapper ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. digital_output_key: Name of the digital output on the gate (default wiring - uses ``qdac_trig_0``). + uses ``qdac_trig``). qdac_trigger_in: Optional QDAC external trigger port. - trigger_pulse_length_ns: Length of the default ``trigger`` pulse on the - wrapper channel when ``with_trigger_channel`` is True. """ if with_trigger_channel: if digital_output_key not in voltage_gate.digital_outputs: @@ -382,24 +391,14 @@ def wire_voltage_gate_qdac( f"{getattr(voltage_gate, 'name', voltage_gate)!r}" ) dig = voltage_gate.digital_outputs[digital_output_key] - digital_ch = Channel( - id=f"qdac_trig_{qdac_output_port}", - digital_outputs={}, - operations={ - "trigger": pulses.Pulse( - length=trigger_pulse_length_ns, digital_marker="ON" - ) - }, - ) + voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, qdac_output_port=qdac_output_port, - opx_trigger_out=digital_ch, + opx_trigger_out=dig.get_reference(), qdac_trigger_in=qdac_trigger_in, ) - del voltage_gate.digital_outputs[digital_output_key] - dig.parent = None - digital_ch.digital_outputs["trigger"] = dig + else: voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, @@ -413,10 +412,9 @@ def apply_qdac_channel_mapping( gate_set_id: str, qdac_mapping: Mapping[str, Mapping[str, Any]], *, - digital_output_key: str = "qdac_trig_0", + digital_output_key: str = "qdac_trig", dac_name: str = "qdac", qdac_trigger_in: Optional[int] = None, - trigger_pulse_length_ns: int = 100, ) -> None: """Apply :meth:`wire_voltage_gate_qdac` to every ``VoltageGate`` listed in ``qdac_mapping``. @@ -445,7 +443,6 @@ def apply_qdac_channel_mapping( with_trigger_channel=bool(entry.get("trigger", False)), digital_output_key=digital_output_key, qdac_trigger_in=qdac_trigger_in, - trigger_pulse_length_ns=trigger_pulse_length_ns, ) def register_quantum_dot_pair( @@ -776,9 +773,12 @@ def initialise(self, qubit_name: str) -> None: except: raise RuntimeError(f"Failed to initialise qubit {qubit_name}") - def connect(self) -> QuantumMachinesManager: + def connect(self, reset_voltages: bool = False, skip_dacs: bool=False) -> QuantumMachinesManager: """Open a Quantum Machine Manager with the credentials ("host" and "cluster_name") as defined in the network file. + Args: + reset_voltages (bool): Whether to reset the voltages of each of the channels to the last-applied voltage, saved in the Quam state. + skip_dacs (bool): Whether to connect to the registered DACs. Returns: QuantumMachinesManager: The opened Quantum Machine Manager. """ @@ -787,9 +787,12 @@ def connect(self) -> QuantumMachinesManager: cluster_name=self.network["cluster_name"], octave=self.get_octave_config(), ) + if "port" in self.network: settings["port"] = self.network["port"] self.qmm = QuantumMachinesManager(**settings) + if self.dac_config and not skip_dacs: + self.connect_to_external_source(reset_voltages) return self.qmm def get_octave_config(self) -> QmOctaveConfig: @@ -850,15 +853,22 @@ def to_dict(self, follow_references=False, include_defaults=False): d.pop("dacs", None) return d - def set_dac_config(self, config: Dict) -> None: - """Set the DAC configuration(s). This will not be serialised as a Quam field""" - if config is None: - object.__setattr__(self, "_dac_config", None) - return + def set_dac_config(self, config: Optional[Dict[str, Any]]) -> None: + """Set DAC driver entries by name. Persisted in ``wiring.json`` (with ``wiring`` / ``network``). + Pass a dict mapping logical DAC names (e.g. ``qdac1``, ``main``) to driver specs. If you + pass a single-driver flat dict (with top-level ``driver_module``), it is wrapped as + ``{"main": config}``. + """ + if not config: + self.dac_config = {} + return if "driver_module" in config: - config = {"main": config} - object.__setattr__(self, "_dac_config", config) + self.dac_config = {"main": dict(config)} + else: + self.dac_config = { + k: dict(v) if isinstance(v, Mapping) else v for k, v in config.items() + } @classmethod def load( @@ -881,27 +891,6 @@ def load( track_integrated_voltage=True ) - # load the dac_api from the same directory too - if not isinstance(filepath_or_dict, dict): - if filepath_or_dict is not None: - state_dir = Path(filepath_or_dict) - if state_dir.is_file(): - state_dir = state_dir.parent - else: - state_path = Path(instance.serialiser._get_state_path()) - state_dir = state_path.parent if state_path.is_file() else state_path - dac_dir = state_dir / "dac" - if dac_dir.is_dir(): - dac_configs = {} - for f in sorted(dac_dir.glob("*.jsonc")): - with open(f) as fh: - dac_configs[f.stem] = json.load(fh) - instance.set_dac_config(dac_configs if dac_configs else None) - else: - instance.set_dac_config(None) - else: - instance.set_dac_config(None) - # We can also update the state_tracker here to hold the value held by QuantumDot.current_voltage. from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros @@ -923,18 +912,3 @@ def save( include_defaults, ignore, ) - - # Save the DAC driver API as a separate JSON file - if getattr(self, "_dac_config", None) is not None: - if path is not None: - state_dir = Path(path) - if state_dir.is_file(): - state_dir = state_dir.parent - else: - state_path = Path(self.serialiser._get_state_path()) - state_dir = state_path.parent if state_path.is_file() else state_path - dac_dir = state_dir / "dac" - dac_dir.mkdir(exist_ok=True) - for name, config in self._dac_config.items(): - with open(dac_dir / f"{name}.jsonc", "w") as f: - json.dump(config, f, indent=2) diff --git a/quam_builder/builder/qop_connectivity/build_quam_wiring.py b/quam_builder/builder/qop_connectivity/build_quam_wiring.py index 2b192b93..1395c81f 100644 --- a/quam_builder/builder/qop_connectivity/build_quam_wiring.py +++ b/quam_builder/builder/qop_connectivity/build_quam_wiring.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, Union +from typing import Any, Dict, Optional, Union from qualang_tools.wirer import Connectivity from quam.components.ports import FEMPortsContainer, OPXPlusPortsContainer @@ -20,6 +20,7 @@ def build_quam_wiring( quam_instance: AnyQuam, port: Optional[int] = None, path: Optional[Union[str, Path]] = None, + dac_config: Optional[Dict[str, Any]] = None, ) -> AnyQuam: """Builds the QUAM wiring configuration and saves the machine setup. @@ -31,6 +32,10 @@ def build_quam_wiring( port (Optional[int]): The port number. Defaults to None. path (Optional[Union[str, Path]]): Directory to save the machine state. Defaults to None (uses the machine's existing save path). + dac_config (Optional[Dict[str, Any]]): Optional map of DAC name → driver spec + (same structure as :meth:`~quam_builder.architecture.quantum_dots.qpu.base_quam_qd.BaseQuamQD.set_dac_config`). + Stored in ``wiring.json`` next to ``wiring`` and ``network`` on quantum-dot roots + that support it. Returns: AnyQuam: The configured QUAM instance. @@ -38,6 +43,10 @@ def build_quam_wiring( machine = quam_instance add_ports_container(connectivity, machine) add_name_and_ip(machine, host_ip, cluster_name, port) + if dac_config is not None: + setter = getattr(machine, "set_dac_config", None) + if callable(setter): + setter(dac_config) machine.wiring = create_wiring(connectivity) machine.save(path) return machine @@ -60,6 +69,10 @@ def add_ports_container(connectivity: Connectivity, machine: AnyQuam): machine.ports = FEMPortsContainer() elif channel.instrument_id in ["opx+"]: machine.ports = OPXPlusPortsContainer() + elif channel.instrument_id == "qdac2" and machine.ports is None: + # No LF/OPX channels on this element: still attach a ports container so QUAM + # machine state is valid (QDAC DC lines use integer wiring keys, not #/ports/...). + machine.ports = OPXPlusPortsContainer() def add_name_and_ip(machine: AnyQuam, host_ip: str, cluster_name: str, port: Union[int, None]): diff --git a/quam_builder/builder/qop_connectivity/create_wiring.py b/quam_builder/builder/qop_connectivity/create_wiring.py index e3dc1706..35df5561 100644 --- a/quam_builder/builder/qop_connectivity/create_wiring.py +++ b/quam_builder/builder/qop_connectivity/create_wiring.py @@ -3,7 +3,11 @@ from qualang_tools.wirer import Connectivity from qualang_tools.wirer.connectivity.element import QubitPairReference, QubitReference from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from qualang_tools.wirer.instruments.instrument_channel import AnyInstrumentChannel +from qualang_tools.wirer.instruments.instrument_channel import ( + AnyInstrumentChannel, + InstrumentChannelQdac2DigitalInput, + InstrumentChannelQdac2Output, +) from quam_builder.builder.qop_connectivity.create_analog_ports import ( create_octave_port, create_mw_fem_port, @@ -14,7 +18,12 @@ create_digital_output_port, ) from quam_builder.builder.qop_connectivity.paths import * - +from quam_builder.builder.qop_connectivity.qdac_wiring import ( + QDAC_OUTPUT_KEY, + QDAC_TRIGGER_KEY, + qdac_output_wiring_entry, + qdac_trigger_wiring_entry, +) def create_wiring(connectivity: Connectivity) -> dict: """Generates a dictionary containing QUAM-compatible JSON references which can be used to generate QUAM `port` objects. @@ -101,6 +110,10 @@ def qubit_wiring( if channel.instrument_id == "external-mixer": key, reference = create_external_mixer_reference(channel, element_id, line_type) qubit_line_wiring[key] = reference + elif isinstance(channel, InstrumentChannelQdac2Output): + qubit_line_wiring[QDAC_OUTPUT_KEY] = qdac_output_wiring_entry(channel) + elif isinstance(channel, InstrumentChannelQdac2DigitalInput): + qubit_line_wiring[QDAC_TRIGGER_KEY] = qdac_trigger_wiring_entry(channel) elif not (channel.signal_type == "digital" and channel.io_type == "input"): key, reference = get_channel_port(channel, channels) qubit_line_wiring[key] = reference @@ -123,7 +136,11 @@ def qubit_pair_wiring(channels: List[AnyInstrumentChannel], element_id: QubitPai "target_qubit": f"{QUBITS_BASE_JSON_PATH}/q{element_id.target_index}", } for channel in channels: - if not (channel.signal_type == "digital" and channel.io_type == "input"): + if isinstance(channel, InstrumentChannelQdac2Output): + qubit_pair_line_wiring[QDAC_OUTPUT_KEY] = qdac_output_wiring_entry(channel) + elif isinstance(channel, InstrumentChannelQdac2DigitalInput): + qubit_pair_line_wiring[QDAC_TRIGGER_KEY] = qdac_trigger_wiring_entry(channel) + elif not (channel.signal_type == "digital" and channel.io_type == "input"): key, reference = get_channel_port(channel, channels) qubit_pair_line_wiring[key] = reference diff --git a/quam_builder/builder/qop_connectivity/get_digital_outputs.py b/quam_builder/builder/qop_connectivity/get_digital_outputs.py index 843f8efd..e152967e 100644 --- a/quam_builder/builder/qop_connectivity/get_digital_outputs.py +++ b/quam_builder/builder/qop_connectivity/get_digital_outputs.py @@ -16,13 +16,21 @@ def get_digital_outputs( dict[str, DigitalOutputChannel]: A dictionary of digital output channels. """ digital_outputs = dict() - for i, item in enumerate([port for port in ports if "digital_output" in port]): + digital_output_ports = [port for port in ports if "digital_output" in port] + num_digital_output_ports = len(digital_output_ports) + + for i, item in enumerate(digital_output_ports): + key = ( + digital_marker_name + if num_digital_output_ports == 1 + else f"{digital_marker_name}_{i}" + ) if digital_marker_name == "octave_switch": if isinstance( DigitalOutputChannel(opx_output=f"{wiring_path}/{item}").opx_output, FEMDigitalOutputPort, ): - digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( + digital_outputs[key] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=14, # 14ns for QOP333 and above buffer=13, # 13ns for QOP333 and above @@ -31,13 +39,13 @@ def get_digital_outputs( DigitalOutputChannel(opx_output=f"{wiring_path}/{item}").opx_output, OPXPlusDigitalOutputPort, ): - digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( + digital_outputs[key] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=57, # 57ns for QOP222 and above buffer=18, # 18ns for QOP222 and above ) else: - digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( + digital_outputs[key] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=0, buffer=0, diff --git a/quam_builder/builder/qop_connectivity/qdac_wiring.py b/quam_builder/builder/qop_connectivity/qdac_wiring.py new file mode 100644 index 00000000..8c5ed39a --- /dev/null +++ b/quam_builder/builder/qop_connectivity/qdac_wiring.py @@ -0,0 +1,112 @@ +"""QDAC-II wiring helpers (quam-builder only; no QUAM fork). + +QUAM resolves any string starting with ``#/`` as a JSON pointer into the Quam root (see +:func:`quam.utils.string_reference.is_reference`). QDAC outputs are not components on the root, +so there is no live ``#/qdac/...`` target to resolve. We therefore store **logical** ids in +``ref`` **without** a ``#/`` prefix (e.g. ``qdac/analog_outputs/qdac1/3``) so traversal of +``machine.wiring`` does not emit missing-reference warnings. Use ``unit_index`` and ``port`` +for programmatic use; ``ref`` is for humans and external tooling. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + +from qualang_tools.wirer.instruments.instrument_channel import ( + InstrumentChannelQdac2DigitalInput, + InstrumentChannelQdac2Output, +) + +__all__ = [ + "QDAC_OUTPUT_KEY", + "QDAC_TRIGGER_KEY", + "qdac_analog_output_ref", + "qdac_digital_input_ref", + "qdac_output_wiring_entry", + "qdac_trigger_wiring_entry", + "extract_qdac_output_port", + "extract_qdac_unit_index", + "extract_qdac_trigger_port", + "extract_qdac_trigger_unit_index", +] + +QDAC_OUTPUT_KEY = "qdac_output" +QDAC_TRIGGER_KEY = "qdac_trigger" + + +def qdac_analog_output_ref(unit_index: int, port: int) -> str: + """Stable logical path for a QDAC DC output (not a QUAM ``#/`` reference).""" + return f"qdac/analog_outputs/qdac{int(unit_index)}/{int(port)}" + + +def qdac_digital_input_ref(unit_index: int, port: int) -> str: + """Stable logical path for a QDAC external trigger input (not a QUAM ``#/`` reference).""" + return f"qdac/digital_inputs/qdac{int(unit_index)}/{int(port)}" + + +def qdac_output_wiring_entry(channel: InstrumentChannelQdac2Output) -> Dict[str, Any]: + u, p = int(channel.con), int(channel.port) + return { + "unit_index": u, + "port": p, + "ref": qdac_analog_output_ref(u, p), + } + + +def qdac_trigger_wiring_entry(channel: InstrumentChannelQdac2DigitalInput) -> Dict[str, Any]: + u, p = int(channel.con), int(channel.port) + return { + "unit_index": u, + "port": p, + "ref": qdac_digital_input_ref(u, p), + } + + +def extract_qdac_output_port(wiring_dict: Mapping[str, Any]) -> Optional[int]: + """Analog output port (1–24) on the QDAC unit, or ``None``.""" + block = wiring_dict.get(QDAC_OUTPUT_KEY) + if isinstance(block, Mapping): + port = block.get("port") + if port is not None: + return int(port) + legacy = wiring_dict.get("qdac_channel") + if isinstance(legacy, int): + return legacy + if isinstance(legacy, Mapping) and legacy.get("port") is not None: + return int(legacy["port"]) + return None + + +def extract_qdac_unit_index(wiring_dict: Mapping[str, Any]) -> Optional[int]: + """Wirer ``con`` / QDAC unit index if present.""" + block = wiring_dict.get(QDAC_OUTPUT_KEY) + if isinstance(block, Mapping): + u = block.get("unit_index") + if u is not None: + return int(u) + legacy = wiring_dict.get("qdac_channel") + if isinstance(legacy, Mapping) and legacy.get("unit_index") is not None: + return int(legacy["unit_index"]) + return None + + +def extract_qdac_trigger_port(wiring_dict: Mapping[str, Any]) -> Optional[int]: + """External trigger input port (1–4), or ``None``.""" + block = wiring_dict.get(QDAC_TRIGGER_KEY) + if isinstance(block, Mapping): + port = block.get("port") + if port is not None: + return int(port) + legacy = wiring_dict.get("qdac_trigger_in") + if isinstance(legacy, int): + return legacy + return None + + +def extract_qdac_trigger_unit_index(wiring_dict: Mapping[str, Any]) -> Optional[int]: + block = wiring_dict.get(QDAC_TRIGGER_KEY) + if isinstance(block, Mapping): + u = block.get("unit_index") + if u is not None: + return int(u) + return None \ No newline at end of file diff --git a/quam_builder/builder/quantum_dots/build_quam.py b/quam_builder/builder/quantum_dots/build_quam.py index 2b1c78dc..6b0a856b 100644 --- a/quam_builder/builder/quantum_dots/build_quam.py +++ b/quam_builder/builder/quantum_dots/build_quam.py @@ -17,6 +17,15 @@ from typing import Optional, Union, Mapping, Any from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam_builder.builder.quantum_dots.build_utils import ( + _extract_qubit_number, + _normalize_element_type, +) +from quam_builder.builder.qop_connectivity.qdac_wiring import ( + extract_qdac_output_port, + extract_qdac_trigger_port, + extract_qdac_unit_index, +) from quam.components import FrequencyConverter, LocalOscillator, Octave from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer from quam_builder.builder.quantum_dots.build_qpu import ( @@ -30,10 +39,104 @@ from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros from quam_builder.architecture.quantum_dots.components import VoltageGate, QdacSpec + +def _to_plain_mapping(obj: Any, max_depth: int = 6) -> Any: + """Recursively unwrap QUAM wiring containers into plain dict/list structures.""" + if max_depth <= 0: + return obj + if obj is None or isinstance(obj, (str, bytes, int, float, bool)): + return obj + if isinstance(obj, Mapping): + return {k: _to_plain_mapping(v, max_depth - 1) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return type(obj)(_to_plain_mapping(x, max_depth - 1) for x in obj) + if hasattr(obj, "get_unreferenced_value") and hasattr(obj, "keys"): + return { + k: _to_plain_mapping(obj.get_unreferenced_value(k), max_depth - 1) + for k in obj.keys() + } + return obj + + +def _dac_entry_from_wiring_plain(plain: Mapping[str, Any]) -> Optional[dict[str, Any]]: + """Build one :func:`add_dacs` entry from flattened port mapping, or ``None`` if no QDAC DC port.""" + ch = extract_qdac_output_port(plain) + if ch is None: + return None + trigger = any("digital_output" in str(k) for k in plain) + qti = extract_qdac_trigger_port(plain) + u = extract_qdac_unit_index(plain) + dac_name = "qdac" if u is None else f"qdac{u}" + return { + "ch": ch, + "trigger": trigger, + "qdac_trigger_in": qti, + "dac_name": dac_name, + } + + +def _dac_mapping_from_wiring(machine: BaseQuamQD) -> dict[str, dict[str, Any]]: + """Mirror :class:`_BaseQpuBuilder` gate ids and barrier ordering from ``machine.wiring``.""" + wiring = machine.wiring or {} + normalized: dict[str, Any] = {} + for element_type_raw, elements in wiring.items(): + et = _normalize_element_type(element_type_raw) + if et: + normalized[et] = elements + + result: dict[str, dict[str, Any]] = {} + + for global_id, wiring_by_line_type in normalized.get("globals", {}).items(): + for _line_type, ports in wiring_by_line_type.items(): + plain = _to_plain_mapping(ports) + if not isinstance(plain, dict): + continue + entry = _dac_entry_from_wiring_plain(plain) + if entry: + result[global_id] = entry + + for sensor_id, wiring_by_line_type in normalized.get("readout", {}).items(): + sensor_gate_id = f"sensor_{_extract_qubit_number(sensor_id)}" + for line_type, ports in wiring_by_line_type.items(): + if line_type != WiringLineType.SENSOR_GATE.value: + continue + plain = _to_plain_mapping(ports) + if not isinstance(plain, dict): + continue + entry = _dac_entry_from_wiring_plain(plain) + if entry: + result[sensor_gate_id] = entry + + for qubit_id, wiring_by_line_type in normalized.get("qubits", {}).items(): + for line_type, ports in wiring_by_line_type.items(): + if line_type != WiringLineType.PLUNGER_GATE.value: + continue + plain = _to_plain_mapping(ports) + if not isinstance(plain, dict): + continue + entry = _dac_entry_from_wiring_plain(plain) + if entry: + result[f"plunger_{_extract_qubit_number(qubit_id)}"] = entry + + barrier_counter = 0 + for _pair_id, wiring_by_line_type in normalized.get("qubit_pairs", {}).items(): + for line_type, ports in wiring_by_line_type.items(): + if line_type != WiringLineType.BARRIER_GATE.value: + continue + plain = _to_plain_mapping(ports) + if not isinstance(plain, dict): + continue + entry = _dac_entry_from_wiring_plain(plain) + if entry: + result[f"barrier_{barrier_counter}"] = entry + barrier_counter += 1 + + return result + + def build_base_quam( machine: BaseQuamQD, calibration_db_path: Optional[Union[Path, str]] = None, - dac_mapping: Optional[dict] = None, connect_qdac: bool = False, macro_profile_path: Optional[Union[Path, str]] = None, component_overrides: Optional[dict] = None, @@ -59,7 +162,6 @@ def build_base_quam( machine: BaseQuamQD instance with wiring defined. calibration_db_path: Path to Octave calibration database. If None, uses the machine's state directory. - dac_mapping: mapping between the voltage gate and the corresponding dac channel {"voltage_gate_name": {"ch": dac_channel_number, "trigger": bool}} connect_qdac: If True, connects to QDAC using qdac_ip or machine.network['qdac_ip']. macro_profile_path: Optional TOML file with macro override definitions. component_overrides: Typed overrides keyed by component class. See @@ -92,8 +194,8 @@ def build_base_quam( _BaseQpuBuilder(machine).build() if getattr(machine, "qpu", None) is None: machine.qpu = QPU() - # Map the connectivity between the DAC channels of the voltage gates - add_dacs(machine, dac_mapping) + # Map QDAC output ports from machine.wiring onto VoltageGate.dac_spec + add_dacs(machine) wire_machine_macros( machine, @@ -229,7 +331,6 @@ def build_quam( machine: Union[BaseQuamQD, LossDiVincenzoQuam], calibration_db_path: Optional[Union[Path, str]] = None, qubit_pair_sensor_map: Optional[dict] = None, - dac_mapping: Optional[dict] = None, connect_qdac: bool = False, macro_profile_path: Optional[Union[Path, str]] = None, component_overrides: Optional[dict] = None, @@ -248,7 +349,6 @@ def build_quam( machine: BaseQuamQD or LossDiVincenzoQuam with wiring defined. calibration_db_path: Path to Octave calibration database. qubit_pair_sensor_map: Sensor mapping for qubit pairs. - dac_mapping: mapping between the voltage gate and the corresponding dac channel {"voltage_gate_name": {"ch": dac_channel_number, "trigger": bool}} connect_qdac: If True, connects to QDAC for external voltage control. macro_profile_path: Optional TOML file with macro override definitions. component_overrides: Typed overrides keyed by component class. See @@ -281,7 +381,6 @@ def build_quam( machine = build_base_quam( machine, calibration_db_path=calibration_db_path, - dac_mapping=dac_mapping, connect_qdac=connect_qdac, macro_profile_path=macro_profile_path, component_overrides=component_overrides, @@ -350,14 +449,15 @@ def add_ports(machine: AnyQuamQD) -> None: Args: machine: QuAM instance with wiring defined. """ + if machine.ports is None: + return for wiring_by_element in machine.wiring.values(): for wiring_by_line_type in wiring_by_element.values(): for ports in wiring_by_line_type.values(): for port in ports: - if "ports" in ports.get_unreferenced_value(port): - machine.ports.reference_to_port( - ports.get_unreferenced_value(port), create=True - ) + raw = ports.get_unreferenced_value(port) + if isinstance(raw, str) and "ports" in raw: + machine.ports.reference_to_port(raw, create=True) def add_qpu(machine: AnyQuamQD, qubit_pair_sensor_map: Optional[dict] = None) -> None: @@ -499,9 +599,8 @@ def _wire_voltage_gate_qdac( qdac_output_port: int, dac_name: str = "qdac", with_trigger_channel: bool = False, - digital_output_key: str = "qdac_trig_0", + digital_output_key: str = "qdac_trig", qdac_trigger_in: Optional[int] = None, - trigger_pulse_length_ns: int = 100, ) -> None: """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. @@ -518,10 +617,8 @@ def _wire_voltage_gate_qdac( with_trigger_channel: If True, move ``digital_output_key`` into a wrapper ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. digital_output_key: Name of the digital output on the gate (default wiring - uses ``qdac_trig_0``). + uses ``qdac_trig``). qdac_trigger_in: Optional QDAC external trigger port. - trigger_pulse_length_ns: Length of the default ``trigger`` pulse on the - wrapper channel when ``with_trigger_channel`` is True. """ if with_trigger_channel: if digital_output_key not in voltage_gate.digital_outputs: @@ -530,24 +627,12 @@ def _wire_voltage_gate_qdac( f"{getattr(voltage_gate, 'name', voltage_gate)!r}" ) dig = voltage_gate.digital_outputs[digital_output_key] - # digital_ch = Channel( - # id=f"qdac_trig_{qdac_output_port}", - # digital_outputs={}, - # operations={ - # "trigger": pulses.Pulse( - # length=trigger_pulse_length_ns, digital_marker="ON" - # ) - # }, - # ) voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, qdac_output_port=qdac_output_port, opx_trigger_out=dig.opx_output.get_reference(), qdac_trigger_in=qdac_trigger_in, ) - # del voltage_gate.digital_outputs[digital_output_key] - # dig.parent = None - # digital_ch.digital_outputs["trigger"] = dig else: voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, @@ -556,40 +641,47 @@ def _wire_voltage_gate_qdac( def add_dacs( machine: BaseQuamQD, - dac_mapping: Mapping[str, Mapping[str, Any]], *, - digital_output_key: str = "qdac_trig_0", + digital_output_key: str = "qdac_trig", dac_name: str = "qdac", qdac_trigger_in: Optional[int] = None, trigger_pulse_length_ns: int = 100, ) -> None: - """Apply :meth:`wire_voltage_gate_qdac` to every ``VoltageGate`` listed in ``qdac_mapping``. - - Keys of ``qdac_mapping`` must match :attr:`VoltageGate.name` (same as the prior - ``quam_config`` loop). Values are dicts with at least ``"ch"`` (QDAC port index, - or ``None`` to skip) and optional ``"trigger"`` (bool, default ``False``). - - Channel entries are resolved via ``virtual_gate_sets[gate_set_id].channels[key]`` - so ``#/`` references are followed while the gate set is already under this root. """ + Attach ``QdacSpec`` to gates using QDAC ports from ``machine.wiring``. + + Uses :func:`_dac_mapping_from_wiring`. No-op when wiring defines no QDAC DC outputs. + """ + by_gate = _dac_mapping_from_wiring(machine) + if not by_gate: + return for vgs_key in machine.virtual_gate_sets.keys(): vgs = machine.virtual_gate_sets[vgs_key] for channel_name in list(vgs.channels.keys()): gate = vgs.channels[channel_name] if not isinstance(gate, VoltageGate): continue - if gate.name not in dac_mapping: + entry = None + for cand in ( + channel_name, + getattr(gate, "id", None), + getattr(gate, "name", None), + ): + if cand and cand in by_gate: + entry = by_gate[cand] + break + if entry is None: continue - entry = dac_mapping[gate.name] ch_nb = entry.get("ch") if ch_nb is None: continue + entry_dac = entry.get("dac_name", dac_name) + entry_qti = entry.get("qdac_trigger_in", qdac_trigger_in) _wire_voltage_gate_qdac( gate, qdac_output_port=ch_nb, - dac_name=dac_name, + dac_name=entry_dac, with_trigger_channel=bool(entry.get("trigger", False)), digital_output_key=digital_output_key, - qdac_trigger_in=qdac_trigger_in, - trigger_pulse_length_ns=trigger_pulse_length_ns, + qdac_trigger_in=entry_qti, ) diff --git a/quam_builder/builder/quantum_dots/build_utils.py b/quam_builder/builder/quantum_dots/build_utils.py index 01898ea5..6b4a3f1b 100644 --- a/quam_builder/builder/quantum_dots/build_utils.py +++ b/quam_builder/builder/quantum_dots/build_utils.py @@ -317,43 +317,84 @@ def _ensure_q_prefix(qubit_token: str) -> str: return control, target -def _extract_qdac_channel(wiring_dict: Dict[str, Any]) -> int | None: - """Extract QDAC channel number from wiring configuration. +_OPX_ANALOG_WIRING_KEYS = frozenset({"opx_output", "opx_output_I", "opx_output_Q"}) + + +def _wiring_has_opx_analog_output(ports: Any) -> bool: + """True if the line wiring includes an OPX/LF analog port reference (not QDAC-only).""" + if not ports or not hasattr(ports, "keys"): + return False + for key in ports.keys(): + if key not in _OPX_ANALOG_WIRING_KEYS: + continue + if hasattr(ports, "get_unreferenced_value"): + raw = ports.get_unreferenced_value(key) + elif hasattr(ports, "get"): + raw = ports.get(key) + else: + raw = ports[key] + if raw is not None and raw != "": + return True + return False - Args: - wiring_dict: Wiring dictionary that may contain 'qdac_channel' key. - Returns: - QDAC channel number if present, None otherwise. +def _extract_qdac_channel(wiring_dict: Dict[str, Any]) -> int | None: + """Extract QDAC DC output port index from wiring (legacy int or structured block). + + Prefer the structured ``qdac_output`` dict from :mod:`quam_builder.builder.qop_connectivity.qdac_wiring`; + fall back to legacy ``qdac_channel`` integer. """ - return wiring_dict.get("qdac_channel") + from quam_builder.builder.qop_connectivity.qdac_wiring import extract_qdac_output_port + + return extract_qdac_output_port(wiring_dict) def _make_voltage_gate_with_qdac( - gate_id: str, wiring_path: str, ports: Dict[str, str], qdac_channel: int | None = None + gate_id: str, wiring_path: str, ports: Mapping[str, Any], qdac_channel: int | None = None ) -> VoltageGate: """Create a voltage gate component with sticky channel and optional QDAC mapping. Args: gate_id: Identifier for the gate. wiring_path: JSON path to wiring configuration. - ports (Dict[str, str]): A dictionary mapping port names to their respective configurations. + ports: Port mapping for this line (OPX refs, ``qdac_output``, digitals, etc.). qdac_channel: Optional QDAC channel number for external voltage control. Returns: Configured VoltageGate instance with QDAC channel if provided. + + When wiring is **QDAC-only** (no OPX/LF analog port keys), :attr:`VoltageGate.opx_output` + is set to ``None`` so QUAM does not resolve a missing ``#/wiring/.../opx_output`` path. + Sticky is omitted so :meth:`~quam.core.quam_classes.QuamRoot.generate_config` does not touch OPX. """ digital_outputs = get_digital_outputs(wiring_path, ports, "qdac_trig") + opx_out = f"{wiring_path}/opx_output" if _wiring_has_opx_analog_output(ports) else None + sticky = _make_sticky_channel() if opx_out is not None else None gate = VoltageGate( id=gate_id, - opx_output=f"{wiring_path}/opx_output", - sticky=_make_sticky_channel(), + opx_output=opx_out, + sticky=sticky, digital_outputs=digital_outputs, ) if qdac_channel is not None: gate.qdac_channel = qdac_channel + from quam_builder.builder.qop_connectivity.qdac_wiring import ( + extract_qdac_trigger_port, + extract_qdac_trigger_unit_index, + extract_qdac_unit_index, + ) + + qdac_unit = extract_qdac_unit_index(ports) + if qdac_unit is not None: + gate.qdac_unit_index = qdac_unit + qdac_trigger_in = extract_qdac_trigger_port(ports) + if qdac_trigger_in is not None: + gate.qdac_trigger_in = qdac_trigger_in + qdac_trig_unit = extract_qdac_trigger_unit_index(ports) + if qdac_trig_unit is not None: + gate.qdac_trigger_unit_index = qdac_trig_unit return gate @@ -481,4 +522,4 @@ def _create_xy_drive_from_wiring( "_implicit_qubit_to_dot_mapping", "_create_xy_drive_from_wiring", ] -# pylint: enable=undefined-all-variable +# pylint: enable=undefined-all-variable \ No newline at end of file diff --git a/quam_builder/tools/voltage_sequence/voltage_sequence.py b/quam_builder/tools/voltage_sequence/voltage_sequence.py index 75a56018..b1ccf1cc 100644 --- a/quam_builder/tools/voltage_sequence/voltage_sequence.py +++ b/quam_builder/tools/voltage_sequence/voltage_sequence.py @@ -21,6 +21,7 @@ ) from quam.components.channels import SingleChannel +from quam.components.pulses import SquarePulse from quam_builder.architecture.quantum_dots.components.gate_set import ( GateSet, @@ -122,6 +123,7 @@ def __init__( """ self.gate_set: GateSet = gate_set + self._update_baseband_pulse_amplitude() self.state_trackers: Dict[str, SequenceStateTracker] = { ch_name: SequenceStateTracker( ch_name, @@ -140,6 +142,23 @@ def __init__( self._batched_voltages = None self._prog_id = None + def _update_baseband_pulse_amplitude(self): + for ch in self.gate_set.channels.values(): + if ch.opx_output is not None: + if DEFAULT_PULSE_NAME not in ch.operations: + ch.operations[DEFAULT_PULSE_NAME] = SquarePulse( + id=DEFAULT_PULSE_NAME, + length=MIN_PULSE_DURATION_NS, + amplitude=0.25, + ) + output_mode = getattr(ch.opx_output, "output_mode", None) + if output_mode is None: + ch.operations[DEFAULT_PULSE_NAME].amplitude = 0.25 + elif output_mode == "direct": + ch.operations[DEFAULT_PULSE_NAME].amplitude = 0.25 + elif output_mode == "amplified": + ch.operations[DEFAULT_PULSE_NAME].amplitude = 1.25 + def _initialise_attenuation_qua_vars(self) -> None: """Lazy initiation of QUA variables that runs only at the start of the QUA program.""" current_program_scope = id(scopes_manager.program_scope) diff --git a/tests/builder/quantum_dots/test_wirer_builder_integration.py b/tests/builder/quantum_dots/test_wirer_builder_integration.py index aeb1ad05..a4f73027 100644 --- a/tests/builder/quantum_dots/test_wirer_builder_integration.py +++ b/tests/builder/quantum_dots/test_wirer_builder_integration.py @@ -2,13 +2,20 @@ # pylint: disable=too-few-public-methods +import json import shutil import tempfile from pathlib import Path import pytest -from qualang_tools.wirer import Instruments, Connectivity, allocate_wiring +from qualang_tools.wirer import ( + Instruments, + Connectivity, + allocate_wiring, + lf_fem_spec, + qdac2_spec, +) from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam_builder.builder.qop_connectivity import build_quam_wiring from quam_builder.builder.quantum_dots import ( @@ -123,6 +130,174 @@ def test_complete_workflow_basic_setup(self, instruments, temp_dir): assert len(machine.sensor_dots) > 0 assert len(machine.quantum_dots) > 0 + def test_build_quam_wiring_lf_fem_and_qdac2_dual_voltage_gate(self, temp_dir): + """Wiring dict includes LF analog ref and QDAC2 channel index from wirer allocation.""" + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[1]) + instruments.add_qdac2(indices=1) + connectivity = Connectivity() + connectivity.add_voltage_gate_lines( + [1], + triggered=False, + name="vg", + constraints=lf_fem_spec(con=1, out_slot=1) & qdac2_spec(index=1), + ) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + + gate_wiring = machine.wiring["globals"]["vg1"][WiringLineType.GLOBAL_GATE.value] + assert "opx_output" in gate_wiring + qo = gate_wiring["qdac_output"] + assert qo["unit_index"] == 1 and qo["port"] == 1 + assert qo["ref"] == "qdac/analog_outputs/qdac1/1" + + def test_dac_config_round_trip_in_wiring_json(self, temp_dir): + """DAC driver map is stored under ``dac_config`` in ``wiring.json``.""" + dac_cfg = { + "qdac1": { + "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", + "driver_class": "QDac2", + "connection": {"visalib": "@py", "address": "TCPIP::127.0.0.1::5025::SOCKET"}, + "channel_method": "channel", + "accessor": "dc_constant_V", + "is_qdac": True, + }, + "qdac2": { + "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", + "driver_class": "QDac2", + "connection": {"visalib": "@py", "address": "TCPIP::127.0.0.2::5025::SOCKET"}, + "channel_method": "channel", + "accessor": "dc_constant_V", + "is_qdac": True, + }, + } + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[1]) + connectivity = Connectivity() + connectivity.add_quantum_dots(quantum_dots=[1], add_drive_lines=False) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + dac_config=dac_cfg, + ) + + wiring_files = list(Path(temp_dir).rglob("wiring.json")) + assert wiring_files, "expected wiring.json under save path" + on_disk = json.loads(wiring_files[0].read_text(encoding="utf-8")) + assert on_disk.get("dac_config") == dac_cfg + assert on_disk.get("network", {}).get("host") == "127.0.0.1" + + def test_qdac_spec_from_wiring(self, temp_dir): + """Stage 1 attaches QdacSpec from ``machine.wiring``.""" + instruments = Instruments() + instruments.add_lf_fem(controller=1, slots=[1]) + instruments.add_qdac2(indices=1) + connectivity = Connectivity() + connectivity.add_voltage_gate_lines( + [1], + triggered=False, + name="vg", + constraints=lf_fem_spec(con=1, out_slot=1) & qdac2_spec(index=1), + ) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + build_base_quam( + machine, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + vgs = machine.virtual_gate_sets["main_qpu"] + gate = vgs.channels["vg1"] + assert gate.dac_spec is not None + assert gate.dac_spec.qdac_output_port == 1 + assert gate.dac_spec.dac_name == "qdac1" + + def test_make_voltage_gate_qdac_only_skips_opx_output_ref(self): + """QDAC-only wiring must not set a dangling ``#/wiring/.../opx_output`` reference.""" + from quam_builder.builder.quantum_dots.build_utils import _make_voltage_gate_with_qdac + + ports = { + "qdac_output": {"unit_index": 1, "port": 10, "ref": "qdac/analog_outputs/qdac1/10"}, + } + gate = _make_voltage_gate_with_qdac( + "source", "#/wiring/globals/source/g", ports, qdac_channel=10 + ) + assert gate.opx_output is None + assert gate.qdac_channel == 10 + assert gate.sticky is None + + def test_make_voltage_gate_lf_plus_qdac_keeps_opx_output_ref(self): + from quam_builder.builder.quantum_dots.build_utils import _make_voltage_gate_with_qdac + + ports = { + "opx_output": "#/ports/analog_outputs/con1/1/1/1", + "qdac_output": {"unit_index": 1, "port": 3, "ref": "qdac/analog_outputs/qdac1/3"}, + } + gate = _make_voltage_gate_with_qdac( + "vg1", "#/wiring/globals/vg1/g", ports, qdac_channel=3 + ) + assert gate.opx_output == "#/wiring/globals/vg1/g/opx_output" + + def test_global_voltage_gate_qdac_only_stage1(self, temp_dir): + """Global gate with only QDAC2 channels: Stage 1 builds a gate without OPX analog ref.""" + instruments = Instruments() + instruments.add_qdac2(indices=1) + connectivity = Connectivity() + connectivity.add_voltage_gate_lines( + "source", + name="", + triggered=False, + constraints=qdac2_spec(index=1, out_port=10), + ) + allocate_wiring(connectivity, instruments) + + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, + host_ip="127.0.0.1", + cluster_name="test_cluster", + quam_instance=machine, + path=temp_dir, + ) + build_base_quam( + machine, + calibration_db_path=temp_dir, + connect_qdac=False, + save=False, + ) + + vgs = machine.virtual_gate_sets["main_qpu"] + gate = vgs.channels["source"] + assert gate.opx_output is None + assert gate.qdac_channel == 10 + assert gate.sticky is None + qua_cfg = machine.generate_config() + assert "source" not in qua_cfg.get("elements", {}) + def test_example_two_stage_workflow(self, temp_dir): """Exercise the two-stage flow used in wiring_example.""" qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} @@ -557,4 +732,4 @@ def test_allocate_wiring_creates_channels(self): for element in connectivity.elements.values(): assert len(element.channels) > 0 for channel_list in element.channels.values(): - assert len(channel_list) > 0 + assert len(channel_list) > 0 \ No newline at end of file From d39f733a07edc49ce243010977989a1ef947ea2d Mon Sep 17 00:00:00 2001 From: KU-QM Date: Tue, 19 May 2026 13:26:57 +0100 Subject: [PATCH 7/9] Create VirtualDCSet from all channels, VirtualGateSet takes OPX only --- pyproject.toml | 2 +- .../quantum_dots/components/virtual_dc_set.py | 7 +- .../quantum_dots/qpu/base_quam_qd.py | 64 +++++++++++++------ .../builder/quantum_dots/build_quam.py | 24 +++++++ 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53b036a8..21c4e62b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [{ name = "Theo Laudat", email = "theo@quantum-machines.co" }] requires-python = ">=3.9,<=3.13" dependencies = [ - "qualang-tools>=0.22.0", + "qualang-tools @ git+https://github.com/qua-platform/py-qua-tools.git@feature/qdac_wiring", "quam>=0.4.0", "qm-qua>=1.2.2", "xarray>=2024.7.0", diff --git a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py index 8cabcd5d..01cbdb80 100644 --- a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py +++ b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py @@ -93,7 +93,12 @@ def name(self): def current_physical_voltages(self) -> Dict[str, float]: """Query, update and return the current dict of all physical voltages""" for name, channel in self.channels.items(): - self._current_physical_voltages[name] = channel.offset_parameter() + if callable(channel.offset_parameter): + self._current_physical_voltages[name] = channel.offset_parameter() + elif getattr(channel, "current_external_voltage", None) is not None: + self._current_physical_voltages[name] = channel.current_external_voltage + else: + self._current_physical_voltages[name] = 0.0 return self._current_physical_voltages def _populate_virtual_gate_voltages(self, physical_voltages_dict): diff --git a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py index d5a624f3..90cea689 100644 --- a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py +++ b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py @@ -267,7 +267,12 @@ def register_global_gates( if isinstance(global_channels, VoltageGate): global_channels = [global_channels] for ch in global_channels: - virtual_name = self._get_virtual_name(ch) + try: + virtual_name = self._get_virtual_name(ch) + except ValueError: + # Channels intentionally excluded from VirtualGateSet (e.g. QDAC-only) + # are still valid global gates and are keyed by their physical id. + virtual_name = ch.id global_gate = GlobalGate( id=virtual_name, physical_channel=ch.get_reference(), @@ -561,6 +566,18 @@ def create_virtual_gate_set( if gate_set_id is None: gate_set_id = f"virtual_gate_set_{len(self.virtual_gate_sets.keys())}" + # Always register channels under the machine, even when excluded from VGS. + for ch in virtual_channel_mapping.values(): + if ch not in list(self.physical_channels.values()): + self.physical_channels[ch.id] = ch + + # VirtualGateSet should contain only OPX-backed channels. + virtual_channel_mapping = { + virtual_name: ch + for virtual_name, ch in virtual_channel_mapping.items() + if getattr(ch, "opx_output", None) is not None + } + virtual_gate_names, physical_gate_names = [], [] channel_mapping = {} for virtual_name, ch in virtual_channel_mapping.items(): @@ -571,10 +588,6 @@ def create_virtual_gate_set( physical_name = ch.id physical_gate_names.append(physical_name) - # Add the channel to self.physical_channels if it does not already exist - if ch not in list(self.physical_channels.values()): - self.physical_channels[ch.id] = ch - # Add to the channel mapping, which (for the VirtualGateSet) maps the physical channel names to the physical channel objects channel_mapping[physical_name] = ch.get_reference() @@ -608,6 +621,7 @@ def create_virtual_dc_set( self, gate_set_id: str, matrix: List[List[float]] = None, + include_all_dac_voltage_gates: bool = True, ) -> None: """ Method to create a VirtualDCSet, using the same structure as the VirtualGateSet. @@ -620,13 +634,26 @@ def create_virtual_dc_set( ) vgs = self.virtual_gate_sets[gate_set_id] - channel_mapping = {name: ch.get_reference() for name, ch in vgs.channels.items()} + # VDS is for external DC control: include only DAC-backed VoltageGates. + source_channels = self.physical_channels + if not include_all_dac_voltage_gates: + source_channels = {name: ch for name, ch in vgs.channels.items()} - virtual_names = list(vgs.layers[0].source_gates) - physical_names = list(vgs.layers[0].target_gates) - gate_set_matrix = [ - row[:] for row in vgs.layers[0].matrix - ] # Copy to avoid any mutability issues, just incase + channel_mapping = {} + for physical_name, ch in source_channels.items(): + if not isinstance(ch, VoltageGate): + continue + if getattr(ch, "dac_spec", None) is None: + continue + channel_mapping[physical_name] = ch.get_reference() + + # Keep VirtualDCSet compensation layer synced to the first VGS layer + # for the subset of physical targets present in the DC set. + layer = vgs.layers[0] + indices = [i for i, name in enumerate(layer.target_gates) if name in channel_mapping] + virtual_names = [layer.source_gates[i] for i in indices] + physical_names = [layer.target_gates[i] for i in indices] + gate_set_matrix = [[layer.matrix[i][j] for j in indices] for i in indices] allow_rectangular_matrices = vgs.allow_rectangular_matrices @@ -635,13 +662,14 @@ def create_virtual_dc_set( channels=channel_mapping, allow_rectangular_matrices=allow_rectangular_matrices, ) - self.virtual_dc_sets[gate_set_id].add_to_layer( - layer_id="compensation_layer", - source_gates=virtual_names, - target_gates=physical_names, - matrix=gate_set_matrix, - ) - if matrix: + if virtual_names and physical_names: + self.virtual_dc_sets[gate_set_id].add_to_layer( + layer_id="compensation_layer", + source_gates=virtual_names, + target_gates=physical_names, + matrix=gate_set_matrix, + ) + if matrix and self.virtual_dc_sets[gate_set_id].layers: self.virtual_dc_sets[gate_set_id].layers[0].matrix = matrix def update_cross_compensation_submatrix( diff --git a/quam_builder/builder/quantum_dots/build_quam.py b/quam_builder/builder/quantum_dots/build_quam.py index 6b0a856b..7392c265 100644 --- a/quam_builder/builder/quantum_dots/build_quam.py +++ b/quam_builder/builder/quantum_dots/build_quam.py @@ -196,6 +196,30 @@ def build_base_quam( machine.qpu = QPU() # Map QDAC output ports from machine.wiring onto VoltageGate.dac_spec add_dacs(machine) + # Minimal fill-in for QDAC-only gates excluded from VirtualGateSet. + for ch in machine.physical_channels.values(): + if not isinstance(ch, VoltageGate): + continue + if getattr(ch, "dac_spec", None) is not None: + continue + qdac_ch = getattr(ch, "qdac_channel", None) + if qdac_ch is None: + continue + qdac_unit = getattr(ch, "qdac_unit_index", None) + qdac_name = "qdac" if qdac_unit is None else f"qdac{qdac_unit}" + ch.dac_spec = QdacSpec( + dac_name=qdac_name, + qdac_output_port=qdac_ch, + qdac_trigger_in=getattr(ch, "qdac_trigger_in", None), + ) + # Keep an always-available DC control layer in sync with OPX virtual gates. + has_dac_voltage_gate = any( + isinstance(ch, VoltageGate) and getattr(ch, "dac_spec", None) is not None + for ch in machine.physical_channels.values() + ) + if has_dac_voltage_gate: + for gate_set_id in machine.virtual_gate_sets.keys(): + machine.create_virtual_dc_set(gate_set_id=gate_set_id, include_all_dac_voltage_gates=True) wire_machine_macros( machine, From 4286610ccb579963b47e2b512e3a0b146bde2488 Mon Sep 17 00:00:00 2001 From: KU-QM Date: Tue, 19 May 2026 17:30:32 +0100 Subject: [PATCH 8/9] digital channel handling --- .../quantum_dots/components/virtual_dc_set.py | 3 +- .../quantum_dots/qpu/base_quam_qd.py | 7 +- .../builder/quantum_dots/build_quam.py | 9 +- .../voltage_sequence/voltage_sequence.py | 95 ++++++++++++++----- 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py index 01cbdb80..977d7908 100644 --- a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py +++ b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py @@ -132,8 +132,9 @@ def valid_channel_names(self) -> list[str]: # Combine physical and virtual gate names return list(self.channels) + list(virtual_channels) - def add_point(self, name: str, voltages: Dict[str, float], duration: int) -> None: + def add_point(self, name: str, voltages: Dict[str, float]) -> None: invalid_channel_names = set(voltages) - set(self.valid_channel_names) + duration = 16 # Will not be used mid-qua programme. Placeholder value if invalid_channel_names: raise ValueError( diff --git a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py index 90cea689..974525de 100644 --- a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py +++ b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py @@ -396,11 +396,16 @@ def wire_voltage_gate_qdac( f"{getattr(voltage_gate, 'name', voltage_gate)!r}" ) dig = voltage_gate.digital_outputs[digital_output_key] + trigger_channel = Channel( + id=f"{getattr(voltage_gate, 'id', 'voltage_gate')}_qdac_trigger", + digital_outputs={"trigger": dig.get_reference()}, + operations={"trigger": pulses.Pulse(length=100, digital_marker="ON")}, + ) voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, qdac_output_port=qdac_output_port, - opx_trigger_out=dig.get_reference(), + opx_trigger_out=trigger_channel, qdac_trigger_in=qdac_trigger_in, ) diff --git a/quam_builder/builder/quantum_dots/build_quam.py b/quam_builder/builder/quantum_dots/build_quam.py index 7392c265..a541d5ac 100644 --- a/quam_builder/builder/quantum_dots/build_quam.py +++ b/quam_builder/builder/quantum_dots/build_quam.py @@ -26,7 +26,7 @@ extract_qdac_trigger_port, extract_qdac_unit_index, ) -from quam.components import FrequencyConverter, LocalOscillator, Octave +from quam.components import Channel, FrequencyConverter, LocalOscillator, Octave, pulses from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer from quam_builder.builder.quantum_dots.build_qpu import ( _QpuBuilder, @@ -651,10 +651,15 @@ def _wire_voltage_gate_qdac( f"{getattr(voltage_gate, 'name', voltage_gate)!r}" ) dig = voltage_gate.digital_outputs[digital_output_key] + trigger_channel = Channel( + id=f"{getattr(voltage_gate, 'id', 'voltage_gate')}_qdac_trigger", + digital_outputs={"trigger": dig.get_reference()}, + operations={"trigger": pulses.Pulse(length=100, digital_marker="ON")}, + ) voltage_gate.dac_spec = QdacSpec( dac_name=dac_name, qdac_output_port=qdac_output_port, - opx_trigger_out=dig.opx_output.get_reference(), + opx_trigger_out=trigger_channel, qdac_trigger_in=qdac_trigger_in, ) else: diff --git a/quam_builder/tools/voltage_sequence/voltage_sequence.py b/quam_builder/tools/voltage_sequence/voltage_sequence.py index b1ccf1cc..fb122d63 100644 --- a/quam_builder/tools/voltage_sequence/voltage_sequence.py +++ b/quam_builder/tools/voltage_sequence/voltage_sequence.py @@ -135,6 +135,20 @@ def __init__( self._temp_qua_vars: Dict[str, QuaVariable] = {} # For ramp_rate etc. self._track_integrated_voltage: bool = track_integrated_voltage self._keep_levels: bool = keep_levels + self._channel_max_voltage: Dict[str, float] = {} + + for ch_name, channel_obj in self.gate_set.channels.items(): + opx_voltage_limit = ( + 2.5 + if hasattr(channel_obj.opx_output, "output_mode") + and channel_obj.opx_output.output_mode == "amplified" + else 0.5 + ) + if self.gate_set.adjust_for_attenuation and hasattr(channel_obj, "attenuation"): + attenuation_scale = 10 ** (channel_obj.attenuation / 20) + self._channel_max_voltage[ch_name] = opx_voltage_limit / attenuation_scale + else: + self._channel_max_voltage[ch_name] = opx_voltage_limit if self._keep_levels: self._keep_levels_tracker = KeepLevels(self.gate_set) @@ -232,9 +246,12 @@ def _play_step_on_channel( ): """Plays a scaled step on a single channel.""" DEFAULT_WF_AMPLITUDE = channel.operations[DEFAULT_PULSE_NAME].amplitude - DEFAULT_AMPLITUDE_BITSHIFT = int(np.log2(1 / DEFAULT_WF_AMPLITUDE)) + MIN_PULSE_DURATION_NS = channel.operations[DEFAULT_PULSE_NAME].length + inv_wf_amplitude = float(np.round(1.0 / DEFAULT_WF_AMPLITUDE, 10)) + log2_inv_wf = np.log2(1.0 / DEFAULT_WF_AMPLITUDE) + if self.gate_set.adjust_for_attenuation: delta_v = self._adjust_for_attenuation(channel, delta_v) @@ -246,9 +263,14 @@ def _play_step_on_channel( return if is_qua_type(delta_v): - scaled_amp = delta_v << DEFAULT_AMPLITUDE_BITSHIFT + # Bit-shift only matches scaling when 1/DEFAULT_WF_AMPLITUDE is a power of two + # (e.g. 0.25 V baseband). Amplified LF-FEM uses 1.25 V → use explicit multiply. + if log2_inv_wf == int(log2_inv_wf) and int(log2_inv_wf) >= 0: + scaled_amp = delta_v << int(log2_inv_wf) + else: + scaled_amp = delta_v * inv_wf_amplitude else: - scaled_amp = np.round(delta_v * (1.0 / (DEFAULT_WF_AMPLITUDE)), 10) + scaled_amp = np.round(delta_v * inv_wf_amplitude, 10) duration_cycles = duration >> 2 # Convert ns to clock cycles if is_qua_type(duration): @@ -732,21 +754,14 @@ def apply_compensation_pulse( self._common_voltages_change(target_voltages_dict=zero_dict, duration=16) for ch_name, channel_obj in self.gate_set.channels.items(): - DEFAULT_WF_AMPLITUDE = channel_obj.operations[DEFAULT_PULSE_NAME].amplitude - DEFAULT_AMPLITUDE_BITSHIFT = int(np.log2(1 / DEFAULT_WF_AMPLITUDE)) - opx_voltage_limit = ( - 2.5 - if hasattr(channel_obj.opx_output, "output_mode") - and channel_obj.opx_output.output_mode == "amplified" - else 0.5 - ) - - if self.gate_set.adjust_for_attenuation and hasattr(channel_obj, "attenuation"): - attenuation_scale = 10 ** (channel_obj.attenuation / 20) - if max_voltage * attenuation_scale > opx_voltage_limit: - raise ValueError( - f"Channel '{ch_name}' attenuation-corrected max_voltage of {max_voltage * attenuation_scale:.2f} exceeds OPX output limit of {opx_voltage_limit}" - ) + channel_limit = self._channel_max_voltage[ch_name] + channel_max_voltage = min(max_voltage, channel_limit) + if max_voltage > channel_limit: + print( + f"Channel '{ch_name}': supplied max_voltage ({max_voltage:.4f}) exceeds channel max_voltage " + f"({channel_limit:.4f}). Using reduced max_voltage of {channel_max_voltage:.4f}." + ) + tracker = self.state_trackers[ch_name] current_v = tracker.current_level @@ -754,7 +769,7 @@ def apply_compensation_pulse( if not is_qua_type(tracker.integrated_voltage) and not is_qua_type(current_v): py_comp_amp, py_comp_dur = self._calculate_python_compensation_params( - tracker, max_voltage + tracker, channel_max_voltage ) if py_comp_dur == 0: # No pulse needed tracker.current_level = py_comp_amp # Should be 0.0 @@ -769,7 +784,7 @@ def apply_compensation_pulse( comp_amp_val, comp_dur_val = py_comp_amp, py_comp_dur else: q_comp_amp, q_comp_dur_4ns = self._calculate_qua_compensation_params( - tracker, max_voltage, channel_obj.name + tracker, channel_max_voltage, channel_obj.name ) delta_v_q = q_comp_amp - current_v with if_(q_comp_dur_4ns > 0): @@ -853,7 +868,9 @@ def ramp_to_zero( Args: ramp_duration: Optional. The duration (ns) of the ramp to zero. - If None, QUA's `ramp_to_zero` command is used for an immediate ramp. + If None, uses QUA's ``ramp_to_zero`` on each element when + ``adjust_for_attenuation`` is off; when it is on, uses an explicit + ramp so OPX scaling matches ``step_to_voltages`` / ``ramp_to_voltages``. Must be >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. reset_tracker: Optional. Reset integrated voltage tracking @@ -873,12 +890,38 @@ def ramp_to_zero( """ if ramp_duration is None: - for ch_name, channel_obj in self.gate_set.channels.items(): - tracker = self.state_trackers[ch_name] - ramp_to_zero(channel_obj.name) - tracker.update_integrated_voltage( - level=0.0, duration=0, ramp_duration=channel_obj.sticky.duration + if self.gate_set.adjust_for_attenuation: + # QUA ramp_to_zero() does not apply the same OPX scaling as _play_*_on_channel + # when adjust_for_attenuation is enabled; use an explicit ramp so attenuation + # and default-pulse amplitude stay consistent (e.g. amplified LF-FEM). + sticky_durations = [ + int(sticky.duration) + for sticky in ( + getattr(ch, "sticky", None) for ch in self.gate_set.channels.values() + ) + if sticky is not None and getattr(sticky, "duration", None) is not None + ] + ramp_ns = max(sticky_durations) if sticky_durations else MIN_PULSE_DURATION_NS + self.ramp_to_voltages( + voltages={ch_name: 0.0 for ch_name in self.gate_set.channels}, + duration=0, + ramp_duration=ramp_ns, ) + if not self._track_integrated_voltage: + for ch_name, channel_obj in self.gate_set.channels.items(): + tracker = self.state_trackers[ch_name] + tracker.update_integrated_voltage( + level=0.0, + duration=0, + ramp_duration=channel_obj.sticky.duration, + ) + else: + for ch_name, channel_obj in self.gate_set.channels.items(): + tracker = self.state_trackers[ch_name] + ramp_to_zero(channel_obj.name) + tracker.update_integrated_voltage( + level=0.0, duration=0, ramp_duration=channel_obj.sticky.duration + ) else: self.ramp_to_voltages( From 76b6959738f68b30f4b95c75f70cedfb0fea5dc3 Mon Sep 17 00:00:00 2001 From: KU-QM Date: Mon, 15 Jun 2026 15:58:16 +0100 Subject: [PATCH 9/9] eval changes in full --- .github/workflows/ci.yml | 4 +- .gitignore | 13 +- .pre-commit-config.yaml | 7 +- .pylintrc | 6 +- .qm_cluster_config.json.example | 4 + .qm_saas_credentials.json.example | 5 + CHANGELOG.md | 18 +- conftest.py | 15 + pylint_qua_plugin/INTEGRATION_GUIDE.md | 2 +- pylint_qua_plugin/README.md | 2 +- pylint_qua_plugin/__init__.py | 2 +- pylint_qua_plugin/pylint_qua_plugin.py | 163 ++- pyproject.toml | 38 +- .../architecture/nv_center/qpu/base_quam.py | 2 +- .../nv_center/qpu/nv_center_quam.py | 2 +- .../architecture/quantum_dots/__init__.py | 18 + .../quantum_dots/components/__init__.py | 2 - .../quantum_dots/components/barrier_gate.py | 4 +- .../quantum_dots/components/dac_spec.py | 19 +- .../quantum_dots/components/gate_set.py | 65 +- .../quantum_dots/components/global_gate.py | 4 +- .../components/mixins/macro_dispatch.py | 30 +- .../components/mixins/voltage_control.py | 46 +- .../components/mixins/voltage_point.py | 31 +- .../quantum_dots/components/pulses.py | 196 +++- .../quantum_dots/components/qpu.py | 4 +- .../quantum_dots/components/quantum_dot.py | 7 +- .../components/quantum_dot_pair.py | 46 +- .../components/readout_resonator.py | 60 +- .../components/readout_transport.py | 14 +- .../quantum_dots/components/sensor_dot.py | 25 +- .../quantum_dots/components/virtual_dc_set.py | 44 +- .../components/virtual_gate_set.py | 36 +- .../quantum_dots/components/voltage_gate.py | 41 +- .../quantum_dots/components/xy_drive.py | 36 +- .../architecture/quantum_dots/defaults.py | 170 +++ .../examples/dcz_macro_example.py | 417 +++++++ .../default_macro_defaults_example.py | 159 --- .../default_macro_overrides_example.py | 251 ---- .../examples/external_macro_demo/__init__.py | 6 +- .../examples/external_macro_demo/catalog.py | 51 +- .../external_macro_package_example.py | 33 +- .../examples/full_workflow_example.py | 226 ++-- .../examples/macro_defaults_example.py | 94 ++ .../examples/macro_overrides_example.py | 156 +++ .../mwe_sensor_resonator_same_port.py | 48 +- .../examples/pulse_overrides_example.py | 154 +-- .../quantum_dots/examples/qm_example.py | 415 +++++++ .../quantum_dots/examples/quam_ld_example.py | 70 +- .../examples/quam_ld_generator_example.py | 14 +- .../quantum_dots/examples/quam_qd_example.py | 245 ++-- .../examples/quam_qd_generator_example.py | 20 +- .../quantum_dots/examples/rabi_chevron.py | 55 +- .../quantum_dots/examples/tutorial_machine.py | 166 ++- .../examples/virtual_gate_set_example.py | 3 +- .../voltage_balanced_macros_example.py | 539 +++++++++ .../quantum_dots/examples/wiring_example.py | 63 +- .../quantum_dots/macro_engine/__init__.py | 28 +- .../quantum_dots/macro_engine/overrides.py | 173 --- .../quantum_dots/macro_engine/wiring.py | 771 +++++-------- .../quantum_dots/operations/README.md | 704 +++--------- .../quantum_dots/operations/__init__.py | 17 +- .../operations/component_macro_catalog.py | 83 -- .../operations/component_pulse_catalog.py | 178 --- .../default_macros/single_qubit_macros.py | 708 ++++++------ .../operations/default_macros/state_macros.py | 235 +++- .../default_macros/two_qubit_macros.py | 813 ++++++++++++- .../operations/default_operations.py | 10 +- .../quantum_dots/operations/macro_catalog.py | 362 ++++++ .../quantum_dots/operations/macro_registry.py | 95 -- .../quantum_dots/operations/names.py | 20 +- .../quantum_dots/operations/pulse_catalog.py | 174 +++ .../quantum_dots/operations/pulse_registry.py | 126 -- .../single_qubit_macros.py | 41 + .../voltage_balanced_macros/state_macros.py | 1010 +++++++++++++++++ .../two_qubit_macros.py | 380 +++++++ .../architecture/quantum_dots/qpu/__init__.py | 3 - .../quantum_dots/qpu/base_quam_qd.py | 436 +++---- .../quantum_dots/qpu/loss_divincenzo_quam.py | 16 +- .../quantum_dots/qubit/ld_qubit.py | 87 +- .../quantum_dots/qubit_pair/ld_qubit_pair.py | 26 +- .../virtual_gates/virtualisation_layer.py | 4 +- .../architecture/superconducting/README.md | 2 +- .../architecture/superconducting/__init__.py | 10 +- .../superconducting/components/twpa.py | 39 +- .../two_qubit_gates.py | 38 +- .../superconducting/qpu/base_quam.py | 2 +- .../qpu/fixed_frequency_quam.py | 2 +- .../superconducting/qpu/flux_tunable_quam.py | 12 +- .../superconducting/qubit/base_transmon.py | 2 + .../nv_center/add_nv_spcm_component.py | 2 +- .../qop_connectivity/build_quam_wiring.py | 22 +- .../builder/qop_connectivity/create_wiring.py | 35 +- .../qop_connectivity/get_digital_outputs.py | 16 +- .../builder/qop_connectivity/qdac_wiring.py | 112 -- quam_builder/builder/quantum_dots/__init__.py | 6 +- .../builder/quantum_dots/build_qpu.py | 77 +- .../builder/quantum_dots/build_qpu_stage1.py | 67 +- .../builder/quantum_dots/build_qpu_stage2.py | 127 ++- .../builder/quantum_dots/build_quam.py | 401 ++----- .../builder/quantum_dots/build_utils.py | 112 +- quam_builder/builder/quantum_dots/pulses.py | 78 +- .../add_transmon_resonator_component.py | 2 +- .../builder/superconducting/build_quam.py | 12 +- quam_builder/tools/macros/measure_macros.py | 2 +- .../sequence_state_tracker.py | 63 +- .../voltage_sequence/voltage_sequence.py | 283 ++--- .../components/test_base_quam_qd.py | 10 +- .../quantum_dots/components/test_ld_qubit.py | 76 +- .../components/test_ld_qubit_pair.py | 10 +- .../components/test_quantum_dot.py | 10 +- .../components/test_quantum_dot_pair.py | 14 +- .../components/test_readout_resonator.py | 10 +- .../components/test_sensor_dot.py | 19 +- .../components/test_virtual_gate_set.py | 30 +- .../quantum_dots/components/test_xy_drive.py | 130 ++- tests/architecture/quantum_dots/conftest.py | 21 +- .../operations/test_alignment_fixes.py | 65 +- .../test_canonical_dot_pair_delegation.py | 570 ++++++++++ .../operations/test_catalog_reset.py | 85 +- .../operations/test_measure_duration_chain.py | 318 ++++++ .../operations/test_pulse_catalog.py | 392 ++++++- .../test_voltage_balanced_macro_catalog.py | 47 + .../quantum_dots/test_crot_macro.py | 195 ++++ .../quantum_dots/test_macro_persistence.py | 4 +- .../quantum_dots/test_rabi_chevron_e2e.py | 28 +- .../test_xy_drive_macro_frequency.py | 91 ++ .../virtual_gates/test_virtual_gates.py | 28 +- .../test_virtual_voltage_sequence.py | 63 +- .../test_virtualisation_layer.py | 4 +- .../test_compensation_pulse.py | 419 +++++++ .../voltage_sequence/test_voltage_sequence.py | 44 +- .../quantum_dots/test_build_qpu_refactor.py | 10 +- tests/builder/quantum_dots/test_build_quam.py | 53 +- .../quantum_dots/test_builder_pulses.py | 58 +- .../quantum_dots/test_e2e_quantum_dots.py | 162 +-- .../builder/quantum_dots/test_macro_names.py | 4 +- .../builder/quantum_dots/test_macro_wiring.py | 147 ++- .../builder/quantum_dots/test_pulse_wiring.py | 226 ++-- .../test_stage_workflow_persistence.py | 30 +- .../quantum_dots/test_two_stage_build.py | 91 +- .../test_wirer_builder_integration.py | 274 +---- .../test_e2e_superconducting.py | 36 +- tests/conftest.py | 23 - tests/macros/conftest.py | 13 +- tests/pylint_plugin/__init__.py | 2 +- tests/pylint_plugin/test_pylint_qua_plugin.py | 21 +- tests/test_utils.py | 15 +- .../voltage_gate_sequence/validation_utils.py | 20 +- tutorials/README.md | 5 + tutorials/calibration_workflow.ipynb | 565 +++++++++ tutorials/macro_customization.ipynb | 382 +++++++ uv.lock | 267 ++++- 153 files changed, 12059 insertions(+), 5418 deletions(-) create mode 100644 .qm_cluster_config.json.example create mode 100644 .qm_saas_credentials.json.example create mode 100644 conftest.py create mode 100644 quam_builder/architecture/quantum_dots/defaults.py create mode 100644 quam_builder/architecture/quantum_dots/examples/dcz_macro_example.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py delete mode 100644 quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/macro_defaults_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/macro_overrides_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/qm_example.py create mode 100644 quam_builder/architecture/quantum_dots/examples/voltage_balanced_macros_example.py delete mode 100644 quam_builder/architecture/quantum_dots/macro_engine/overrides.py delete mode 100644 quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py delete mode 100644 quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py create mode 100644 quam_builder/architecture/quantum_dots/operations/macro_catalog.py delete mode 100644 quam_builder/architecture/quantum_dots/operations/macro_registry.py create mode 100644 quam_builder/architecture/quantum_dots/operations/pulse_catalog.py delete mode 100644 quam_builder/architecture/quantum_dots/operations/pulse_registry.py create mode 100644 quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/single_qubit_macros.py create mode 100644 quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/state_macros.py create mode 100644 quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/two_qubit_macros.py delete mode 100644 quam_builder/builder/qop_connectivity/qdac_wiring.py create mode 100644 tests/architecture/quantum_dots/operations/test_canonical_dot_pair_delegation.py create mode 100644 tests/architecture/quantum_dots/operations/test_measure_duration_chain.py create mode 100644 tests/architecture/quantum_dots/operations/test_voltage_balanced_macro_catalog.py create mode 100644 tests/architecture/quantum_dots/test_crot_macro.py create mode 100644 tests/architecture/quantum_dots/test_xy_drive_macro_frequency.py create mode 100644 tests/architecture/quantum_dots/voltage_sequence/test_compensation_pulse.py create mode 100644 tutorials/README.md create mode 100644 tutorials/calibration_workflow.ipynb create mode 100644 tutorials/macro_customization.ipynb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5543feec..879c441e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout code @@ -61,7 +61,7 @@ jobs: - name: Run tests with pytest run: | - uv run pytest tests/ --verbose --cov --cov-report=xml --cov-report=term + uv run pytest -m "not server" --verbose --cov --cov-report=xml --cov-report=term - name: Upload coverage to Codecov if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index f60ed4b8..69abe0b1 100644 --- a/.gitignore +++ b/.gitignore @@ -187,7 +187,6 @@ tmp_file.py # Virtual environments .venv* -uv.lock *.code-workspace quam_builder/architecture/quantum_dots/other @@ -196,3 +195,15 @@ misc.xml quam-builder.iml /quam_builder/architecture/quantum_dots/examples/config/ /quam_builder/architecture/quantum_dots/examples/quam_state/ +.quam_builder/architecture/quantum_dots/examples/configs.py +quam_builder/architecture/quantum_dots/examples/configs.py +quam_builder/architecture/quantum_dots/examples/data/ + +# QM connection configs (contain credentials) +.qm_cluster_config.json +.qm_saas_credentials.json + +# Local-only tooling and IDE metadata +/.claude/ +/.idea/inspectionProfiles/ +.planning/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e2c4c33..e12dd442 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,11 +24,14 @@ repos: hooks: - id: pylint name: pylint - entry: python -m pylint --rcfile=.pylintrc + # Monorepo: git (and pre-commit) run from ``CS_installations`` root, not ``quam-builder/`` + entry: python -m pylint --rcfile=quam-builder/.pylintrc language: system types: [python] + # Only the quam-builder package; ``qualibration_graphs`` etc. live alongside at repo root + files: ^quam-builder/ # Exclude the pylint plugin and its test files (which contain intentional lint violations) - exclude: ^(pylint_qua_plugin|tests/pylint_plugin|tests)/ + exclude: ^quam-builder/(pylint_qua_plugin|tests/pylint_plugin|tests)/ # 4. Type checking – heavier, so run on push instead of every commit - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.11.1 diff --git a/.pylintrc b/.pylintrc index 52e5d126..13e7c8a6 100644 --- a/.pylintrc +++ b/.pylintrc @@ -22,7 +22,7 @@ enable=E, R, F, C # - invalid-name: Module names start with numbers (01a_time_of_flight), variables use physics conventions (I, Q) # - import-error, no-name-in-module: quam_config is project-specific, pylint can't resolve it # - too-many-locals: Quantum calibration code often needs many variables -disable=W, I, invalid-name, import-error, no-name-in-module, too-many-locals, duplicate-code, too-many-branches, too-many-return-statements, too-many-arguments, too-many-positional-arguments, too-many-lines, wrong-import-order, ungrouped-imports, consider-iterating-dictionary, use-implicit-booleaness-not-comparison-to-zero, unexpected-keyword-arg, undefined-all-variable, missing-module-docstring, missing-function-docstring, missing-class-docstring, missing-final-newline, line-too-long, wrong-import-position, import-outside-toplevel, consider-using-with, no-else-raise, unnecessary-dunder-call, undefined-variable, no-else-return, no-else-continue, too-many-instance-attributes, too-many-public-methods, useless-return, consider-using-in, use-dict-literal, no-value-for-parameter, too-few-public-methods, inconsistent-return-statements, unnecessary-comprehension, redefined-outer-name, use-implicit-booleaness-not-comparison, function-redefined, use-implicit-booleaness-not-comparison-to-string +disable=W, I, invalid-name, import-error, no-name-in-module, too-many-locals, duplicate-code, too-many-branches, too-many-return-statements, too-many-arguments, too-many-positional-arguments, too-many-lines, wrong-import-order, ungrouped-imports, consider-iterating-dictionary, unexpected-keyword-arg, undefined-all-variable, missing-module-docstring, missing-function-docstring, missing-class-docstring, missing-final-newline, line-too-long, wrong-import-position, import-outside-toplevel, consider-using-with, no-else-raise, unnecessary-dunder-call, undefined-variable, no-else-return, no-else-continue, too-many-instance-attributes, too-many-public-methods, useless-return, consider-using-in, use-dict-literal, no-value-for-parameter, too-few-public-methods, inconsistent-return-statements, unnecessary-comprehension, redefined-outer-name, function-redefined, use-implicit-booleaness-not-comparison-to-zero, use-implicit-booleaness-not-comparison, use-implicit-booleaness-not-comparison-to-string [REPORTS] @@ -33,7 +33,3 @@ reports=no [FORMAT] # Optional — set your preferred line length max-line-length=120 - - -[DESIGN] -max-parents=15 diff --git a/.qm_cluster_config.json.example b/.qm_cluster_config.json.example new file mode 100644 index 00000000..fc6ecf88 --- /dev/null +++ b/.qm_cluster_config.json.example @@ -0,0 +1,4 @@ +{ + "host": "172.16.33.114", + "cluster_name": "CS_4" +} diff --git a/.qm_saas_credentials.json.example b/.qm_saas_credentials.json.example new file mode 100644 index 00000000..2e268961 --- /dev/null +++ b/.qm_saas_credentials.json.example @@ -0,0 +1,5 @@ +{ + "email": "YOUR_EMAIL", + "password": "YOUR_PASSWORD", + "host": "qm-saas.dev.quantum-machines.co" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a390781d..942eb830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,22 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] -### Changed -Updated qualang-tools requirement `"qualang-tools>=0.22.0"`. ### Added -- Updated the TWPA component with isolation pump and added corresponding builder functions. -### Fixed -- Fix the default behavior of `def initialize_qpu(self, isolation: bool = False, **kwargs):` in the SC `BaseQuam`. - -## [0.3.0] - 2026-03-31 -### Added -- Added support for Python 3.13. - Add support for cloud-based QMM instances in `machine.connect()` -- A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. -- TWPA: add `pumpline_attenuation` and `signalline_attenuation` (float, optional) attributes. -- BaseQuam , XYDriveBase and ReadoutResonatorBase: `extras` (dict) for additional QUAM-level attributes. -### Changed -- FluxTunableQuam.set_all_fluxes: `target` is now optional; when `target=None`, settle and align are applied to all qubits. + - A custom QMM class can be specified in the network configuration, and enabled/disabled with the `use_custom_qmm` flag. ### Fixed - NV center - fix invalid `SPCM` component. @@ -50,8 +37,7 @@ Updated qualang-tools requirement `"qualang-tools>=0.22.0"`. - Builder functions for the general QUAM wiring. - Builder functions for Transmons. -[Unreleased]: https://github.com/qua-platform/quam-builder/compare/v0.3.0...HEAD -[0.3.0]: https://github.com/qua-platform/quam-builder/releases/tag/v0.3.0 +[Unreleased]: https://github.com/qua-platform/quam-builder/compare/v0.2.0...HEAD [0.2.0]: https://github.com/qua-platform/quam-builder/releases/tag/v0.2.0 [0.1.2]: https://github.com/qua-platform/quam-builder/releases/tag/v0.1.2 [0.1.1]: https://github.com/qua-platform/quam-builder/releases/tag/v0.1.1 diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..d5019eae --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +"""Root conftest — applies to all test directories.""" + +import pathlib + +import pytest + +_SERVER_DIR = pathlib.Path(__file__).parent / "tests_against_server" + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Auto-mark every test under tests_against_server/ with ``@pytest.mark.server``.""" + marker = pytest.mark.server + for item in items: + if pathlib.Path(item.fspath).is_relative_to(_SERVER_DIR): + item.add_marker(marker) diff --git a/pylint_qua_plugin/INTEGRATION_GUIDE.md b/pylint_qua_plugin/INTEGRATION_GUIDE.md index 53e20ed3..8a6d7b8c 100644 --- a/pylint_qua_plugin/INTEGRATION_GUIDE.md +++ b/pylint_qua_plugin/INTEGRATION_GUIDE.md @@ -342,4 +342,4 @@ If pre-commit isn't finding the plugin: ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/pylint_qua_plugin/README.md b/pylint_qua_plugin/README.md index 6e6e2979..f8514d3a 100644 --- a/pylint_qua_plugin/README.md +++ b/pylint_qua_plugin/README.md @@ -153,4 +153,4 @@ if_(n_op & 1 == 0) # pylint: disable=C1805 ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/pylint_qua_plugin/__init__.py b/pylint_qua_plugin/__init__.py index f6c3b3b5..d4924f11 100644 --- a/pylint_qua_plugin/__init__.py +++ b/pylint_qua_plugin/__init__.py @@ -7,4 +7,4 @@ from .pylint_qua_plugin import register __version__ = "0.1.0" -__all__ = ["register"] \ No newline at end of file +__all__ = ["register"] diff --git a/pylint_qua_plugin/pylint_qua_plugin.py b/pylint_qua_plugin/pylint_qua_plugin.py index f2d07fcd..bd9de4ed 100644 --- a/pylint_qua_plugin/pylint_qua_plugin.py +++ b/pylint_qua_plugin/pylint_qua_plugin.py @@ -8,11 +8,11 @@ Usage: 1. Add to your pylint configuration: - + # pyproject.toml [tool.pylint.main] load-plugins = ["pylint_qua_plugin"] - + # Or .pylintrc [MAIN] load-plugins=pylint_qua_plugin @@ -25,7 +25,7 @@ Suppressed rules: - C1805: use-implicit-booleaness-not-comparison-to-zero - - C1803: use-implicit-booleaness-not-comparison-to-string + - C1803: use-implicit-booleaness-not-comparison-to-string - C0121: singleton-comparison - R1714: consider-using-in - W0104: pointless-statement @@ -49,7 +49,16 @@ # Rules that conflict with QUA DSL semantics (both codes and symbols) QUA_SUPPRESSED_MSGIDS: Set[str] = { # Message codes (uppercase) - "C1805", "C1803", "C0121", "R1714", "W0104", "W0106", "R1705", "R1720", "R1709", "W0127", + "C1805", + "C1803", + "C0121", + "R1714", + "W0104", + "W0106", + "R1705", + "R1720", + "R1709", + "W0127", # Symbolic names (pylint uses these internally) "use-implicit-booleaness-not-comparison-to-zero", "use-implicit-booleaness-not-comparison-to-string", @@ -67,44 +76,83 @@ # These can appear outside of `with qua.program()` blocks in helper functions QUA_CONTROL_FLOW_FUNCTIONS: Set[str] = { # Control flow - "if_", "else_", "elif_", - "for_", "for_each_", + "if_", + "else_", + "elif_", + "for_", + "for_each_", "while_", - "switch_", "case_", "default_", + "switch_", + "case_", + "default_", # Variable operations - "assign", "assign_addition_", "assign_subtraction_", - "declare", "declare_stream", + "assign", + "assign_addition_", + "assign_subtraction_", + "declare", + "declare_stream", # Timing and synchronization - "wait", "wait_for_trigger", "align", "reset_phase", "reset_frame", "reset_global_phase", - "frame_rotation", "frame_rotation_2pi", "update_frequency", + "wait", + "wait_for_trigger", + "align", + "reset_phase", + "reset_frame", + "reset_global_phase", + "frame_rotation", + "frame_rotation_2pi", + "update_frequency", # Playback - "play", "measure", "save", "pause", - "ramp", "ramp_to_zero", + "play", + "measure", + "save", + "pause", + "ramp", + "ramp_to_zero", # Math operations that take QUA variables - "Math.abs", "Math.log", "Math.log2", "Math.log10", "Math.exp", "Math.sqrt", - "Math.pow", "Math.sin", "Math.cos", "Math.tan", "Math.asin", "Math.acos", "Math.atan", - "Math.div", "Math.msb", "Math.sum", "Math.max", "Math.min", - "Cast.to_int", "Cast.to_fixed", "Cast.to_bool", "Cast.unsafe_cast_int", "Cast.unsafe_cast_fixed", + "Math.abs", + "Math.log", + "Math.log2", + "Math.log10", + "Math.exp", + "Math.sqrt", + "Math.pow", + "Math.sin", + "Math.cos", + "Math.tan", + "Math.asin", + "Math.acos", + "Math.atan", + "Math.div", + "Math.msb", + "Math.sum", + "Math.max", + "Math.min", + "Cast.to_int", + "Cast.to_fixed", + "Cast.to_bool", + "Cast.unsafe_cast_int", + "Cast.unsafe_cast_fixed", "Util.cond", # I/O - "set_dc_offset", "get_dc_offset", - "IO1", "IO2", + "set_dc_offset", + "get_dc_offset", + "IO1", + "IO2", # Randomization - "Random.rand_int", "Random.rand_fixed", + "Random.rand_int", + "Random.rand_fixed", } # Also match these as bare function names (without module prefix) QUA_CONTROL_FLOW_BARE_NAMES: Set[str] = { name.split(".")[-1] for name in QUA_CONTROL_FLOW_FUNCTIONS -} | { - name for name in QUA_CONTROL_FLOW_FUNCTIONS if "." not in name -} +} | {name for name in QUA_CONTROL_FLOW_FUNCTIONS if "." not in name} def _is_qua_context_call(node: nodes.Call) -> bool: """Check if a Call node represents a QUA program context manager.""" func = node.func - + if isinstance(func, nodes.Attribute): if func.attrname != "program": return False @@ -118,21 +166,21 @@ def _is_qua_context_call(node: nodes.Call) -> bool: parts.insert(0, current.name) module_path = ".".join(parts) return "qua" in module_path.lower() - + elif isinstance(func, nodes.Name): return func.name == "program" - + return False def _is_qua_control_flow_call(node: nodes.Call) -> bool: """Check if a Call node is a QUA control flow function.""" func = node.func - + # Handle bare function names: if_(condition) if isinstance(func, nodes.Name): return func.name in QUA_CONTROL_FLOW_BARE_NAMES - + # Handle attribute access: qua.if_(condition), Math.abs(x) if isinstance(func, nodes.Attribute): # Build the full call path @@ -143,18 +191,18 @@ def _is_qua_control_flow_call(node: nodes.Call) -> bool: current = current.expr if isinstance(current, nodes.Name): parts.insert(0, current.name) - + full_path = ".".join(parts) - + # Check if it matches any QUA function pattern # Match: Math.abs, Cast.to_int, qua.if_, etc. if full_path in QUA_CONTROL_FLOW_FUNCTIONS: return True - + # Also match if the last part is a QUA function (e.g., qm.qua.if_) if func.attrname in QUA_CONTROL_FLOW_BARE_NAMES: return True - + return False @@ -168,18 +216,20 @@ def _get_call_line_range(node: nodes.Call) -> Tuple[int, int]: def _collect_qua_ranges(module: nodes.Module) -> List[Tuple[int, int]]: """Collect all line ranges inside QUA contexts and QUA control flow calls.""" ranges = [] - + # Collect `with qua.program()` blocks for node in module.nodes_of_class(nodes.With): for context_item, _ in node.items: - if isinstance(context_item, nodes.Call) and _is_qua_context_call(context_item): + if isinstance(context_item, nodes.Call) and _is_qua_context_call( + context_item + ): ranges.append((node.lineno, node.end_lineno or node.lineno)) - + # Collect QUA control flow function calls (can be anywhere in the file) for node in module.nodes_of_class(nodes.Call): if _is_qua_control_flow_call(node): ranges.append(_get_call_line_range(node)) - + return ranges @@ -196,8 +246,8 @@ def _in_qua_range(line: int, ranges: List[Tuple[int, int]]) -> bool: def _module_transform(module: nodes.Module) -> nodes.Module: """AST transform that collects QUA ranges before any checking happens.""" global _qua_ranges - filepath = getattr(module, 'file', None) - if filepath and filepath != '': + filepath = getattr(module, "file", None) + if filepath and filepath != "": ranges = _collect_qua_ranges(module) if ranges: _qua_ranges[filepath] = ranges @@ -208,15 +258,15 @@ def register(linter: PyLinter) -> None: """Register the QUA plugin with pylint.""" global _qua_ranges, _transform_registered _qua_ranges = {} - + # Register the AST transform (only once globally) if not _transform_registered: MANAGER.register_transform(nodes.Module, _module_transform) _transform_registered = True - + # Wrap add_message to filter QUA-incompatible messages original_add_message = linter.add_message - + @functools.wraps(original_add_message) def filtered_add_message( msgid: str, @@ -230,33 +280,42 @@ def filtered_add_message( ) -> None: # Get line number check_line = line if line is not None else (node.lineno if node else None) - + # Get filepath - try multiple sources - filepath = getattr(linter, 'current_file', None) + filepath = getattr(linter, "current_file", None) if not filepath and node is not None: # Try to get file from the node's root root = node.root() - filepath = getattr(root, 'file', None) - + filepath = getattr(root, "file", None) + # Check if should suppress if ( check_line is not None and filepath and filepath in _qua_ranges - and (msgid in QUA_SUPPRESSED_MSGIDS or msgid.upper() in QUA_SUPPRESSED_MSGIDS) + and ( + msgid in QUA_SUPPRESSED_MSGIDS or msgid.upper() in QUA_SUPPRESSED_MSGIDS + ) and _in_qua_range(check_line, _qua_ranges[filepath]) ): return # Suppress this message - + return original_add_message( - msgid, line=line, node=node, args=args, - confidence=confidence, col_offset=col_offset, - end_lineno=end_lineno, end_col_offset=end_col_offset, + msgid, + line=line, + node=node, + args=args, + confidence=confidence, + col_offset=col_offset, + end_lineno=end_lineno, + end_col_offset=end_col_offset, ) - + linter.add_message = filtered_add_message if __name__ == "__main__": print("QUA Pylint Plugin - suppresses rules in `with qua.program()` contexts") - print(f"Suppressed codes: {sorted(r for r in QUA_SUPPRESSED_MSGIDS if r[0].isupper())}") + print( + f"Suppressed codes: {sorted(r for r in QUA_SUPPRESSED_MSGIDS if r[0].isupper())}" + ) diff --git a/pyproject.toml b/pyproject.toml index 21c4e62b..59596ee9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,15 @@ [project] name = "quam-builder" -version = "0.3.0" +version = "0.2.0" description = "A Python tool designed to programmatically construct QUAM (Quantum Abstract Machine) configurations for the Quantum Orchestration Platform (QOP)." readme = "README.md" authors = [{ name = "Theo Laudat", email = "theo@quantum-machines.co" }] -requires-python = ">=3.9,<=3.13" +requires-python = ">=3.9,<3.13" dependencies = [ - "qualang-tools @ git+https://github.com/qua-platform/py-qua-tools.git@feature/qdac_wiring", - "quam>=0.4.0", - "qm-qua>=1.2.2", + "qualang-tools>=0.22.0", + "quam>=0.5.0a1", + "qm-qua>=1.3.0a1", + "scipy>=1.10.0", "xarray>=2024.7.0", "qcodes-contrib-drivers>=0.22.0", "qm-saas>=1.1.7", @@ -27,13 +28,10 @@ allow-direct-references = true [dependency-groups] dev = [ "ipykernel>=7.0.0a1", + "nbconvert", "pre-commit", "pylint>=3.0", "astroid>=3.0", - "black", - "ruff", - "mypy", - "commitizen", "pytest>=8.3.5", "pytest-cov", "pytest-mock>=3.14.0", @@ -48,6 +46,28 @@ prerelease = "allow" [tool.uv] prerelease = "allow" +[project.optional-dependencies] +dev = [ + "pre-commit", + "black", + "pylint>=3.0", + "astroid>=3.0", + "ruff", + "mypy", + "pytest", + "pytest-cov", + "commitizen", +] + + +# ----------------------------- +# Pytest configuration +# ----------------------------- +[tool.pytest.ini_options] +markers = [ + "server: requires a live QM hardware server (deselected in CI)", +] + # ----------------------------- # Ruff configuration # ----------------------------- diff --git a/quam_builder/architecture/nv_center/qpu/base_quam.py b/quam_builder/architecture/nv_center/qpu/base_quam.py index af3b4a0f..27245382 100644 --- a/quam_builder/architecture/nv_center/qpu/base_quam.py +++ b/quam_builder/architecture/nv_center/qpu/base_quam.py @@ -70,7 +70,7 @@ def get_serialiser(cls) -> JSONSerialiser: This method can be overridden by subclasses to provide a custom serialiser. """ return JSONSerialiser( - content_mapping={"wiring": "wiring.json", "network": "wiring.json"} + content_mapping={"wiring": "wiring_old.json", "network": "wiring_old.json"} ) def get_octave_config(self) -> QmOctaveConfig: diff --git a/quam_builder/architecture/nv_center/qpu/nv_center_quam.py b/quam_builder/architecture/nv_center/qpu/nv_center_quam.py index d88f7b8d..0c6ca5e4 100644 --- a/quam_builder/architecture/nv_center/qpu/nv_center_quam.py +++ b/quam_builder/architecture/nv_center/qpu/nv_center_quam.py @@ -21,7 +21,7 @@ class NVCenterQuam(BaseQuamNV): qubit_pairs (Dict[str, NVCenterPair]): A dictionary of qubit pairs composing the QUAM. Methods: - load: Loads the QUAM from the state.json file. + load: Loads the QUAM from the state_old.json file. """ qubit_type: ClassVar[Type[NVCenter]] = NVCenter diff --git a/quam_builder/architecture/quantum_dots/__init__.py b/quam_builder/architecture/quantum_dots/__init__.py index 8388308b..b06106c8 100644 --- a/quam_builder/architecture/quantum_dots/__init__.py +++ b/quam_builder/architecture/quantum_dots/__init__.py @@ -5,6 +5,16 @@ """ from .components import * +from .defaults import ( + DEFAULTS, + ExchangeDefaults, + FrequencyDefaults, + MiscDefaults, + QDDefaults, + ReadoutDefaults, + StateMacroDefaults, + XYPulseDefaults, +) from .examples import * from .macro_engine import * from .operations import * @@ -13,6 +23,14 @@ from .qubit_pair import * __all__ = [ + "DEFAULTS", + "ExchangeDefaults", + "FrequencyDefaults", + "MiscDefaults", + "QDDefaults", + "ReadoutDefaults", + "StateMacroDefaults", + "XYPulseDefaults", *components.__all__, *examples.__all__, *macro_engine.__all__, diff --git a/quam_builder/architecture/quantum_dots/components/__init__.py b/quam_builder/architecture/quantum_dots/components/__init__.py index b1a859d7..10b538e3 100644 --- a/quam_builder/architecture/quantum_dots/components/__init__.py +++ b/quam_builder/architecture/quantum_dots/components/__init__.py @@ -1,7 +1,5 @@ """Quantum dots components module.""" -from typing import Union - from . import voltage_gate from . import virtual_gate_set from . import virtual_dc_set diff --git a/quam_builder/architecture/quantum_dots/components/barrier_gate.py b/quam_builder/architecture/quantum_dots/components/barrier_gate.py index 2a2e8983..83f7c4c9 100644 --- a/quam_builder/architecture/quantum_dots/components/barrier_gate.py +++ b/quam_builder/architecture/quantum_dots/components/barrier_gate.py @@ -44,7 +44,9 @@ def voltage_sequence(self) -> VoltageSequence: """Return the associated voltage sequence, if available.""" machine = self.machine try: - virtual_gate_set_name = machine._get_virtual_gate_set(self.physical_channel).id + virtual_gate_set_name = machine._get_virtual_gate_set( + self.physical_channel + ).id return machine.get_voltage_sequence(virtual_gate_set_name) except (AttributeError, ValueError, KeyError): return None diff --git a/quam_builder/architecture/quantum_dots/components/dac_spec.py b/quam_builder/architecture/quantum_dots/components/dac_spec.py index 233ade04..9fadcc79 100644 --- a/quam_builder/architecture/quantum_dots/components/dac_spec.py +++ b/quam_builder/architecture/quantum_dots/components/dac_spec.py @@ -1,8 +1,13 @@ +from typing import TYPE_CHECKING + import numpy as np -from typing import Union, List, Optional -from quam.core import quam_dataclass, QuamComponent from quam.components import Channel +from quam.core import QuamComponent, quam_dataclass +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS + +if TYPE_CHECKING: + from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD __all__ = ["DacSpec", "QdacSpec"] @@ -75,7 +80,7 @@ def free_all_triggers(self) -> None: def load_dc_list( self, - voltages: Union[List, np.ndarray], + voltages: list | np.ndarray, dwell_s: float = 200e-6, stepped: bool = True, ) -> None: @@ -89,15 +94,15 @@ def load_dc_list( def play_triangle_wave( self, - frequency_Hz: Optional[float] = None, - period_s: Optional[float] = None, + frequency_Hz: float | None = None, + period_s: float | None = None, repetitions: int = -1, duty_cycle_percent: float = 50.0, inverted: bool = False, - span_V: float = 0.2, + span_V: float = DEFAULTS.qdac.triangle_span_V, offset_V: float = 0.0, delay_s: float = 0, - slew_V_s: Optional[float] = None, + slew_V_s: float | None = None, triggered: bool = True, ): """An example of how to fully utilise the QDAC API in the QdacSpec. This example plays a triangle wave.""" diff --git a/quam_builder/architecture/quantum_dots/components/gate_set.py b/quam_builder/architecture/quantum_dots/components/gate_set.py index e6fc7437..ccf814cf 100644 --- a/quam_builder/architecture/quantum_dots/components/gate_set.py +++ b/quam_builder/architecture/quantum_dots/components/gate_set.py @@ -1,18 +1,21 @@ +from typing import TYPE_CHECKING + from quam.components import QuantumComponent, pulses from quam.components.channels import SingleChannel from quam.core import quam_dataclass from quam.core.macro import QuamMacro - - -from typing import Dict, TYPE_CHECKING +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.tools.qua_tools import ( + MIN_PULSE_DURATION_NS, + VoltageLevelType, +) if TYPE_CHECKING: from quam_builder.tools.voltage_sequence import ( VoltageSequence, ) -from quam_builder.tools.qua_tools import VoltageLevelType - +DEFAULT_PULSE_NAME = "half_max_square" __all__ = ["GateSet", "VoltageTuningPoint"] @@ -35,7 +38,7 @@ class VoltageTuningPoint(QuamMacro): - Minimum duration is 16ns """ - voltages: Dict[str, float] + voltages: dict[str, float] duration: int def apply(self, *args, **kwargs): @@ -55,6 +58,8 @@ class GateSet(QuantumComponent): sequences - Resolve voltages for all channels with default fallbacks - Create voltage sequences with proper channel configuration + - Automatically configures DEFAULT_PULSE_NAME operations for all channels based on + their output mode (amplified vs direct) before creating the sequence. The GateSet acts as a logical grouping of related channels (e.g., gates controlling a specific quantum dot) and enables high-level voltage control @@ -89,17 +94,37 @@ class GateSet(QuantumComponent): ... seq.step_to_point("load") # Uses the predefined voltage point """ - channels: Dict[str, SingleChannel] + channels: dict[str, SingleChannel] adjust_for_attenuation: bool = False + def __post_init__(self): + for ch in self.channels.values(): + if isinstance(ch, str): + continue + if hasattr(ch.opx_output, "output_mode"): + if ch.opx_output.output_mode == "amplified": + ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( + amplitude=DEFAULTS.voltage_pulse.amplified_amplitude, + length=MIN_PULSE_DURATION_NS, + ) + else: + ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( + amplitude=DEFAULTS.voltage_pulse.direct_amplitude, + length=MIN_PULSE_DURATION_NS, + ) + else: + ch.operations[DEFAULT_PULSE_NAME] = pulses.SquarePulse( + amplitude=DEFAULTS.voltage_pulse.direct_amplitude, + length=MIN_PULSE_DURATION_NS, + ) @property def name(self) -> str: return self.id def resolve_voltages( - self, voltages: Dict[str, VoltageLevelType], allow_extra_entries: bool = False - ) -> Dict[str, VoltageLevelType]: + self, voltages: dict[str, VoltageLevelType], allow_extra_entries: bool = False + ) -> dict[str, VoltageLevelType]: """ Resolves voltages for all channels in the GateSet. @@ -160,7 +185,7 @@ def resolve_voltages( def valid_channel_names(self) -> list[str]: return list(self.channels.keys()) - def add_point(self, name: str, voltages: Dict[str, float], duration: int): + def add_point(self, name: str, voltages: dict[str, float], duration: int): """ Adds a named voltage tuning point (macro) to this GateSet. @@ -187,7 +212,7 @@ def add_point(self, name: str, voltages: Dict[str, float], duration: int): # Ensure macros dict exists if not handled by Pydantic model of QuantumComponent if not hasattr(self, "macros") or self.macros is None: - self.macros: Dict[str, QuamMacro] = {} + self.macros: dict[str, QuamMacro] = {} self.macros[name] = VoltageTuningPoint(voltages=voltages, duration=duration) @@ -195,7 +220,8 @@ def new_sequence( self, track_integrated_voltage: bool = False, keep_levels: bool = True, - enforce_qua_calcs: bool = False, + enforce_qua_calcs: bool = True, + limit_play_commands: bool = False, ) -> "VoltageSequence": """ Creates a new VoltageSequence instance associated with this GateSet. @@ -207,8 +233,11 @@ def new_sequence( keep_levels: without keep_levels, the default behaviour for resolving voltages will be that any unspecified voltages will be treated as 0, with keep_levels, unspecified voltages instead use the latest value - enforce_qua_calcs: Enforcing qua calcs can be required to correctly - track the current level for certain programs, defaults to False. + enforce_qua_calcs: When True, all voltage and integrated-voltage + tracking uses QUA variables, ensuring correct runtime behaviour + inside QUA loops and switch/case blocks. Defaults to True. + Set to False only if you need pure Python-mode tracking for + a program with no QUA control flow around voltage operations. Returns: VoltageSequence: A new voltage sequence instance configured with this GateSet @@ -219,4 +248,10 @@ def new_sequence( VoltageSequence, ) - return VoltageSequence(self, track_integrated_voltage, keep_levels, enforce_qua_calcs) + return VoltageSequence( + self, + track_integrated_voltage, + keep_levels, + enforce_qua_calcs, + limit_play_commands, + ) diff --git a/quam_builder/architecture/quantum_dots/components/global_gate.py b/quam_builder/architecture/quantum_dots/components/global_gate.py index d591376f..610778e3 100644 --- a/quam_builder/architecture/quantum_dots/components/global_gate.py +++ b/quam_builder/architecture/quantum_dots/components/global_gate.py @@ -42,7 +42,9 @@ def machine(self) -> "BaseQuamQD": def voltage_sequence(self) -> VoltageSequence: machine = self.machine try: - virtual_gate_set_name = machine._get_virtual_gate_set(self.physical_channel).id + virtual_gate_set_name = machine._get_virtual_gate_set( + self.physical_channel + ).id return machine.get_voltage_sequence(virtual_gate_set_name) except (AttributeError, ValueError, KeyError): return None diff --git a/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py b/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py index 3291b0b5..e94e7b30 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/macro_dispatch.py @@ -2,7 +2,7 @@ Provides the ``MacroDispatchMixin`` base class that gives any quantum-dot component automatic macro storage, default materialization from the -:mod:`~quam_builder.architecture.quantum_dots.operations.macro_registry`, +:mod:`~quam_builder.architecture.quantum_dots.operations.macro_catalog`, and attribute-based macro invocation. Macro execution goes directly through ``macro.apply(**kwargs)`` without @@ -12,7 +12,7 @@ from __future__ import annotations import warnings -from typing import Dict +from typing import Any, Dict from dataclasses import field @@ -20,11 +20,10 @@ from quam.core import quam_dataclass from quam.core.macro import QuamMacro -from quam_builder.architecture.quantum_dots.operations.component_macro_catalog import ( - register_default_component_macro_factories, -) -from quam_builder.architecture.quantum_dots.operations.macro_registry import ( - get_default_macro_factories, +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + DefaultMacroCatalog, + MacroRegistry, + UtilityMacroCatalog, ) __all__ = ["MacroDispatchMixin"] @@ -49,8 +48,9 @@ class MacroDispatchMixin(QuantumComponent): macros: Dict[str, QuamMacro] = field(default_factory=dict) - def __post_init__(self): + def __post_init__(self) -> None: """Initialize macro storage, defaults, and parent links.""" + super().__post_init__() self._ensure_macros_dict() self.ensure_default_macros() self._ensure_macro_parents() @@ -62,10 +62,12 @@ def _ensure_macros_dict(self) -> None: def ensure_default_macros(self) -> None: """Materialize default macro instances for this component type.""" - register_default_component_macro_factories() - for macro_name, macro_class in get_default_macro_factories(self).items(): + registry = MacroRegistry() + registry.register_catalog(UtilityMacroCatalog()) + registry.register_catalog(DefaultMacroCatalog()) + for macro_name, factory in registry.resolve_factories(self).items(): if macro_name not in self.macros: - self.macros[macro_name] = macro_class() + self.macros[macro_name] = factory() def _ensure_macro_parents(self) -> None: """Ensure all macros have their parent set to this component.""" @@ -79,7 +81,7 @@ def set_macro(self, name: str, macro: QuamMacro) -> None: if getattr(macro, "parent", None) is None: macro.parent = self - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: """Expose macros via attribute access. Returns the macro object itself when callable (has ``__call__``), @@ -99,4 +101,6 @@ def __getattr__(self, name): if callable(macro): return macro return macro.apply - raise AttributeError(f"'{type(self).__name__}' object has no attribute or macro '{name}'") + raise AttributeError( + f"'{type(self).__name__}' object has no attribute or macro '{name}'" + ) diff --git a/quam_builder/architecture/quantum_dots/components/mixins/voltage_control.py b/quam_builder/architecture/quantum_dots/components/mixins/voltage_control.py index 8ec872d8..f2ce1330 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/voltage_control.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/voltage_control.py @@ -10,6 +10,7 @@ from quam.core import quam_dataclass from quam.components import QuantumComponent +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.tools.qua_tools import DurationType, VoltageLevelType if TYPE_CHECKING: @@ -83,7 +84,9 @@ def _validate_component_id_in_gate_set(self, component_id: str) -> None: f"Valid channel names: {list(valid_channel_names)}" ) - def go_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: DurationType) -> None: + def go_to_voltages( + self, voltages: Dict[str, VoltageLevelType], duration: DurationType + ) -> None: """Agnostic function to set voltage in a sequence.simultaneous block. Whether it is a step or a ramp should be determined by the context manager. @@ -96,21 +99,30 @@ def go_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: Durati self.voltage_sequence.step_to_voltages(voltages, duration=duration) def step_to_voltages( - self, voltages: Dict[str, VoltageLevelType], duration: DurationType + self, + voltages: Dict[str, VoltageLevelType], + duration: DurationType, + ensure_align: bool = True, ) -> None: """Step to a specified voltage. Args: voltages: Target voltages (key: gate/qubit name, value: voltage) duration: Duration to hold the voltage in nanoseconds + ensure_align: If False, skip the internal align() call. + Set to False inside strict_timing_() blocks where + alignment is already managed externally. """ - self.voltage_sequence.step_to_voltages(voltages, duration=duration) + self.voltage_sequence.step_to_voltages( + voltages, duration=duration, ensure_align=ensure_align + ) def ramp_to_voltages( self, voltages: Dict[str, VoltageLevelType], duration: DurationType, ramp_duration: DurationType, + ensure_align: bool = True, ) -> None: """Ramp to a specified voltage. @@ -118,5 +130,31 @@ def ramp_to_voltages( voltages: Target voltages (key: gate/qubit name, value: voltage) ramp_duration: Duration of the ramp in nanoseconds duration: Duration to hold the final voltage in nanoseconds + ensure_align: If False, skip the internal align() call. + Set to False inside strict_timing_() blocks where + alignment is already managed externally. + """ + self.voltage_sequence.ramp_to_voltages( + voltages, duration, ramp_duration, ensure_align=ensure_align + ) + + def step_to_zero( + self, + duration: DurationType = DEFAULTS.state_macro.point_duration, + ensure_align: bool = True, + ) -> None: + """Step to zero voltage. + + Args: + duration: Duration to hold the voltage in nanoseconds + ensure_align: If False, skip the internal align() call. + Set to False inside strict_timing_() blocks where + alignment is already managed externally. """ - self.voltage_sequence.ramp_to_voltages(voltages, duration, ramp_duration) + self.voltage_sequence.step_to_voltages( + voltages={ + ch_name: 0.0 for ch_name in self.voltage_sequence.gate_set.channels + }, + duration=duration, + ensure_align=ensure_align, + ) diff --git a/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py b/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py index 779cb4ca..de04049c 100644 --- a/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py +++ b/quam_builder/architecture/quantum_dots/components/mixins/voltage_point.py @@ -8,6 +8,7 @@ from quam.core import quam_dataclass +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from .voltage_control import VoltageControlMixin if TYPE_CHECKING: @@ -29,7 +30,7 @@ def add_point( self, point_name: str, voltages: Dict[str, float], - duration: int = 16, + duration: int = DEFAULTS.state_macro.point_duration, replace_existing_point: bool = True, ) -> str: """Define a reusable voltage point in the gate set. @@ -97,7 +98,12 @@ def _create_point_name(self, point_name: str) -> str: """ return f"{self.id}_{point_name}" - def step_to_point(self, point_name: str, duration: Optional[int] = None) -> None: + def step_to_point( + self, + point_name: str, + duration: Optional[int] = None, + ensure_align: bool = True, + ) -> None: """Step instantly to a pre-defined voltage point (convenience method). This is a convenience wrapper around voltage_sequence.step_to_point() @@ -106,6 +112,9 @@ def step_to_point(self, point_name: str, duration: Optional[int] = None) -> None Args: point_name: Local point name (without prefix, must be added via add_point()) duration: Hold duration in nanoseconds (default: uses point's default) + ensure_align: If False, skip the internal align() call. + Set to False inside strict_timing_() blocks where + alignment is already managed externally. Raises: KeyError: If the point has not been registered via add_point() @@ -118,10 +127,16 @@ def step_to_point(self, point_name: str, duration: Optional[int] = None) -> None quantum_dot.step_to_point('idle', duration=100) """ full_name = self._create_point_name(point_name) - self.voltage_sequence.step_to_point(name=full_name, duration=duration) + self.voltage_sequence.step_to_point( + name=full_name, duration=duration, ensure_align=ensure_align + ) def ramp_to_point( - self, point_name: str, ramp_duration: int, duration: Optional[int] = None + self, + point_name: str, + ramp_duration: int, + duration: Optional[int] = None, + ensure_align: bool = True, ) -> None: """Ramp gradually to a pre-defined voltage point (convenience method). @@ -132,6 +147,9 @@ def ramp_to_point( point_name: Local point name (without prefix, must be added via add_point()) ramp_duration: Time for voltage transition in nanoseconds duration: Hold duration at target in nanoseconds (default: uses point's default) + ensure_align: If False, skip the internal align() call. + Set to False inside strict_timing_() blocks where + alignment is already managed externally. Raises: KeyError: If the point has not been registered via add_point() @@ -145,5 +163,8 @@ def ramp_to_point( """ full_name = self._create_point_name(point_name) self.voltage_sequence.ramp_to_point( - name=full_name, duration=duration, ramp_duration=ramp_duration + name=full_name, + duration=duration, + ramp_duration=ramp_duration, + ensure_align=ensure_align, ) diff --git a/quam_builder/architecture/quantum_dots/components/pulses.py b/quam_builder/architecture/quantum_dots/components/pulses.py index db4c2e43..bdf7ef85 100644 --- a/quam_builder/architecture/quantum_dots/components/pulses.py +++ b/quam_builder/architecture/quantum_dots/components/pulses.py @@ -3,10 +3,20 @@ from __future__ import annotations import numpy as np -from quam.components.pulses import GaussianPulse +from scipy.special import i0 # pylint: disable=no-name-in-module + +from quam.components.pulses import GaussianPulse, Pulse from quam.core import quam_dataclass -__all__ = ["ScalableGaussianPulse"] +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS + +__all__ = [ + "ScalableGaussianPulse", + "ScalableSquarePulse", + "ScalableKaiserPulse", + "ScalableHermitePulse", + "ScalableDragPulse", +] @quam_dataclass @@ -19,7 +29,8 @@ class ScalableGaussianPulse(GaussianPulse): Args: amplitude (float): Peak amplitude of the pulse in volts. - length (int): Duration of the pulse in ns (samples). + length (int): Pulse length in nanoseconds (samples at 1 GS/s). + Must be a multiple of 4. sigma_ratio (float): Ratio ``sigma / length``. Default ``1/6`` matches the conventional ``sigma = length / 6``. axis_angle (float, optional): IQ axis angle in radians. @@ -28,11 +39,12 @@ class ScalableGaussianPulse(GaussianPulse): """ sigma: float = None - sigma_ratio: float = 1 / 6 + sigma_ratio: float = DEFAULTS.xy_pulse.sigma_ratio def __post_init__(self): super().__post_init__() - self.sigma = self.length * self.sigma_ratio + if not isinstance(self.length, str) and not isinstance(self.sigma_ratio, str): + self.sigma = self.length * self.sigma_ratio def waveform_function(self): sigma = self.length * self.sigma_ratio @@ -47,3 +59,177 @@ def waveform_function(self): waveform = waveform * np.exp(1j * self.axis_angle) return waveform + + +@quam_dataclass +class ScalableSquarePulse(Pulse): + """Constant-amplitude (rectangular) pulse envelope for XY drive channels. + + Produces a flat-top waveform of duration ``length`` at the given + ``amplitude``. Supports IQ channels via ``axis_angle``. + + Args: + amplitude (float): Pulse amplitude in volts. + length (int): Pulse length in nanoseconds (samples at 1 GS/s). + Must be a multiple of 4. + axis_angle (float, optional): IQ axis angle in radians. ``None`` + produces a real-valued waveform (for SingleChannel drives). + """ + + length: int = DEFAULTS.xy_pulse.length + amplitude: float = DEFAULTS.xy_pulse.amplitude + axis_angle: float = None + + def waveform_function(self): + waveform = self.amplitude * np.ones(self.length) + if self.axis_angle is not None: + waveform = waveform * np.exp(1j * self.axis_angle) + return waveform + + +@quam_dataclass +class ScalableKaiserPulse(Pulse): + """Kaiser-window pulse envelope for XY drive channels. + + The Kaiser window suppresses off-resonant spectral components + compared to rectangular or Gaussian envelopes, reducing crosstalk + in multi-qubit systems (Wu et al., arXiv:2507.11918). + + The waveform is peak-normalized: the maximum sample equals + ``amplitude``. This gives the same calibration workflow as + Gaussian and Square pulses — fix duration, sweep amplitude, and + measure Rabi oscillations to find pi and pi/2 amplitudes. + + Args: + amplitude (float): Peak amplitude of the pulse in volts. + length (int): Pulse length in nanoseconds (samples at 1 GS/s). + Must be a multiple of 4. + beta (float): Kaiser shape parameter. Fixed at 8.0 per + arXiv:2507.11918. + axis_angle (float, optional): IQ axis angle in radians. ``None`` + produces a real-valued waveform (for SingleChannel drives). + """ + + length: int = DEFAULTS.xy_pulse.length + amplitude: float = DEFAULTS.xy_pulse.amplitude + beta: float = 8.0 + axis_angle: float = None + + def waveform_function(self): + n = np.arange(self.length, dtype=float) + center = (self.length - 1) / 2.0 + arg = self.beta * np.sqrt( + 1.0 - ((n - center) / center) ** 2 + ) + window = i0(arg) / i0(self.beta) + window = window / np.max(window) + waveform = self.amplitude * window + if self.axis_angle is not None: + waveform = waveform * np.exp(1j * self.axis_angle) + return waveform + + +@quam_dataclass +class ScalableHermitePulse(Pulse): + """Hermite-Gaussian pulse envelope for XY drive channels. + + The envelope is a Gaussian multiplied by a second-order Hermite-like + polynomial term controlled by ``hermite_coeff``: + + exp(-x^2 / 2) * (1 - hermite_coeff * x^2) + + where ``x = (t - center) / sigma``. The waveform is peak-normalized so + the maximum absolute sample equals ``amplitude``. + + Args: + amplitude (float): Peak amplitude of the pulse in volts. + length (int): Pulse length in nanoseconds (samples at 1 GS/s). + Must be a multiple of 4. + sigma_ratio (float): Ratio ``sigma / length`` used to derive + ``sigma`` from ``length``. + hermite_coeff (float): Weight of the Hermite polynomial term. + axis_angle (float, optional): IQ axis angle in radians. + """ + + length: int = DEFAULTS.xy_pulse.length + amplitude: float = DEFAULTS.xy_pulse.amplitude + sigma: float = None + sigma_ratio: float = DEFAULTS.xy_pulse.sigma_ratio + hermite_coeff: float = 0.5 + axis_angle: float = None + + def __post_init__(self): + super().__post_init__() + if not isinstance(self.length, str) and not isinstance(self.sigma_ratio, str): + self.sigma = self.length * self.sigma_ratio + + def waveform_function(self): + sigma = self.length * self.sigma_ratio + t = np.arange(self.length, dtype=float) + center = (self.length - 1) / 2.0 + x = (t - center) / sigma + + base = np.exp(-(x**2) / 2.0) + hermite_shape = 1.0 - self.hermite_coeff * (x**2) + waveform = base * hermite_shape + + peak = np.max(np.abs(waveform)) + if peak > 0: + waveform = self.amplitude * (waveform / peak) + else: + waveform = np.zeros_like(waveform) + + if self.axis_angle is not None: + waveform = waveform * np.exp(1j * self.axis_angle) + return waveform + + +@quam_dataclass +class ScalableDragPulse(Pulse): + """DRAG pulse envelope for XY drive channels. + + Implements a Gaussian in-phase envelope plus a derivative quadrature: + + I(x) = exp(-x^2 / 2) + Q(x) = drag_coefficient * (-x * exp(-x^2 / 2)) + + with ``x = (t - center) / sigma`` and ``sigma = length * sigma_ratio``. + + For IQ drives (``axis_angle`` set), the pulse is emitted as ``I + iQ`` + and rotated by ``exp(i * axis_angle)``. For SingleChannel drives + (``axis_angle=None``), only the in-phase Gaussian component is used. + """ + + length: int = DEFAULTS.xy_pulse.length + amplitude: float = DEFAULTS.xy_pulse.amplitude + sigma: float = None + sigma_ratio: float = DEFAULTS.xy_pulse.sigma_ratio + drag_coefficient: float = 0.5 + axis_angle: float = None + + def __post_init__(self): + super().__post_init__() + if not isinstance(self.length, str) and not isinstance(self.sigma_ratio, str): + self.sigma = self.length * self.sigma_ratio + + def waveform_function(self): + sigma = self.length * self.sigma_ratio + t = np.arange(self.length, dtype=float) + center = (self.length - 1) / 2.0 + x = (t - center) / sigma + + gaussian = np.exp(-(x**2) / 2.0) + gaussian = gaussian / np.max(gaussian) + i_envelope = self.amplitude * gaussian + + derivative = -x * gaussian + deriv_peak = np.max(np.abs(derivative)) + if deriv_peak > 0: + derivative = derivative / deriv_peak + q_envelope = self.amplitude * self.drag_coefficient * derivative + + if self.axis_angle is None: + return i_envelope + + waveform = i_envelope + 1j * q_envelope + return waveform * np.exp(1j * self.axis_angle) diff --git a/quam_builder/architecture/quantum_dots/components/qpu.py b/quam_builder/architecture/quantum_dots/components/qpu.py index 94941318..9c918832 100644 --- a/quam_builder/architecture/quantum_dots/components/qpu.py +++ b/quam_builder/architecture/quantum_dots/components/qpu.py @@ -39,7 +39,9 @@ def machine(self) -> "BaseQuamQD": def voltage_sequence(self) -> VoltageSequence: machine = self.machine try: - virtual_gate_set_name = machine._get_virtual_gate_set(self.physical_channel).id + virtual_gate_set_name = machine._get_virtual_gate_set( + self.physical_channel + ).id return machine.get_voltage_sequence(virtual_gate_set_name) except (AttributeError, ValueError, KeyError): return None diff --git a/quam_builder/architecture/quantum_dots/components/quantum_dot.py b/quam_builder/architecture/quantum_dots/components/quantum_dot.py index e510a6ab..2f8f99e8 100644 --- a/quam_builder/architecture/quantum_dots/components/quantum_dot.py +++ b/quam_builder/architecture/quantum_dots/components/quantum_dot.py @@ -69,7 +69,9 @@ def machine(self) -> "BaseQuamQD": def voltage_sequence(self) -> VoltageSequence: machine = self.machine try: - virtual_gate_set_name = machine._get_virtual_gate_set(self.physical_channel).id + virtual_gate_set_name = machine._get_virtual_gate_set( + self.physical_channel + ).id return machine.get_voltage_sequence(virtual_gate_set_name) except (AttributeError, ValueError, KeyError): return None @@ -106,7 +108,8 @@ def __matmul__(self, other: "QuantumDot") -> "QuantumDotPair": pair_name = machine.find_quantum_dot_pair(self.id, other.id) if pair_name is None: raise ValueError( - f"No QuantumDotPair registered for dots " f"'{self.id}' and '{other.id}'." + f"No QuantumDotPair registered for dots " + f"'{self.id}' and '{other.id}'." ) return machine.quantum_dot_pairs[pair_name] diff --git a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py index aee2c31d..5ce4579e 100644 --- a/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py +++ b/quam_builder/architecture/quantum_dots/components/quantum_dot_pair.py @@ -1,15 +1,16 @@ from typing import Dict, List, TYPE_CHECKING from dataclasses import field +from qm.qua import Cast, assign, declare, fixed from quam.core import quam_dataclass +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.tools.voltage_sequence.voltage_sequence import VoltageSequence from .quantum_dot import QuantumDot from .sensor_dot import SensorDot from .barrier_gate import BarrierGate from .mixins import VoltageMacroMixin from .voltage_gate import VoltageGate -from .virtual_gate_set import VirtualGateSet if TYPE_CHECKING: from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD @@ -48,7 +49,9 @@ class QuantumDotPair(VoltageMacroMixin): # pylint: disable=too-many-ancestors def __post_init__(self): super().__post_init__() - if isinstance(self.quantum_dots[0], str): + if isinstance(self.quantum_dots[0], str) or isinstance( + self.quantum_dots[1], str + ): return if len(self.quantum_dots) != 2: raise ValueError( @@ -57,7 +60,10 @@ def __post_init__(self): if self.id is None: self.id = f"{self.quantum_dots[0].id}_{self.quantum_dots[1].id}" - if self.quantum_dots[0].voltage_sequence is not self.quantum_dots[1].voltage_sequence: + if ( + self.quantum_dots[0].voltage_sequence + is not self.quantum_dots[1].voltage_sequence + ): raise ValueError("Quantum Dots not part of same VoltageSequence") self.detuning_axis_name = f"{self.id}_epsilon" @@ -69,10 +75,6 @@ def physical_channel(self) -> VoltageGate: @property def voltage_sequence(self) -> VoltageSequence: return self.quantum_dots[0].voltage_sequence - - @property - def gate_set(self) -> VirtualGateSet: - return self.voltage_sequence.gate_set @property def machine(self) -> "BaseQuamQD": @@ -96,7 +98,7 @@ def define_detuning_axis( # Ensure that the detuning axis name held in object is consistent self.detuning_axis_name = detuning_axis_name - virtual_gate_set = self.gate_set + virtual_gate_set = self.voltage_sequence.gate_set # Should be the correct virtual axes in the first layer of the VirtualGateSet target_gates = [qd.id for qd in self.quantum_dots] @@ -124,22 +126,37 @@ def define_detuning_axis( matrix=matrix, ) - def go_to_detuning(self, voltage: float, duration: int = 16) -> None: + def go_to_detuning( + self, + voltage: float, + duration: int = DEFAULTS.state_macro.point_duration, + ) -> None: """To be used in a simultaneous block to step/ramp detuning to specified value. Can only be used after define_detuning_axis.""" return self.voltage_sequence.step_to_voltages( {self.detuning_axis_name: voltage}, duration=duration ) - def step_to_detuning(self, voltage: float, duration: int = 16) -> None: + def step_to_detuning( + self, + voltage: float, + duration: int = DEFAULTS.state_macro.point_duration, + ) -> None: """Steps the detuning to the specified value. Can only be used after define_detuning_axis.""" return self.voltage_sequence.step_to_voltages( {self.detuning_axis_name: voltage}, duration=duration ) - def ramp_to_detuning(self, voltage: float, ramp_duration: int, duration: int = 16): + def ramp_to_detuning( + self, + voltage: float, + ramp_duration: int, + duration: int = DEFAULTS.state_macro.point_duration, + ): """Ramps the detuning to the specified value. Can only be used after define_detuning_axis.""" return self.voltage_sequence.ramp_to_voltages( - {self.detuning_axis_name: voltage}, duration=duration, ramp_duration=ramp_duration + {self.detuning_axis_name: voltage}, + duration=duration, + ramp_duration=ramp_duration, ) def _get_component_id_for_voltages(self) -> str: @@ -151,9 +168,6 @@ def _get_component_id_for_voltages(self) -> str: """ return self.detuning_axis_name - def measure(self): - pass - def readout_state( self, state, @@ -173,7 +187,7 @@ def readout_state( sensor_dot = self.sensor_dots[0] - threshold, projector = sensor_dot._readout_threshold(self.id) + threshold, projector = sensor_dot._readout_params(self.id) sensor_dot.measure(pulse_name, qua_vars=(I, Q)) diff --git a/quam_builder/architecture/quantum_dots/components/readout_resonator.py b/quam_builder/architecture/quantum_dots/components/readout_resonator.py index b31dc9b9..2c2595d7 100644 --- a/quam_builder/architecture/quantum_dots/components/readout_resonator.py +++ b/quam_builder/architecture/quantum_dots/components/readout_resonator.py @@ -1,8 +1,10 @@ -from typing import Optional, Union +from typing import Optional from quam.core import quam_dataclass from quam.components.channels import InOutIQChannel, InOutMWChannel, InOutSingleChannel +from qualang_tools.units import unit as _unit + from quam_builder.tools.power_tools import ( calculate_voltage_scaling_factor, set_output_power_mw_channel, @@ -17,7 +19,6 @@ "ReadoutResonatorIQ", "ReadoutResonatorMW", "ReadoutResonatorSingle", - "ANY_READOUT_RESONATOR", ] @@ -33,7 +34,9 @@ class ReadoutResonatorBase: frequency_bare: float = None @staticmethod - def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): + def calculate_voltage_scaling_factor( + fixed_power_dBm: float, target_power_dBm: float + ): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -51,22 +54,63 @@ def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: f class ReadoutResonatorSingle(InOutSingleChannel, ReadoutResonatorBase): intermediate_frequency: int = "#/inferred_intermediate_frequency" + def __post_init__(self): + if hasattr(self.opx_output, "upsampling_mode"): + self.opx_output.upsampling_mode = "mw" + def set_output_power( self, power_in_dbm: float, - gain: Optional[int] = None, + gain: Optional[float] = None, max_amplitude: Optional[float] = None, Z: int = 50, operation: Optional[str] = "readout", ): + """Set readout pulse amplitude from a target power at the load (same dBm→V model as IQ path). + + ``power_in_dbm`` is the **desired** output power (dBm, 50 Ω). Any fixed gain in the analog + chain (e.g. MW-FEM or off-chip amp modelled as an additive dB) is given by ``gain``: + the OPX amplitude is chosen as the voltage that corresponds to ``power_in_dbm - gain`` + in dBm, matching :func:`~quam_builder.tools.power_tools.set_output_power_iq_channel` + (``amplitude = dBm2volts(P - gain)`` with Octave gain). + + Parameters + ---------- + power_in_dbm + Target power at the load (dBm). + gain + Chain gain in **dB** to subtract before converting to volts (default ``0`` = no extra term). + Not a linear voltage divider; use ``0`` if you do not model a converter/amp. + max_amplitude + Absolute ceiling on ``|amplitude|`` (V). Default ``0.5`` (OPX+ single-ended limit). + Z + Impedance for dBm↔V conversion (Ω), default 50. + operation + Pulse name on this resonator, default ``"readout"``. + """ + if max_amplitude is None: + max_amplitude = 0.5 + if gain is None: + gain = 0.0 - pass + u = _unit(coerce_to_integer=True) + amplitude_in_v = u.dBm2volts(power_in_dbm - gain, Z=Z) + if abs(amplitude_in_v) > max_amplitude: + raise ValueError( + f"Converted amplitude |{amplitude_in_v}| V exceeds max_amplitude {max_amplitude} V " + f"(power_in_dbm={power_in_dbm} dBm, gain={gain} dB, Z={Z} Ω)." + ) + self.operations[operation].amplitude = amplitude_in_v @quam_dataclass class ReadoutResonatorIQ(InOutIQChannel, ReadoutResonatorBase): intermediate_frequency: int = "#./inferred_intermediate_frequency" + def __post_init__(self): + if hasattr(self.opx_output, "upsampling_mode"): + self.opx_output.upsampling_mode = "mw" + @property def upconverter_frequency(self): """Returns the up-converter/LO frequency in Hz.""" @@ -113,7 +157,9 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) + return set_output_power_iq_channel( + self, power_in_dbm, gain, max_amplitude, Z, operation + ) @quam_dataclass @@ -161,5 +207,3 @@ def set_output_power( return set_output_power_mw_channel( self, power_in_dbm, operation, full_scale_power_dbm, max_amplitude ) - -ANY_READOUT_RESONATOR = Union[ReadoutResonatorBase, ReadoutResonatorIQ, ReadoutResonatorMW, ReadoutResonatorSingle] diff --git a/quam_builder/architecture/quantum_dots/components/readout_transport.py b/quam_builder/architecture/quantum_dots/components/readout_transport.py index d623f778..89ec3b2c 100644 --- a/quam_builder/architecture/quantum_dots/components/readout_transport.py +++ b/quam_builder/architecture/quantum_dots/components/readout_transport.py @@ -1,11 +1,15 @@ """Transport readout channel components.""" from quam.core import quam_dataclass -from quam.components.channels import InSingleChannel, InOutSingleChannel -from typing import Union +from quam.components.channels import ( + InSingleChannel, + InOutSingleChannel, + InMWChannel, + InIQChannel, +) -__all__ = ["ReadoutTransportBase", "ReadoutTransportSingle", "ReadoutTransportSingleIO", "ANY_READOUT_TRANSPORT"] +__all__ = ["ReadoutTransportBase", "ReadoutTransportSingle", "ReadoutTransportSingleIO"] @quam_dataclass @@ -16,6 +20,7 @@ class ReadoutTransportBase: # pylint: disable=too-few-public-methods pass + @quam_dataclass class ReadoutTransportSingle( InSingleChannel, ReadoutTransportBase @@ -26,6 +31,7 @@ class ReadoutTransportSingle( pass + @quam_dataclass class ReadoutTransportSingleIO( InOutSingleChannel, ReadoutTransportBase @@ -38,5 +44,3 @@ class ReadoutTransportSingleIO( """ pass - -ANY_READOUT_TRANSPORT = Union[ReadoutTransportBase, ReadoutTransportSingle, ReadoutTransportSingleIO] diff --git a/quam_builder/architecture/quantum_dots/components/sensor_dot.py b/quam_builder/architecture/quantum_dots/components/sensor_dot.py index 30c46f61..8f71b6c7 100644 --- a/quam_builder/architecture/quantum_dots/components/sensor_dot.py +++ b/quam_builder/architecture/quantum_dots/components/sensor_dot.py @@ -39,14 +39,19 @@ class SensorDot(QuantumDot): # pylint: disable=too-many-ancestors readout_reservoir: DrainSingle = None def _add_readout_params( - self, quantum_dot_pair_id: str, threshold: float, projector: Union[dict, Projector] = None + self, + quantum_dot_pair_id: str, + threshold: float, + projector: Union[dict, Projector] = None, ) -> None: if projector is None: projector = Projector() self._add_readout_threshold(quantum_dot_pair_id, threshold) self._add_readout_projector(quantum_dot_pair_id, projector) - def _add_readout_threshold(self, quantum_dot_pair_id: str, threshold: float) -> None: + def _add_readout_threshold( + self, quantum_dot_pair_id: str, threshold: float + ) -> None: self.readout_thresholds[quantum_dot_pair_id] = threshold def _add_readout_projector( @@ -56,7 +61,9 @@ def _add_readout_projector( projector = asdict(projector) self.readout_projectors[quantum_dot_pair_id] = projector - def _readout_params(self, quantum_dot_pair_id: str) -> Union[float, dict[str, float]]: + def _readout_params( + self, quantum_dot_pair_id: str + ) -> Union[float, dict[str, float]]: return ( self.readout_thresholds[quantum_dot_pair_id], self.readout_projectors[quantum_dot_pair_id], @@ -69,7 +76,9 @@ def calibrate_octave( self, QM: QuantumMachine, calibrate_resonator: bool = True, - ) -> Tuple[Union[None, MixerCalibrationResults], Union[None, MixerCalibrationResults]]: + ) -> Tuple[ + Union[None, MixerCalibrationResults], Union[None, MixerCalibrationResults] + ]: """Calibrate the Octave channels (EDSR and possible resonator) linked to this qubit for the LO frequency, intermediate frequency and Octave gain as defined in the state. Args: @@ -106,7 +115,9 @@ def readout_resonator(self): if ro is None: return None if not isinstance(ro, ReadoutResonatorBase): - raise TypeError("The associated readout mechanism is not a ReadoutResonatorBase.") + raise TypeError( + "The associated readout mechanism is not a ReadoutResonatorBase." + ) return ro @readout_resonator.setter @@ -121,7 +132,9 @@ def readout_transport(self): if ro is None: return None if not isinstance(ro, ReadoutTransportBase): - raise TypeError("The associated readout mechanism is not a ReadoutTransportBase.") + raise TypeError( + "The associated readout mechanism is not a ReadoutTransportBase." + ) return ro @readout_transport.setter diff --git a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py index 977d7908..192bef60 100644 --- a/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py +++ b/quam_builder/architecture/quantum_dots/components/virtual_dc_set.py @@ -8,7 +8,9 @@ from .virtual_gate_set import VirtualizationLayer from .voltage_gate import VoltageGate -from quam_builder.architecture.quantum_dots.components.gate_set import VoltageTuningPoint +from quam_builder.architecture.quantum_dots.components.gate_set import ( + VoltageTuningPoint, +) __all__ = ["VirtualDCSet"] @@ -93,12 +95,7 @@ def name(self): def current_physical_voltages(self) -> Dict[str, float]: """Query, update and return the current dict of all physical voltages""" for name, channel in self.channels.items(): - if callable(channel.offset_parameter): - self._current_physical_voltages[name] = channel.offset_parameter() - elif getattr(channel, "current_external_voltage", None) is not None: - self._current_physical_voltages[name] = channel.current_external_voltage - else: - self._current_physical_voltages[name] = 0.0 + self._current_physical_voltages[name] = channel.offset_parameter() return self._current_physical_voltages def _populate_virtual_gate_voltages(self, physical_voltages_dict): @@ -111,7 +108,9 @@ def _populate_virtual_gate_voltages(self, physical_voltages_dict): for layer in self.layers: target_names = layer.target_gates source_names = layer.source_gates - target_voltages_array = np.array([full_voltages_dict[name] for name in target_names]) + target_voltages_array = np.array( + [full_voltages_dict[name] for name in target_names] + ) matrix_array = np.asarray(layer.matrix, dtype=float) source_voltages_array = matrix_array @ target_voltages_array for idx, name in enumerate(source_names): @@ -132,9 +131,8 @@ def valid_channel_names(self) -> list[str]: # Combine physical and virtual gate names return list(self.channels) + list(virtual_channels) - def add_point(self, name: str, voltages: Dict[str, float]) -> None: + def add_point(self, name: str, voltages: Dict[str, float], duration: int) -> None: invalid_channel_names = set(voltages) - set(self.valid_channel_names) - duration = 16 # Will not be used mid-qua programme. Placeholder value if invalid_channel_names: raise ValueError( @@ -193,7 +191,11 @@ def _validate_new_layer( if self.layers: # Not the first layer # Get all source gates from previous layers all_previous_source_gates = set() - for lyr in self.layers: # Iterate through existing layers before adding the new one + for ( + lyr + ) in ( + self.layers + ): # Iterate through existing layers before adding the new one all_previous_source_gates.update(lyr.source_gates) # Combine with physical channels for the very first layer's target check @@ -236,7 +238,9 @@ def _validate_new_layer( # Check 5: The layer name must be unique for lyr in self.layers: if layer_id == lyr.id: - raise ValueError(f"Layer name '{layer_id}' is already used in a previous layer.") + raise ValueError( + f"Layer name '{layer_id}' is already used in a previous layer." + ) matrix_array = np.array(matrix, dtype=float) expected_shape = (len(source_gates), len(target_gates)) @@ -258,7 +262,9 @@ def _validate_new_layer( try: det = np.linalg.det(matrix_array) if abs(det) < 1e-10: # Use small tolerance for floating point - raise ValueError(f"Matrix is not invertible (determinant ≈ 0): {det}") + raise ValueError( + f"Matrix is not invertible (determinant ≈ 0): {det}" + ) except np.linalg.LinAlgError as e: raise ValueError(f"Matrix inversion failed: {e}") @@ -341,7 +347,9 @@ def add_to_layer( f"Expected {expected_shape}, got {matrix_array.shape}" ) - target_overlap_layer = next((lyr for lyr in self.layers if lyr.id == layer_id), None) + target_overlap_layer = next( + (lyr for lyr in self.layers if lyr.id == layer_id), None + ) if target_overlap_layer is None: return self.add_layer( @@ -447,7 +455,9 @@ def resolve_voltages( # Apply each virtualization layer in reverse order (from highest to lowest) # Each layer resolves its virtual gates to the next lower layer for layer in reversed(self.layers): - resolved_voltages = layer.resolve_voltages(resolved_voltages, allow_extra_entries=True) + resolved_voltages = layer.resolve_voltages( + resolved_voltages, allow_extra_entries=True + ) base_resolved_voltages = {} for ch_name in self.channels: @@ -482,7 +492,9 @@ def set_voltages( if resync: self._current_levels = self.all_current_voltages.copy() else: - self._current_levels = self._populate_virtual_gate_voltages(physical_voltages) + self._current_levels = self._populate_virtual_gate_voltages( + physical_voltages + ) def get_voltage(self, name: str, requery: bool = False) -> float: """ diff --git a/quam_builder/architecture/quantum_dots/components/virtual_gate_set.py b/quam_builder/architecture/quantum_dots/components/virtual_gate_set.py index 0fbe9f40..137fff71 100644 --- a/quam_builder/architecture/quantum_dots/components/virtual_gate_set.py +++ b/quam_builder/architecture/quantum_dots/components/virtual_gate_set.py @@ -4,7 +4,7 @@ # pylint: disable=unsubscriptable-object from dataclasses import field -from typing import Any, Dict, List +from typing import Any, Dict, List, Set import warnings import numpy as np @@ -92,9 +92,10 @@ def resolve_voltages( for target_gate, inv_matrix_row in zip(self.target_gates, inverse_matrix): resolved_voltages.setdefault(target_gate, 0.0) - resolved_voltages[target_gate] = ( - resolved_voltages[target_gate] + inv_matrix_row @ source_voltages - ) + for elem, source in zip(inv_matrix_row, source_voltages): + if np.abs(elem) < 1e-8: + continue + resolved_voltages[target_gate] = resolved_voltages[target_gate] + elem * source return resolved_voltages @@ -181,6 +182,30 @@ class VirtualGateSet(GateSet): layers: List[VirtualizationLayer] = field(default_factory=list) allow_rectangular_matrices: bool = False + _influence_map: Dict[str, List[str]] = field(default_factory=dict) + play_threshold: float = 2 ** (-16) # resolution of the QUA fixed type + + @property + def influence_map(self) -> Dict[str, Set[str]]: + """A dictionary mapping each gate to the set of physical gates that it influences.""" + return {k: set(v) for k, v in self._influence_map.items()} + + def _calculate_influence_map(self) -> None: + """Calculates the influence map by probing each gate with a unit voltage.""" + # Base physical gates do not influence any others, so they are mapped to itself + influence_map = {gate: [gate] for gate in self.channels.keys()} + for layer in self.layers: + for source_gate in layer.source_gates: + # TODO: This is an inefficient way to get the influence map. It should be replaced with a more efficient method. + dummy_dict = { + source_gate: 2.5 + } # Maximum dummy input, so that the resulting map should represent the maximum possible output + resolved_voltages = self.resolve_voltages(dummy_dict) + influence_map[source_gate] = [ + pg for pg, v in resolved_voltages.items() if abs(v) > self.play_threshold + ] + self._influence_map = influence_map + @property def valid_channel_names(self) -> list[str]: """ @@ -338,6 +363,7 @@ def add_layer( use_pseudoinverse=use_pseudoinverse, ) self.layers.append(virtualization_layer) + self._calculate_influence_map() return virtualization_layer def add_to_layer( @@ -451,7 +477,7 @@ def add_to_layer( layer.target_gates = existing_targets layer.matrix = full_matrix.tolist() layer.use_pseudoinverse = True - + self._calculate_influence_map() return layer def resolve_voltages( diff --git a/quam_builder/architecture/quantum_dots/components/voltage_gate.py b/quam_builder/architecture/quantum_dots/components/voltage_gate.py index 9363e3df..ad294dfd 100644 --- a/quam_builder/architecture/quantum_dots/components/voltage_gate.py +++ b/quam_builder/architecture/quantum_dots/components/voltage_gate.py @@ -1,11 +1,10 @@ -from typing import Any, Dict, Generator, Optional, Sequence, Union +from typing import Optional, Dict, Union -from quam.components import SingleChannel -from quam.core import quam_dataclass -from quam.core.quam_classes import QuamBase +from quam.components import SingleChannel, Channel +from quam.core import quam_dataclass, QuamComponent -from .readout_transport import ANY_READOUT_TRANSPORT -from .readout_resonator import ANY_READOUT_RESONATOR +from .readout_resonator import ReadoutResonatorBase +from .readout_transport import ReadoutTransportBase from .dac_spec import DacSpec, QdacSpec @@ -23,10 +22,6 @@ class VoltageGate(SingleChannel): automatically. Default is None. offset_parameter: The optional DC offset of the voltage gate Can be e.g. a QDAC channel. - opx_output: When ``None``, the gate is **QDAC-only** (no OPX/LF analog port). It is omitted from - :meth:`~quam.core.quam_classes.QuamRoot.generate_config` (no ``elements`` entry, no sticky); - use :attr:`dac_spec` / external drivers for DC. - Example: >>> @@ -50,8 +45,7 @@ class VoltageGate(SingleChannel): # current_external_voltage, an attribute to help with serialising the experimental state current_external_voltage: Optional[float] = None dac_spec: DacSpec = None - readout: Union[ANY_READOUT_RESONATOR, ANY_READOUT_TRANSPORT] = None - opx_output: Any = None + readout: Union[ReadoutTransportBase, ReadoutResonatorBase] = None def __post_init__(self): super().__post_init__() @@ -81,26 +75,3 @@ def settle(self): """Wait for the voltage bias to settle""" if self.settling_time is not None: self.wait(int(self.settling_time) // 4 * 4) - - def iterate_components( - self, skip_elems: Optional[Sequence[QuamBase]] = None - ) -> Generator[QuamBase, None, None]: - """QDAC-only gates are excluded from QUA config generation (no OPX element conflicts).""" - if self.opx_output is None: - return - yield from super().iterate_components(skip_elems=skip_elems) - - def apply_to_config(self, config: Dict[str, dict]) -> None: - """Only OPX-backed gates contribute to ``elements``; QDAC-only gates skip via :meth:`iterate_components`.""" - if self.opx_output is None: - return - super().apply_to_config(config) - - def set_dc_offset(self, offset): # noqa: ANN001 — match SingleChannel API - """Raise for QDAC-only gates; OPX has no DC line to offset.""" - if self.opx_output is None: - raise RuntimeError( - "VoltageGate has no OPX analog output (QDAC-only wiring); " - "cannot use set_dc_offset on the OPX. Control DC via dac_spec / external DAC." - ) - super().set_dc_offset(offset) diff --git a/quam_builder/architecture/quantum_dots/components/xy_drive.py b/quam_builder/architecture/quantum_dots/components/xy_drive.py index b8230011..43b9931c 100644 --- a/quam_builder/architecture/quantum_dots/components/xy_drive.py +++ b/quam_builder/architecture/quantum_dots/components/xy_drive.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import ClassVar, Dict, Optional from quam.core import quam_dataclass @@ -20,8 +20,23 @@ class XYDriveBase: QUAM component for a XY drive line. """ + IF_LIMIT: ClassVar[float] = 400e6 + + def validate_intermediate_frequency(self) -> None: + """Raise ValueError if |IF| exceeds the OPX +-400 MHz band.""" + if_freq = self.intermediate_frequency # pylint: disable=no-member + if abs(if_freq) > self.IF_LIMIT: + name = getattr(self, "name", self.__class__.__name__) + raise ValueError( + f"Intermediate frequency {if_freq / 1e6:.2f} MHz exceeds " + f"\u00b1{self.IF_LIMIT / 1e6:.0f} MHz on '{name}'. " + f"Adjust LO_frequency or larmor_frequency." + ) + @staticmethod - def calculate_voltage_scaling_factor(fixed_power_dBm: float, target_power_dBm: float): + def calculate_voltage_scaling_factor( + fixed_power_dBm: float, target_power_dBm: float + ): """ Calculate the voltage scaling factor required to scale fixed power to target power. @@ -61,11 +76,13 @@ def add_pulse(self, name: str, pulse: pulses.Pulse) -> None: self.operations[name] = pulse -class XYDriveIQ(IQChannel, XYDriveBase): +@quam_dataclass +class XYDriveIQ(IQChannel, XYDriveBase): # pylint: disable=too-many-ancestors """ QUAM component for a XY drive line through an IQ channel. """ + RF_frequency: float = "#../larmor_frequency" intermediate_frequency: int = "#./inferred_intermediate_frequency" @property @@ -120,11 +137,16 @@ def set_output_power( ValueError: If `gain` or `amplitude` is outside their valid ranges. """ - return set_output_power_iq_channel(self, power_in_dbm, gain, max_amplitude, Z, operation) + return set_output_power_iq_channel( + self, power_in_dbm, gain, max_amplitude, Z, operation + ) @quam_dataclass -class XYDriveMW(MWChannel, XYDriveBase): +class XYDriveMW(MWChannel, XYDriveBase): # pylint: disable=too-many-ancestors + IF_LIMIT: ClassVar[float] = 500e6 # MW FEM NCO supports ±500 MHz + + RF_frequency: float = "#../larmor_frequency" intermediate_frequency: float = "#./inferred_intermediate_frequency" @property @@ -169,3 +191,7 @@ def set_output_power( return set_output_power_mw_channel( self, power_in_dbm, operation, full_scale_power_dbm, max_amplitude ) + + def add_pulse(self, name: str, pulse: pulses.Pulse) -> None: + """Add or update a pulse in the drive operations""" + self.operations[name] = pulse diff --git a/quam_builder/architecture/quantum_dots/defaults.py b/quam_builder/architecture/quantum_dots/defaults.py new file mode 100644 index 00000000..fe207b7d --- /dev/null +++ b/quam_builder/architecture/quantum_dots/defaults.py @@ -0,0 +1,170 @@ +"""Centralized default parameters for the quantum-dot architecture. + +This module is the **single source of truth** for every default parameter +value used by macros, pulses, and builders. Other modules import the +module-level ``DEFAULTS`` instance instead of defining their own constants. + +To customize defaults, modify ``DEFAULTS`` **before** importing builder or +macro modules — field defaults on macro classes are evaluated at import +time (when the class is first defined). + +Example:: + + from quam_builder.architecture.quantum_dots.defaults import ( + DEFAULTS, XYPulseDefaults, ReadoutDefaults, + ) + + DEFAULTS.xy_pulse = XYPulseDefaults(length=500, amplitude=0.25) + DEFAULTS.readout = ReadoutDefaults(length=3000, amplitude=0.35) + + # Now import and build — modules will pick up the modified values. + from quam_builder.builder.quantum_dots import build_quam + ... +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class XYPulseDefaults: + """Default parameters for the XY drive Gaussian pulse.""" + + length: int = 1000 + """Pulse length in nanoseconds (samples at 1 GS/s) → 1 µs.""" + + amplitude: float = 0.25 + """Normalized amplitude (pi-rotation reference).""" + + sigma_ratio: float = 1 / 6 + """Sigma = length × sigma_ratio.""" + + +@dataclass +class ReadoutDefaults: + """Default parameters for the sensor readout pulse.""" + + frequency: float = 400e6 + """Default readout intermediate frequency in Hz.""" + + length: int = 5000 + """Pulse length in nanoseconds (samples at 1 GS/s) → 1 µs.""" + + amplitude: float = 0.15 + """Normalized amplitude.""" + + +@dataclass +class FrequencyDefaults: + """Default frequency parameters for qubit XY drives.""" + + larmor_frequency: float | None = 5e9 + """Qubit resonance frequency in Hz. ``None`` means the builder + falls back to ``LO + intermediate_frequency``.""" + + lo_frequency: float | None = 5e9 + """Default LO/upconverter frequency in Hz.""" + + intermediate_frequency: float = 0 + """Default XY-drive intermediate frequency in Hz.""" + + +@dataclass +class StateMacroDefaults: + """Default timing for state macros (initialize, measure, empty).""" + + ramp_duration: int = 524 + """Ramp time in ns.""" + + hold_duration: int = 524 + """Hold time in ns for macros that still source it (e.g. voltage-balanced round-trips). + + Canonical :class:`~quam_builder.architecture.quantum_dots.operations.default_macros.state_macros.EmptyStateMacro` + and :class:`~quam_builder.architecture.quantum_dots.operations.default_macros.state_macros.InitializeStateMacro` + default ``hold_duration`` to ``None`` so the :class:`~quam_builder.architecture.quantum_dots.components.gate_set.VoltageTuningPoint` + duration applies unless the macro field is set. + """ + + buffer_duration: int = 524 + """Buffer time in ns. ``None`` means no extra buffer.""" + + point_duration: int = 524 + """Default voltage-point duration in ns.""" + + +@dataclass +class ExchangeDefaults: + """Default timing for exchange macros.""" + + ramp_duration: int = 524 + """Ramp time in ns.""" + + wait_duration: int = 524 + """Wait/hold time at the exchange point in ns.""" + + cz_duration: int = 524 + """Default hold time at the CZ voltage point in ns.""" + + +@dataclass +class VoltagePulseDefaults: + """Default voltage pulse parameters for gate channels.""" + + direct_amplitude: float = 0.25 + """Square pulse amplitude for direct voltage outputs.""" + + amplified_amplitude: float = 1.25 + """Square pulse amplitude for amplified voltage outputs.""" + + +@dataclass +class QdacDefaults: + """Default QDAC helper parameters.""" + + triangle_span_V: float = 0.2 + """Default span for QDAC triangle-wave helper in volts.""" + + +@dataclass +class MiscDefaults: + """Miscellaneous defaults.""" + + sticky_duration: int = 16 + """StickyChannelAddon duration in ns.""" + + identity_duration: int = 16 + """IdentityMacro wait duration in ns.""" + + +@dataclass +class QubitDefaults: + """Default parameters for LD qubit components.""" + + thermalization_time_factor: int = 5 + """Multiplier applied to T1 for thermal reset wait time.""" + + fallback_t1: float = 10e-6 + """Fallback T1 in seconds when a qubit has no calibrated T1.""" + + +@dataclass +class QDDefaults: + """Top-level container for all quantum-dot architecture defaults. + + Instantiate with no arguments for architecture defaults. + Override only the groups/fields that differ for your lab. + """ + + xy_pulse: XYPulseDefaults = field(default_factory=XYPulseDefaults) + readout: ReadoutDefaults = field(default_factory=ReadoutDefaults) + frequency: FrequencyDefaults = field(default_factory=FrequencyDefaults) + state_macro: StateMacroDefaults = field(default_factory=StateMacroDefaults) + exchange: ExchangeDefaults = field(default_factory=ExchangeDefaults) + voltage_pulse: VoltagePulseDefaults = field(default_factory=VoltagePulseDefaults) + qdac: QdacDefaults = field(default_factory=QdacDefaults) + qubit: QubitDefaults = field(default_factory=QubitDefaults) + misc: MiscDefaults = field(default_factory=MiscDefaults) + + +DEFAULTS = QDDefaults() diff --git a/quam_builder/architecture/quantum_dots/examples/dcz_macro_example.py b/quam_builder/architecture/quantum_dots/examples/dcz_macro_example.py new file mode 100644 index 00000000..607a5013 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/dcz_macro_example.py @@ -0,0 +1,417 @@ +# ruff: noqa: E402, I001 +# pylint: disable=line-too-long +"""Simulate the dynamically decoupled CZ (DCZ) macro on QM SaaS. + +The DCZ gate runs: CZ – X180(control, target) – CZ, refocusing low-frequency +noise while accumulating twice the exchange phase. + +Example: + python quam_builder/architecture/quantum_dots/examples/dcz_macro_example.py \ + --sim-backend cloud \ + --cloud-config .qm_saas_credentials.json + + Opens an interactive plot window by default. To also save a PNG, pass ``--plot-dir``. + Use ``--no-plot`` in headless/CI runs. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from collections.abc import Iterator, Mapping +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parents[4] +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +_CS_INSTALLATIONS_TESTS_CONFIG = ( + _PROJECT_ROOT.parent / "tests" / ".qm_cluster_config.json" +) +_DCZ_SIM_CLOCK_CYCLES = 8_000 +_LF_FEM_OUTPUT_DELAY_NS = 161 + +from qm import QuantumMachinesManager, SimulationConfig, qua # noqa: E402 +from quam.components.ports import LFFEMAnalogOutputPort # noqa: E402 + +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( # noqa: E402 + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, +) # noqa: E402 +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( # noqa: E402 + VoltageBalancedMacroCatalog, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + TwoQubitMacroName, + VoltagePointName, +) # noqa: E402 +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.two_qubit_macros import ( # noqa: E402 + BalancedDCz2QMacro, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam # noqa: E402 + + +def build_dcz_tutorial_machine() -> LossDiVincenzoQuam: + cluster_cfg = ( + _CS_INSTALLATIONS_TESTS_CONFIG + if _CS_INSTALLATIONS_TESTS_CONFIG.is_file() + else None + ) + machine = build_tutorial_machine( + mw_fem_slots=[1], + lf_fem_slots=[3, 5], + cluster_config_path=cluster_cfg, + ) + wire_machine_macros(machine, catalogs=[VoltageBalancedMacroCatalog()], save=False) + _set_demo_voltage_points(machine) + _apply_lf_fem_output_delay(machine) + return machine + + +def _set_demo_voltage_points(machine: LossDiVincenzoQuam) -> None: + pair = next(iter(machine.quantum_dot_pairs.values())) + dot_ids = [dot.id for dot in pair.quantum_dots] + pair.add_point( + VoltagePointName.EMPTY, + dict.fromkeys(dot_ids, 0.05), + duration=1000, + replace_existing_point=True, + ) + pair.add_point( + VoltagePointName.MEASURE, + dict.fromkeys(dot_ids, 0.12), + duration=1000, + replace_existing_point=True, + ) + + qubit_pair = next(iter(machine.qubit_pairs.values())) + exchange_dot_ids = [ + qubit_pair.quantum_dot_pair.quantum_dots[0].id, + qubit_pair.quantum_dot_pair.quantum_dots[1].id, + ] + qubit_pair.add_point( + VoltagePointName.EXCHANGE, + dict.fromkeys(exchange_dot_ids, 0.06), + duration=1000, + replace_existing_point=True, + ) + + +def _apply_lf_fem_output_delay(machine: LossDiVincenzoQuam) -> None: + for virtual_gate_set in machine.virtual_gate_sets.values(): + for channel in virtual_gate_set.channels.values(): + output = getattr(channel, "opx_output", None) + if isinstance(output, LFFEMAnalogOutputPort): + output.delay = _LF_FEM_OUTPUT_DELAY_NS + + for sensor_dot in machine.sensor_dots.values(): + resonator = getattr(sensor_dot, "readout_resonator", None) + output = ( + getattr(resonator, "opx_output", None) if resonator is not None else None + ) + if isinstance(output, LFFEMAnalogOutputPort): + output.delay = _LF_FEM_OUTPUT_DELAY_NS + + +def swap_qubit_pair_cz_to_dcz(machine: LossDiVincenzoQuam) -> None: + """Replace the BalancedCz2QMacro on the first qubit pair with BalancedDCz2QMacro.""" + qubit_pair = next(iter(machine.qubit_pairs.values())) + + before_type = type(qubit_pair.macros[TwoQubitMacroName.CZ.value]).__name__ + qubit_pair.macros[TwoQubitMacroName.CZ.value] = BalancedDCz2QMacro() + after_type = type(qubit_pair.macros[TwoQubitMacroName.CZ.value]).__name__ + + print( + f"Swapped qubit pair '{qubit_pair.name}' CZ macro: {before_type} → {after_type}", + flush=True, + ) + + +def demonstrate_dcz_persistence( + machine: LossDiVincenzoQuam, +) -> LossDiVincenzoQuam: + """Save the machine state to disk, reload it, and verify the DCZ macro survives.""" + qubit_pair = next(iter(machine.qubit_pairs.values())) + macro_before: BalancedDCz2QMacro = qubit_pair.macros[TwoQubitMacroName.CZ.value] + + assert isinstance(macro_before, BalancedDCz2QMacro), ( + f"Expected BalancedDCz2QMacro, got {type(macro_before).__name__}" + ) + print( + f"Before save — type={type(macro_before).__name__}, " + f"wait_duration={macro_before.wait_duration}, " + f"ramp_duration={macro_before.ramp_duration}, " + f"x180_pulse_name={macro_before.x180_pulse_name!r}", + flush=True, + ) + + with tempfile.TemporaryDirectory() as save_dir: + machine.save(save_dir) + print(f"Saved QuAM state → {save_dir}", flush=True) + loaded = LossDiVincenzoQuam.load(save_dir) + + loaded_pair = next(iter(loaded.qubit_pairs.values())) + macro_after: BalancedDCz2QMacro = loaded_pair.macros[TwoQubitMacroName.CZ.value] + + assert isinstance(macro_after, BalancedDCz2QMacro), ( + f"Reload failed: expected BalancedDCz2QMacro, got {type(macro_after).__name__}" + ) + assert macro_after.wait_duration == macro_before.wait_duration + assert macro_after.ramp_duration == macro_before.ramp_duration + assert macro_after.x180_pulse_name == macro_before.x180_pulse_name + + print( + f"After reload — type={type(macro_after).__name__}, " + f"wait_duration={macro_after.wait_duration}, " + f"ramp_duration={macro_after.ramp_duration}, " + f"x180_pulse_name={macro_after.x180_pulse_name!r}", + flush=True, + ) + print( + "Persistence check passed: BalancedDCz2QMacro round-trips cleanly.", + flush=True, + ) + return loaded + + +def build_dcz_program(machine: LossDiVincenzoQuam) -> object: + """QUA program: initialize → x90 → DCZ → measure.""" + pair = next(iter(machine.quantum_dot_pairs.values())) + qubit = next(iter(machine.qubits.values())) + qubit_pair = next(iter(machine.qubit_pairs.values())) + + with qua.program() as program: + pair.initialize() + qua.align(qubit.xy.name) + qubit.x90() + qua.align() + qubit_pair.cz() # BalancedDCz2QMacro: CZ – X180(ctrl, tgt) – CZ + qua.align() + pair.measure() + + return program + + +def _load_cloud_config(path: Path | None) -> tuple[str, str, str, Path]: + config_path = path or _PROJECT_ROOT / ".qm_saas_credentials.json" + if not config_path.is_file(): + raise FileNotFoundError(f"Cloud config not found: {config_path}") + + with open(config_path, encoding="utf-8") as file: + config = json.load(file) + + return ( + config["email"], + config["password"], + config.get("host", "qm-saas.dev.quantum-machines.co"), + config_path, + ) + + +def _iter_controller_samples(samples: object) -> Iterator[tuple[str, object]]: + if isinstance(samples, Mapping): + for controller_id, controller_samples in samples.items(): + if controller_samples is not None: + yield str(controller_id), controller_samples + return + + for controller_id in ("con1", "con2", "con3", "con4", "con5"): + if hasattr(samples, controller_id): + yield controller_id, getattr(samples, controller_id) + + +def _lf_output_sample_keys( + machine: LossDiVincenzoQuam, + gate_set_id: str = "main_qpu", +) -> Iterator[tuple[str, str, float, str]]: + for channel in machine.virtual_gate_sets[gate_set_id].channels.values(): + output = channel.opx_output + if not isinstance(output, LFFEMAnalogOutputPort): + continue + dt = 1.0 / float(output.sampling_rate) if output.sampling_rate else 1e-9 + yield output.controller_id, f"{output.fem_id}-{output.port_id}", dt, channel.id + + +def _dt_for_sample( + controller_samples: object, + sim_key: str, + machine: LossDiVincenzoQuam, + controller_id: str, +) -> float: + sampling_rates = getattr(controller_samples, "analog_sampling_rate", None) + if isinstance(sampling_rates, Mapping) and sampling_rates.get(sim_key): + return 1.0 / float(sampling_rates[sim_key]) + + for ctrl, key, dt, _label in _lf_output_sample_keys(machine): + if ctrl == controller_id and key == sim_key: + return dt + return 1e-9 + + +def _as_waveform_array(waveform: object) -> object: + import numpy as np + + return np.asarray(waveform).ravel() + + +def _check_lf_integrals( + samples: object, + machine: LossDiVincenzoQuam, + *, + atol_v_s: float = 2e-5, +) -> None: + checked = 0 + controller_samples = dict(_iter_controller_samples(samples)) + for controller_id, sim_key, _dt, _label in _lf_output_sample_keys(machine): + samples_for_controller = controller_samples.get(controller_id) + if ( + samples_for_controller is None + or sim_key not in samples_for_controller.analog + ): + continue + + dt = _dt_for_sample(samples_for_controller, sim_key, machine, controller_id) + values = _as_waveform_array(samples_for_controller.analog[sim_key]).real + integral = float(values.sum() * dt) + checked += 1 + if abs(integral) > atol_v_s: + raise AssertionError( + f"DC integral check failed: controller={controller_id} key={sim_key} " + f"integral={integral:.3e} V*s (tol {atol_v_s:.3e})" + ) + + if checked == 0: + print( + "No LF analog samples were returned; skipped DC integral check.", flush=True + ) + else: + print(f"DC integral check passed on {checked} LF line(s).", flush=True) + + +def _plot_samples( + samples: object, + machine: LossDiVincenzoQuam, + *, + output_path: Path | None = None, + title: str = "DCZ macro sequence (CZ – X180 – CZ)", +) -> None: + import matplotlib.pyplot as plt + import numpy as np + + lf_labels = { + (controller_id, sim_key): label + for controller_id, sim_key, _dt, label in _lf_output_sample_keys(machine) + } + fig, ax = plt.subplots(figsize=(9, 3.5)) + plotted = 0 + for controller_id, controller_samples in _iter_controller_samples(samples): + for sim_key, waveform in controller_samples.analog.items(): + values = _as_waveform_array(waveform) + if len(values) == 0: + continue + dt = _dt_for_sample(controller_samples, sim_key, machine, controller_id) + label = lf_labels.get( + (controller_id, sim_key), f"{controller_id} {sim_key}" + ) + time_us = np.arange(len(values)) * dt * 1e6 + ax.plot(time_us, values.real, lw=0.8, label=f"{label} real") + plotted += 1 + if np.any(values.imag): + ax.plot( + time_us, + values.imag, + lw=0.8, + ls="--", + label=f"{label} imag", + ) + plotted += 1 + + if plotted == 0: + ax.text(0.5, 0.5, "No analog data", transform=ax.transAxes, ha="center") + else: + ax.legend(loc="best", fontsize=6, framealpha=0.92) + + ax.set_xlabel("Time (us)") + ax.set_ylabel("V") + ax.set_title(title) + fig.tight_layout() + if output_path is not None: + fig.savefig(output_path, dpi=120) + plt.show() + plt.close(fig) + + +def run_cloud_dcz_simulation( + machine: LossDiVincenzoQuam, + *, + cloud_config: Path | None, + plot_dir: Path | None, + no_plot: bool = False, +) -> None: + swap_qubit_pair_cz_to_dcz(machine) + machine = demonstrate_dcz_persistence(machine) + _apply_lf_fem_output_delay(machine) + + try: + import qm_saas # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError("Cloud simulation requires the `qm_saas` package.") from exc + + email, password, host, config_path = _load_cloud_config(cloud_config) + print( + f"Using QM cloud simulator: host={host!r} (credentials: {config_path})", + flush=True, + ) + + client = qm_saas.QmSaas(email=email, password=password, host=host) + client.close_all() + with client.simulator(client.latest_version()) as instance: + qmm = QuantumMachinesManager( + host=instance.host, + port=instance.port, + connection_headers=instance.default_connection_headers, + ) + job = qmm.simulate( + machine.generate_config(), + build_dcz_program(machine), + SimulationConfig(duration=_DCZ_SIM_CLOCK_CYCLES), + ) + job.wait_until("Done", timeout=600) + samples = job.get_simulated_samples() + + _check_lf_integrals(samples, machine) + if not no_plot: + save_path = plot_dir / "dcz_sequence.png" if plot_dir is not None else None + if save_path is not None: + save_path.parent.mkdir(parents=True, exist_ok=True) + _plot_samples(samples, machine, output_path=save_path) + print("Simulated: DCZ macro sequence (CZ – X180 – CZ)", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sim-backend", choices=("cloud",), default="cloud") + parser.add_argument("--cloud-config", type=Path, default=None) + parser.add_argument("--plot-dir", type=Path, default=None) + parser.add_argument( + "--no-plot", + action="store_true", + help="Skip matplotlib (use in headless/CI; no window, no file).", + ) + # parse_known_args: Jupyter/IPython pass e.g. --f=... for the kernel connection file. + args, _unknown = parser.parse_known_args() + + run_cloud_dcz_simulation( + build_dcz_tutorial_machine(), + cloud_config=args.cloud_config, + plot_dir=args.plot_dir, + no_plot=args.no_plot, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py b/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py deleted file mode 100644 index 67a24f47..00000000 --- a/quam_builder/architecture/quantum_dots/examples/default_macro_defaults_example.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Example: wire, parameterize, and use built-in default macros (no overrides). - -This script demonstrates: -1. Building a small two-qubit quantum-dots machine. -2. Wiring architecture defaults with ``wire_machine_macros(machine)`` only. -3. Parameterizing instantiated default macro objects and reference pulses directly on components. -4. Building a QUA program that calls those default macros. -""" - -# pylint: disable=no-member # WiringLineType enum members are runtime-only - -from __future__ import annotations - -from typing import Dict - -import numpy as np -from qm import qua -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from quam.components import pulses - -from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros -from quam_builder.architecture.quantum_dots.operations.names import ( - DrivePulseName, - SingleQubitMacroName, - VoltagePointName, -) -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam -from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder -from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder - - -def _plunger_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} - - -def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} - - -def _barrier_ports(pair_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} - - -def build_demo_machine() -> LossDiVincenzoQuam: - """Build a small machine with 2 qubits and 1 qubit pair.""" - machine = BaseQuamQD() - machine.wiring = { - "qubits": { - "q1": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), - }, - "q2": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), - WiringLineType.DRIVE.value: _mw_drive_ports("q2"), - }, - }, - "qubit_pairs": { - "q1_q2": { - WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2") - }, # pylint: disable=no-member - }, - } - machine = _BaseQpuBuilder(machine).build() - machine = _LDQubitBuilder(machine).build() - - if getattr(machine, "qpu", None) is None: - machine.qpu = QPU() - - # Seed minimal reference pulse used by default XY macros. - # Only one pulse is needed — XYDriveMacro scales amplitude for angle - # and applies virtual-Z for rotation axis. - for qubit in machine.qubits.values(): - if qubit.xy is None: - continue - qubit.xy.operations.setdefault( - DrivePulseName.GAUSSIAN, - pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16), - ) - - # Defaults only: no profile and no runtime overrides. - wire_machine_macros(machine) - return machine - - -def add_default_state_points(machine: LossDiVincenzoQuam) -> None: - """Define canonical voltage points consumed by state macros.""" - for qubit in machine.qubits.values(): - dot_id = qubit.quantum_dot.id - qubit.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) - qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=220) - qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=180) - - -def parameterize_default_macros(machine: LossDiVincenzoQuam) -> None: - """Tune parameters on already-wired default macro instances and pulses.""" - for qubit in machine.qubits.values(): - qubit.macros[VoltagePointName.INITIALIZE].ramp_duration = 64 - qubit.macros[VoltagePointName.MEASURE].hold_duration = 240 - qubit.xy.operations[DrivePulseName.GAUSSIAN].amplitude = 0.0085 - # Identity duration may start as a reference; concretize before assigning numeric value. - qubit.macros[SingleQubitMacroName.IDENTITY].duration = None - qubit.macros[SingleQubitMacroName.IDENTITY].duration = 24 - - -def print_macro_parameters(machine: LossDiVincenzoQuam) -> None: - """Print key default-macro class bindings and tuned parameters.""" - q1 = machine.qubits["q1"] - print("\n=== Default Macro Parameterization ===") - print("q1.initialize class:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) - print("q1.xy_drive class:", type(q1.macros[SingleQubitMacroName.XY_DRIVE]).__name__) - print("q1.I class:", type(q1.macros[SingleQubitMacroName.IDENTITY]).__name__) - print( - "q1.initialize.ramp_duration:", - q1.macros[VoltagePointName.INITIALIZE].ramp_duration, - ) - print( - "q1.measure.hold_duration:", - q1.macros[VoltagePointName.MEASURE].hold_duration, - ) - print( - "q1.gaussian.amplitude:", - q1.xy.operations[DrivePulseName.GAUSSIAN].amplitude, - ) - print("q1.I.duration:", q1.macros[SingleQubitMacroName.IDENTITY].duration) - - -def build_program(machine: LossDiVincenzoQuam): - """Build a QUA program that uses default macros only.""" - q1 = machine.qubits["q1"] - q2 = machine.qubits["q2"] - - with qua.program() as prog: - q1.initialize() - q2.initialize() - q1.x90() - q1.x(angle=-np.pi / 2) # Negative-angle behavior comes from default XY logic. - q2.y(angle=np.pi / 3) - q1.z90() - q2.I() - q1.measure() - q2.measure() - - return prog - - -def main() -> None: - machine = build_demo_machine() - add_default_state_points(machine) - parameterize_default_macros(machine) - print_macro_parameters(machine) - - _ = build_program(machine) - print("\nBuilt QUA program successfully using parameterized default macros (no overrides).") - - -if __name__ == "__main__": - main() diff --git a/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py b/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py deleted file mode 100644 index f3636c1a..00000000 --- a/quam_builder/architecture/quantum_dots/examples/default_macro_overrides_example.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Example: macro wiring with typed override helpers. - -Demonstrates the recommended Python API for overriding macros and pulses: - -1. ``wire_machine_macros(machine)`` — wire all defaults. -2. ``component_overrides={LDQubit: overrides(...)}`` — override all qubits of a type. -3. ``instance_overrides={"qubits.q1": overrides(...)}`` — override one specific qubit. -4. ``macro(Factory, **params)`` — validated macro entry (catches bad factories early). -5. ``pulse("GaussianPulse", length=500, ...)`` — typed pulse entry. -6. ``disabled()`` — remove a macro or pulse from a component. - -Key imports:: - - from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, # wiring entry point - macro, # build a macro override entry - disabled, # remove a macro/pulse - pulse, # build a pulse override entry - overrides, # group macros + pulses for one component - ) -""" - -# pylint: disable=no-member # WiringLineType enum members are runtime-only - -# Macro classes in this demo inherit from framework mixins with deep MRO. -# pylint: disable=too-many-ancestors - -from __future__ import annotations - -from typing import Dict - -from qm import qua -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from quam.components import pulses -from quam.components.macro import QubitPairMacro - -from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - macro, - overrides, -) -from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( - X180Macro, -) -from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( - InitializeStateMacro, -) -from quam_builder.architecture.quantum_dots.operations.names import ( - DrivePulseName, - SingleQubitMacroName, - TwoQubitMacroName, - VoltagePointName, -) -from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam -from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder -from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder - - -# --------------------------------------------------------------------------- -# Custom macro classes (users would define these in their lab package) -# --------------------------------------------------------------------------- - - -class TunedX180Macro(X180Macro): - """Lab-calibrated X180 macro for a specific qubit. - - Inheriting from the default X180Macro means the delegation chain - (x180 → x → xy_drive → qubit.xy.play) is preserved. - """ - - pass - - -class DemoCZMacro(QubitPairMacro): - """Placeholder CZ gate showing how users replace default 2Q stubs. - - Default two-qubit macros raise NotImplementedError — users replace - them with calibration-specific logic via overrides. - """ - - duration_ns: int = 64 - - @property - def inferred_duration(self) -> float: - return self.duration_ns * 1e-9 - - def apply(self, duration_ns: int | None = None, **kwargs): - duration = self.duration_ns if duration_ns is None else duration_ns - duration_cycles = max(0, int(round(duration / 4.0))) - - control_xy = self.qubit_pair.qubit_control.xy.name - target_xy = self.qubit_pair.qubit_target.xy.name - qua.align(control_xy, target_xy) - if duration_cycles > 0: - qua.wait(duration_cycles, control_xy, target_xy) - - -# --------------------------------------------------------------------------- -# Machine construction helpers (same as other examples) -# --------------------------------------------------------------------------- - - -def _plunger_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} - - -def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} - - -def _barrier_ports(pair_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} - - -def build_demo_machine() -> LossDiVincenzoQuam: - """Build a small machine with 2 qubits and 1 qubit pair.""" - machine = BaseQuamQD() - machine.wiring = { - "qubits": { - "q1": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), - }, - "q2": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), - WiringLineType.DRIVE.value: _mw_drive_ports("q2"), - }, - }, - "qubit_pairs": { - "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, - }, - } - machine = _BaseQpuBuilder(machine).build() - machine = _LDQubitBuilder(machine).build() - if getattr(machine, "qpu", None) is None: - machine.qpu = QPU() - - for qubit in machine.qubits.values(): - if qubit.xy is None: - continue - qubit.xy.operations.setdefault( - DrivePulseName.GAUSSIAN, - pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16), - ) - - # Wire all defaults — no overrides yet - wire_machine_macros(machine) - return machine - - -def add_default_state_points(machine: LossDiVincenzoQuam) -> None: - """Define canonical voltage points used by default state macros.""" - for qubit in machine.qubits.values(): - dot_id = qubit.quantum_dot.id - qubit.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) - qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=200) - qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=200) - - -def print_macro_summary(machine: LossDiVincenzoQuam, title: str) -> None: - """Print macro class bindings for key components/macros.""" - q1 = machine.qubits["q1"] - pair = machine.qubit_pairs["q1_q2"] - print(f"\n=== {title} ===") - print("q1 macros:", sorted(q1.macros.keys())) - print("q1.initialize:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) - print("q1.x180:", type(q1.macros[SingleQubitMacroName.X_180]).__name__) - print("q1_q2.cz:", type(pair.macros[TwoQubitMacroName.CZ]).__name__) - - -# --------------------------------------------------------------------------- -# Override wiring using the typed API -# --------------------------------------------------------------------------- - - -def apply_macro_overrides(machine: LossDiVincenzoQuam) -> None: - """Apply component-type and instance-level overrides using the typed API. - - This demonstrates the three key helpers: - - ``overrides(macros={...})`` groups macro overrides for a component - - ``macro(Factory, **params)`` creates a validated macro entry - - Component type keys use the actual class (LDQubit, LDQubitPair) - - Instance keys are path strings ("qubits.q1") - """ - wire_machine_macros( - machine, - # --- Override all LDQubits: custom initialize with longer ramp --- - # --- Override all LDQubitPairs: replace placeholder CZ --- - component_overrides={ - LDQubit: overrides( - macros={ - SingleQubitMacroName.INITIALIZE: macro( - InitializeStateMacro, - ramp_duration=64, - ), - } - ), - LDQubitPair: overrides( - macros={ - TwoQubitMacroName.CZ: macro(DemoCZMacro), - } - ), - }, - # --- Override one specific qubit: custom X180 on q1 only --- - instance_overrides={ - "qubits.q1": overrides( - macros={ - SingleQubitMacroName.X_180: macro(TunedX180Macro), - } - ), - }, - strict=True, - ) - - -def build_program(machine: LossDiVincenzoQuam): - """Build a QUA program using default and overridden macros.""" - q1 = machine.qubits["q1"] - q2 = machine.qubits["q2"] - pair = machine.qubit_pairs["q1_q2"] - - with qua.program() as prog: - q1.initialize() - q2.initialize() - q1.x90() - q1.x180() # Uses TunedX180Macro after override - q2.empty() - pair.cz() # Uses DemoCZMacro after override - q1.measure() - q2.measure() - - return prog - - -def main() -> None: - machine = build_demo_machine() - add_default_state_points(machine) - print_macro_summary(machine, "Defaults") - - apply_macro_overrides(machine) - print_macro_summary(machine, "After Overrides") - - _ = build_program(machine) - print("\nBuilt QUA program successfully with wired default+override macros.") - - -if __name__ == "__main__": - main() diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py index 829cee52..9a4ba166 100644 --- a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/__init__.py @@ -1,8 +1,8 @@ """Demo package for external macro catalog workflow. -Provides build_component_overrides for use with wire_machine_macros. +Provides LabMacroCatalog for use with wire_machine_macros. """ -from .catalog import LabInitializeMacro, build_component_overrides +from .catalog import LabInitializeMacro, LabMacroCatalog -__all__ = ["LabInitializeMacro", "build_component_overrides"] +__all__ = ["LabInitializeMacro", "LabMacroCatalog"] diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py index fcd0de4c..4ea1c23a 100644 --- a/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_demo/catalog.py @@ -1,27 +1,27 @@ """External macro catalog demonstrating lab-owned macro package pattern. -This module provides ``build_component_overrides()`` for use with +This module implements the ``MacroCatalog`` protocol for use with ``wire_machine_macros``. Custom macro classes use ``@quam_dataclass`` so they survive QuAM serialization. Usage:: - from my_lab_macros.catalog import build_component_overrides + from my_lab_macros.catalog import LabMacroCatalog from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros - wire_machine_macros( - machine, - component_overrides=build_component_overrides(), - strict=True, - ) + wire_machine_macros(machine, catalogs=[LabMacroCatalog()]) """ from __future__ import annotations +from functools import partial +from typing import Any + from quam.core import quam_dataclass -from quam_builder.architecture.quantum_dots.components import QuantumDot -from quam_builder.architecture.quantum_dots.macro_engine import macro, overrides +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + MacroFactoryMap, +) from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( InitializeStateMacro, ) @@ -43,33 +43,30 @@ def apply( self, ramp_duration: int | None = None, hold_duration: int | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: """Ramp to initialize point using lab_ramp_duration when ramp_duration not specified.""" ramp = ramp_duration if ramp_duration is not None else self.lab_ramp_duration return super().apply(ramp_duration=ramp, hold_duration=hold_duration, **kwargs) -def build_component_overrides() -> dict: - """Build component_overrides for wire_machine_macros. +class LabMacroCatalog: + """Lab-owned macro catalog implementing the MacroCatalog protocol. - Returns a mapping keyed by component class, suitable for:: + Override QuantumDot.initialize with LabInitializeMacro. + """ - wire_machine_macros( - machine, - component_overrides=build_component_overrides(), - strict=True, - ) + priority = 200 - Overrides QuantumDot.initialize with LabInitializeMacro. - """ - return { - QuantumDot: overrides( - macros={ - SingleQubitMacroName.INITIALIZE: macro( + def get_factories(self, component_type: type) -> MacroFactoryMap: + """Return lab-specific factories for *component_type*.""" + from quam_builder.architecture.quantum_dots.components import QuantumDot + + if issubclass(component_type, QuantumDot): + return { + SingleQubitMacroName.INITIALIZE: partial( LabInitializeMacro, lab_ramp_duration=80, ), } - ), - } + return {} diff --git a/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py b/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py index 203c5f1c..98425d70 100644 --- a/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py +++ b/quam_builder/architecture/quantum_dots/examples/external_macro_package_example.py @@ -1,12 +1,12 @@ """Example: external macro package workflow. -This script demonstrates the external macro package pattern — custom macros -in a separate package, imported and passed to wire_machine_macros. -Runs without QM hardware (no qm.open, qm.run, or machine.connect). +This script demonstrates the external macro package pattern -- custom macros +in a separate package implementing the ``MacroCatalog`` protocol, passed to +``wire_machine_macros``. Runs without QM hardware (no qm.open, qm.run, or machine.connect). The key idea: keep lab-owned macro logic in a separate package so it -survives upstream quam-builder pulls. The package exports a dict suitable -for the ``component_overrides`` kwarg of ``wire_machine_macros``. +survives upstream quam-builder pulls. The package exports a catalog object +for the ``catalogs`` kwarg of ``wire_machine_macros``. """ from __future__ import annotations @@ -14,37 +14,34 @@ import sys from pathlib import Path -# Allow running as: python quam_builder/.../external_macro_package_example.py _project_root = Path(__file__).resolve().parents[4] if str(_project_root) not in sys.path: sys.path.insert(0, str(_project_root)) from qm import qua # noqa: E402 from quam_builder.architecture.quantum_dots.examples.external_macro_demo.catalog import ( # noqa: E402 - build_component_overrides, + LabMacroCatalog, ) from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( # noqa: E402 build_tutorial_machine, ) -from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros # noqa: E402 +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, +) # noqa: E402 def main() -> None: - """Build machine, wire macros with external overrides, and verify.""" + """Build machine, wire macros with external catalog, and verify.""" machine = build_tutorial_machine() - # Pass the external catalog's overrides to wire_machine_macros. - # component_overrides is keyed by actual class objects (e.g. QuantumDot), - # so typos in class names are caught at import time, not wiring time. wire_machine_macros( machine, - component_overrides=build_component_overrides(), - strict=True, + catalogs=[LabMacroCatalog()], ) - q1 = machine.qubits["Q1"] - q2 = machine.qubits["Q2"] - pair = machine.quantum_dot_pairs["dot1_dot2_pair"] + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + pair = machine.quantum_dot_pairs["virtual_dot_1_virtual_dot_2_pair"] sensor_dot = machine.sensor_dots["virtual_sensor_1"] with qua.program() as _: @@ -55,7 +52,7 @@ def main() -> None: q1.measure() q2.measure() - print("Built QUA program successfully with external macro overrides.") + print("Built QUA program successfully with external macro catalog.") if __name__ == "__main__": diff --git a/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py b/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py index a5a79ea9..64e2e9d5 100644 --- a/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py +++ b/quam_builder/architecture/quantum_dots/examples/full_workflow_example.py @@ -1,35 +1,28 @@ """Full workflow example: wiring, macros, pulses, and overrides. -This script demonstrates the complete end-to-end workflow for building -a Loss-DiVincenzo qubit machine with default macros and pulses, and -shows how to: +This script demonstrates the complete end-to-end workflow for a +Loss-DiVincenzo qubit machine with default macros and pulses: -1. Wire a Loss-DiVincenzo qubit machine (combined single-stage workflow). +1. Build the machine via the combined wiring workflow. 2. Wire default macros via ``wire_machine_macros()``. 3. Update operation parameters and the reference pulse itself. -4. Update the drive pulse type (e.g. swap Gaussian for DRAG). +4. Switch the pulse family (e.g. Gaussian -> Kaiser). 5. Replace a particular macro (instance-level and type-level overrides). """ -# pylint: disable=no-member # pylint: disable=too-many-ancestors from __future__ import annotations -from typing import Dict - -import numpy as np from qm import qua -from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from quam.components import pulses from quam.components.macro import QubitPairMacro -from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - macro, - overrides, +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + TypeOverrideCatalog, ) from quam_builder.architecture.quantum_dots.operations.names import ( DrivePulseName, @@ -41,81 +34,11 @@ X180Macro, ) from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam -from quam_builder.builder.qop_connectivity import build_quam_wiring -from quam_builder.builder.quantum_dots import build_quam - - -######################################################################################################################## -# %% Static parameters -######################################################################################################################## - -host_ip = "172.16.33.115" -cluster_name = "CS_3" - -global_gates = [1, 2] -sensor_dots = [1, 2] -quantum_dots = [1, 2, 3] -quantum_dot_pairs = [(1, 2), (2, 3)] - -qubit_pair_sensor_map = { - "q1_q2": ["sensor_1"], - "q2_q3": ["sensor_2"], -} - +from quam_builder.architecture.quantum_dots.qubit_pair.ld_qubit_pair import LDQubitPair +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam ######################################################################################################################## -# %% STEP 1: Wire a Loss-DiVincenzo qubit machine -######################################################################################################################## - - -def build_wired_machine() -> LossDiVincenzoQuam: - """Build a machine using the combined single-stage workflow. - - This creates connectivity with all components (dots, sensors, drive lines) - in one go, allocates wiring, and builds the full Loss-DiVincenzo QUAM. - """ - print("=" * 80) - print("STEP 1: Build wired Loss-DiVincenzo machine") - print("=" * 80) - - instruments = Instruments() - instruments.add_mw_fem(controller=1, slots=[1]) - instruments.add_lf_fem(controller=1, slots=[2, 3]) - - connectivity = Connectivity() - connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") - connectivity.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False - ) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) - connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) - - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring(connectivity, host_ip, cluster_name, machine) - machine = build_quam( - machine, - qubit_pair_sensor_map=qubit_pair_sensor_map, - connect_qdac=False, - save=False, - ) - - print(f" Qubits: {list(machine.qubits.keys())}") - print(f" Qubit pairs: {list(machine.qubit_pairs.keys())}") - print(f" Sensor dots: {list(machine.sensor_dots.keys())}") - return machine - - -######################################################################################################################## -# %% STEP 2: Wire default macros +# %% STEP 1: Wire default macros ######################################################################################################################## @@ -129,22 +52,24 @@ def wire_defaults(machine: LossDiVincenzoQuam) -> None: - Default ``gaussian`` reference pulse on each qubit's XY drive - Default ``readout`` pulse on sensor dot resonators """ - print("\n" + "=" * 80) - print("STEP 2: Wire default macros and pulses") + print("=" * 80) + print("STEP 1: Wire default macros and pulses") print("=" * 80) wire_machine_macros(machine) q1 = machine.qubits["q1"] print(f" q1 macros: {sorted(q1.macros.keys())}") - print(f" q1 xy_drive pulse: {type(q1.xy.operations.get(DrivePulseName.GAUSSIAN)).__name__}") + print( + f" q1 xy_drive pulse: {type(q1.xy.operations.get(DrivePulseName.GAUSSIAN)).__name__}" + ) print( f" q1 reference_pulse_name: {q1.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name}" ) ######################################################################################################################## -# %% STEP 3: Update operation parameters +# %% STEP 2: Update operation parameters ######################################################################################################################## @@ -155,78 +80,61 @@ def update_operation_parameters(machine: LossDiVincenzoQuam) -> None: Their parameters can be tuned directly without re-wiring. """ print("\n" + "=" * 80) - print("STEP 3: Update operation parameters") + print("STEP 2: Update operation parameters") print("=" * 80) for qubit in machine.qubits.values(): - # Tune initialize ramp duration qubit.macros[VoltagePointName.INITIALIZE].ramp_duration = 64 - - # Tune measure hold duration - qubit.macros[VoltagePointName.MEASURE].hold_duration = 240 - - # Update the source-of-truth gaussian amplitude directly. - qubit.xy.operations[DrivePulseName.GAUSSIAN].amplitude = 0.17 - - # Set identity wait duration + qubit.macros[VoltagePointName.MEASURE].buffer_duration = 240 + qubit.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude = 0.17 qubit.macros[SingleQubitMacroName.IDENTITY].duration = 24 q1 = machine.qubits["q1"] - print(f" q1.initialize.ramp_duration = {q1.macros[VoltagePointName.INITIALIZE].ramp_duration}") - print(f" q1.measure.hold_duration = {q1.macros[VoltagePointName.MEASURE].hold_duration}") - print(" q1.gaussian.amplitude = " f"{q1.xy.operations[DrivePulseName.GAUSSIAN].amplitude}") + print( + f" q1.initialize.ramp_duration = {q1.macros[VoltagePointName.INITIALIZE].ramp_duration}" + ) + print( + f" q1.measure.buffer_duration = {q1.macros[VoltagePointName.MEASURE].buffer_duration}" + ) + print( + " q1.gaussian_x90.amplitude = " + f"{q1.xy.operations[f'{DrivePulseName.GAUSSIAN}_x90'].amplitude}" + ) print(f" q1.I.duration = {q1.macros[SingleQubitMacroName.IDENTITY].duration}") ######################################################################################################################## -# %% STEP 4: Update the drive pulse type (Gaussian -> DRAG) +# %% STEP 3: Update the drive pulse type (Gaussian -> DRAG) ######################################################################################################################## def update_drive_pulse_type(machine: LossDiVincenzoQuam) -> None: - """Replace the default Gaussian drive with a DRAG pulse. + """Switch all XY-drive macros from Gaussian to Kaiser pulse family. - The ``XYDriveMacro`` uses ``reference_pulse_name`` to select which pulse - from ``qubit.xy.operations`` is the single source of truth for all gates. + The ``pulse_family`` field on each ``XYDriveMacro`` determines which + set of operations (e.g. ``kaiser_x90``, ``kaiser_x180``) is used. + All families are pre-wired at build time; switching is instantaneous. - To switch from Gaussian to DRAG: - 1. Register a DRAG pulse under ``DrivePulseName.DRAG`` - 2. Update ``reference_pulse_name`` on the xy_drive macro - - All gate macros (x90, x180, y90, etc.) automatically pick up the change. + Use ``machine.set_pulse_family(family)`` to propagate the change to + every qubit's XY macros at once. """ print("\n" + "=" * 80) - print("STEP 4: Update drive pulse type (Gaussian -> DRAG)") + print("STEP 3: Switch pulse family (Gaussian -> Kaiser)") print("=" * 80) - for qubit in machine.qubits.values(): - if qubit.xy is None: - continue - - # 1. Register a DRAG pulse alongside the existing Gaussian - qubit.xy.operations[DrivePulseName.DRAG] = pulses.DragPulse( - length=500, - amplitude=0.25, - sigma=83, - alpha=0.5, - anharmonicity=-200e6, - detuning=0, - ) - - # 2. Point the macro at the new pulse - qubit.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name = DrivePulseName.DRAG + machine.set_pulse_family(DrivePulseName.KAISER.value) q1 = machine.qubits["q1"] print(f" q1.xy.operations keys: {sorted(q1.xy.operations.keys())}") - print( - f" q1.xy_drive.reference_pulse_name = {q1.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name}" - ) - print(f" Both gaussian and drag are registered; macro now uses drag.") - print(f" All gates (x90, y180, etc.) derive from the drag pulse automatically.") + xy_macro = q1.macros[SingleQubitMacroName.XY_DRIVE] + print(f" q1.xy_drive.pulse_family = {xy_macro.pulse_family}") + print(f" q1.xy_drive.pulse_name = {xy_macro.pulse_name}") + print(" All families (gaussian, square, kaiser) remain registered;") + print(" macros now resolve to kaiser_* operations.") ######################################################################################################################## -# %% STEP 5: Replace a particular macro +# %% STEP 4: Replace a particular macro ######################################################################################################################## @@ -261,28 +169,25 @@ def replace_macros(machine: LossDiVincenzoQuam) -> None: Override precedence: instance > type > default """ print("\n" + "=" * 80) - print("STEP 5: Replace macros (instance + type overrides)") + print("STEP 4: Replace macros (instance + type overrides)") print("=" * 80) wire_machine_macros( machine, - # Type-level: replace CZ on ALL qubit pairs - component_overrides={ - LDQubitPair: overrides( - macros={ - TwoQubitMacroName.CZ: macro(DemoCZMacro), + catalogs=[ + TypeOverrideCatalog( + { + LDQubitPair: { + TwoQubitMacroName.CZ: DemoCZMacro, + }, } ), - }, - # Instance-level: replace X180 on q1 only + ], instance_overrides={ - "qubits.q1": overrides( - macros={ - SingleQubitMacroName.X_180: macro(TunedX180Macro), - } - ), + "qubits.q1": { + SingleQubitMacroName.X_180: TunedX180Macro, + }, }, - strict=True, ) q1 = machine.qubits["q1"] @@ -290,10 +195,17 @@ def replace_macros(machine: LossDiVincenzoQuam) -> None: pair = machine.qubit_pairs["q1_q2"] print( - " q1.x180 class: " f"{type(q1.macros[SingleQubitMacroName.X_180]).__name__} (overridden)" + " q1.x180 class: " + f"{type(q1.macros[SingleQubitMacroName.X_180]).__name__} (overridden)" + ) + print( + " q2.x180 class: " + f"{type(q2.macros[SingleQubitMacroName.X_180]).__name__} (default)" + ) + print( + " q1_q2.cz class: " + f"{type(pair.macros[TwoQubitMacroName.CZ]).__name__} (overridden)" ) - print(" q2.x180 class: " f"{type(q2.macros[SingleQubitMacroName.X_180]).__name__} (default)") - print(" q1_q2.cz class: " f"{type(pair.macros[TwoQubitMacroName.CZ]).__name__} (overridden)") ######################################################################################################################## @@ -302,7 +214,7 @@ def replace_macros(machine: LossDiVincenzoQuam) -> None: def main() -> None: - machine = build_wired_machine() + machine = build_tutorial_machine() wire_defaults(machine) update_operation_parameters(machine) update_drive_pulse_type(machine) diff --git a/quam_builder/architecture/quantum_dots/examples/macro_defaults_example.py b/quam_builder/architecture/quantum_dots/examples/macro_defaults_example.py new file mode 100644 index 00000000..67fcc2c6 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/macro_defaults_example.py @@ -0,0 +1,94 @@ +"""Example: wire, parameterize, and use built-in default macros (no overrides). + +This script demonstrates: +1. Building a machine via the combined wiring workflow. +2. Wiring architecture defaults with ``wire_machine_macros(machine)`` only. +3. Parameterizing instantiated default macro objects and reference pulses directly on components. +4. Building a QUA program that calls those default macros. +""" + +from __future__ import annotations + +import numpy as np +from qm import qua + +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam + + +def parameterize_default_macros(machine: LossDiVincenzoQuam) -> None: + """Tune parameters on already-wired default macro instances and pulses.""" + for qubit in machine.qubits.values(): + qubit.macros[VoltagePointName.INITIALIZE].ramp_duration = 64 + qubit.macros[VoltagePointName.MEASURE].buffer_duration = 240 + qubit.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude = 0.0085 + qubit.macros[SingleQubitMacroName.IDENTITY].duration = None + qubit.macros[SingleQubitMacroName.IDENTITY].duration = 24 + + +def print_macro_parameters(machine: LossDiVincenzoQuam) -> None: + """Print key default-macro class bindings and tuned parameters.""" + q1 = machine.qubits["q1"] + print("\n=== Default Macro Parameterization ===") + print("q1.initialize class:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) + print("q1.xy_drive class:", type(q1.macros[SingleQubitMacroName.XY_DRIVE]).__name__) + print("q1.I class:", type(q1.macros[SingleQubitMacroName.IDENTITY]).__name__) + print( + "q1.initialize.ramp_duration:", + q1.macros[VoltagePointName.INITIALIZE].ramp_duration, + ) + print( + "q1.measure.buffer_duration:", + q1.macros[VoltagePointName.MEASURE].buffer_duration, + ) + print( + "q1.gaussian_x90.amplitude:", + q1.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude, + ) + print("q1.I.duration:", q1.macros[SingleQubitMacroName.IDENTITY].duration) + + +def build_program(machine: LossDiVincenzoQuam): + """Build a QUA program that uses default macros only.""" + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + + with qua.program() as prog: + q1.initialize() + q2.initialize() + q1.x90() + q1.x(angle=-np.pi / 2) + q2.y(angle=np.pi / 3) + q1.z90() + q2.I() + q1.measure() + q2.measure() + + return prog + + +def main() -> None: + machine = build_tutorial_machine() + + # Re-wire to demonstrate explicit default wiring (idempotent). + wire_machine_macros(machine) + + parameterize_default_macros(machine) + print_macro_parameters(machine) + + _ = build_program(machine) + print( + "\nBuilt QUA program successfully using parameterized default macros (no overrides)." + ) + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/macro_overrides_example.py b/quam_builder/architecture/quantum_dots/examples/macro_overrides_example.py new file mode 100644 index 00000000..37354d0f --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/macro_overrides_example.py @@ -0,0 +1,156 @@ +"""Example: macro wiring with catalog and instance overrides. + +Demonstrates the recommended Python API for overriding macros: + +1. ``wire_machine_macros(machine)`` -- wire all defaults. +2. ``TypeOverrideCatalog({LDQubit: {...}})`` -- override all qubits of a type. +3. ``instance_overrides={"qubits.q1": {...}}`` -- override one specific qubit. +4. ``DISABLED`` sentinel -- remove a macro from a component. +""" + +# pylint: disable=too-many-ancestors + +from __future__ import annotations + +from functools import partial + +from qm import qua +from quam.components.macro import QubitPairMacro + +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + TypeOverrideCatalog, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + X180Macro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + InitializeStateMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit_pair.ld_qubit_pair import LDQubitPair +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam + +# --------------------------------------------------------------------------- +# Custom macro classes (users would define these in their lab package) +# --------------------------------------------------------------------------- + + +class TunedX180Macro(X180Macro): + """Lab-calibrated X180 macro for a specific qubit.""" + + pass + + +class DemoCZMacro(QubitPairMacro): + """Placeholder CZ gate showing how users replace default 2Q stubs.""" + + duration_ns: int = 64 + + @property + def inferred_duration(self) -> float: + return self.duration_ns * 1e-9 + + def apply(self, duration_ns: int | None = None, **kwargs): + duration = self.duration_ns if duration_ns is None else duration_ns + duration_cycles = max(0, int(round(duration / 4.0))) + + control_xy = self.qubit_pair.qubit_control.xy.name + target_xy = self.qubit_pair.qubit_target.xy.name + qua.align(control_xy, target_xy) + if duration_cycles > 0: + qua.wait(duration_cycles, control_xy, target_xy) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def print_macro_summary(machine: LossDiVincenzoQuam, title: str) -> None: + """Print macro class bindings for key components/macros.""" + q1 = machine.qubits["q1"] + pair = machine.qubit_pairs["q1_q2"] + print(f"\n=== {title} ===") + print("q1 macros:", sorted(q1.macros.keys())) + print("q1.initialize:", type(q1.macros[VoltagePointName.INITIALIZE]).__name__) + print("q1.x180:", type(q1.macros[SingleQubitMacroName.X_180]).__name__) + print("q1_q2.cz:", type(pair.macros[TwoQubitMacroName.CZ]).__name__) + + +# --------------------------------------------------------------------------- +# Override wiring using the catalog API +# --------------------------------------------------------------------------- + + +def apply_macro_overrides(machine: LossDiVincenzoQuam) -> None: + """Apply type-level and instance-level overrides. + + - ``TypeOverrideCatalog`` replaces macros on all instances of a type. + - ``instance_overrides`` replaces macros on one specific component. + """ + wire_machine_macros( + machine, + catalogs=[ + TypeOverrideCatalog( + { + LDQubit: { + SingleQubitMacroName.INITIALIZE: partial( + InitializeStateMacro, + ramp_duration=64, + ), + }, + LDQubitPair: { + TwoQubitMacroName.CZ: DemoCZMacro, + }, + } + ), + ], + instance_overrides={ + "qubits.q1": { + SingleQubitMacroName.X_180: TunedX180Macro, + }, + }, + ) + + +def build_program(machine: LossDiVincenzoQuam): + """Build a QUA program using default and overridden macros.""" + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + pair = machine.qubit_pairs["q1_q2"] + + with qua.program() as prog: + q1.initialize() + q2.initialize() + q1.x90() + q1.x180() + q2.empty() + pair.cz() + q1.measure() + q2.measure() + + return prog + + +def main() -> None: + machine = build_tutorial_machine() + print_macro_summary(machine, "Defaults") + + apply_macro_overrides(machine) + print_macro_summary(machine, "After Overrides") + + _ = build_program(machine) + print("\nBuilt QUA program successfully with wired default+override macros.") + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/mwe_sensor_resonator_same_port.py b/quam_builder/architecture/quantum_dots/examples/mwe_sensor_resonator_same_port.py index 73123e49..8a38471b 100644 --- a/quam_builder/architecture/quantum_dots/examples/mwe_sensor_resonator_same_port.py +++ b/quam_builder/architecture/quantum_dots/examples/mwe_sensor_resonator_same_port.py @@ -7,8 +7,12 @@ QUANTUM_DOT_PAIRS = list(zip(QUANTUM_DOTS, QUANTUM_DOTS[1:])) SENSOR_GATE_CONSTRAINTS = lf_fem_spec(con=1, out_slot=3) -S1_RESONATOR_CONSTRAINTS = lf_fem_spec(con=1, in_slot=2, out_slot=2, in_port=1, out_port=1) -S2TO3_RESONATOR_CONSTRAINTS = lf_fem_spec(con=1, in_slot=3, out_slot=3, in_port=2, out_port=8) +S1_RESONATOR_CONSTRAINTS = lf_fem_spec( + con=1, in_slot=2, out_slot=2, in_port=1, out_port=1 +) +S2TO3_RESONATOR_CONSTRAINTS = lf_fem_spec( + con=1, in_slot=3, out_slot=3, in_port=2, out_port=8 +) BAR_PAIR_1_CONSTRAINTS = lf_fem_spec(con=1, out_slot=2, out_port=2) BAR_PAIR_2_CONSTRAINTS = lf_fem_spec(con=1, out_slot=2, out_port=3) @@ -70,7 +74,9 @@ def case_2_resonator_plus_sensor_gate() -> None: shared_line=False, constraints=S1_RESONATOR_CONSTRAINTS, ) - connectivity.add_sensor_dot_voltage_gate_lines([SENSOR_DOTS[0]], constraints=SENSOR_GATE_CONSTRAINTS) + connectivity.add_sensor_dot_voltage_gate_lines( + [SENSOR_DOTS[0]], constraints=SENSOR_GATE_CONSTRAINTS + ) _run_allocation(connectivity, "resonator + sensor gate voltage line") @@ -86,7 +92,9 @@ def case_3_resonators_and_sensor_gates() -> None: shared_line=True, constraints=S2TO3_RESONATOR_CONSTRAINTS, ) - connectivity.add_sensor_dot_voltage_gate_lines(SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) + connectivity.add_sensor_dot_voltage_gate_lines( + SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) _run_allocation(connectivity, "3 sensors: resonators + sensor gate voltages") @@ -102,8 +110,12 @@ def case_4_add_quantum_dot_plungers() -> None: shared_line=True, constraints=S2TO3_RESONATOR_CONSTRAINTS, ) - connectivity.add_sensor_dot_voltage_gate_lines(SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) - connectivity.add_quantum_dot_voltage_gate_lines(QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) + connectivity.add_sensor_dot_voltage_gate_lines( + SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) + connectivity.add_quantum_dot_voltage_gate_lines( + QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) _run_allocation(connectivity, "add quantum dot plunger gates") @@ -119,8 +131,12 @@ def case_5_add_barriers_unconstrained() -> None: shared_line=True, constraints=S2TO3_RESONATOR_CONSTRAINTS, ) - connectivity.add_sensor_dot_voltage_gate_lines(SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) - connectivity.add_quantum_dot_voltage_gate_lines(QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) + connectivity.add_sensor_dot_voltage_gate_lines( + SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) + connectivity.add_quantum_dot_voltage_gate_lines( + QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) connectivity.add_quantum_dot_pairs(QUANTUM_DOT_PAIRS) _run_allocation(connectivity, "add barrier gates (unconstrained)") @@ -137,10 +153,18 @@ def case_6_add_barriers_constrained_unused_slot() -> None: shared_line=True, constraints=S2TO3_RESONATOR_CONSTRAINTS, ) - connectivity.add_sensor_dot_voltage_gate_lines(SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) - connectivity.add_quantum_dot_voltage_gate_lines(QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS) - connectivity.add_quantum_dot_pairs([QUANTUM_DOT_PAIRS[0]], constraints=BAR_PAIR_1_CONSTRAINTS) - connectivity.add_quantum_dot_pairs([QUANTUM_DOT_PAIRS[1]], constraints=BAR_PAIR_2_CONSTRAINTS) + connectivity.add_sensor_dot_voltage_gate_lines( + SENSOR_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) + connectivity.add_quantum_dot_voltage_gate_lines( + QUANTUM_DOTS, constraints=SENSOR_GATE_CONSTRAINTS + ) + connectivity.add_quantum_dot_pairs( + [QUANTUM_DOT_PAIRS[0]], constraints=BAR_PAIR_1_CONSTRAINTS + ) + connectivity.add_quantum_dot_pairs( + [QUANTUM_DOT_PAIRS[1]], constraints=BAR_PAIR_2_CONSTRAINTS + ) _run_allocation(connectivity, "add barrier gates (constrained to free slot)") diff --git a/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py b/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py index 314288ac..b969530c 100644 --- a/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py +++ b/quam_builder/architecture/quantum_dots/examples/pulse_overrides_example.py @@ -1,89 +1,24 @@ -"""Example: default pulse wiring and selective pulse overrides. +"""Example: default pulse wiring and direct pulse configuration. -Demonstrates using the typed override API for pulse configuration: +Demonstrates pulse management: -1. ``wire_machine_macros(machine)`` — adds default ``gaussian`` pulse to XY drives. -2. ``component_overrides`` with ``pulse()`` helper — override all qubits of a type. -3. ``instance_overrides`` with ``pulse()`` helper — override one specific qubit. - -Key imports for pulse overrides:: - - from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - pulse, # build a pulse override entry - overrides, # group macros + pulses for one component - ) +1. ``wire_machine_macros(machine)`` -- adds default ``gaussian`` pulse to XY drives. +2. Direct manipulation of pulse objects on channels. Only one reference pulse (``gaussian``) is registered per qubit by default. ``XYDriveMacro`` scales amplitude for rotation angle and applies virtual-Z for rotation axis, so all single-qubit gates derive from this single pulse. """ -# pylint: disable=no-member # WiringLineType enum members are runtime-only - -# Components in this demo inherit from framework mixins with deep MRO. -# pylint: disable=too-many-ancestors - from __future__ import annotations -from typing import Dict - -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam.components.pulses import GaussianPulse -from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - pulse, - overrides, +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( + build_tutorial_machine, ) -from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam -from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder -from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder - - -def _plunger_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/p/opx_output"} - - -def _mw_drive_ports(qubit_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubits/{qubit_id}/xy/opx_output"} - - -def _barrier_ports(pair_id: str) -> Dict[str, str]: - return {"opx_output": f"#/wiring/qubit_pairs/{pair_id}/b/opx_output"} - - -def build_demo_machine() -> LossDiVincenzoQuam: - """Build a small machine with 2 qubits and default pulse. - - ``wire_machine_macros()`` adds the default ``gaussian`` GaussianPulse - to each qubit's XY drive. All single-qubit gate macros derive from - this single pulse. - """ - machine = BaseQuamQD() - machine.wiring = { - "qubits": { - "q1": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), - WiringLineType.DRIVE.value: _mw_drive_ports("q1"), - }, - "q2": { - WiringLineType.PLUNGER_GATE.value: _plunger_ports("q2"), - WiringLineType.DRIVE.value: _mw_drive_ports("q2"), - }, - }, - "qubit_pairs": { - "q1_q2": {WiringLineType.BARRIER_GATE.value: _barrier_ports("q1_q2")}, - }, - } - machine = _BaseQpuBuilder(machine).build() - machine = _LDQubitBuilder(machine).build() - if getattr(machine, "qpu", None) is None: - machine.qpu = QPU() - - wire_machine_macros(machine) - return machine +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam def print_pulse_summary(machine: LossDiVincenzoQuam, title: str) -> None: @@ -105,57 +40,46 @@ def print_pulse_summary(machine: LossDiVincenzoQuam, title: str) -> None: ) -def apply_type_level_overrides(machine: LossDiVincenzoQuam) -> None: - """Override gaussian pulse for ALL LDQubit instances. +def update_all_qubit_pulses(machine: LossDiVincenzoQuam) -> None: + """Update the gaussian pulse on all qubits directly. - Uses ``component_overrides`` keyed by the ``LDQubit`` class. - The ``pulse()`` helper constructs a validated pulse entry. - - Since all gate macros derive from this single pulse, the change - affects x90, x180, y90, y180, etc. automatically. + After wiring, pulse objects are accessible on the channel's operations dict. + Modify them in place or replace with new instances. """ - wire_machine_macros( - machine, - component_overrides={ - LDQubit: overrides( - pulses={ - "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), - } - ), - }, + for qubit in machine.qubits.values(): + xy = getattr(qubit, "xy", None) + if xy is None: + continue + xy.operations["gaussian_x90"] = GaussianPulse( + length=500, + amplitude=0.3, + sigma=83, + ) + + +def update_single_qubit_pulse(machine: LossDiVincenzoQuam) -> None: + """Override gaussian on q1 only.""" + q1 = machine.qubits["q1"] + q1.xy.operations["gaussian_x90"] = GaussianPulse( + length=800, + amplitude=0.15, + sigma=133, ) -def apply_instance_level_overrides(machine: LossDiVincenzoQuam) -> None: - """Override gaussian on q1 only. - - Uses ``instance_overrides`` keyed by the component path string. - Instance-level overrides take precedence over type-level overrides. - """ - wire_machine_macros( - machine, - instance_overrides={ - "qubits.q1": overrides( - pulses={ - "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), - } - ), - }, - ) +def main() -> None: + machine = build_tutorial_machine() + # Re-wire to demonstrate explicit default wiring (idempotent). + wire_machine_macros(machine) -def main() -> None: - # 1. Build machine with default pulse - machine = build_demo_machine() print_pulse_summary(machine, "Default Pulse") - # 2. Apply type-level override: all qubits get a shorter gaussian - apply_type_level_overrides(machine) - print_pulse_summary(machine, "After Type-Level Override (gaussian on all LDQubits)") + update_all_qubit_pulses(machine) + print_pulse_summary(machine, "After Updating All Qubit Pulses") - # 3. Apply instance-level override: q1 gets custom gaussian - apply_instance_level_overrides(machine) - print_pulse_summary(machine, "After Instance-Level Override (q1.gaussian custom)") + update_single_qubit_pulse(machine) + print_pulse_summary(machine, "After Updating q1 Pulse Only") if __name__ == "__main__": diff --git a/quam_builder/architecture/quantum_dots/examples/qm_example.py b/quam_builder/architecture/quantum_dots/examples/qm_example.py new file mode 100644 index 00000000..f6bdc386 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/qm_example.py @@ -0,0 +1,415 @@ +""" +Builds a LossDiVincenzoQuam using the combined wiring workflow +(Connectivity -> Instruments -> allocate_wiring -> build_quam_wiring -> +build_quam). Adds voltage step points so default state macros can run. + +The machine comes pre-wired with default macros and pulses (via build_quam). +The tutorial notebook re-calls wire_machine_macros with custom catalogs and +overrides to demonstrate the macro customization API. + +Default parameters +------------------ +All architecture defaults (pulse lengths, amplitudes, frequencies, macro +timing, etc.) are defined in ``quam_builder.architecture.quantum_dots.defaults``. + +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +import numpy as np +from typing import List + +from qualang_tools.wirer import ( + Connectivity, + Instruments, + allocate_wiring, + visualize, +) # noqa: E402 + +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( # noqa: E402 + VoltageBalancedMacroCatalog, +) +from quam_builder.architecture.quantum_dots.operations.names import ( # noqa: E402 + DrivePulseName, + SingleQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qpu import ( + BaseQuamQD, + LossDiVincenzoQuam, +) # noqa: E402 +from quam_builder.builder.qop_connectivity import build_quam_wiring # noqa: E402 +from quam_builder.builder.quantum_dots import build_quam # noqa: E402 + + +def build_machine(path: None | Path = None) -> LossDiVincenzoQuam: + """Build a machine using the combined wiring workflow. + + Uses Connectivity + Instruments + allocate_wiring to define the + hardware layout, then build_quam to construct the full + LossDiVincenzoQuam in a single stage. + + Returns a machine with: + + - 4 quantum dots (virtual_dot_1, virtual_dot_2) + - 3 quantum dot pair (virtual_dot_1_virtual_dot_2_pair) + - 2 sensor dot (virtual_sensor_1) + - 4 qubits (q1, q2) with shared MW drive line + - 3 qubit pair (q1_q2) + - Voltage step points (initialize, measure, empty) for state macros + - Voltage Balanced macros and pulses (wired by build_quam) + """ + connectivity = Connectivity() + + # Create dummy sensor_dot elements? + connectivity.add_sensor_dots(sensor_dots=[1, 2], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3, 4]) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=[1, 2, 3, 4], shared_line=True, use_mw_fem=True + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3), (3, 4)]) + + # connectivity.add_voltage_gate_lines( + # voltage_gates=[1, 2], name="rb" + # ) # right, middle and left barriers to reservoirs + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[5, 6]) + + allocate_wiring(connectivity, instruments) + + config_path = Path(__file__).resolve().parents[4] / ".qm_cluster_config.json.example" + with open(config_path, encoding="utf-8") as f: + cluster_config = json.load(f) + host = cluster_config["host"] + cluster_name = cluster_config["cluster_name"] + + print("Allocating wiring...") + if path is None: + path = Path(__file__).resolve().parent / "quam_state" + + machine = build_quam_wiring( + connectivity, + host, + cluster_name, + BaseQuamQD(), + path=path, + ) + + machine = build_quam( + machine, + qubit_pair_sensor_map={ + "q1_q2": ["sensor_1"], + "q2_q3": ["sensor_1"], + "q3_q4": ["sensor_2"], + }, + catalogs=[VoltageBalancedMacroCatalog()], + save=True, + path=path, + ) + + # Optional: Visualize Wiring + # Uncomment to visualize wiring (requires a GUI backend) + import matplotlib + + # matplotlib.use("TkAgg") + import matplotlib.pyplot as plt # noqa: E402 + + visualize( + connectivity.elements, + available_channels=instruments.available_channels, + use_matplotlib=True, + ) + plt.show() + + return machine + + +def populate_machine(machine: LossDiVincenzoQuam): + + ####################################### + ###### Qubits Physical Properties ##### + ####################################### + + # XY / MW-FEM: QuAM uses IF = larmor_frequency - MW_upconverter (see XYDriveMW). + # QM enforces |IF| <= 500 MHz. The old name ``LO`` here was really the *Larmor* + # centre (~9.7 GHz), not the FEM LO; leaving upconverter at ~5 GHz made IF ~4.7 GHz. + larmor_center_hz = 9.697371455e9 + mw_upconverter_hz = larmor_center_hz + qubit_frequencies = [ + larmor_center_hz - 15e6, + larmor_center_hz - 5e6, + larmor_center_hz + 5e6, + larmor_center_hz + 15e6, + ] + + for i, q in enumerate(machine.qubits.values()): + q.xy.opx_output.band = 3 + # Same params for each qubit for now. Subject to change. + q.macros[VoltagePointName.INITIALIZE].update(ramp_duration=2000, hold_duration=200) + q.macros[VoltagePointName.MEASURE].update(buffer_duration=240) + q.macros[VoltagePointName.EMPTY].update(hold_duration=80) + + # MW FEM LO on this XY line (shared port → same value each iteration is fine). + q.xy.opx_output.upconverter_frequency = mw_upconverter_hz + + # Absolute drive / Larmor frequency (RF), not the OPX IF. + q_xy = q.macros[SingleQubitMacroName.XY_DRIVE] + q_xy.update(frequency=qubit_frequencies[i]) + + q.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude = 0.17 + + # Default values + q.T1 = 1e-6 + q.T2ramsey = 0.5e-6 + q.T2echo = 2e-6 + + ######################### + ###### State Points ##### + ######################### + + for i, qdp in enumerate(machine.quantum_dot_pairs.values()): + qdp.add_point( + point_name=VoltagePointName.INITIALIZE, + voltages={d.id: (i + 1) * 0.015 for d in qdp.quantum_dots}, + duration=1000, + ) + qdp.add_point( + point_name=VoltagePointName.EMPTY, + voltages={d.id: (i + 1) * 0.02 for d in qdp.quantum_dots}, + duration=1500, + ) + qdp.add_point( + point_name=VoltagePointName.MEASURE, + voltages={d.id: (i + 1) * 0.025 for d in qdp.quantum_dots}, + duration=1000, + ) + + ############################## + ###### Sensor Properties ##### + ############################## + + resonator_frequencies = [300.78e6, 436.542e6] + for i, s in enumerate(machine.sensor_dots.values()): + s.readout_resonator.intermediate_frequency = resonator_frequencies[i] + s.readout_resonator.operations["readout"].amplitude = 0.02 + s.readout_resonator.operations["readout"].length = 50_000 # 50us + + ################################ + ###### Compensation Matrix ##### + ################################ + + full_given_matrix = np.array( + [ + [1.49696, 0.5218, 0.36891, 1.0, -0.15019, 0.11477, 0.02468], + [-0.54456, 0.4782, 0.33809, 1.0, 0.01011, 0.04221, 0.09137], + [-0.55239, -0.58, 0.58994, 1.0, 0.08125, -0.14962, 0.02272], + [-0.40001, -0.42, -1.29694, 1.0, 0.05883, -0.00736, -0.13877], + [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], + ] + ) + + inverse_matrix = np.linalg.inv(full_given_matrix) + barrier_orthogonalising_submatrix = -full_given_matrix[:4, 4:] + + gate_set_id = next(iter(machine.virtual_gate_sets)) + vgs = machine.virtual_gate_sets[gate_set_id] + qds = machine.quantum_dots + + # Orthogonalise the barriers. Detuning will be another layer. + machine.update_cross_compensation_submatrix( + virtual_names=["virtual_barrier_1", "virtual_barrier_2", "virtual_barrier_3"], + channels=[ + qds["virtual_dot_1"].physical_channel, + qds["virtual_dot_2"].physical_channel, + qds["virtual_dot_3"].physical_channel, + qds["virtual_dot_4"].physical_channel, + ], + matrix=barrier_orthogonalising_submatrix, + target="opx", + ) + + ################################# + ###### Define Detuning Axis ##### + ################################# + + update_detuning_axis(machine, inverse_matrix) + + vgs.add_to_layer( + source_gates=["delta_2134"], + target_gates=[qd.id for qd in machine.quantum_dots.values()], + layer_id="quantum_dot_pair_detuning_matrix", + matrix=inverse_matrix[3:4, :4], + ) + + return machine + + +def update_detuning_axis( + machine: LossDiVincenzoQuam, + full_matrix: List[List[float]], +): + vgs = machine.virtual_gate_sets[next(iter(machine.virtual_gate_sets))] + target_gates = [qd.id for qd in machine.quantum_dots.values()] + for i, qdp in enumerate(machine.quantum_dot_pairs.values()): + source_gates = [qdp.detuning_axis_name] + matrix = full_matrix[i : i + 1, :4] + vgs.add_to_layer( + source_gates=source_gates, + target_gates=target_gates, + matrix=matrix, + layer_id="quantum_dot_pair_detuning_matrix", + ) + + +# pylint: disable-next=too-many-statements +def test_machine( + machine: LossDiVincenzoQuam, +) -> None: + """Update representative parameters, save to disk, reload, and verify round-trip. + + Covers every major parameter category: + - State macro timing (ramp_duration, buffer_duration, hold_duration) + - XY drive calibration via the macro update() API + - Direct pulse attribute modification + - Qubit physical properties (T1, T2, larmor_frequency) + - Machine-level field (b_field) + - Sensor readout parameters (threshold, pulse amplitude/length) + - Cross-compensation / virtualization matrix + - Named voltage tuning points + """ + q1 = machine.qubits["q1"] + q2 = machine.qubits["q2"] + + # ── 1. State macro timing ───────────────────────────────────────── + # update() persistently sets calibration fields on the underlying macro. + q1.macros[VoltagePointName.INITIALIZE].update(ramp_duration=64, hold_duration=100) + q1.macros[VoltagePointName.MEASURE].update(buffer_duration=240) + q1.macros[VoltagePointName.EMPTY].update(hold_duration=80) + + # ── 2. XY drive calibration via macro update() API ──────────────── + # update() on the XY-drive macro is the single entry-point for persistent + # calibration: pi_amplitude sets the x180 pulse amplitude (x90 gets half), + # duration rescales the pulse length/sigma, and frequency sets + # qubit.larmor_frequency (the IF is derived as larmor - LO). + xy_macro = q1.macros[SingleQubitMacroName.XY_DRIVE] + xy_macro.update(pi_amplitude=0.5, duration=400, frequency=4.5e9) + # Capture the transformed pulse length for later verification (the macro + # quantises nanoseconds to clock-cycle units internally). + expected_pulse_length = q1.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].length + + # frequency_offset adds a delta to the current larmor_frequency instead + # of replacing it -- handy for fine-tuning after an initial calibration. + q2_xy = q2.macros[SingleQubitMacroName.XY_DRIVE] + q2_xy.update(frequency=4.5e9) + q2_xy.update(frequency_offset=1.5e6) + + # ── 3. Direct pulse attribute modification on q2 ────────────────── + # Pulse objects on qubit.xy.operations can also be edited in place. + q2.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude = 0.17 + + # ── 4. Qubit physical properties ────────────────────────────────── + q1.T1 = 1e-6 + q1.T2ramsey = 0.5e-6 + q1.T2echo = 2e-6 + + # ── 5. Machine-level property ───────────────────────────────────── + machine.b_field = 0.15 + + # ── 6. Sensor readout parameters ────────────────────────────────── + sensor_name = next(iter(machine.sensor_dots)) + sensor = machine.sensor_dots[sensor_name] + sensor.readout_resonator.operations["readout"].amplitude = 0.35 + sensor.readout_resonator.operations["readout"].length = 3000 + # Readout thresholds are keyed by quantum-dot-pair ID. + pair_id = next(iter(machine.quantum_dot_pairs)) + sensor.readout_thresholds[pair_id] = 0.42 + + # ── 7. Virtualization matrix (cross-compensation) ───────────────── + # Replace the first-layer matrix with identity + 2 % cross-talk. + gate_set_name = next(iter(machine.virtual_gate_sets)) + layer = machine.virtual_gate_sets[gate_set_name].layers[0] + n_src, n_tgt = len(layer.source_gates), len(layer.target_gates) + cross_talk_matrix = [[1.0 if i == j else 0.02 for j in range(n_tgt)] for i in range(n_src)] + machine.update_full_cross_compensation(cross_talk_matrix, gate_set_name, target="opx") + + # ── 8. Named voltage tuning points ──────────────────────────────── + # add_point registers a VoltageTuningPoint in the VirtualGateSet, + # stored under the key "{dot_id}_{point_name}". + dot_id = q1.quantum_dot.id + q1.add_point(VoltagePointName.INITIALIZE, {dot_id: 0.10}, duration=200) + q1.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=200) + q1.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=200) + + # ═══════════════════════════════════════════════════════════════════ + # Save, reload, and verify every parameter round-trips cleanly. + # ═══════════════════════════════════════════════════════════════════ + with tempfile.TemporaryDirectory() as save_dir: + machine.save(save_dir) + loaded = LossDiVincenzoQuam.load(save_dir) + + lq1 = loaded.qubits["q1"] + lq2 = loaded.qubits["q2"] + + # -- State macros -- + assert lq1.macros[VoltagePointName.INITIALIZE].ramp_duration == 64 + assert lq1.macros[VoltagePointName.INITIALIZE].hold_duration == 100 + assert lq1.macros[VoltagePointName.MEASURE].buffer_duration == 240 + assert lq1.macros[VoltagePointName.EMPTY].hold_duration == 80 + + # -- XY drive calibration (amplitude, duration, frequency) -- + assert lq1.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude == 0.25 + assert lq1.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].length == expected_pulse_length + assert lq1.larmor_frequency == 4.5e9 + assert lq2.larmor_frequency == 4.5e9 + 1.5e6 # base + offset + + # -- Direct pulse -- + assert lq2.xy.operations[f"{DrivePulseName.GAUSSIAN}_x90"].amplitude == 0.17 + + # -- Qubit properties -- + assert lq1.T1 == 1e-6 + assert lq1.T2ramsey == 0.5e-6 + assert lq1.T2echo == 2e-6 + + # -- Machine property -- + assert loaded.b_field == 0.15 + + # -- Sensor readout -- + l_sensor = loaded.sensor_dots[sensor_name] + assert l_sensor.readout_resonator.operations["readout"].amplitude == 0.35 + assert l_sensor.readout_resonator.operations["readout"].length == 3000 + assert l_sensor.readout_thresholds[pair_id] == 0.42 + + # -- Virtualization matrix -- + l_layer = loaded.virtual_gate_sets[gate_set_name].layers[0] + for i, row in enumerate(l_layer.matrix): + for j, val in enumerate(row): + expected = 1.0 if i == j else 0.02 + assert abs(val - expected) < 1e-9, f"matrix[{i}][{j}]: {val} != {expected}" + + # -- Voltage points -- + init_name = f"{dot_id}_{VoltagePointName.INITIALIZE}" + l_vgs = loaded.virtual_gate_sets[gate_set_name] + assert init_name in l_vgs.macros, f"point '{init_name}' missing after reload" + assert l_vgs.macros[init_name].voltages[dot_id] == 0.10 + assert l_vgs.macros[init_name].duration == 200 + + print("All round-trip assertions passed.") + return loaded + + +if __name__ == "__main__": + path = Path(__file__).resolve().parent / "quam_state" + + machine = build_machine(path) + machine = populate_machine(machine) + machine.save(path) + config = machine.generate_config() + machine.save() + # machine.save("/Users/kalidu_laptop/_nodes/CS_installations/quam_state") diff --git a/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py b/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py index 416d2778..55459652 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_ld_example.py @@ -33,7 +33,9 @@ # if not state_path: # print("QUAM_STATE_PATH not set; skipping LD example. Set it to run.") # raise SystemExit(0) -machine = LossDiVincenzoQuam.load("/Users/kalidu_laptop/_nodes/CS_installations/quam_state") +machine = LossDiVincenzoQuam.load( + "/Users/kalidu_laptop/_nodes/CS_installations/quam_state" +) lf_fem = 6 mw_fem = 1 @@ -41,7 +43,12 @@ xy_q1 = XYDriveMW( id="Q1_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=1, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", + mw_fem, + port_id=5, + upconverter_frequency=5e9, + band=2, + full_scale_power_dbm=10, ), intermediate_frequency=10e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -49,7 +56,12 @@ xy_q2 = XYDriveMW( id="Q2_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=2, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", + mw_fem, + port_id=6, + upconverter_frequency=5e9, + band=2, + full_scale_power_dbm=10, ), intermediate_frequency=12e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -57,7 +69,12 @@ xy_q3 = XYDriveMW( id="Q3_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=3, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", + mw_fem, + port_id=7, + upconverter_frequency=5e9, + band=2, + full_scale_power_dbm=10, ), intermediate_frequency=13e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -65,7 +82,12 @@ xy_q4 = XYDriveMW( id="Q4_xy", opx_output=MWFEMAnalogOutputPort( - "con1", mw_fem, port_id=4, upconverter_frequency=5e9, band=2, full_scale_power_dbm=10 + "con1", + mw_fem, + port_id=8, + upconverter_frequency=5e9, + band=2, + full_scale_power_dbm=10, ), intermediate_frequency=14e6, operations={"x90": pulses.GaussianPulse(length=200, amplitude=0.01, sigma=50)}, @@ -114,7 +136,7 @@ # Fill out the grid location and arbitrary larmor frequencies of the qubit for i in range(1, 5): machine.qubits[f"Q{i}"].grid_location = f"0,{i}" - object.__setattr__(machine.qubits[f"Q{i}"], "larmor_frequency", 5e6 + 1e6 * i) + machine.qubits[f"Q{i}"].larmor_frequency = 4.8e9 + 100e6 * i ################################## ###### Register Qubit Pairs ###### @@ -173,42 +195,6 @@ replace_existing_point=True, ) -qdp1 = machine.quantum_dot_pairs["dot1_dot2_pair"] -qdp2 = machine.quantum_dot_pairs["dot3_dot4_pair"] -qdp1.add_point("empty", voltages = { - "virtual_dot_1": 0.06, - "virtual_dot_2": -0.04, - "virtual_barrier_1": 0.08 -}, duration = 1000) -qdp1.add_point("measure", voltages = { - "virtual_dot_1": -0.03, - "virtual_dot_2": 0.09, - "virtual_barrier_1": 0.07 -}, duration = 1000) -qdp1.add_point("initialize", voltages = { - "virtual_dot_1": 0.02, - "virtual_dot_2": -0.10, - "virtual_barrier_1": 0.09 -}, duration = 1000) -qdp1.sensor_dots[0]._add_readout_params(name, threshold = 0.01) - -qdp2.add_point("empty", voltages = { - "virtual_dot_3": 0.08, - "virtual_dot_4": -0.03, - "virtual_barrier_3": 0.15 -}, duration = 1000) -qdp2.add_point("measure", voltages = { - "virtual_dot_3": -0.09, - "virtual_dot_4": 0.03, - "virtual_barrier_3": 0.09 -}, duration = 1000) -qdp2.add_point("initialize", voltages = { - "virtual_dot_3": 0.05, - "virtual_dot_4": -0.06, - "virtual_barrier_3": 0.1 -}, duration = 1000) -qdp2.sensor_dots[0]._add_readout_params(name, threshold = 0.01) - # # Example QUA programme: # with program() as prog: diff --git a/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py b/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py index ef6d0f62..f046e023 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_ld_generator_example.py @@ -16,7 +16,9 @@ import numpy as np # Instantiate Quam -state_path = Path(os.environ.get("QUAM_STATE_PATH", "/Users/kalidu_laptop/.qualibrate/quam_state")) +state_path = Path( + os.environ.get("QUAM_STATE_PATH", "/Users/kalidu_laptop/.qualibrate/quam_state") +) if not state_path.exists(): print(f"State path not found: {state_path}. Set QUAM_STATE_PATH to run.") raise SystemExit(0) @@ -47,7 +49,11 @@ intermediate_frequency=5e6 + 1e6 * i, operations={ "x90": pulses.GaussianPulse( - length=200, id="x90_Gaussian", digital_marker=None, amplitude=0.02, sigma=50 + length=200, + id="x90_Gaussian", + digital_marker=None, + amplitude=0.02, + sigma=50, ) }, ), @@ -78,6 +84,8 @@ for i in range(len(machine.quantum_dots)): neighbor_idx = i - 1 if i == len(machine.quantum_dots) - 1 else i + 1 - machine.qubits[f"Q{i}"].preferred_readout_quantum_dot = f"virtual_dot_{neighbor_idx}" + machine.qubits[f"Q{i}"].preferred_readout_quantum_dot = ( + f"virtual_dot_{neighbor_idx}" + ) config = machine.generate_config() diff --git a/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py b/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py index 92e938fe..b2c53cd2 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_qd_example.py @@ -73,58 +73,59 @@ p1 = VoltageGate( id=f"plunger_1", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=1), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=1, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p2 = VoltageGate( id=f"plunger_2", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=2), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=2, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p3 = VoltageGate( id=f"plunger_3", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=3), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=3, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) p4 = VoltageGate( id=f"plunger_4", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=4), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=4, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b1 = VoltageGate( id=f"barrier_1", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=5), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=5, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b2 = VoltageGate( id=f"barrier_2", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=6), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=6, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) b3 = VoltageGate( id=f"barrier_3", - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=7), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=7, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) s1 = VoltageGate( id=f"sensor_DC", - opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=8), + opx_output=LFFEMAnalogOutputPort( + "con1", lf_fem, port_id=8, upsampling_mode="pulse" + ), sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} -) - -s2 = VoltageGate( - id=f"sensor_DC_2", - opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=7), - sticky=StickyChannelAddon(duration=16, digital=False), - operations = {"half_max_square" : pulses.SquarePulse(length=16, amplitude=0.25, id="half_max_square")} ) @@ -132,29 +133,20 @@ resonator = ReadoutResonatorSingle( id="readout_resonator", frequency_bare=0, - intermediate_frequency=350e6, + intermediate_frequency=500e6, operations={"readout": readout_pulse}, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1, upsampling_mode="mw"), - opx_input=LFFEMAnalogInputPort("con1", 5, port_id=1), -) -readout_pulse2 = pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) -resonator2 = ReadoutResonatorSingle( - id="readout_resonator_2", - frequency_bare=0, - intermediate_frequency=250e6, - operations={"readout": readout_pulse2}, - opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=2, upsampling_mode="mw"), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), ) -# drain = DrainSingle( -# id="drain", -# opx_output=("con1", lf_fem, 1), # Dummy output -# readout=ReadoutTransportSingle( -# id="readout_transport", -# opx_input=LFFEMAnalogInputPort("con1", lf_fem, port_id=2), -# ), -# ) +drain = DrainSingle( + id="drain", + opx_output=("con1", lf_fem, 1), # Dummy output + readout=ReadoutTransportSingle( + id="readout_transport", + opx_input=LFFEMAnalogInputPort("con1", lf_fem, port_id=2), + ), +) ##################################### @@ -173,7 +165,6 @@ "virtual_barrier_2": b2, "virtual_barrier_3": b3, "virtual_sensor_1": s1, - "virtual_sensor_2": s2, }, gate_set_id="main_qpu", ) @@ -187,7 +178,8 @@ machine.register_channel_elements( plunger_channels=[p1, p2, p3, p4], barrier_channels=[b1, b2, b3], - sensor_resonator_mappings={s1: resonator, s2: resonator2}, + sensor_resonator_mappings={s1: resonator}, + sensor_drain_mappings={s1: drain}, ) ################################################################## @@ -203,7 +195,10 @@ qdac_name: { "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", "driver_class": "QDac2", - "connection": {"visalib": "@py", "address": f"TCPIP::{qdac_ip}::5025::SOCKET"}, + "connection": { + "visalib": "@py", + "address": f"TCPIP::{qdac_ip}::5025::SOCKET", + }, "channel_method": "channel", "accessor": "dc_constant_V", "is_qdac": True, @@ -222,7 +217,9 @@ opx_output=("con1", lf_fem, i + 1), delay=0, buffer=0 ) }, - operations={"trigger": pulses.Pulse(length=100, digital_marker="ON")}, + operations={ + "trigger": pulses.Pulse(length=100, digital_marker="ON") + }, ), qdac_output_port=i + 1, ) @@ -245,23 +242,12 @@ machine.register_quantum_dot_pair( id="dot3_dot4_pair", quantum_dot_ids=["virtual_dot_3", "virtual_dot_4"], - sensor_dot_ids=["virtual_sensor_2"], + sensor_dot_ids=["virtual_sensor_1"], barrier_gate_id="virtual_barrier_3", ) -# Define the detuning axes for both QuantumDotPairs -machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot1_dot2_pair_epsilon", - set_dc_virtual_axis=False, -) - -machine.quantum_dot_pairs["dot3_dot4_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot3_dot4_pair_epsilon", - set_dc_virtual_axis=False, -) - +# Detuning axes ({id}_epsilon, matrix [[1, -1]]) are applied inside +# :meth:`BaseQuamQD.register_quantum_dot_pair`. ################################################## ###### Update the Cross Compensation Matrix ###### @@ -278,12 +264,15 @@ machine.update_cross_compensation_submatrix( virtual_names=["virtual_dot_1", "virtual_dot_2", "virtual_dot_3", "virtual_dot_4"], channels=[p1, p2, p3, p4], - matrix=[[1, 0.1, 0.1, 0.3], [0.2, 1, 0.6, 0.8], [0.1, 0.3, 1, 0.3], [0.2, 0.5, 0.1, 1]], + matrix=[ + [1, 0.1, 0.1, 0.3], + [0.2, 1, 0.6, 0.8], + [0.1, 0.3, 1, 0.3], + [0.2, 0.5, 0.1, 1], + ], target="opx", ) -machine.save("/Users/kalidu_laptop/_nodes/CS_installations/quam_state") - ########################### ###### Example Usage ###### ########################### @@ -295,104 +284,36 @@ # In this example, we purposefully keep all the barrier and sensor voltages identical, so that they can be initialised together, and no gate should hold two voltages at once. +qd_pairs = machine.quantum_dot_pairs + +for pair_name, pair in qd_pairs.items(): + pair.add_point( + point_name="initialize", + voltages={ + "virtual_dot_1": 0.01, + "virtual_dot_2": -0.02, + "virtual_dot_3": 0.04, + "virtual_dot_4": -0.02, + }, + ) + pair.add_point( + point_name="measure", + voltages={ + "virtual_dot_1": 0.02, + "virtual_dot_2": 0.01, + "virtual_dot_3": -0.01, + "virtual_dot_4": 0.04, + }, + ) + pair.add_point( + point_name="empty", + voltages={ + "virtual_dot_1": -0.01, + "virtual_dot_2": -0.01, + "virtual_dot_3": 0.01, + "virtual_dot_4": -0.04, + }, + ) -# machine.quantum_dots["virtual_dot_1"].add_point( -# point_name="loading", -# voltages={ -# "virtual_dot_1": 0.1, -# "virtual_barrier_1": 0.4, -# "virtual_barrier_2": 0.45, -# "virtual_barrier_3": 0.42, -# "virtual_sensor_1": 0.15, -# }, -# ) - -# machine.quantum_dots["virtual_dot_2"].add_point( -# point_name="loading", -# voltages={ -# "virtual_dot_2": 0.15, -# "virtual_barrier_1": 0.4, -# "virtual_barrier_2": 0.45, -# "virtual_barrier_3": 0.42, -# "virtual_sensor_1": 0.15, -# }, -# ) - -# # We can also initialise a tuning point for a qubit pair: -# machine.quantum_dot_pairs["dot3_dot4_pair"].add_point( -# point_name="some_detuning_points", -# voltages={ -# "virtual_dot_3": 0.2, -# "virtual_dot_4": 0.25, -# "virtual_barrier_1": 0.4, -# "virtual_barrier_2": 0.45, -# "virtual_barrier_3": 0.42, -# "virtual_sensor_1": 0.15, -# }, -# ) - - -# # Example QUA programme: -# with program() as prog: -# i = declare(int) -# seq = machine.voltage_sequences["main_qpu"] -# with for_(i, 0, i<100, i+1): - -# # Option 1 for simultaneous stepping -# seq.step_to_voltages({"virtual_dot_1": -0.4, "virtual_dot_2": -0.2}, duration = 1000) - -# # Option 2 for simultaneous stepping: May be easier for the user -# with seq.simultaneous(duration = 1000): -# machine.quantum_dots["virtual_dot_1"].go_to_voltages(0.4, duration = 1000) -# machine.quantum_dots["virtual_dot_2"].go_to_voltages(0.2, duration = 1000) -# machine.quantum_dot_pairs["dot3_dot4_pair"].go_to_detuning(0.2, duration = 1000) - -# # Simulteneous ramping simply with a ramp_duration argument in seq.simultaneous -# with seq.simultaneous(duration = 1500, ramp_duration = 1500): -# machine.quantum_dots["virtual_dot_1"].go_to_voltages(0.1, duration = 1000) -# machine.quantum_dots["virtual_dot_2"].go_to_voltages(-0.2, duration = 1000) - -# # For sequential stepping, use outside of simultaneous block -# # These two commands will NOT happen simultaneously. Remember, commands can be used interchangeably with machine.qubits -# machine.quantum_dots["virtual_dot_3"].step_to_voltages(0.5, duration = 1000) -# machine.quantum_dots["virtual_dot_4"].step_to_voltages(0.1, duration = 1000) - -# # Can also use point macros saved in qubit and QD objects, inside a simultaneous block. -# # Remember that no point should have repeated dict entries, as this would indicate a gate should be at two voltages at once! -# with seq.simultaneous(duration = 1000): -# machine.quantum_dots["virtual_dot_1"].step_to_point("loading") -# machine.quantum_dots["virtual_dot_2"].step_to_point("loading") -# machine.quantum_dot_pairs["dot3_dot4_pair"].step_to_point("some_detuning_points") -# # If there are repeated dict entries, internally, the last entered voltage for that particular gate wins. - -# machine.sensor_dots["virtual_sensor_1"].readout_resonator.measure("readout") -# seq.ramp_to_zero() - - -# from qm import QuantumMachinesManager, SimulationConfig -# qmm = QuantumMachinesManager(host = "172.16.33.115", cluster_name="CS_3") - -# config = machine.generate_config() - -# simulate = True - - -# if simulate: -# # Simulates the QUA program for the specified duration -# simulation_config = SimulationConfig(duration=10_000//4) # In clock cycles = 4ns -# # Simulate blocks python until the simulation is done -# job = qmm.simulate(config, prog, simulation_config) -# # Get the simulated samples -# samples = job.get_simulated_samples() -# # Plot the simulated samples -# samples.con1.plot() -# # Get the waveform report object -# waveform_report = job.get_simulated_waveform_report() -# # Cast the waveform report to a python dictionary -# waveform_dict = waveform_report.to_dict() -# # Visualize and save the waveform report -# waveform_report.create_plot(samples, plot=True) -# else: -# qm = qmm.open_qm(config) -# # Send the QUA program to the OPX, which compiles and executes it - Execute does not block python! -# job = qm.execute(prog) + for s in pair.sensor_dots: + s._add_readout_params(pair_name, threshold=0.01) diff --git a/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py b/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py index 5df571b4..5d39fe61 100644 --- a/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py +++ b/quam_builder/architecture/quantum_dots/examples/quam_qd_generator_example.py @@ -40,7 +40,11 @@ MWFEMAnalogOutputPort, ) -from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveMW, QPU +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + XYDriveMW, + QPU, +) from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam from quam_builder.architecture.quantum_dots.components import ReadoutResonatorSingle from qm.qua import * @@ -130,7 +134,11 @@ def _alloc_lf_port(index: int) -> tuple[int, int]: id=f"readout_resonator_{i}", frequency_bare=0, intermediate_frequency=500e6, - operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + operations={ + "readout": pulses.SquareReadoutPulse( + length=200, id="readout", amplitude=0.01 + ) + }, opx_output=LFFEMAnalogOutputPort("con1", lf_fem_resonators, port_id=i + 1), opx_input=LFFEMAnalogInputPort("con1", lf_fem_resonators, port_id=i + 1), sticky=StickyChannelAddon(duration=16, digital=False), @@ -182,10 +190,6 @@ def _alloc_lf_port(index: int) -> tuple[int, int]: barrier_gate_id=f"virtual_barrier_{i}", ) - machine.quantum_dot_pairs[dot_id].define_detuning_axis( - matrix=[[1, -1]], set_dc_virtual_axis=False - ) - ##################################### ###### Register Qubits & Pairs ###### ##################################### @@ -231,7 +235,9 @@ def _alloc_lf_port(index: int) -> tuple[int, int]: # Set a preferred readout quantum dot for each qubit for i in range(plunger_gates): neighbor_idx = i - 1 if i == plunger_gates - 1 else i + 1 - machine.qubits[f"Q{i}"].preferred_readout_quantum_dot = f"virtual_dot_{neighbor_idx}" + machine.qubits[f"Q{i}"].preferred_readout_quantum_dot = ( + f"virtual_dot_{neighbor_idx}" + ) # Register adjacent qubit pairs (Q0_Q1, Q1_Q2, ...) for i in range(plunger_gates - 1): diff --git a/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py b/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py index 6be17872..a1534363 100644 --- a/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py +++ b/quam_builder/architecture/quantum_dots/examples/rabi_chevron.py @@ -33,36 +33,40 @@ import json from pathlib import Path -from typing import List, Tuple + +import matplotlib +import qm_saas + +from qm import QuantumMachinesManager, SimulationConfig from qm.qua import ( - program, - for_, + align, declare, declare_stream, fixed, - update_frequency, - align, + for_, + program, save, stream_processing, + update_frequency, ) -from qm import SimulationConfig, QuantumMachinesManager -import qm_saas -import matplotlib matplotlib.use("TkAgg") # Use TkAgg backend for PyCharm compatibility import matplotlib.pyplot as plt + from quam.components import pulses -from quam.components.ports import LFFEMAnalogOutputPort, MWFEMAnalogOutputPort, LFFEMAnalogInputPort from quam.components.channels import StickyChannelAddon - -from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam +from quam.components.ports import ( + LFFEMAnalogInputPort, + LFFEMAnalogOutputPort, + MWFEMAnalogOutputPort, +) from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveMW from quam_builder.architecture.quantum_dots.components.readout_resonator import ( ReadoutResonatorSingle, ) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam from quam_builder.architecture.quantum_dots.qubit import LDQubit - # ============================================================================= # SECTION 1: Create Machine and Physical Channels # ============================================================================= @@ -307,8 +311,8 @@ def add_qubit_macros(qubit: LDQubit): Args: qubit: The qubit to enhance with macros """ - from quam.core.macro.quam_macro import QuamMacro from quam.core import quam_dataclass + from quam.core.macro.quam_macro import QuamMacro # ------------------------------------------------------------------------- # Drive Macro @@ -336,7 +340,7 @@ def inferred_duration(self) -> float | None: try: parent_qubit = self.parent.parent pulse = parent_qubit.xy.operations[self.pulse_name] - return pulse.length * 1e-9 + return pulse.length * 4e-9 except Exception: return None @@ -391,7 +395,8 @@ def inferred_duration(self) -> float | None: parent_qubit = self.parent.parent machine = parent_qubit.machine qd_pair_id = machine.find_quantum_dot_pair( - parent_qubit.quantum_dot.id, parent_qubit.preferred_readout_quantum_dot + parent_qubit.quantum_dot.id, + parent_qubit.preferred_readout_quantum_dot, ) if ( qd_pair_id is not None @@ -402,11 +407,11 @@ def inferred_duration(self) -> float | None: else: sensor_dot = next(iter(machine.sensor_dots.values())) readout_pulse = sensor_dot.readout_resonator.operations[self.pulse_name] - return (64 * 4 + readout_pulse.length) * 1e-9 + return (64 + readout_pulse.length) * 4e-9 except Exception: return None - def apply(self, **kwargs) -> Tuple: + def apply(self, **kwargs) -> tuple: """ Perform demodulated measurement and return I, Q values. @@ -416,8 +421,8 @@ def apply(self, **kwargs) -> Tuple: pulse = kwargs.get("pulse_name", self.pulse_name) # Declare QUA variables for I and Q quadratures - I = declare(fixed) - Q = declare(fixed) + i_qua = declare(fixed) + q_qua = declare(fixed) # Navigate to qubit and get the associated sensor dot parent_qubit = self.parent.parent @@ -438,10 +443,10 @@ def apply(self, **kwargs) -> Tuple: sensor_dot.readout_resonator.wait(64) sensor_dot.readout_resonator.measure( pulse, - qua_vars=(I, Q), + qua_vars=(i_qua, q_qua), ) - return I, Q + return i_qua, q_qua # Register the measure macro measure_macro = MeasureMacro(pulse_name="readout") @@ -453,7 +458,7 @@ def apply(self, **kwargs) -> Tuple: # ============================================================================= -def create_rabi_chevron_program(qubits: List[LDQubit]): +def create_rabi_chevron_program(qubits: list[LDQubit]): """ Create the Rabi chevron QUA program that sweeps frequency and duration. @@ -513,9 +518,9 @@ def create_rabi_chevron_program(qubits: List[LDQubit]): # Step 3: Readout - move to PSB configuration and measure qubit.step_to_point("readout") - I, Q = qubit.measure() - save(I, I_stream) - save(Q, Q_stream) + i_qua, q_qua = qubit.measure() + save(i_qua, I_stream) + save(q_qua, Q_stream) align() diff --git a/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py b/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py index c474b759..932a48ed 100644 --- a/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py +++ b/quam_builder/architecture/quantum_dots/examples/tutorial_machine.py @@ -1,98 +1,97 @@ """Shared machine builder for the macro customization tutorial. -Provides a minimal LossDiVincenzoQuam with quantum_dots, quantum_dot_pairs, -sensor_dots, qubits, and qubit_pairs. Includes voltage step points so default -state macros can run. Does NOT call wire_machine_macros — that is done by -the tutorial notebook. +Builds a LossDiVincenzoQuam using the combined wiring workflow +(Connectivity -> Instruments -> allocate_wiring -> build_quam_wiring -> +build_quam). Adds voltage step points so default state macros can run. + +The machine comes pre-wired with default macros and pulses (via build_quam). +The tutorial notebook re-calls wire_machine_macros with custom catalogs and +overrides to demonstrate the macro customization API. """ from __future__ import annotations -from typing import Dict +import json +import tempfile +from pathlib import Path +from typing import Dict, Optional, Union -from quam.components import StickyChannelAddon, pulses -from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort +from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring -from quam_builder.architecture.quantum_dots.components import ( - VoltageGate, - ReadoutResonatorSingle, -) from quam_builder.architecture.quantum_dots.operations.names import VoltagePointName -from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam - - -def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: - return VoltageGate( - id=gate_id, - opx_output=LFFEMAnalogOutputPort("con1", lf_fem, port_id=port), - sticky=StickyChannelAddon(duration=16, digital=False), - ) - - -def build_tutorial_machine() -> LossDiVincenzoQuam: - """Build a minimal machine for the macro customization tutorial. - - Returns a LossDiVincenzoQuam with: - - 2 quantum dots, 1 quantum dot pair, 1 sensor dot - - 2 qubits, 1 qubit pair - - Voltage gates, readout resonator, virtual gate set +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.builder.qop_connectivity import build_quam_wiring +from quam_builder.builder.quantum_dots import build_quam + + +def build_tutorial_machine( + *, + mw_fem_slots: Optional[list[int]] = None, + lf_fem_slots: Optional[list[int]] = None, + cluster_config_path: Optional[Union[Path, str]] = None, +) -> LossDiVincenzoQuam: + """Build a minimal machine using the combined wiring workflow. + + Uses Connectivity + Instruments + allocate_wiring to define the + hardware layout, then build_quam to construct the full + LossDiVincenzoQuam in a single stage. + + Args: + mw_fem_slots: MW FEM slot indices (OPX+), default ``[1]``. + lf_fem_slots: LF FEM slot indices, default ``[2]`` (single module). + cluster_config_path: If set, read ``host`` and ``cluster_name`` for + :func:`build_quam_wiring` from this JSON (e.g. ``.qm_cluster_config.json``). + Otherwise use ``127.0.0.1`` and ``"tutorial"``. + + Returns a machine with: + + - 2 quantum dots (virtual_dot_1, virtual_dot_2) + - 1 quantum dot pair (virtual_dot_1_virtual_dot_2_pair) + - 1 sensor dot (virtual_sensor_1) + - 2 qubits (q1, q2) with shared MW drive line + - 1 qubit pair (q1_q2) - Voltage step points (initialize, measure, empty) for state macros - - Does not call wire_machine_macros. The caller (tutorial/script) does that. + - Default macros and pulses (wired by build_quam) """ - machine = LossDiVincenzoQuam() - lf = 6 - - p1 = _make_voltage_gate(lf, 1, "plunger_1") - p2 = _make_voltage_gate(lf, 2, "plunger_2") - b1 = _make_voltage_gate(lf, 5, "barrier_1") - b2 = _make_voltage_gate(lf, 6, "barrier_2") - s1 = _make_voltage_gate(lf, 8, "sensor_DC") - - resonator = ReadoutResonatorSingle( - id="readout_resonator", - frequency_bare=0, - intermediate_frequency=500e6, - operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, - opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), - opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), - sticky=StickyChannelAddon(duration=16, digital=False), - ) - - machine.create_virtual_gate_set( - virtual_channel_mapping={ - "virtual_dot_1": p1, - "virtual_dot_2": p2, - "virtual_barrier_1": b1, - "virtual_barrier_2": b2, - "virtual_sensor_1": s1, - }, - gate_set_id="main_qpu", - ) - - machine.register_channel_elements( - plunger_channels=[p1, p2], - barrier_channels=[b1, b2], - sensor_resonator_mappings={s1: resonator}, + mw = mw_fem_slots if mw_fem_slots is not None else [1] + lf = lf_fem_slots if lf_fem_slots is not None else [2] + host, cluster = "127.0.0.1", "tutorial" + if cluster_config_path is not None: + cfg_path = Path(cluster_config_path) + if cfg_path.is_file(): + with open(cfg_path, encoding="utf-8") as f: + cluster_json = json.load(f) + host = cluster_json.get("host", host) + cluster = cluster_json.get("cluster_name", cluster) + + connectivity = Connectivity() + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=[1, 2], shared_line=True, use_mw_fem=True ) - - machine.register_qubit(quantum_dot_id="virtual_dot_1", qubit_name="Q1") - machine.register_qubit(quantum_dot_id="virtual_dot_2", qubit_name="Q2") - - machine.register_quantum_dot_pair( - id="dot1_dot2_pair", - quantum_dot_ids=["virtual_dot_1", "virtual_dot_2"], - sensor_dot_ids=["virtual_sensor_1"], - barrier_gate_id="virtual_barrier_2", - ) - - machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot1_dot2_epsilon", - set_dc_virtual_axis=False, - ) - - machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=mw) + instruments.add_lf_fem(controller=1, slots=lf) + + allocate_wiring(connectivity, instruments) + + with tempfile.TemporaryDirectory() as tmp: + machine = build_quam_wiring( + connectivity, + host, + cluster, + BaseQuamQD(), + path=tmp, + ) + machine = build_quam( + machine, + calibration_db_path=tmp, + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, + save=False, + ) machine.reset_voltage_sequence("main_qpu") _add_tutorial_state_points(machine) @@ -107,7 +106,6 @@ def _add_tutorial_state_points(machine: LossDiVincenzoQuam) -> None: qubit.add_point(VoltagePointName.MEASURE, {dot_id: 0.15}, duration=200) qubit.add_point(VoltagePointName.EMPTY, {dot_id: 0.00}, duration=200) - # Pairs share voltage sequence with dots; add points for both dots for pair in machine.quantum_dot_pairs.values(): dot_ids = [qd.id for qd in pair.quantum_dots] v_init: Dict[str, float] = {d: 0.10 for d in dot_ids} diff --git a/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py b/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py index fdb18927..e0a55b5d 100644 --- a/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py +++ b/quam_builder/architecture/quantum_dots/examples/virtual_gate_set_example.py @@ -12,7 +12,8 @@ def _channels(names): return { - name: SingleChannel(id=name, opx_output=("con", idx + 1)) for idx, name in enumerate(names) + name: SingleChannel(id=name, opx_output=("con", idx + 1)) + for idx, name in enumerate(names) } diff --git a/quam_builder/architecture/quantum_dots/examples/voltage_balanced_macros_example.py b/quam_builder/architecture/quantum_dots/examples/voltage_balanced_macros_example.py new file mode 100644 index 00000000..a79f466d --- /dev/null +++ b/quam_builder/architecture/quantum_dots/examples/voltage_balanced_macros_example.py @@ -0,0 +1,539 @@ +# ruff: noqa: E402, I001 +# pylint: disable=line-too-long +"""Run the voltage-balanced chained macro simulation on QM SaaS. + +Example: + python quam_builder/architecture/quantum_dots/examples/voltage_balanced_macros_example.py \ + --sim-backend cloud \ + --cloud-config .qm_saas_credentials.json \ + --chained-sequence + + Opens an interactive plot window by default. To also save a PNG, pass ``--plot-dir``. + Use ``--no-plot`` in headless/CI runs. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from collections.abc import Iterator, Mapping +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parents[4] +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +_CS_INSTALLATIONS_TESTS_CONFIG = ( + _PROJECT_ROOT.parent / "tests" / ".qm_cluster_config.json" +) +_CHAINED_SIM_CLOCK_CYCLES = 4_800 +_TWO_STAGE_SIM_CLOCK_CYCLES = 6_000 +_LF_FEM_OUTPUT_DELAY_NS = 161 + +from qm import QuantumMachinesManager, SimulationConfig, qua # noqa: E402 +from quam.components.ports import LFFEMAnalogOutputPort # noqa: E402 + +from quam_builder.architecture.quantum_dots.examples.tutorial_machine import ( # noqa: E402 + build_tutorial_machine, +) +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, +) # noqa: E402 +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( # noqa: E402 + VoltageBalancedMacroCatalog, +) +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros import ( # noqa: E402 + TwoStageBalancedInitializeMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + VoltagePointName, +) # noqa: E402 +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam # noqa: E402 + + +def build_voltage_balanced_tutorial_machine() -> LossDiVincenzoQuam: + cluster_cfg = ( + _CS_INSTALLATIONS_TESTS_CONFIG + if _CS_INSTALLATIONS_TESTS_CONFIG.is_file() + else None + ) + machine = build_tutorial_machine( + mw_fem_slots=[1], + lf_fem_slots=[3, 5], + cluster_config_path=cluster_cfg, + ) + wire_machine_macros(machine, catalogs=[VoltageBalancedMacroCatalog()], save=False) + _set_demo_voltage_points(machine) + _apply_lf_fem_output_delay(machine) + return machine + + +def _set_demo_voltage_points(machine: LossDiVincenzoQuam) -> None: + pair = next(iter(machine.quantum_dot_pairs.values())) + dot_ids = [dot.id for dot in pair.quantum_dots] + pair.add_point( + VoltagePointName.EMPTY, + dict.fromkeys(dot_ids, 0.05), + duration=1000, + replace_existing_point=True, + ) + pair.add_point( + VoltagePointName.MEASURE, + dict.fromkeys(dot_ids, 0.12), + duration=1000, + replace_existing_point=True, + ) + + qubit_pair = next(iter(machine.qubit_pairs.values())) + exchange_dot_ids = [ + qubit_pair.quantum_dot_pair.quantum_dots[0].id, + qubit_pair.quantum_dot_pair.quantum_dots[1].id, + ] + qubit_pair.add_point( + VoltagePointName.EXCHANGE, + dict.fromkeys(exchange_dot_ids, 0.06), + duration=1000, + replace_existing_point=True, + ) + + +def _apply_lf_fem_output_delay(machine: LossDiVincenzoQuam) -> None: + for virtual_gate_set in machine.virtual_gate_sets.values(): + for channel in virtual_gate_set.channels.values(): + output = getattr(channel, "opx_output", None) + if isinstance(output, LFFEMAnalogOutputPort): + output.delay = _LF_FEM_OUTPUT_DELAY_NS + + for sensor_dot in machine.sensor_dots.values(): + resonator = getattr(sensor_dot, "readout_resonator", None) + output = ( + getattr(resonator, "opx_output", None) if resonator is not None else None + ) + if isinstance(output, LFFEMAnalogOutputPort): + output.delay = _LF_FEM_OUTPUT_DELAY_NS + + +def build_chained_program(machine: LossDiVincenzoQuam) -> object: + pair = next(iter(machine.quantum_dot_pairs.values())) + qubit = next(iter(machine.qubits.values())) + qubit_pair = next(iter(machine.qubit_pairs.values())) + + with qua.program() as program: + pair.empty() + qua.align() + qubit.measure() + qua.align() + pair.initialize() + qua.align() + qubit.x90() + qua.align() + qubit_pair.cz() + qua.align() + qubit.x180() + qua.align() + pair.measure() + + return program + + +def _load_cloud_config(path: Path | None) -> tuple[str, str, str, Path]: + config_path = path or _PROJECT_ROOT / ".qm_saas_credentials.json" + if not config_path.is_file(): + raise FileNotFoundError(f"Cloud config not found: {config_path}") + + with open(config_path, encoding="utf-8") as file: + config = json.load(file) + + return ( + config["email"], + config["password"], + config.get("host", "qm-saas.dev.quantum-machines.co"), + config_path, + ) + + +def run_cloud_chained_simulation( + machine: LossDiVincenzoQuam, + *, + cloud_config: Path | None, + plot_dir: Path | None, + no_plot: bool = False, +) -> None: + try: + import qm_saas # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError("Cloud simulation requires the `qm_saas` package.") from exc + + email, password, host, config_path = _load_cloud_config(cloud_config) + print( + f"Using QM cloud simulator: host={host!r} (credentials: {config_path})", + flush=True, + ) + + client = qm_saas.QmSaas(email=email, password=password, host=host) + client.close_all() + with client.simulator(client.latest_version()) as instance: + qmm = QuantumMachinesManager( + host=instance.host, + port=instance.port, + connection_headers=instance.default_connection_headers, + ) + job = qmm.simulate( + machine.generate_config(), + build_chained_program(machine), + SimulationConfig(duration=_CHAINED_SIM_CLOCK_CYCLES), + ) + job.wait_until("Done", timeout=600) + samples = job.get_simulated_samples() + + _check_lf_integrals(samples, machine) + if not no_plot: + save_path = plot_dir / "chained_sequence.png" if plot_dir is not None else None + if save_path is not None: + save_path.parent.mkdir(parents=True, exist_ok=True) + _plot_samples(samples, machine, output_path=save_path) + print("Simulated: chained voltage-balanced macro sequence", flush=True) + + +def _iter_controller_samples(samples: object) -> Iterator[tuple[str, object]]: + if isinstance(samples, Mapping): + for controller_id, controller_samples in samples.items(): + if controller_samples is not None: + yield str(controller_id), controller_samples + return + + for controller_id in ("con1", "con2", "con3", "con4", "con5"): + if hasattr(samples, controller_id): + yield controller_id, getattr(samples, controller_id) + + +def _lf_output_sample_keys( + machine: LossDiVincenzoQuam, + gate_set_id: str = "main_qpu", +) -> Iterator[tuple[str, str, float, str]]: + for channel in machine.virtual_gate_sets[gate_set_id].channels.values(): + output = channel.opx_output + if not isinstance(output, LFFEMAnalogOutputPort): + continue + dt = 1.0 / float(output.sampling_rate) if output.sampling_rate else 1e-9 + yield output.controller_id, f"{output.fem_id}-{output.port_id}", dt, channel.id + + +def _dt_for_sample( + controller_samples: object, + sim_key: str, + machine: LossDiVincenzoQuam, + controller_id: str, +) -> float: + sampling_rates = getattr(controller_samples, "analog_sampling_rate", None) + if isinstance(sampling_rates, Mapping) and sampling_rates.get(sim_key): + return 1.0 / float(sampling_rates[sim_key]) + + for ctrl, key, dt, _label in _lf_output_sample_keys(machine): + if ctrl == controller_id and key == sim_key: + return dt + return 1e-9 + + +def _as_waveform_array(waveform: object) -> object: + import numpy as np + + return np.asarray(waveform).ravel() + + +def _check_lf_integrals( + samples: object, + machine: LossDiVincenzoQuam, + *, + atol_v_s: float = 2e-5, +) -> None: + checked = 0 + controller_samples = dict(_iter_controller_samples(samples)) + for controller_id, sim_key, _dt, _label in _lf_output_sample_keys(machine): + samples_for_controller = controller_samples.get(controller_id) + if ( + samples_for_controller is None + or sim_key not in samples_for_controller.analog + ): + continue + + dt = _dt_for_sample(samples_for_controller, sim_key, machine, controller_id) + values = _as_waveform_array(samples_for_controller.analog[sim_key]).real + integral = float(values.sum() * dt) + checked += 1 + if abs(integral) > atol_v_s: + raise AssertionError( + f"DC integral check failed: controller={controller_id} key={sim_key} " + f"integral={integral:.3e} V*s (tol {atol_v_s:.3e})" + ) + + if checked == 0: + print( + "No LF analog samples were returned; skipped DC integral check.", flush=True + ) + else: + print(f"DC integral check passed on {checked} LF line(s).", flush=True) + + +def _plot_samples( + samples: object, + machine: LossDiVincenzoQuam, + *, + output_path: Path | None = None, + title: str = "Chained voltage-balanced macro sequence", +) -> None: + import matplotlib.pyplot as plt + import numpy as np + + lf_labels = { + (controller_id, sim_key): label + for controller_id, sim_key, _dt, label in _lf_output_sample_keys(machine) + } + fig, ax = plt.subplots(figsize=(9, 3.5)) + plotted = 0 + for controller_id, controller_samples in _iter_controller_samples(samples): + for sim_key, waveform in controller_samples.analog.items(): + values = _as_waveform_array(waveform) + if len(values) == 0: + continue + dt = _dt_for_sample(controller_samples, sim_key, machine, controller_id) + label = lf_labels.get( + (controller_id, sim_key), f"{controller_id} {sim_key}" + ) + time_us = np.arange(len(values)) * dt * 1e6 + ax.plot(time_us, values.real, lw=0.8, label=f"{label} real") + plotted += 1 + if np.any(values.imag): + ax.plot( + time_us, + values.imag, + lw=0.8, + ls="--", + label=f"{label} imag", + ) + plotted += 1 + + if plotted == 0: + ax.text(0.5, 0.5, "No analog data", transform=ax.transAxes, ha="center") + else: + ax.legend(loc="best", fontsize=6, framealpha=0.92) + + ax.set_xlabel("Time (us)") + ax.set_ylabel("V") + ax.set_title(title) + fig.tight_layout() + if output_path is not None: + fig.savefig(output_path, dpi=120) + plt.show() + plt.close(fig) + + +# ── Two-stage balanced initialize: swap, persist, simulate ────────────────── + + +def swap_pair_initialize_to_two_stage(machine: LossDiVincenzoQuam) -> None: + """Replace the BalancedInitializeMacro on the first QuantumDotPair with + TwoStageBalancedInitializeMacro after the initial wiring stage. + + Also registers the INITIALIZE voltage point (inner stage, V1) on the pair + so the two-stage macro has both voltage levels it needs: the inner + INITIALIZE point (V1, smaller excursion) and the outer EMPTY point (V2). + """ + pair = next(iter(machine.quantum_dot_pairs.values())) + dot_ids = [dot.id for dot in pair.quantum_dots] + + # Inner voltage point (V1): slightly smaller than EMPTY (V2 = 0.12 V). + pair.add_point( + 'init1', + dict.fromkeys(dot_ids, 0.16), + duration=1000, + replace_existing_point=True, + ) + + pair.add_point( + 'init2', + dict.fromkeys(dot_ids, 0.08), + duration=1000, + replace_existing_point=True, + ) + + before_type = type(pair.macros[VoltagePointName.INITIALIZE]).__name__ + pair.macros[VoltagePointName.INITIALIZE] = TwoStageBalancedInitializeMacro( + point_1='init1', + point_2='init2', + ramp_duration_1=524, + ramp_duration_2=1048, + ramp_duration_mid=16, + hold_duration=1048, + ) + after_type = type(pair.macros[VoltagePointName.INITIALIZE]).__name__ + + print( + f"Swapped pair '{pair.id}' initialize macro: {before_type} → {after_type}", + flush=True, + ) + + +def demonstrate_two_stage_persistence( + machine: LossDiVincenzoQuam, +) -> LossDiVincenzoQuam: + """Save the machine state to disk, reload it, and verify the macro type and + parameters survive the round-trip. + + Returns the reloaded machine so the caller can use it for simulation. + """ + pair = next(iter(machine.quantum_dot_pairs.values())) + macro_before: TwoStageBalancedInitializeMacro = pair.macros[VoltagePointName.INITIALIZE] + + assert isinstance(macro_before, TwoStageBalancedInitializeMacro), ( + f"Expected TwoStageBalancedInitializeMacro, got {type(macro_before).__name__}" + ) + print( + f"Before save — type={type(macro_before).__name__}, " + f"point_1={macro_before.point_1!r}, point_2={macro_before.point_2!r}, " + f"hold_duration={macro_before.hold_duration}, " + f"ramp_duration_1={macro_before.ramp_duration_1}", + flush=True, + ) + + with tempfile.TemporaryDirectory() as save_dir: + machine.save(save_dir) + print(f"Saved QuAM state → {save_dir}", flush=True) + loaded = LossDiVincenzoQuam.load(save_dir) + + loaded_pair = next(iter(loaded.quantum_dot_pairs.values())) + macro_after: TwoStageBalancedInitializeMacro = loaded_pair.macros[VoltagePointName.INITIALIZE] + + assert isinstance(macro_after, TwoStageBalancedInitializeMacro), ( + f"Reload failed: expected TwoStageBalancedInitializeMacro, " + f"got {type(macro_after).__name__}" + ) + assert macro_after.hold_duration == macro_before.hold_duration + assert macro_after.ramp_duration_1 == macro_before.ramp_duration_1 + assert macro_after.ramp_duration_2 == macro_before.ramp_duration_2 + assert macro_after.ramp_duration_mid == macro_before.ramp_duration_mid + assert macro_after.point_1 == macro_before.point_1 + assert macro_after.point_2 == macro_before.point_2 + + print( + f"After reload — type={type(macro_after).__name__}, " + f"point_1={macro_after.point_1!r}, point_2={macro_after.point_2!r}, " + f"hold_duration={macro_after.hold_duration}, " + f"ramp_duration_1={macro_after.ramp_duration_1}", + flush=True, + ) + print( + "Persistence check passed: TwoStageBalancedInitializeMacro round-trips cleanly.", + flush=True, + ) + return loaded + + +def build_two_stage_init_program(machine: LossDiVincenzoQuam) -> object: + """QUA program that exercises the two-stage balanced initialize macro.""" + pair = next(iter(machine.quantum_dot_pairs.values())) + qubit = next(iter(machine.qubits.values())) + + with qua.program() as program: + pair.initialize() + + + return program + + +def run_cloud_two_stage_simulation( + machine: LossDiVincenzoQuam, + *, + cloud_config: Path | None, + plot_dir: Path | None, + no_plot: bool = False, +) -> None: + """Swap to the two-stage init macro, verify persistence, then simulate.""" + swap_pair_initialize_to_two_stage(machine) + machine = demonstrate_two_stage_persistence(machine) + _apply_lf_fem_output_delay(machine) + + try: + import qm_saas # type: ignore[import-not-found] + except ImportError as exc: + raise ImportError("Cloud simulation requires the `qm_saas` package.") from exc + + email, password, host, config_path = _load_cloud_config(cloud_config) + print( + f"Using QM cloud simulator: host={host!r} (credentials: {config_path})", + flush=True, + ) + + client = qm_saas.QmSaas(email=email, password=password, host=host) + client.close_all() + with client.simulator(client.latest_version()) as instance: + qmm = QuantumMachinesManager( + host=instance.host, + port=instance.port, + connection_headers=instance.default_connection_headers, + ) + job = qmm.simulate( + machine.generate_config(), + build_two_stage_init_program(machine), + SimulationConfig(duration=_TWO_STAGE_SIM_CLOCK_CYCLES), + ) + job.wait_until("Done", timeout=600) + samples = job.get_simulated_samples() + + _check_lf_integrals(samples, machine) + if not no_plot: + save_path = ( + plot_dir / "two_stage_init_sequence.png" if plot_dir is not None else None + ) + if save_path is not None: + save_path.parent.mkdir(parents=True, exist_ok=True) + _plot_samples( + samples, + machine, + output_path=save_path, + title="Two-stage balanced initialize sequence", + ) + print("Simulated: two-stage balanced initialize sequence", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sim-backend", choices=("cloud",), default="cloud") + parser.add_argument("--cloud-config", type=Path, default=None) + parser.add_argument("--plot-dir", type=Path, default=None) + parser.add_argument( + "--no-plot", + action="store_true", + help="Skip matplotlib (use in headless/CI; no window, no file).", + ) + parser.add_argument( + "--two-stage-init", + action="store_false", + help=( + "Swap the pair initialize macro to TwoStageBalancedInitializeMacro, " + "verify persistence across save/reload, then simulate." + ), + ) + # parse_known_args: Jupyter/IPython pass e.g. --f=... for the kernel connection file. + args, _unknown = parser.parse_known_args() + + if args.two_stage_init: + run_cloud_two_stage_simulation( + build_voltage_balanced_tutorial_machine(), + cloud_config=args.cloud_config, + plot_dir=args.plot_dir, + no_plot=args.no_plot, + ) + else: + run_cloud_chained_simulation( + build_voltage_balanced_tutorial_machine(), + cloud_config=args.cloud_config, + plot_dir=args.plot_dir, + no_plot=args.no_plot, + ) + + +if __name__ == "__main__": + main() diff --git a/quam_builder/architecture/quantum_dots/examples/wiring_example.py b/quam_builder/architecture/quantum_dots/examples/wiring_example.py index adf92cbf..ab1cbb37 100644 --- a/quam_builder/architecture/quantum_dots/examples/wiring_example.py +++ b/quam_builder/architecture/quantum_dots/examples/wiring_example.py @@ -67,11 +67,15 @@ def example_1_two_stage_workflow(): # Add sensor dots (voltage gates + resonator lines) # Using convenience method that adds both voltage gates and resonator lines - connectivity_stage1.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False) + connectivity_stage1.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) # Add quantum dots with plunger gates ONLY (no drive lines) # Note: add_drive_lines=False is the default, so we're only adding plunger gates - connectivity_stage1.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + connectivity_stage1.add_quantum_dots( + quantum_dots=quantum_dots, add_drive_lines=False + ) # Add quantum dot pairs (barrier gates) connectivity_stage1.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) @@ -89,6 +93,7 @@ def example_1_two_stage_workflow(): except Exception as e: print(f"✗ Error in allocate_wiring: {e}") import traceback + traceback.print_exc() raise @@ -106,6 +111,7 @@ def example_1_two_stage_workflow(): except Exception as e: print(f"✗ Error in build_quam_wiring: {e}") import traceback + traceback.print_exc() raise @@ -119,11 +125,14 @@ def example_1_two_stage_workflow(): save=True, # Save the BaseQuamQD state ) print("✓ Stage 1 BaseQuamQD build successful") - print(" → You can now calibrate quantum dots, update cross-compensation matrix, etc.") + print( + " → You can now calibrate quantum dots, update cross-compensation matrix, etc." + ) print(" → Saved state can be loaded later for Stage 2") except Exception as e: print(f"✗ Error in build_base_quam: {e}") import traceback + traceback.print_exc() raise @@ -145,7 +154,9 @@ def example_1_two_stage_workflow(): # Add the same dot-layer components as Stage 1 connectivity_stage2.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") - connectivity_stage2.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False) + connectivity_stage2.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) connectivity_stage2.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) # Add quantum dots WITH drive lines this time @@ -165,6 +176,7 @@ def example_1_two_stage_workflow(): except Exception as e: print(f"✗ Error in allocate_wiring: {e}") import traceback + traceback.print_exc() raise @@ -181,6 +193,7 @@ def example_1_two_stage_workflow(): except Exception as e: print(f"✗ Error in build_quam_wiring: {e}") import traceback + traceback.print_exc() raise @@ -198,6 +211,7 @@ def example_1_two_stage_workflow(): except Exception as e: print(f"✗ Error in build_loss_divincenzo_quam: {e}") import traceback + traceback.print_exc() raise @@ -214,7 +228,9 @@ def example_2_combined_workflow(): print("\n" + "=" * 80) print("EXAMPLE 2: Combined Workflow (Single-Stage)") print("=" * 80) - print("\n--- Setting up connectivity WITH all components (including drive lines) ---") + print( + "\n--- Setting up connectivity WITH all components (including drive lines) ---" + ) # Create fresh instruments for the combined workflow instruments_combined = Instruments() @@ -228,7 +244,9 @@ def example_2_combined_workflow(): connectivity_combined.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") # Add sensor dots - connectivity_combined.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False) + connectivity_combined.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) # Add quantum dots WITH drive lines in a single call connectivity_combined.add_quantum_dots( @@ -249,6 +267,7 @@ def example_2_combined_workflow(): except Exception as e: print(f"✗ Error in allocate_wiring: {e}") import traceback + traceback.print_exc() raise @@ -266,6 +285,7 @@ def example_2_combined_workflow(): except Exception as e: print(f"✗ Error in build_quam_wiring: {e}") import traceback + traceback.print_exc() raise @@ -283,6 +303,7 @@ def example_2_combined_workflow(): except Exception as e: print(f"✗ Error in build_quam: {e}") import traceback + traceback.print_exc() raise @@ -315,10 +336,14 @@ def example_3_incremental_drive_lines(): connectivity_stage3.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") # Add sensor dots (voltage gates + resonator lines) - connectivity_stage3.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False) + connectivity_stage3.add_sensor_dots( + sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + ) # Add quantum dots with plunger gates ONLY (no drive lines) - connectivity_stage3.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + connectivity_stage3.add_quantum_dots( + quantum_dots=quantum_dots, add_drive_lines=False + ) # Add quantum dot pairs (barrier gates) connectivity_stage3.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) @@ -331,6 +356,7 @@ def example_3_incremental_drive_lines(): except Exception as e: print(f"✗ Error in allocate_wiring: {e}") import traceback + traceback.print_exc() raise @@ -348,6 +374,7 @@ def example_3_incremental_drive_lines(): except Exception as e: print(f"✗ Error in build_quam_wiring: {e}") import traceback + traceback.print_exc() raise @@ -363,6 +390,7 @@ def example_3_incremental_drive_lines(): except Exception as e: print(f"✗ Error in build_base_quam: {e}") import traceback + traceback.print_exc() raise @@ -372,7 +400,9 @@ def example_3_incremental_drive_lines(): print("\n" + "=" * 80) print("--- STAGE 2: Adding ONLY drive lines to existing connectivity ---") print("=" * 80) - print("Note: Using the SAME instruments instance and adding drive lines to the SAME connectivity object") + print( + "Note: Using the SAME instruments instance and adding drive lines to the SAME connectivity object" + ) # Add ONLY drive lines to the existing connectivity # The dot-layer components (gates, sensors, pairs) are already there from Stage 1 @@ -389,11 +419,16 @@ def example_3_incremental_drive_lines(): print("Allocating wiring for Stage 2 (drive lines only)...") try: allocate_wiring(connectivity_stage3, instruments_stage3) - print("✓ Stage 2 wiring allocation successful (drive lines added to existing connectivity)") + print( + "✓ Stage 2 wiring allocation successful (drive lines added to existing connectivity)" + ) except Exception as e: print(f"✗ Error in allocate_wiring: {e}") - print(" → This might fail if there aren't enough available channels after Stage 1") + print( + " → This might fail if there aren't enough available channels after Stage 1" + ) import traceback + traceback.print_exc() raise @@ -410,6 +445,7 @@ def example_3_incremental_drive_lines(): except Exception as e: print(f"✗ Error in build_quam_wiring: {e}") import traceback + traceback.print_exc() raise @@ -424,10 +460,13 @@ def example_3_incremental_drive_lines(): ) print("✓ Stage 3 LossDiVincenzoQuam build successful") print(" → Machine now has both quantum dots AND qubits with drive lines") - print(" → Successfully reused same instruments instance and added drive lines incrementally") + print( + " → Successfully reused same instruments instance and added drive lines incrementally" + ) except Exception as e: print(f"✗ Error in build_loss_divincenzo_quam: {e}") import traceback + traceback.print_exc() raise diff --git a/quam_builder/architecture/quantum_dots/macro_engine/__init__.py b/quam_builder/architecture/quantum_dots/macro_engine/__init__.py index 6e53f4ab..3830cd8b 100644 --- a/quam_builder/architecture/quantum_dots/macro_engine/__init__.py +++ b/quam_builder/architecture/quantum_dots/macro_engine/__init__.py @@ -1,24 +1,22 @@ """Macro runtime entry points for quantum-dot components. Exports: - load_macro_profile: Read TOML macro profile data. - wire_machine_macros: Materialize defaults and apply user overrides. - macro: Create a macro override entry with early validation. - disabled: Create a disabled override entry (removes macro/pulse). - pulse: Create a pulse override entry. - overrides: Create a ComponentOverrides grouping macros and pulses. - ComponentOverrides: Typed container for macro and pulse overrides. + wire_machine_macros: Wire macros and pulses onto a machine (main entry point). + MacroWirer: Materializes macros from a MacroRegistry. + PulseWirer: Materializes default pulses onto channels. + DISABLED: Sentinel to remove a macro in override dicts. + +For the catalog protocol and registry classes, import from +:mod:`quam_builder.architecture.quantum_dots.operations.macro_catalog`. """ -from .wiring import load_macro_profile, wire_machine_macros -from .overrides import macro, disabled, pulse, overrides, ComponentOverrides +from .wiring import MacroWirer, PulseWirer, wire_machine_macros + +from quam_builder.architecture.quantum_dots.operations.macro_catalog import DISABLED __all__ = [ - "load_macro_profile", "wire_machine_macros", - "macro", - "disabled", - "pulse", - "overrides", - "ComponentOverrides", + "MacroWirer", + "PulseWirer", + "DISABLED", ] diff --git a/quam_builder/architecture/quantum_dots/macro_engine/overrides.py b/quam_builder/architecture/quantum_dots/macro_engine/overrides.py deleted file mode 100644 index 391db008..00000000 --- a/quam_builder/architecture/quantum_dots/macro_engine/overrides.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Typed helpers for macro and pulse override construction. - -These helpers replace raw nested dicts with validated, IDE-friendly calls. -They produce the same internal format consumed by :func:`wire_machine_macros`, -so raw dicts still work for backward compatibility and TOML profiles. - -Usage:: - - from quam_builder.architecture.quantum_dots.macro_engine.overrides import ( - macro, disabled, pulse, overrides, - ) - - wire_machine_macros( - machine, - component_overrides={ - LDQubit: overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), - }), - }, - instance_overrides={ - "qubits.q2": overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), - SingleQubitMacroName.X_180: disabled(), - }), - }, - ) -""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any - -from quam.core.macro import QuamMacro - -__all__ = [ - "macro", - "disabled", - "pulse", - "overrides", - "ComponentOverrides", -] - - -def macro(factory: type[QuamMacro] | str, **params: Any) -> dict[str, Any]: - """Create a macro override entry with early validation. - - Args: - factory: A ``QuamMacro`` subclass or ``"module.path:ClassName"`` string. - **params: Keyword arguments forwarded to the macro constructor or set - as attributes after construction. - - Returns: - Override dict in the format expected by :func:`wire_machine_macros`. - - Raises: - TypeError: If *factory* is not a QuamMacro subclass or import string. - """ - if isinstance(factory, type): - if not issubclass(factory, QuamMacro): - raise TypeError(f"Macro factory must be a QuamMacro subclass, got {factory.__name__}.") - elif not isinstance(factory, str): - raise TypeError( - f"Macro factory must be a QuamMacro subclass or 'module:Class' string, " - f"got {type(factory).__name__}." - ) - entry: dict[str, Any] = {"factory": factory} - if params: - entry["params"] = params - return entry - - -def disabled() -> dict[str, Any]: - """Create a disabled override entry that removes the macro or pulse.""" - return {"enabled": False} - - -def pulse(pulse_type: str, **params: Any) -> dict[str, Any]: - """Create a pulse override entry. - - Args: - pulse_type: Pulse class name (``"GaussianPulse"``, ``"SquarePulse"``, - ``"SquareReadoutPulse"``, ``"DragPulse"``). - **params: Pulse constructor keyword arguments (``length``, ``amplitude``, - ``sigma``, etc.). - - Returns: - Override dict in the format expected by pulse wiring. - """ - return {"type": pulse_type, **params} - - -@dataclass(frozen=True) -class ComponentOverrides: - """Typed container grouping macro and pulse overrides for one component.""" - - macros: dict[str, dict[str, Any]] = field(default_factory=dict) - pulses: dict[str, dict[str, Any]] = field(default_factory=dict) - - def _to_dict(self) -> dict[str, Any]: - """Convert to the internal dict format.""" - result: dict[str, Any] = {} - if self.macros: - result["macros"] = dict(self.macros) - if self.pulses: - result["pulses"] = dict(self.pulses) - return result - - -def overrides( - macros: dict[str, dict[str, Any]] | None = None, - pulses: dict[str, dict[str, Any]] | None = None, -) -> ComponentOverrides: - """Create a :class:`ComponentOverrides` for a component type or instance. - - Args: - macros: Macro name -> override entry mapping (use :func:`macro` / - :func:`disabled` to build entries). - pulses: Pulse name -> override entry mapping (use :func:`pulse` / - :func:`disabled` to build entries). - """ - return ComponentOverrides( - macros=macros or {}, - pulses=pulses or {}, - ) - - -def _convert_typed_overrides( - component_overrides: Mapping[type | str, ComponentOverrides | Mapping] | None, - instance_overrides: Mapping[str, ComponentOverrides | Mapping] | None, -) -> dict[str, Any]: - """Convert typed override kwargs to the internal dict format. - - Accepts class objects or strings as component type keys, and - ``ComponentOverrides`` or raw dicts as values. - """ - result: dict[str, Any] = {} - - if component_overrides: - types_dict: dict[str, Any] = {} - for key, value in component_overrides.items(): - type_name = key.__name__ if isinstance(key, type) else str(key) - if isinstance(value, ComponentOverrides): - types_dict[type_name] = value._to_dict() - elif isinstance(value, Mapping): - types_dict[type_name] = dict(value) - else: - raise TypeError( - f"component_overrides[{type_name}] must be a ComponentOverrides " - f"or mapping, got {type(value).__name__}." - ) - result["component_types"] = types_dict - - if instance_overrides: - instances_dict: dict[str, Any] = {} - for path, value in instance_overrides.items(): - if not isinstance(path, str): - raise TypeError( - f"Instance override keys must be strings, got {type(path).__name__}." - ) - if isinstance(value, ComponentOverrides): - instances_dict[path] = value._to_dict() - elif isinstance(value, Mapping): - instances_dict[path] = dict(value) - else: - raise TypeError( - f"instance_overrides[{path!r}] must be a ComponentOverrides " - f"or mapping, got {type(value).__name__}." - ) - result["instances"] = instances_dict - - return result diff --git a/quam_builder/architecture/quantum_dots/macro_engine/wiring.py b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py index ccf67b64..f46518d0 100644 --- a/quam_builder/architecture/quantum_dots/macro_engine/wiring.py +++ b/quam_builder/architecture/quantum_dots/macro_engine/wiring.py @@ -1,276 +1,244 @@ -"""Macro and pulse wiring with user override utilities for quantum-dot machines. +"""Macro and pulse wiring for quantum-dot machines. -This module is the runtime entry point for: -1. Materializing component defaults from the component-type registry. -2. Applying user overrides from a TOML profile and/or Python mapping. -3. Supporting partial overrides while retaining all untouched defaults. -4. Wiring default pulses onto qubit XY drives and sensor dot resonators. +This module is the runtime entry point for materializing macro defaults +from the catalog-based registry and applying user overrides. + +Public API +---------- +- ``wire_machine_macros`` -- top-level function that wires macros **and** pulses. +- ``MacroWirer`` -- materializes macros from a ``MacroRegistry``. +- ``PulseWirer`` -- materializes default pulses onto channels. """ from __future__ import annotations -from collections.abc import Mapping -import importlib -import inspect -from pathlib import Path -from typing import Any -import tomllib +from collections.abc import Mapping, Sequence +from typing import Any, Iterable -from qm.qua._dsl import amplitude from quam.core.macro import QuamMacro -from quam_builder.architecture.quantum_dots.operations.macro_registry import ( - get_default_macro_factories, -) -from quam_builder.architecture.quantum_dots.operations.component_macro_catalog import ( - register_default_component_macro_factories, -) -from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( - register_default_component_pulse_factories, - _make_xy_pulse_factories, - _make_readout_pulse, - _make_baseband_pulse, +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + DISABLED, + DefaultMacroCatalog, + MacroCatalog, + MacroFactoryMap, + MacroRegistry, + UtilityMacroCatalog, ) -from quam_builder.architecture.quantum_dots.macro_engine.overrides import ( - ComponentOverrides, - _convert_typed_overrides, +from quam_builder.architecture.quantum_dots.operations.pulse_catalog import ( + make_readout_pulse, + make_xy_pulse_factories, ) -from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME __all__ = [ "wire_machine_macros", - "load_macro_profile", + "MacroWirer", + "PulseWirer", ] -def load_macro_profile(profile_path: str | Path | None) -> dict[str, Any]: - """Load an optional TOML macro profile. - - Args: - profile_path: Path to a TOML file, or ``None`` to skip profile loading. - - Returns: - Decoded profile data as a mapping. - - Raises: - FileNotFoundError: If the path is provided and does not exist. - ValueError: If the parsed file is not a dictionary-like structure. - """ - if profile_path is None: - return {} - path = Path(profile_path) - if not path.exists(): - raise FileNotFoundError(f"Macro profile not found: {path}") - with path.open("rb") as f: - data = tomllib.load(f) - if not isinstance(data, dict): - raise ValueError("Macro profile must decode to a dictionary.") - return data - - -def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: - """Recursively merge ``override`` onto ``base`` and return a new mapping.""" - merged = dict(base) - for key, value in override.items(): - if isinstance(value, Mapping) and isinstance(merged.get(key), dict): - merged[key] = _deep_merge(merged[key], value) # type: ignore[arg-type] - else: - merged[key] = value - return merged - - -def _iter_macro_components(machine: Any): - """Iterate components on a machine that expose a ``macros`` mapping. - - Yields: - Tuples of ``(, )`` where - ``component_path`` is suitable for ``instances.`` overrides. - """ - collection_names = ( - "quantum_dots", - "sensor_dots", - "barrier_gates", - "global_gates", - "quantum_dot_pairs", - "qubits", - "qubit_pairs", - ) - for collection_name in collection_names: - collection = getattr(machine, collection_name, None) - if isinstance(collection, Mapping): - for name, component in collection.items(): - if hasattr(component, "macros"): - yield f"{collection_name}.{name}", component - - qpu = getattr(machine, "qpu", None) - if qpu is not None and hasattr(qpu, "macros"): - yield "qpu", qpu +# --------------------------------------------------------------------------- +# MacroWirer +# --------------------------------------------------------------------------- -def _resolve_macro_factory(factory_spec: Any): - """Resolve macro factory spec to a concrete ``QuamMacro`` subclass. +class MacroWirer: + """Materializes macros onto machine components from a :class:`MacroRegistry`. Args: - factory_spec: Either a class object or a ``module.path:Symbol`` string. - - Returns: - A ``QuamMacro`` subclass. + registry: The registry to resolve macro factories from. """ - if isinstance(factory_spec, str): - if ":" not in factory_spec: - raise ValueError( - f"Macro factory '{factory_spec}' must use 'module.path:SymbolName' format." - ) - module_name, symbol_name = factory_spec.split(":", 1) - module = importlib.import_module(module_name) - factory = getattr(module, symbol_name) - else: - factory = factory_spec - - if not isinstance(factory, type) or not issubclass(factory, QuamMacro): - raise TypeError( - f"Resolved macro factory '{factory}' must be a QuamMacro subclass, " - f"got '{type(factory).__name__}'." - ) - return factory - - -def _set_component_macro(component: Any, name: str, macro: QuamMacro) -> None: - """Set or replace a macro on a component while keeping parent links valid.""" - set_macro = getattr(component, "set_macro", None) - if callable(set_macro): - set_macro(name, macro) - return - - if not hasattr(component, "macros") or component.macros is None: - component.macros = {} - component.macros[name] = macro - if getattr(macro, "parent", None) is None: - macro.parent = component - -def _remove_component_macro(component: Any, name: str, strict: bool) -> None: - """Remove a macro from a component and invalidate dispatch cache if needed.""" - macros = getattr(component, "macros", None) - if not isinstance(macros, Mapping): - if strict: + def __init__(self, registry: MacroRegistry) -> None: + self._registry = registry + + def wire( + self, + machine: object, + *, + instance_overrides: dict[str, MacroFactoryMap] | None = None, + fill_only: bool = False, + ) -> None: + """Materialize defaults and apply overrides for all components. + + Args: + machine: Target machine whose components should be wired. + instance_overrides: Per-component-path overrides applied after + catalog-based defaults. Keys are paths like ``"qubits.q1"``. + Use ``DISABLED`` as a factory value to remove a macro. + fill_only: If ``True``, only add macros that are completely + absent from a component. Existing macros (regardless of + type) are never replaced. Use during ``load()`` to + preserve deserialized macro types and calibrated properties. + + Raises: + KeyError: If an instance override path does not match any + component, or if ``DISABLED`` targets a non-existent macro. + """ + components = dict(self._iter_components(machine)) + + for component in components.values(): + self._materialize_from_registry(component, fill_only=fill_only) + + if instance_overrides: + self._apply_instance_overrides(components, instance_overrides) + + # -- internal ----------------------------------------------------------- + + def _materialize_from_registry( + self, component: object, *, fill_only: bool = False + ) -> None: + """Materialize macros from the registry onto *component*. + + For each name in the resolved factory map: + - If the component has no macro for that name, create one. + - If *fill_only* is ``False`` and the component already has one + whose type differs from the factory, replace it (catalog + override during build). + - If *fill_only* is ``True``, never replace an existing macro + (preserves deserialized types and calibrated properties during + load). + """ + factories = self._registry.resolve_factories(component) + macros = getattr(component, "macros", None) + if macros is None: + return + for name, factory in factories.items(): + if name not in macros: + self._set_macro(component, name, factory()) + elif not fill_only and self._factory_overrides_existing( + factory, macros[name] + ): + self._set_macro(component, name, factory()) + + @staticmethod + def _factory_overrides_existing(factory: Any, existing_macro: QuamMacro) -> bool: + """Return True if *factory* would produce a different macro type or config. + + For bare classes, checks isinstance. For partials or other + callables (which carry custom parameters), always replace. + """ + if isinstance(factory, type): + return not isinstance(existing_macro, factory) + # Non-type callable (e.g. functools.partial with custom params) -- + # always treat as an override since we can't compare config. + return True + + def _apply_instance_overrides( + self, + components: dict[str, object], + overrides: dict[str, MacroFactoryMap], + ) -> None: + for path, factory_map in overrides.items(): + if path not in components: + raise KeyError( + f"Unknown component path '{path}'. " f"Known: {sorted(components)}" + ) + component = components[path] + for name, factory_or_sentinel in factory_map.items(): + if factory_or_sentinel is DISABLED: + self._remove_macro(component, name) + else: + macro = factory_or_sentinel() + self._set_macro(component, name, macro) + + @staticmethod + def _set_macro(component: object, name: str, macro: QuamMacro) -> None: + set_macro = getattr(component, "set_macro", None) + if callable(set_macro): + set_macro(name, macro) + return + if not hasattr(component, "macros") or component.macros is None: + component.macros = {} # type: ignore[attr-defined] + component.macros[name] = macro # type: ignore[attr-defined] + if getattr(macro, "parent", None) is None: + macro.parent = component + + @staticmethod + def _remove_macro(component: object, name: str) -> None: + macros = getattr(component, "macros", None) + if not isinstance(macros, Mapping): raise KeyError(f"Component '{component}' has no macros container.") - return - if name not in macros: - if strict: - raise KeyError(f"Macro '{name}' not found on component '{component.id}'.") - return - del macros[name] - - -def _normalize_macro_override(entry: Any) -> tuple[type[QuamMacro] | None, dict[str, Any], bool]: - """Normalize one macro override entry into factory/params/enabled tuple.""" - if isinstance(entry, str) or (isinstance(entry, type) and issubclass(entry, QuamMacro)): - return _resolve_macro_factory(entry), {}, True - - if not isinstance(entry, Mapping): - raise TypeError( - "Macro override entry must be a mapping, a QuamMacro class, or import path string." - ) - - enabled = bool(entry.get("enabled", True)) - if not enabled: - return None, {}, False - - factory_spec = entry.get("factory") - if factory_spec is None: - raise ValueError("Enabled macro override must provide 'factory'.") - params = entry.get("params", {}) - if not isinstance(params, Mapping): - raise TypeError("Macro override 'params' must be a mapping.") - return _resolve_macro_factory(factory_spec), dict(params), True - - -def _apply_macros_to_component( - component: Any, - macros_config: Mapping[str, Any], - *, - strict: bool, - context: str, -) -> None: - """Apply normalized macro override entries to one component.""" - known_macros = set(get_default_macro_factories(component).keys()) - known_macros.update(getattr(component, "macros", {}).keys()) - - for macro_name, entry in macros_config.items(): - if strict and macro_name not in known_macros: + if name not in macros: raise KeyError( - f"[{context}] Unknown macro '{macro_name}' for component " - f"{type(component).__name__}({getattr(component, 'id', '?')}). " - f"Known macros: {sorted(known_macros)}" + f"Macro '{name}' not found on " + f"'{getattr(component, 'id', component)}'." ) + del macros[name] + + @staticmethod + def _iter_components(machine: object) -> Iterable[tuple[str, Any]]: + """Yield ``(path, component)`` for all macro-capable components.""" + collection_names = ( + "quantum_dots", + "sensor_dots", + "barrier_gates", + "global_gates", + "quantum_dot_pairs", + "qubits", + "qubit_pairs", + ) + for collection_name in collection_names: + collection = getattr(machine, collection_name, None) + if isinstance(collection, Mapping): + for name, component in collection.items(): + if hasattr(component, "macros"): + yield f"{collection_name}.{name}", component - factory, params, enabled = _normalize_macro_override(entry) - if not enabled: - _remove_component_macro(component, macro_name, strict=strict) - continue - - init_param_names = set(inspect.signature(factory).parameters) - init_params = {key: value for key, value in params.items() if key in init_param_names} - runtime_params = { - key: value for key, value in params.items() if key not in init_param_names - } - - macro = factory(**init_params) # type: ignore[misc] - for key, value in runtime_params.items(): - if strict and not hasattr(macro, key): - raise TypeError( - f"[{context}] Macro '{macro_name}' on component " - f"{type(component).__name__}({getattr(component, 'id', '?')}) " - f"does not expose attribute '{key}'." - ) - setattr(macro, key, value) - _set_component_macro(component, macro_name, macro) - + qpu = getattr(machine, "qpu", None) + if qpu is not None and hasattr(qpu, "macros"): + yield "qpu", qpu -def _ensure_default_pulses(machine: Any) -> None: - """Materialize default pulses onto qubit XY drives and sensor dot resonators. - Called automatically at the end of :func:`wire_machine_macros`, after macro - wiring is complete. Pulse wiring is additive: only pulse names not already - present in a channel's ``operations`` dict are added. User-supplied or - override-supplied pulses always take precedence. +# --------------------------------------------------------------------------- +# PulseWirer +# --------------------------------------------------------------------------- - Qubit XY drives: - For each qubit in ``machine.qubits`` that has an ``xy`` drive with an - ``operations`` dict, adds a single ``GaussianPulse`` reference pulse - named ``"gaussian"``. The ``XYDriveMacro`` scales amplitude for - rotation angle and applies virtual-Z for rotation axis, so only one - calibrated pulse is needed. The pulse is drive-type aware: IQ/MW - channels get ``axis_angle=0.0``; ``SingleChannel`` uses ``axis_angle=None``. - Sensor dot readout resonators: - For each sensor dot in ``machine.sensor_dots`` that has a - ``readout_resonator`` with an ``operations`` dict, adds a default - ``SquareReadoutPulse`` named ``"readout"``. +class PulseWirer: + """Materializes default pulses onto machine channels. - Args: - machine: Target machine object with ``qubits`` and/or ``sensor_dots`` - collections. + Pulse wiring is additive: only pulse names not already present in a + channel's ``operations`` dict are added. """ - register_default_component_pulse_factories() - qubits = getattr(machine, "qubits", None) - if isinstance(qubits, Mapping): + def wire(self, machine: object) -> None: + """Add default pulses where missing. + + Args: + machine: Target machine whose channels receive default pulses. + """ + self._wire_xy_pulses(machine) + self._wire_readout_pulses(machine) + + @staticmethod + def _wire_xy_pulses(machine: object) -> None: + qubits = getattr(machine, "qubits", None) for qubit in qubits.values(): - xy = getattr(qubit, "xy", None) - if xy is None: - continue - operations = getattr(xy, "operations", None) - if operations is None: + if qubit.xy is None: continue - default_pulses = _make_xy_pulse_factories(xy) - for pulse_name, pulse in default_pulses.items(): - if pulse_name not in operations: - operations[pulse_name] = pulse - - sensor_dots = getattr(machine, "sensor_dots", None) - if isinstance(sensor_dots, Mapping): + for name, pulse in make_xy_pulse_factories(qubit.xy).items(): + if name not in qubit.xy.operations: + qubit.xy.add_pulse(name, pulse) + + + @staticmethod + def _wire_readout_pulses(machine: object) -> None: + sensor_dots = getattr(machine, "sensor_dots", None) + + # Should give us a sensor : [qd_pairs] mapping allowing us to create per-qd_pair readout pulses + if sensor_dots is not None: + sensor_to_qdpair_mapping = {s : [] for s in list(sensor_dots.keys())} + quantum_dot_pairs = getattr(machine, "quantum_dot_pairs", None) + if quantum_dot_pairs is not None: + for qd_pair_name, qd_pair in quantum_dot_pairs.items(): + sensors_for_qdp = [sen.name for sen in qd_pair.sensor_dots] + for s in sensor_dots.keys(): + if s in sensors_for_qdp: + sensor_to_qdpair_mapping[s].append(qd_pair_name) + + if not isinstance(sensor_dots, Mapping): + return for sensor_dot in sensor_dots.values(): resonator = getattr(sensor_dot, "readout_resonator", None) if resonator is None: @@ -279,282 +247,89 @@ def _ensure_default_pulses(machine: Any) -> None: if operations is None: continue if "readout" not in operations: - operations["readout"] = _make_readout_pulse() - - physical_channels = getattr(machine, "physical_channels", None) - if isinstance(physical_channels, Mapping): - for physical_channel in physical_channels.values(): - if physical_channel.opx_output is not None: - operations = getattr(physical_channel, "operations", None) - if operations is None: - continue - if DEFAULT_PULSE_NAME not in operations: - output_mode = getattr(physical_channel.opx_output, "output_mode", None) - if output_mode is None: - operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(0.25) - elif output_mode == "direct": - operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(0.25) - elif output_mode == "amplified": - operations[DEFAULT_PULSE_NAME] = _make_baseband_pulse(1.25) - else: - raise ValueError("Unknown output mode '{}'".format(output_mode)) -def _apply_pulse_overrides( - machine: Any, - merged_overrides: Mapping[str, Any], -) -> None: - """Apply pulse overrides from a TOML profile or runtime mapping. - - Called automatically at the end of :func:`wire_machine_macros`, after - ``_ensure_default_pulses`` has materialized defaults. - - Override schema (inside both ``component_types`` and ``instances`` scopes):: - - [component_types.LDQubit.pulses] - x180 = {type = "GaussianPulse", length = 500, amplitude = 0.3, sigma = 83} - - [instances."qubits.q1".pulses] - x180 = {type = "GaussianPulse", length = 800, amplitude = 0.15, sigma = 133} - - Supported pulse types: ``GaussianPulse``, ``SquarePulse``, - ``SquareReadoutPulse``, ``DragPulse`` (if available in the quam version). - - To remove a pulse, set ``enabled = false``:: - - [instances."qubits.q1".pulses] - "-y90" = {enabled = false} - - Precedence (last wins): - 1. Default pulses from ``_ensure_default_pulses`` - 2. Type-level overrides (``component_types..pulses``) - 3. Instance-level overrides (``instances..pulses``) + operations["readout"] = make_readout_pulse() + for qd_pair_name in sensor_to_qdpair_mapping[sensor_dot.name]: + op_name = f"readout_{qd_pair_name}" + if op_name not in operations: + operations[op_name] = make_readout_pulse(qd_pair_name) - Args: - machine: Target machine whose component pulses should be overridden. - merged_overrides: Combined TOML profile + runtime override mapping, - as produced by ``_deep_merge(profile_data, macro_overrides)``. - """ - from quam.components import pulses as quam_pulses - - _PULSE_TYPE_MAP = { - "GaussianPulse": quam_pulses.GaussianPulse, - "SquarePulse": quam_pulses.SquarePulse, - "SquareReadoutPulse": quam_pulses.SquareReadoutPulse, - } - if hasattr(quam_pulses, "DragPulse"): - _PULSE_TYPE_MAP["DragPulse"] = quam_pulses.DragPulse - - def _apply_pulse_config_to_operations(operations: dict, pulses_config: Mapping, context: str): - for pulse_name, entry in pulses_config.items(): - if not isinstance(entry, Mapping): - continue - enabled = entry.get("enabled", True) - if not enabled: - operations.pop(pulse_name, None) - continue - pulse_type_name = entry.get("type") - if pulse_type_name is None: - continue - pulse_cls = _PULSE_TYPE_MAP.get(pulse_type_name) - if pulse_cls is None: - raise ValueError( - f"[{context}] Unknown pulse type '{pulse_type_name}'. " - f"Known types: {sorted(_PULSE_TYPE_MAP)}" - ) - params = {k: v for k, v in entry.items() if k not in ("type", "enabled")} - operations[pulse_name] = pulse_cls(**params) - - def _get_pulse_target_operations(component: Any) -> dict | None: - """Find operations dict on a component's drive or resonator.""" - xy = getattr(component, "xy", None) - if xy is not None: - return getattr(xy, "operations", None) - rr = getattr(component, "readout_resonator", None) - if rr is not None: - return getattr(rr, "operations", None) - return getattr(component, "operations", None) - - components_by_path = dict(_iter_macro_components(machine)) - - type_overrides = merged_overrides.get("component_types", {}) - if isinstance(type_overrides, Mapping): - for _, component in components_by_path.items(): - for type_key in ( - type(component).__name__, - f"{type(component).__module__}.{type(component).__qualname__}", - ): - type_config = type_overrides.get(type_key) - if type_config is None: - continue - pulses_config = type_config.get("pulses", {}) - if not isinstance(pulses_config, Mapping) or not pulses_config: - continue - operations = _get_pulse_target_operations(component) - if operations is not None: - _apply_pulse_config_to_operations( - operations, pulses_config, f"component_types.{type_key}" - ) - - instance_overrides = merged_overrides.get("instances", {}) - if isinstance(instance_overrides, Mapping): - for component_path, component_config in instance_overrides.items(): - if component_path not in components_by_path: - continue - pulses_config = component_config.get("pulses", {}) - if not isinstance(pulses_config, Mapping) or not pulses_config: - continue - component = components_by_path[component_path] - operations = _get_pulse_target_operations(component) - if operations is not None: - _apply_pulse_config_to_operations( - operations, pulses_config, f"instances.{component_path}" - ) + +# --------------------------------------------------------------------------- +# Top-level entry point +# --------------------------------------------------------------------------- def wire_machine_macros( - machine: Any, + machine: object, *, - macro_profile_path: str | Path | None = None, - component_overrides: Mapping[type | str, ComponentOverrides | Mapping] | None = None, - instance_overrides: Mapping[str, ComponentOverrides | Mapping] | None = None, - strict: bool = True, -) -> None: - """Wire defaults and user-configured macro/pulse overrides onto machine components. - - Two override sources are merged in this order (last wins): - - 1. TOML profile from *macro_profile_path*. - 2. Typed *component_overrides* / *instance_overrides* kwargs. - - The typed kwargs accept class objects as component-type keys (e.g. - ``LDQubit``) and validate macro factories at construction time via - :func:`macro` / :func:`disabled` helpers from - :mod:`~quam_builder.architecture.quantum_dots.macro_engine.overrides`. + catalogs: Sequence[MacroCatalog] | None = None, + instance_overrides: dict[str, MacroFactoryMap] | None = None, + fill_only: bool = True, + save: bool = False, +) -> object: + """Wire macros and pulses onto a machine. + + This is the main user-facing entry point. It builds a + :class:`MacroRegistry` from the built-in catalogs plus any + user-supplied catalogs, materializes defaults, applies overrides, + and wires default pulses. Args: machine: Target machine whose components should be wired. - macro_profile_path: Optional TOML file path. - component_overrides: Overrides keyed by component class or class name. - Values are :class:`ComponentOverrides` (from :func:`overrides`) - or raw dicts. Example:: - - from quam_builder.architecture.quantum_dots.macro_engine import ( - macro, overrides, - ) - - component_overrides={ - LDQubit: overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), - }), - } - - instance_overrides: Overrides keyed by component path string - (e.g. ``"qubits.q2"``). Values are :class:`ComponentOverrides` - or raw dicts. Example:: + catalogs: Additional catalogs (e.g. lab packages). Applied in + priority order after built-in defaults. + instance_overrides: Per-component-path overrides applied last. + Keys are paths like ``"qubits.q1"``. Values are + macro-name -> factory dicts (use enum keys from + :mod:`~quam_builder.architecture.quantum_dots.operations.names`). + Use the ``DISABLED`` sentinel to remove a macro. + fill_only: If ``True``, only add macros that are completely + absent from a component — existing macros are never replaced. + Use during ``load()`` to preserve deserialized macro types + and calibrated property values from JSON. - instance_overrides={ - "qubits.q2": overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), - }), - } + Raises: + KeyError: If an instance override path does not match any + component, or if ``DISABLED`` targets a non-existent macro. - strict: If True, unknown paths/macros raise explicit errors. + Example -- defaults only:: - Example: - Wire defaults only (most common):: + wire_machine_macros(machine) - wire_machine_macros(machine) + Example -- external catalog:: - Override initialize on all LDQubits:: + from my_lab.catalog import LabMacroCatalog - from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, macro, overrides, - ) - from quam_builder.architecture.quantum_dots.qubit import LDQubit - from quam_builder.architecture.quantum_dots.operations.names import ( - SingleQubitMacroName, - ) + wire_machine_macros(machine, catalogs=[LabMacroCatalog()]) - wire_machine_macros( - machine, - component_overrides={ - LDQubit: overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro( - InitializeStateMacro, ramp_duration=64, - ), - }), - }, - ) + Example -- instance override:: - Override one qubit, keep all other defaults:: + from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + ) - wire_machine_macros( - machine, - instance_overrides={ - "qubits.q1": overrides(macros={ - SingleQubitMacroName.X_180: macro(TunedX180Macro), - }), - }, - ) + wire_machine_macros( + machine, + instance_overrides={ + "qubits.q1": {SingleQubitMacroName.X_180: TunedX180Macro}, + }, + ) """ + registry = MacroRegistry() + registry.register_catalog(UtilityMacroCatalog()) + registry.register_catalog(DefaultMacroCatalog()) + + if catalogs: + for catalog in catalogs: + registry.register_catalog(catalog) + + MacroWirer(registry).wire( + machine, + instance_overrides=instance_overrides, + fill_only=fill_only, + ) + PulseWirer().wire(machine) - register_default_component_macro_factories() - profile_data = load_macro_profile(macro_profile_path) - - typed_dict = _convert_typed_overrides(component_overrides, instance_overrides) - merged_overrides = _deep_merge(profile_data, typed_dict) - - components_by_path = dict(_iter_macro_components(machine)) - - # Ensure defaults are materialized before applying overrides. - for component in components_by_path.values(): - ensure_defaults = getattr(component, "ensure_default_macros", None) - if callable(ensure_defaults): - ensure_defaults() - - type_overrides = merged_overrides.get("component_types", {}) - if type_overrides: - if not isinstance(type_overrides, Mapping): - raise TypeError("'component_types' overrides must be a mapping.") - for _, component in components_by_path.items(): - for type_key in ( - type(component).__name__, - f"{type(component).__module__}.{type(component).__qualname__}", - ): - type_config = type_overrides.get(type_key) - if type_config is None: - continue - macros_config = type_config.get("macros", {}) - if not isinstance(macros_config, Mapping): - raise TypeError(f"component_types.{type_key}.macros must be a mapping.") - _apply_macros_to_component( - component, - macros_config, - strict=strict, - context=f"component_types.{type_key}", - ) - - instance_overrides = merged_overrides.get("instances", {}) - if instance_overrides: - if not isinstance(instance_overrides, Mapping): - raise TypeError("'instances' overrides must be a mapping.") - for component_path, component_config in instance_overrides.items(): - if component_path not in components_by_path: - if strict: - raise KeyError( - f"Unknown component path '{component_path}' in macro overrides. " - f"Known paths: {sorted(components_by_path)}" - ) - continue - macros_config = component_config.get("macros", {}) - if not isinstance(macros_config, Mapping): - raise TypeError(f"instances.{component_path}.macros must be a mapping.") - _apply_macros_to_component( - components_by_path[component_path], - macros_config, - strict=strict, - context=f"instances.{component_path}", - ) - - # Wire default pulses and apply pulse overrides. - _ensure_default_pulses(machine) - _apply_pulse_overrides(machine, merged_overrides) + if save: + machine.save() + return machine diff --git a/quam_builder/architecture/quantum_dots/operations/README.md b/quam_builder/architecture/quantum_dots/operations/README.md index 6adcc7a8..6654ef20 100644 --- a/quam_builder/architecture/quantum_dots/operations/README.md +++ b/quam_builder/architecture/quantum_dots/operations/README.md @@ -1,43 +1,52 @@ # Quantum Dots Operations and Macro Defaults -This folder contains the default operations + macro wiring system for quantum-dot QuAM components. +This folder contains the default operations and catalog-based macro wiring system for quantum-dot QuAM components. The main goal is to keep macro behavior decoupled from component classes while making defaults and user overrides explicit, composable, and serializable. ## Architecture Overview Core modules: -- [`names.py`](./names.py): canonical string names (voltage points and macro names). +- [`names.py`](./names.py): canonical string names (voltage points and macro names) as `StrEnum`s. - [`default_macros/`](./default_macros): built-in macro classes and default per-component macro maps. -- [`macro_registry.py`](./macro_registry.py): component-type -> default macro factory registration/resolution. -- [`component_macro_catalog.py`](./component_macro_catalog.py): idempotent registration of architecture defaults (`QPU`, `LDQubit`, `LDQubitPair`). -- [`pulse_registry.py`](./pulse_registry.py): component-type -> default pulse factory registration/resolution (parallel to macro registry). -- [`component_pulse_catalog.py`](./component_pulse_catalog.py): idempotent registration of default pulse factories (`LDQubit` XY pulses, `SensorDot` readout). +- [`macro_catalog.py`](./macro_catalog.py): `MacroCatalog` protocol, `MacroRegistry`, and built-in catalogs (`UtilityMacroCatalog`, `DefaultMacroCatalog`, `TypeOverrideCatalog`). +- [`pulse_catalog.py`](./pulse_catalog.py): helper builders for the default pulse materialization pass (`LDQubit` XY pulses, `SensorDot` readout). - [`../macro_engine/wiring.py`](../macro_engine/wiring.py): runtime wiring API (`wire_machine_macros`) that materializes macro and pulse defaults and applies overrides. - [`default_operations.py`](./default_operations.py): operation signatures exposed through `OperationsRegistry`. -## Canonical Voltage Point Enums +## Macro System Design -Voltage-point names are centralized in [`names.py`](./names.py): +``` + ┌────────────────────┐ + │ wire_machine_macros() │ User-facing entry point + └─────────┬──────────┘ + │ + ┌────────────▼────────────┐ + │ MacroRegistry │ Aggregates catalogs + │ (sorted by priority) │ + └────────────┬────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ +┌────────▼─────────┐ ┌─────────▼──────────┐ ┌─────────▼──────────┐ +│UtilityMacroCatalog│ │DefaultMacroCatalog │ │ User Catalog(s) │ +│ priority = 0 │ │ priority = 100 │ │ priority = 200+ │ +│ align, wait │ │ MRO-based defaults │ │ Lab-owned macros │ +└───────────────────┘ └────────────────────┘ └────────────────────┘ +``` -- `initialize` -- `measure` -- `empty` -- `exchange` +Resolution order: catalogs are merged low-to-high priority. Higher priority wins per macro name. Instance overrides are applied last. -These are represented by `VoltagePointName` (`StrEnum`) and reused by default state macros. -Default state macros assume these points exist in each relevant voltage sequence (for example via `add_point(...)`). -In Python examples below, prefer the enum members directly. -These are `StrEnum`s, so `TwoQubitMacroName.CZ` already behaves like `"cz"`. -In TOML profiles, use the serialized enum values (`initialize`, `x180`, `gaussian`, ...). +## Canonical Names -Canonical macro names are also centralized as enums in the same module: +Canonical names are centralized in [`names.py`](./names.py) as `StrEnum`s: -- `SingleQubitMacroName` for built-in 1Q defaults — state macros (`initialize`, `measure`, `empty`, `exchange`) and gate macros (`xy_drive`, `x`, `y`, `z`, `x180`, ...) -- `TwoQubitMacroName` for built-in 2Q defaults — state macros (`initialize`, `measure`, `empty`, `exchange`) and gate macros (`cnot`, `cz`, `swap`, `iswap`) +- `VoltagePointName`: `initialize`, `measure`, `empty`, `exchange` +- `SingleQubitMacroName`: state macros + gate macros (`xy_drive`, `x`, `y`, `z`, `x180`, `x90`, ...) +- `TwoQubitMacroName`: state macros + gate macros (`cnot`, `cz`, `swap`, `iswap`) +- `DrivePulseName`: `gaussian`, `square`, `kaiser`, `hermite`, `drag` -Alias spellings (for example `-x90`, `-y90`) remain explicit strings via -`SINGLE_QUBIT_MACRO_ALIASES` and `SINGLE_QUBIT_MACRO_ALIAS_MAP`. +Since these are `StrEnum`s, `SingleQubitMacroName.X_180` already behaves like `"x180"`. ## Default Macro Logic by Component Type @@ -50,422 +59,166 @@ From [`../../../tools/macros/default_macros.py`](../../../tools/macros/default_m ### `QPU` -From [`default_macros/state_macros.py`](./default_macros/state_macros.py): - -- `initialize` -- `measure` -- `empty` - -QPU state macros dispatch to active qubits/pairs when configured; otherwise they broadcast to all registered qubits/pairs (with stage-1 fallbacks). +- `initialize`, `measure`, `empty` ### `LDQubit` -From [`default_macros/single_qubit_macros.py`](./default_macros/single_qubit_macros.py): - - State macros: `initialize`, `measure`, `empty`, `exchange` - Canonical 1Q macros: `xy_drive`, `x`, `y`, `z` -- Fixed-angle wrappers: `x180`, `x90`, `x_neg90` (and `-x90` alias), `y180`, `y90`, `y_neg90` (and `-y90` alias), `z180`, `z90` +- Fixed-angle wrappers: `x180`, `x90`, `x_neg90`, `y180`, `y90`, `y_neg90`, `z180`, `z90` - Identity: `I` -Canonical chain: - -- `x` and `y` delegate to `xy_drive` with phase offsets. -- `x90`/`x180`/`x_neg90` and `y*` wrappers delegate to canonical `x`/`y`. -- `z90`/`z180` wrappers delegate to canonical `z`. -- Negative XY angles are encoded as positive-angle drives with an additional `+pi` - phase shift (on top of the axis phase), so amplitude scaling is based on - `abs(angle)`. - -Practical consequence: overriding one canonical macro (for example `xy_drive` or `x`) automatically affects all wrappers above it. +Canonical chain: `x`/`y` delegate to `xy_drive` with phase offsets; fixed-angle wrappers delegate to canonical axes. ### `LDQubitPair` -From [`default_macros/two_qubit_macros.py`](./default_macros/two_qubit_macros.py): - - State macros: `initialize`, `measure`, `empty`, `exchange` -- Two-qubit gates: `cnot`, `cz`, `swap`, `iswap` - -The `exchange` macro ramps to a configurable exchange voltage point, holds for a wait duration, then ramps back to the initialize point. Ramp duration, wait duration, and both voltage targets are configurable via class fields and runtime kwargs. - -Default two-qubit gate macros (`cnot`, `cz`, `swap`, `iswap`) are explicit placeholders (`NotImplementedError`) until user calibration logic is supplied through overrides. - -## Invocation Paths: Registry vs Direct vs Macro - -All invocation paths ultimately execute `macro.apply()`. Choose based on your use case: - -| Invocation | When to use | Applicable component types | -|------------|-------------|----------------------------| -| `from ...default_operations import x180; x180(q)` | Generic algorithms, type-safe protocol code, IDE completion | LDQubit (1Q gates); LDQubitPair (2Q gates); QuantumDot, QuantumDotPair, SensorDot (state macros) | -| `q.x180()` | Component-specific code; natural direct call via `__getattr__` dispatch | Same as registry | -| `q.macros["x180"].apply()` | Direct access; use when you need the macro object itself (introspection, custom dispatch) | Any component with `macros` dict | - -`q.x180()` and `q.macros["x180"].apply()` are equivalent — `__getattr__` returns `macro.apply` directly. - -See [`default_operations.py`](./default_operations.py) module docstring for the registry vs direct comparison in prose. - -## When and Where Macros Are Wired - -Wiring happens in three places: - -1. Component construction (`MacroDispatchMixin.__post_init__`): materializes missing defaults. -2. Build flow (`build_base_quam`, `build_loss_divincenzo_quam`, `build_quam`): calls `wire_machine_macros(...)`. -3. Load flow (`BaseQuamQD.load`, `LossDiVincenzoQuam.load`): calls `wire_machine_macros(instance)` after load. - -Important behavior: - -- Default materialization is additive: only missing default macro names are inserted. -- Existing macro entries are not reset unless an override explicitly targets that name. - -## Override Model (Detailed) - -`wire_machine_macros` supports two override scopes via typed kwargs: - -- `component_overrides={LDQubit: overrides(macros={...})}` — all instances of a type -- `instance_overrides={"qubits.q1": overrides(macros={...})}` — one specific instance - -Use the helpers from `quam_builder.architecture.quantum_dots.macro_engine`: +- Two-qubit gates: `cnot`, `cz`, `swap`, `iswap` (placeholders until user-supplied) -| Helper | Purpose | -|--------|---------| -| `macro(Factory, **params)` | Create a macro override entry (validates factory is QuamMacro) | -| `pulse("GaussianPulse", **params)` | Create a pulse override entry | -| `disabled()` | Remove a macro or pulse | -| `overrides(macros={...}, pulses={...})` | Group macro and pulse overrides for one component | +## Wire Machine API -### Precedence and Merge Order - -1. Architecture defaults (registry/catalog). -2. TOML profile from `macro_profile_path`. -3. `component_overrides` applied to each matching instance. -4. `instance_overrides` applied last. - -So, effective precedence for a specific macro name on one component is: - -`instance override` > `type override` > `TOML profile` > `default`. - -### Targeting Component Types - -Type override keys use the actual Python class (recommended) or a string: - -- class reference: `LDQubit` (import-time validation, IDE autocomplete) -- short class name string: `"LDQubit"` (for TOML profiles) -- fully qualified string: `"quam_builder.architecture.quantum_dots.qubit.LDQubit"` - -### Targeting Component Instances - -Instance override keys are component paths, for example: - -- `qpu` -- `qubits.q1` -- `qubit_pairs.q1_q2` -- `quantum_dots.dot_1` -- `quantum_dot_pairs.dot_1_dot_2` -- `sensor_dots.s1` -- `barrier_gates.b12` -- `global_gates.g1` - -Only components exposing a `macros` mapping are eligible. - -### Macro Entry Forms (Python) - -Use the `macro()` helper to build entries: +The top-level API is `wire_machine_macros()`: ```python -from quam_builder.architecture.quantum_dots.macro_engine import macro, disabled - -# With parameters: -macro(InitializeStateMacro, ramp_duration=64) - -# Class only (no extra params): -macro(TunedX180Macro) - -# Import-path string (for TOML compatibility): -macro("my_pkg.macros:TunedX180Macro") -``` - -`macro()` validates at call time that the factory is a `QuamMacro` subclass. - -### Macro Entry Forms (TOML) - -TOML profiles use string keys and the `factory` / `params` / `enabled` format: - -```toml -[component_types.LDQubit.macros.initialize] -factory = "my_pkg.macros:CustomInitializeMacro" -enabled = true -[component_types.LDQubit.macros.initialize.params] -ramp_duration = 64 +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros -[component_types.LDQubitPair.macros] -cz = "my_pkg.macros:CalibratedCZMacro" +wire_machine_macros(machine) ``` -### Disable/Remove a Macro - -Python: +### With a lab catalog ```python -instance_overrides={ - "qubit_pairs.q1_q2": overrides(macros={ - TwoQubitMacroName.CZ: disabled(), - }), -} -``` - -TOML: +from my_lab_macros.catalog import LabMacroCatalog -```toml -[instances."qubit_pairs.q1_q2".macros.cz] -enabled = false +wire_machine_macros(machine, catalogs=[LabMacroCatalog()]) ``` -### Strict vs Non-Strict Mode - -- `strict=True` (default): - - unknown component path -> error - - unknown macro name for that component -> error -- `strict=False`: - - unknown paths are ignored - - unknown macro names can be added (or ignored on removal) - -Use `strict=True` in production profiles to catch typos early. - -## How Users Override Only One Macro and Keep Defaults - -This is the common case and is fully supported. - -Example: override only `x180` on one qubit; all other macros remain default. +### With instance overrides ```python -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, macro, overrides, -) from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName wire_machine_macros( machine, instance_overrides={ - "qubits.q1": overrides(macros={ - SingleQubitMacroName.X_180: macro(TunedX180Macro), - }), + "qubits.q1": { + SingleQubitMacroName.X_180: TunedX180Macro, + }, }, - strict=True, ) ``` -Result: - -- `qubits.q1.macros["x180"]` is replaced with `TunedX180Macro`. -- All other macros on `q1` are untouched. -- All macros on other qubits are untouched. -- Persistent single-qubit amplitude calibration should live on the reference - pulse object, not on wrapper macros. - -## Type-Level Override + Instance-Level Specialization - -Pattern: set lab-wide default for a component type, then specialize one device instance. +### With type-level overrides (ad-hoc) ```python -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, macro, overrides, -) +from functools import partial +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.macro_catalog import TypeOverrideCatalog from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName from quam_builder.architecture.quantum_dots.qubit import LDQubit wire_machine_macros( machine, - component_overrides={ - LDQubit: overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=64), - }), - }, - instance_overrides={ - "qubits.q2": overrides(macros={ - SingleQubitMacroName.INITIALIZE: macro(InitMacro, ramp_duration=96), - }), - }, -) -``` - -`qubits.q2` wins over type-level settings because instance overrides are applied last. - -## Profile + Runtime Combination - -Typical workflow: - -1. Keep stable, shared calibration defaults in TOML (`macro_profile_path`). -2. Apply session-specific tweaks with `component_overrides` / `instance_overrides` in Python. - -Because runtime overrides are deep-merged over profile data, users can patch only one leaf value without duplicating the full profile map. - -## Importing a Full Macro Catalog From Another Package - -For users who want a custom default set that survives upstream pulls, keep macro logic in a separate package/repo and import it as `component_overrides`. - -### Where This Should Sit - -Use this layout: - -1. External package (stable lab-owned code): `my_lab_qd_macros/` -2. Experiment/build entrypoint (in your experiment repo): where `wire_machine_macros(...)` is called -3. Optional local TOML for small per-run tweaks - -### External package example - -`my_lab_qd_macros/catalog.py`: - -```python -from __future__ import annotations - -from quam_builder.architecture.quantum_dots.macro_engine import macro, pulse, overrides -from quam_builder.architecture.quantum_dots.operations.names import ( - DrivePulseName, - SingleQubitMacroName, - TwoQubitMacroName, - VoltagePointName, -) -from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.qubit.ld_qubit_pair import LDQubitPair - -from .single_qubit import LabInitialize1Q, LabMeasure1Q, LabEmpty1Q, LabX, LabY, LabZ -from .two_qubit import LabCZ, LabISWAP, LabCNOT, LabSWAP -from .qpu import LabInitializeQPU, LabMeasureQPU, LabEmptyQPU - - -def build_component_overrides() -> dict: - """Return component_overrides dict for wire_machine_macros.""" - return { - QPU: overrides(macros={ - VoltagePointName.INITIALIZE: macro(LabInitializeQPU), - VoltagePointName.MEASURE: macro(LabMeasureQPU), - VoltagePointName.EMPTY: macro(LabEmptyQPU), - }), - LDQubit: overrides( - macros={ - SingleQubitMacroName.INITIALIZE: macro(LabInitialize1Q), - SingleQubitMacroName.MEASURE: macro(LabMeasure1Q), - SingleQubitMacroName.EMPTY: macro(LabEmpty1Q), - SingleQubitMacroName.X: macro(LabX), - SingleQubitMacroName.Y: macro(LabY), - SingleQubitMacroName.Z: macro(LabZ), + catalogs=[ + TypeOverrideCatalog({ + LDQubit: { + SingleQubitMacroName.INITIALIZE: partial(InitMacro, ramp_duration=64), }, - pulses={ - DrivePulseName.GAUSSIAN: pulse( - "GaussianPulse", length=1000, amplitude=0.17, sigma=167, - ), - }, - ), - LDQubitPair: overrides(macros={ - TwoQubitMacroName.CNOT: macro(LabCNOT), - TwoQubitMacroName.CZ: macro(LabCZ), - TwoQubitMacroName.SWAP: macro(LabSWAP), - TwoQubitMacroName.ISWAP: macro(LabISWAP), }), - } -``` - -### Experiment call site example - -In your experiment repo, at the machine-build call site: - -```python -from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros -from my_lab_qd_macros.catalog import build_component_overrides - -wire_machine_macros( - machine, - component_overrides=build_component_overrides(), - strict=True, + ], ) ``` -This keeps custom defaults out of `quam-builder` itself, so pulling upstream changes does not overwrite your catalog. - -### Optional pattern: catalog defaults + local one-off tweak +### Disabling a macro ```python -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, macro, overrides, -) -from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName -from my_lab_qd_macros.catalog import build_component_overrides +from quam_builder.architecture.quantum_dots.macro_engine import DISABLED wire_machine_macros( machine, - component_overrides=build_component_overrides(), instance_overrides={ - "qubits.q3": overrides(macros={ - SingleQubitMacroName.X_180: macro( - "my_lab_qd_macros.single_qubit:Q3TunedX180" - ), - }), + "qubit_pairs.q1_q2": { + TwoQubitMacroName.CZ: DISABLED, + }, }, - strict=True, ) ``` -This is the recommended model for common lab usage: central catalog + selective per-device override. +## Override Precedence + +1. `UtilityMacroCatalog` (priority 0) -- `align`, `wait` +2. `DefaultMacroCatalog` (priority 100) -- architecture defaults +3. User catalogs (priority 200+) -- lab packages, `TypeOverrideCatalog` +4. Instance overrides -- per-component-path, applied last -## Calibrated Parameters: Storage and Persistence +Effective: `instance override` > `catalog (highest priority)` > `default`. -There are two storage layers: +## MacroCatalog Protocol -1. Source-of-truth config (optional): - - TOML profile in your repository/lab config. -2. Runtime QuAM object: - - instantiated macro objects in `component.macros` - - pulse objects in channel `operations` mappings +Any object implementing `get_factories(component_type) -> MacroFactoryMap` and `priority -> int` can be registered as a catalog: -Parameter examples: +```python +from quam_builder.architecture.quantum_dots.operations.macro_catalog import MacroFactoryMap -- `machine.qubits["q1"].macros[SingleQubitMacroName.INITIALIZE].ramp_duration` -- `machine.qubits["q1"].xy.operations[DrivePulseName.GAUSSIAN].amplitude` +class LabMacroCatalog: + priority = 200 -Serialization behavior: + def get_factories(self, component_type: type) -> MacroFactoryMap: + from quam_builder.architecture.quantum_dots.qubit import LDQubit -- Macro objects/fields in `component.macros` are part of QuAM state. -- Pulse objects/fields in `channel.operations` are part of QuAM state. + if issubclass(component_type, LDQubit): + return { + SingleQubitMacroName.INITIALIZE: LabInitMacro, + SingleQubitMacroName.X: LabXMacro, + } + return {} +``` -## Recommended Override Strategy +### MacroFactory types -1. Override the reference pulse first if you want broad single-qubit amplitude or envelope changes. -2. Use canonical macros (`xy_drive`, `x`, `y`, `z`) for behavior changes such as phase logic or dispatch behavior. -3. Avoid persistent amplitude defaults on wrapper macros; keep amplitude calibration on the pulse object itself. -4. Keep two-qubit calibrated logic in profile/type-level overrides and specialize only exceptional instances. -5. Use `strict=True` for CI and release configs. +A factory is either: -## Default Pulse Wiring +- A `QuamMacro` subclass (called with no args to instantiate) +- A zero-arg callable returning a `QuamMacro` (e.g. `functools.partial(InitMacro, ramp_duration=64)`) -`wire_machine_macros()` also wires default pulses onto component channels. Pulse wiring is additive — only pulse names not already present are added. +## Targeting Component Instances -### Qubit XY Drive Pulse +Instance override keys are component paths: + +- `qpu` +- `qubits.q1` +- `qubit_pairs.q1_q2` +- `quantum_dots.dot_1` +- `quantum_dot_pairs.dot_1_dot_2` +- `sensor_dots.s1` +- `barrier_gates.b12` +- `global_gates.g1` -A single reference pulse is registered per qubit. `XYDriveMacro` scales amplitude for rotation angle and applies virtual-Z for rotation axis (X/Y), so all single-qubit gates derive from this one pulse. +Only components with a `macros` mapping are eligible. -Composition rules: -- `x`/`y` add their canonical axis phase to any runtime `phase=...`. -- `xy_drive` runtime `amplitude_scale=...` multiplies the angle-derived scale from the reference pulse instead of replacing it. +## Error Handling -### Single-Qubit Gate Composition Model +Invalid instance override paths (e.g. a typo like `"qubits.q99"`) and +`DISABLED` removals targeting non-existent macros always raise `KeyError`. +This catches configuration mistakes early. -#### Delegation chain +## Single-Qubit Gate Composition Model -All single-qubit XY gate calls flow through a strict delegation chain: +### Delegation chain ``` q.x90() # fixed-angle wrapper - └─ q.macros["x"].apply() # canonical axis macro (adds phase=0 for X) - └─ q.macros["xy_drive"].apply() # core XY drive - ├─ q.virtual_z(phase) # frame rotation (if phase != 0) - ├─ q.voltage_sequence.step_to_voltages({}, duration) # hold voltages - ├─ q.xy.play(pulse_name, amplitude_scale, duration) # hardware play - └─ q.virtual_z(-phase) # restore frame + -> q.macros["x"].apply() # canonical axis macro (adds phase=0 for X) + -> q.macros["xy_drive"].apply() # core XY drive + -> q.virtual_z(phase) + -> q.voltage_sequence.step_to_voltages(...) + -> q.xy.play(pulse_name, amplitude_scale, duration) + -> q.virtual_z(-phase) ``` -Overriding a single canonical macro automatically affects all wrappers above it. For example, replacing `xy_drive` changes the behavior of every XY gate. +Overriding one canonical macro automatically affects all wrappers above it. -#### Source of truth +### Source of truth The reference pulse is the single source of truth for all single-qubit XY rotations: @@ -473,220 +226,69 @@ The reference pulse is the single source of truth for all single-qubit XY rotati qubit.xy.operations[qubit.macros["xy_drive"].reference_pulse_name] ``` -- Pulse-envelope parameters (`amplitude`, `length`, `sigma`, `axis_angle`, `alpha`, `anharmonicity`) live on that pulse object, not in the wrapper macros. -- Replacing the pulse object, or switching `reference_pulse_name`, updates the whole single-qubit gate family. -- All gates derive their amplitude scale from the reference pulse using: `abs(angle) / reference_angle`. - -#### What to calibrate +### What to calibrate | Parameter | Where it lives | Affects | |-----------|---------------|---------| -| Pi-pulse amplitude | `qubit.xy.operations["gaussian"].amplitude` | All XY gates (single source of truth) | -| Pulse envelope shape | `qubit.xy.operations["gaussian"]` (length, sigma, etc.) | All XY gates | -| Drive frequency | `qubit.xy.intermediate_frequency` / `qubit.xy.LO_frequency` | All XY gates | -| Reference angle | `qubit.macros["xy_drive"].reference_angle` | Scale factor mapping (default: pi) | -| Voltage points | `qubit.add_point("initialize", {...})` etc. | State macros | - -#### Modulation order - -- `xy_drive` converts `angle` into an angle-derived amplitude scale: `abs(angle) / reference_angle`. Duration is always the reference pulse length (no stretching). -- `x` or `y` adds its axis phase (`0` for X, `pi/2` for Y) to any runtime `phase`. -- A fixed-angle wrapper such as `x90` contributes its default angle only. -- Runtime `amplitude_scale` multiplies the angle-derived scale (compositional, not replacing). -- Runtime `duration` / `pulse_duration` overrides the reference pulse duration. - -#### Negative angle handling - -Negative angles are encoded as positive-angle drives with a `+pi` phase shift on top of the axis phase. This means amplitude scaling is always computed from `abs(angle)`, and the sign information is carried in the virtual-Z frame rotation. - -#### Effective play parameters - -- Effective phase = `sign-normalization phase + axis phase + wrapper phase + runtime phase` -- Effective amplitude scale = `(abs(angle) / reference_angle) * runtime amplitude_scale` -- Effective duration = `runtime duration` if provided, otherwise reference pulse length - -#### Representative cases - -1. Base calibration only - - Reference pulse: gaussian with `amplitude=1.0`, `length=1000` - - Call: `q.x180()` - - Result: plays with scale `1.0`, phase `0`, duration `1000 ns` - -2. Calibrated amplitude - - `q.xy.operations["gaussian"].amplitude = 0.17` - - Call: `q.x180()` → effective amplitude `0.17 * 1.0` - - Call: `q.x90()` → effective amplitude `0.17 * 0.5` - - Consistency: both derive from the same pulse object - -3. Runtime modulation - - Call: `q.y90(amplitude_scale=0.5, phase=0.1)` - - Effective phase: `pi/2 + 0.1` - - Effective amplitude scale: `0.5 * 0.5 = 0.25` (runtime multiplies angle-derived) - -4. Negative rotation - - Call: `q.x(angle=-pi/2)` - - Effective phase: `0 + pi` (sign-flip phase) - - Effective amplitude scale: `0.5` (from `abs(-pi/2) / pi`) - - Frame is restored after the play - -#### Default reference pulse - -| Pulse Name | Type | Amplitude | Length | Sigma | Axis Angle (IQ/MW) | Axis Angle (SingleChannel) | -|------------|------|-----------|--------|-------|---------------------|---------------------------| -| `gaussian` | `GaussianPulse` | 1.0 | 1000 ns | 167 ns | 0.0 | `None` | - -Pulse names are centralized in `DrivePulseName` (`StrEnum`) in `names.py`: -- `DrivePulseName.GAUSSIAN` = `"gaussian"` (default) -- `DrivePulseName.DRAG` = `"drag"` (user-registered) - -To switch pulse type (e.g. Gaussian → DRAG): -1. Register the new pulse: `qubit.xy.operations[DrivePulseName.DRAG] = DragPulse(...)` -2. Update the macro: `qubit.macros[SingleQubitMacroName.XY_DRIVE].reference_pulse_name = DrivePulseName.DRAG` -3. All gate macros (x90, x180, y90, etc.) automatically use the new pulse. - -### Sensor Dot Readout Pulses - -For each sensor dot in `machine.sensor_dots` with a `readout_resonator`, a default `SquareReadoutPulse` named `"readout"` is added (length 2000 ns, amplitude 0.1). - -### Pulse Override Schema - -Pulse overrides use the same scoping as macro overrides, under a `pulses` key: - -```toml -# Type-level: all LDQubits get a shorter gaussian pulse -[component_types.LDQubit.pulses] -gaussian = {type = "GaussianPulse", length = 500, amplitude = 0.3, sigma = 83} +| Pi-pulse amplitude | `qubit.xy.operations["gaussian_x180"].amplitude` | All XY gates | +| Pulse envelope | `qubit.xy.operations["gaussian_x90"]` (length, sigma) | All XY gates | +| Drive frequency | `qubit.xy.intermediate_frequency` | All XY gates | +| Reference angle | `qubit.macros["xy_drive"].reference_angle` | Scale factor (default: pi) | +| Voltage points | `qubit.add_point("initialize", {...})` | State macros | -# Instance-level: qubit q1 gets a custom gaussian -[instances."qubits.q1".pulses] -gaussian = {type = "GaussianPulse", length = 800, amplitude = 0.15, sigma = 133} - -# Remove a pulse -[instances."qubits.q2".pulses] -gaussian = {enabled = false} -``` - -Supported pulse types: `GaussianPulse`, `SquarePulse`, `SquareReadoutPulse`, `DragPulse`. - -Precedence (last wins): default → type-level override → instance-level override. - -Python runtime overrides use the typed helpers: - -```python -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, pulse, overrides, -) -from quam_builder.architecture.quantum_dots.qubit import LDQubit - -wire_machine_macros( - machine, - component_overrides={ - LDQubit: overrides(pulses={ - "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), - }), - }, - instance_overrides={ - "qubits.q1": overrides(pulses={ - "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), - }), - }, -) -``` - -### Pulse Registry (Advanced) - -For custom component types that need default pulses, use the pulse registry directly: - -```python -from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( - register_component_pulse_factories, -) -from quam.components.pulses import SquarePulse +## Default Pulse Wiring -register_component_pulse_factories( - MyCustomComponent, - {"drive": lambda: SquarePulse(length=200, amplitude=0.5)}, -) -``` +`wire_machine_macros()` also wires default pulses onto component channels via `PulseWirer`. Pulse wiring is additive -- only pulse names not already present are added. -The registry follows MRO resolution: derived classes can override individual pulse names registered on a base class. +### XY Drive Pulse -## Public APIs +Five pulse families (Gaussian, Square, Kaiser, Hermite, DRAG) are loaded per qubit with the naming convention `{family}_{gate}` (e.g. `"gaussian_x90"`, `"drag_x180"`). Default length 1000 ns, amplitude 1.0. Drive-type aware: `SingleChannel` gets `axis_angle=None`; IQ/MW gets `axis_angle=0.0`. The active family is selected via `machine.pulse_family`. -Register macro defaults for a custom component type: +### Readout Pulse -```python -from quam_builder.architecture.quantum_dots.operations.macro_registry import ( - register_component_macro_factories, -) +`SquareReadoutPulse` named `"readout"` on each sensor dot resonator (length 2000 ns, amplitude 1.0). -register_component_macro_factories(MyComponent, {"my_macro": MyMacroClass}) -``` +## External Macro Package Pattern -Register pulse defaults for a custom component type: +Keep lab-owned macro logic in a separate package: ```python -from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( - register_component_pulse_factories, -) +# my_lab_macros/catalog.py +from quam_builder.architecture.quantum_dots.operations.macro_catalog import MacroFactoryMap +from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName -register_component_pulse_factories(MyComponent, {"drive": lambda: SquarePulse(length=200, amplitude=0.5)}) +class LabMacroCatalog: + priority = 200 + + def get_factories(self, component_type: type) -> MacroFactoryMap: + from quam_builder.architecture.quantum_dots.qubit import LDQubit + if issubclass(component_type, LDQubit): + return { + SingleQubitMacroName.INITIALIZE: LabInitMacro, + SingleQubitMacroName.X: LabXMacro, + } + return {} ``` -Wire defaults + overrides (macros and pulses): +At the experiment call site: ```python -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, macro, pulse, overrides, -) -from quam_builder.architecture.quantum_dots.qubit import LDQubit +from my_lab_macros.catalog import LabMacroCatalog +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros -wire_machine_macros( - machine, - macro_profile_path="macros.toml", # optional TOML profile - component_overrides={ # all instances of a type - LDQubit: overrides(macros={...}, pulses={...}), - }, - instance_overrides={ # one specific instance - "qubits.q1": overrides(macros={...}), - }, - strict=True, -) +wire_machine_macros(machine, catalogs=[LabMacroCatalog()]) ``` -Builder integration: - -- `build_base_quam(...)` -- `build_loss_divincenzo_quam(...)` -- `build_quam(...)` - -all accept `macro_profile_path`, `component_overrides`, and `instance_overrides`. - -## End-to-End Example - -See [`../examples/default_macro_overrides_example.py`](../examples/default_macro_overrides_example.py) for: - -1. default macro wiring -2. one-macro instance override -3. type-level override -4. program build using default + overridden macros - -See [`../examples/default_macro_defaults_example.py`](../examples/default_macro_defaults_example.py) for: - -1. default-only wiring (no profile and no runtime overrides) -2. parameterizing built-in default macro instances on components -3. program build using parameterized default macros only +This keeps custom defaults out of `quam-builder` itself. -See [`../examples/pulse_overrides_example.py`](../examples/pulse_overrides_example.py) for: +## Builder Integration -1. default pulse wiring via `wire_machine_macros` -2. type-level pulse overrides (all qubits of a type) -3. instance-level pulse overrides (one specific qubit) +`build_base_quam()`, `build_loss_divincenzo_quam()`, and `build_quam()` all accept `catalogs` and `instance_overrides`. -See [`../examples/full_workflow_example.py`](../examples/full_workflow_example.py) for: +## Examples -1. wiring a Loss-DiVincenzo qubit machine (combined single-stage workflow) -2. wiring default macros and pulses -3. updating operation parameters -4. swapping drive pulse type (Gaussian → DRAG) -5. replacing macros (instance-level and type-level overrides) +- [`../examples/macro_defaults_example.py`](../examples/macro_defaults_example.py): default-only wiring and parameterization. +- [`../examples/macro_overrides_example.py`](../examples/macro_overrides_example.py): catalog and instance overrides. +- [`../examples/pulse_overrides_example.py`](../examples/pulse_overrides_example.py): pulse wiring and configuration. +- [`../examples/full_workflow_example.py`](../examples/full_workflow_example.py): complete end-to-end workflow. +- [`../examples/external_macro_package_example.py`](../examples/external_macro_package_example.py): external catalog package pattern. diff --git a/quam_builder/architecture/quantum_dots/operations/__init__.py b/quam_builder/architecture/quantum_dots/operations/__init__.py index 31c408f0..ff5f46e5 100644 --- a/quam_builder/architecture/quantum_dots/operations/__init__.py +++ b/quam_builder/architecture/quantum_dots/operations/__init__.py @@ -5,24 +5,19 @@ - Default operations registry with gate-level operations - Default macros for state preparation, single-qubit gates, and two-qubit gates - Canonical name constants for voltage points and supported macros -- A component-type macro registry for decoupled macro-default wiring -- A component-type pulse registry for decoupled pulse-default wiring +- A catalog-based macro registry for decoupled macro-default wiring +- Channel-aware builders for the default pulse materialization pass """ -# Operations registry and operations -from .component_macro_catalog import * -from .component_pulse_catalog import * -from .macro_registry import * -from .pulse_registry import * +from .macro_catalog import * +from .pulse_catalog import * from .names import * from .default_macros import * from .default_operations import * __all__ = [ - *component_macro_catalog.__all__, - *component_pulse_catalog.__all__, - *macro_registry.__all__, - *pulse_registry.__all__, + *macro_catalog.__all__, + *pulse_catalog.__all__, *names.__all__, *default_macros.__all__, *default_operations.__all__, diff --git a/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py b/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py deleted file mode 100644 index c718f446..00000000 --- a/quam_builder/architecture/quantum_dots/operations/component_macro_catalog.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Default component->macro catalog registration for quantum-dot architecture.""" - -from __future__ import annotations - -from quam_builder.architecture.quantum_dots.operations.default_macros import ( - QPU_STATE_MACROS, - SINGLE_QUBIT_MACROS, - STATE_POINT_MACROS, - TWO_QUBIT_MACROS, -) -from quam_builder.architecture.quantum_dots.operations.macro_registry import ( - register_component_macro_factories, -) - -_REGISTERED = False - -__all__ = [ - "register_default_component_macro_factories", -] - - -def register_default_component_macro_factories() -> None: - """Register built-in macro factories for core quantum-dot component types. - - Registration is idempotent and intentionally centralized to keep default - behavior decoupled from component class definitions. - """ - global _REGISTERED - if _REGISTERED: - return - - # Import lazily to avoid import-cycle side effects during module initialization. - from quam_builder.architecture.quantum_dots.components import QPU - from quam_builder.architecture.quantum_dots.qubit import LDQubit - from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair - - register_component_macro_factories(QPU, QPU_STATE_MACROS) - register_component_macro_factories(LDQubit, SINGLE_QUBIT_MACROS) - register_component_macro_factories(LDQubitPair, TWO_QUBIT_MACROS) - - # Phase 1 additions: QuantumDot voltage-only components - from quam_builder.architecture.quantum_dots.components.quantum_dot import ( - QuantumDot, - ) - from quam_builder.architecture.quantum_dots.components.quantum_dot_pair import ( - QuantumDotPair, - ) - from quam_builder.architecture.quantum_dots.components.sensor_dot import ( - SensorDot, - ) - from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( - MeasurePSBPairMacro, - SensorDotMeasureMacro, - ) - from quam_builder.architecture.quantum_dots.operations.names import ( - VoltagePointName, - ) - - register_component_macro_factories(QuantumDot, STATE_POINT_MACROS) - qdpair_macros = { - **STATE_POINT_MACROS, - VoltagePointName.MEASURE.value: MeasurePSBPairMacro, - } - register_component_macro_factories(QuantumDotPair, qdpair_macros) - # SensorDot inherits from QuantumDot — replace=True prevents initialize/empty - # from flowing down via MRO resolution. CAT-03: measure only. - register_component_macro_factories( - SensorDot, - {VoltagePointName.MEASURE.value: SensorDotMeasureMacro}, - replace=True, - ) - - _REGISTERED = True - - -def _reset_registration() -> None: - """Reset global registration state. FOR TESTING ONLY. - - Called by the reset_catalog pytest fixture to ensure each test that - explicitly verifies registration behavior starts from a clean slate. - """ - global _REGISTERED - _REGISTERED = False diff --git a/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py b/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py deleted file mode 100644 index 460d27bd..00000000 --- a/quam_builder/architecture/quantum_dots/operations/component_pulse_catalog.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Default pulse catalog for quantum-dot component types. - -Parallel to :mod:`~quam_builder.architecture.quantum_dots.operations.component_macro_catalog`, -this module provides idempotent registration of default pulse factories for -core quantum-dot component types (``LDQubit``, ``SensorDot``). - -Default pulse parameters ------------------------- -Single-qubit XY drive pulse (on ``qubit.xy``): - A single ``GaussianPulse`` named ``"gaussian"`` — length 1000 ns, - amplitude 1.0 (π rotation reference), sigma 167 ns. - This is the **single source of truth** for all XY rotations. - The ``XYDriveMacro`` scales amplitude for different rotation angles - and applies virtual-Z frame rotations for the rotation axis (X/Y), - so only one reference pulse is needed. - - Drive-type aware: IQ/MW channels get ``axis_angle=0.0`` for hardware - mixing; ``SingleChannel`` (``XYDriveSingle``) uses ``axis_angle=None``. - -Readout pulse (on ``sensor_dot.readout_resonator``): - ``SquareReadoutPulse`` — length 2000 ns, amplitude 0.1. - -Usage:: - - from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( - register_default_component_pulse_factories, - _make_xy_pulse_factories, - _make_readout_pulse, - ) - - # Register all built-in defaults (idempotent): - register_default_component_pulse_factories() - - # Or build pulse dicts directly for a specific drive channel: - pulses = _make_xy_pulse_factories(qubit.xy) - readout = _make_readout_pulse() -""" - -from __future__ import annotations - -from quam.components.channels import SingleChannel -from quam.components.pulses import SquareReadoutPulse, SquarePulse - -from quam_builder.architecture.quantum_dots.components.pulses import ScalableGaussianPulse -from quam_builder.architecture.quantum_dots.operations.names import DrivePulseName -from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( - register_component_pulse_factories, -) -from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME, MIN_PULSE_DURATION_NS - -_REGISTERED = False - -__all__ = [ - "register_default_component_pulse_factories", -] - -# Default single-qubit XY pulse parameters -_PULSE_LENGTH = 1000 # ns -_PULSE_AMP = 1.0 # normalized (macro stores calibrated scaling) -_SIGMA_RATIO = 1 / 6 - -# Default readout pulse parameters -_READOUT_LENGTH = 2000 # ns -_READOUT_AMP = 1.0 - - -def _make_xy_pulse_factories(drive_channel): - """Build default XY drive reference pulse for an XY drive channel. - - Returns a dict with a single ``"gaussian"`` pulse — the reference - envelope used by ``XYDriveMacro`` for all single-qubit rotations. The macro - handles amplitude scaling (for rotation angle) and virtual-Z frame rotation - (for rotation axis), so only one calibrated pulse is needed. - - Users can register additional pulse types (e.g. ``"drag"``) and - point ``XYDriveMacro.reference_pulse_name`` at them to switch the - envelope used for all gates. - - Drive-type awareness: - - ``SingleChannel`` (``XYDriveSingle``): ``axis_angle=None`` — real-valued - waveforms only, rotation axis encoded via virtual-Z. - - IQ/MW channels: ``axis_angle=0.0`` — hardware IQ mixing; rotation - axis is set by virtual-Z frame rotation in the macro. - - Args: - drive_channel: The XY drive channel instance. Checked with - ``isinstance(drive_channel, SingleChannel)`` to determine pulse type. - - Returns: - Dict with one entry: ``{"gaussian": GaussianPulse(...)}``. - - Example:: - - pulses = _make_xy_pulse_factories(qubit.xy) - qubit.xy.operations.update(pulses) - """ - is_single = isinstance(drive_channel, SingleChannel) - axis_angle = None if is_single else 0.0 - - return { - DrivePulseName.GAUSSIAN.value: ScalableGaussianPulse( - id=DrivePulseName.GAUSSIAN.value, - length=_PULSE_LENGTH, - amplitude=_PULSE_AMP, - sigma_ratio=_SIGMA_RATIO, - axis_angle=axis_angle, - ), - } - - -def _make_readout_pulse(): - """Build default readout pulse for sensor dot resonators. - - Returns: - ``SquareReadoutPulse`` with id ``"readout"``, length 2000 ns, - amplitude 0.1. - """ - return SquareReadoutPulse( - id="readout", - length=_READOUT_LENGTH, - amplitude=_READOUT_AMP, - ) - -def _make_baseband_pulse(amplitude: float): - """Build default baseband pulse for all voltage gates connected to the OPX. - - Args: - amplitude: The baseband pulse amplitude in Volt. - - Returns: - ``SquarePulse`` with id ``DEFAULT_PULSE_NAME``, length MIN_PULSE_DURATION_NS ns, and specified amplitude. - """ - return SquarePulse( - id=DEFAULT_PULSE_NAME, - length=MIN_PULSE_DURATION_NS, - amplitude=amplitude, - ) - -def register_default_component_pulse_factories() -> None: - """Register built-in pulse factories for core quantum-dot component types. - - This function is idempotent — calling it multiple times has no effect after - the first registration. It is called automatically by - :func:`~quam_builder.architecture.quantum_dots.macro_engine.wiring._ensure_default_pulses` - during ``wire_machine_macros()``. - - Registration is intentionally centralized here (rather than in component - ``__post_init__``) to keep default behavior decoupled from component class - definitions. Actual pulse instances are created at wiring time by - ``_ensure_default_pulses``, which inspects each drive channel's type to - select the correct ``axis_angle``. - - Registered component types: - - ``LDQubit``: placeholder (actual XY pulses created at wiring time - based on drive channel type). - - ``SensorDot``: placeholder (readout pulse created at wiring time). - """ - global _REGISTERED - if _REGISTERED: - return - - from quam_builder.architecture.quantum_dots.qubit import LDQubit - from quam_builder.architecture.quantum_dots.components.sensor_dot import SensorDot - - # LDQubit: XY drive pulses (actual instances created at wiring time - # since drive type must be inspected) - register_component_pulse_factories(LDQubit, {}) - - # SensorDot: readout pulse - register_component_pulse_factories(SensorDot, {}) - - _REGISTERED = True - - -def _reset_registration() -> None: - """Reset global registration state. FOR TESTING ONLY.""" - global _REGISTERED - _REGISTERED = False diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py index 9d64e3c7..7977ad93 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/single_qubit_macros.py @@ -1,10 +1,20 @@ """Single-qubit default macros for quantum-dot qubits. +Pulse family switching +---------------------- +All single-qubit XY rotations are parameterised by a **pulse family** +(``"gaussian"``, ``"square"``, ``"kaiser"``, ``"hermite"``, or ``"drag"``). The active family is +stored in ``XYDriveMacro.pulse_family`` and determines which operation +from ``qubit.xy.operations`` is played. Operations follow the naming +convention ``{family}_{gate}`` (e.g. ``"kaiser_x180"``). + +Changing ``machine.pulse_family`` (and propagating via +``machine.set_pulse_family()``) switches **all** macros simultaneously. + Rescaling philosophy -------------------- -All single-qubit rotations derive from a **single reference pulse** (by -default a ``GaussianPulse`` named ``"gaussian"``). The ``XYDriveMacro`` -rescales only **amplitude** and **phase** at the macro level: +The ``XYDriveMacro`` rescales only **amplitude** and **phase** at the +macro level: * **Amplitude** is scaled proportionally to the requested rotation angle relative to ``reference_angle`` (default π). @@ -12,16 +22,15 @@ (0 → X, π/2 → Y, arbitrary → any XY axis). The pulse is **never time-stretched** via QUA's ``play(duration=…)`` -parameter, because arbitrary waveforms (Gaussian, DRAG) have internal -shape parameters (e.g. ``sigma``) defined in absolute samples. Stretching -the waveform without scaling ``sigma`` distorts the envelope. By always -playing at the pulse's native ``length``, the macro guarantees the -waveform shape is self-consistent. +parameter, because arbitrary waveforms (Gaussian, Kaiser) have internal +shape parameters defined in absolute samples. By always playing at the +pulse's native ``length``, the macro guarantees the waveform shape is +self-consistent. For experiments that require sweeping pulse duration (e.g. time-Rabi), users should define a custom macro that explicitly accepts the -sigma/length trade-off, or register multiple pulses with different -(length, sigma) pairs. +shape/length trade-off, or register multiple pulses with different +length parameters. """ # Framework macro base classes introduce deep inheritance chains by design. @@ -29,23 +38,33 @@ from __future__ import annotations +import dataclasses import math +from typing import ClassVar import numpy as np + from qm.qua import wait from quam.components.macro import QubitMacro +from quam.components.quantum_components.qubit import qua +from quam.core import quam_dataclass +from quam.core.macro import QuamMacro +from virtual_qpu import pulse +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + EmptyStateMacro, + InitializeStateMacro, + MeasurePSBPairMacro, +) from quam_builder.architecture.quantum_dots.operations.names import ( + X_NEG_90_ALIAS, + Y_NEG_90_ALIAS, DrivePulseName, SingleQubitMacroName, VoltagePointName, - X_NEG_90_ALIAS, - Y_NEG_90_ALIAS, -) -from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( - EmptyStateMacro, - InitializeStateMacro, ) +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.tools.qua_tools import CLOCK_CYCLE_NS, is_qua_type __all__ = [ "SINGLE_QUBIT_MACROS", @@ -95,12 +114,90 @@ def _compose_amplitude_scale( return scale -class Initialize1QMacro(InitializeStateMacro, QubitMacro): - """Initialize qubit by ramping to the `initialize` voltage point.""" +def _resolve_qubit_pair(qubit): + """Resolve the LDQubitPair for a qubit via preferred_readout_quantum_dot. + + Iterates ``machine.qubit_pairs`` to find a pair where one member is + *qubit* and the other member's quantum dot matches + ``preferred_readout_quantum_dot``. + + Raises: + ValueError: If preferred_readout_quantum_dot is not set or pair not found. + """ + preferred_dot_id = getattr(qubit, "preferred_readout_quantum_dot", None) + if preferred_dot_id is None: + raise ValueError( + f"Qubit '{qubit.id}' has no preferred_readout_quantum_dot set." + ) + machine = qubit.machine + for pair in machine.qubit_pairs.values(): + qc, qt = pair.qubit_control, pair.qubit_target + if qc is qubit and qt.quantum_dot.id == preferred_dot_id: + return pair + if qt is qubit and qc.quantum_dot.id == preferred_dot_id: + return pair + raise ValueError( + f"No QubitPair found for qubit '{qubit.id}' with " + f"preferred_readout_quantum_dot '{preferred_dot_id}'." + ) + + +def _state_macro_field_names(state_macro_cls: type) -> frozenset[str]: + """Dataclass field names on *state_macro_cls* excluding QuamMacro base fields.""" + base = {f.name for f in dataclasses.fields(QuamMacro)} + return frozenset(f.name for f in dataclasses.fields(state_macro_cls)) - base + - point: str = VoltagePointName.INITIALIZE.value +@quam_dataclass +class Initialize1QMacro(QubitMacro): + """Initialize qubit by delegating to the QuantumDotPair's initialize macro.""" + _CANONICAL_MACRO_NAME = "initialize" + def _resolve_canonical_macro(self): + pair = _resolve_qubit_pair(self.qubit) + return pair.macros[self._CANONICAL_MACRO_NAME] + + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None + + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, **kwargs): + # Let qubit.initialize() default to driving/conditioning on itself when + # the underlying initialize macro supports heralded arguments. + if kwargs.get("qubit_name") is None: + kwargs["qubit_name"] = self.qubit.name + return self._resolve_canonical_macro().apply(**kwargs) + + def __getattr__(self, name): + field_names = _state_macro_field_names(InitializeStateMacro) + if name in field_names: + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + try: + field_names = _state_macro_field_names(InitializeStateMacro) + except TypeError: + field_names = frozenset() + if name in field_names: + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) + + +@quam_dataclass class Measure1QMacro(QubitMacro): """PSB measure macro for a single qubit. @@ -109,152 +206,140 @@ class Measure1QMacro(QubitMacro): pair's measure macro which performs the full PSB readout chain. """ + _CANONICAL_MACRO_NAME = "measure" + + def _resolve_canonical_macro(self): + pair = _resolve_qubit_pair(self.qubit) + return pair.macros[self._CANONICAL_MACRO_NAME] + + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None + + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) + def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) def apply(self, **kwargs): - """Perform PSB readout via the qubit's preferred readout dot pair. - - Returns: - QUA boolean expression from the PSB state discrimination. - """ - qubit = self.qubit - preferred_dot_id = getattr(qubit, "preferred_readout_quantum_dot", None) - if preferred_dot_id is None: - raise ValueError(f"Qubit '{qubit.id}' has no preferred_readout_quantum_dot set.") - - own_dot = qubit.quantum_dot - machine = qubit.machine - pair_name = machine.find_quantum_dot_pair(own_dot.id, preferred_dot_id) - if pair_name is None: - raise ValueError( - f"No QuantumDotPair found for dots '{own_dot.id}' and " f"'{preferred_dot_id}'." - ) + return self._resolve_canonical_macro().apply(**kwargs) + + def __getattr__(self, name): + field_names = _state_macro_field_names(MeasurePSBPairMacro) + if name in field_names: + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) - qd_pair = machine.quantum_dot_pairs[pair_name] - return qd_pair.macros[SingleQubitMacroName.MEASURE].apply(**kwargs) + def __setattr__(self, name, value): + try: + field_names = _state_macro_field_names(MeasurePSBPairMacro) + except TypeError: + field_names = frozenset() + if name in field_names: + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) -class Empty1QMacro(EmptyStateMacro, QubitMacro): - """Move qubit to the `empty` voltage point.""" +@quam_dataclass +class Empty1QMacro(QubitMacro): + """Move qubit to empty by delegating to the QuantumDotPair's empty macro.""" - point: str = VoltagePointName.EMPTY.value + _CANONICAL_MACRO_NAME = "empty" + def _resolve_canonical_macro(self): + pair = _resolve_qubit_pair(self.qubit) + return pair.macros[self._CANONICAL_MACRO_NAME] -class XYDriveMacro(QubitMacro): - """Canonical XY-drive macro with angle-to-amplitude conversion. + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None - The macro converts rotation angle to drive amplitude using a reference - pulse (``"gaussian"`` by default). The pulse is **always played at its - native length** — only amplitude and phase are rescaled at the macro - level. This guarantees the waveform shape (e.g. Gaussian sigma) - remains self-consistent, avoiding distortion from QUA's - ``play(duration=…)`` waveform stretching. + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) - Calibrated amplitude is stored as ``reference_amplitude`` on the macro - (not on the pulse), keeping pulse definitions as normalised shape - templates. The pulse amplitude should remain at 1.0. + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) - Negative angles are represented as a +π phase shift on the requested - axis, so amplitude scaling is always computed from ``abs(angle)``. + def apply(self, **kwargs): + return self._resolve_canonical_macro().apply(**kwargs) + + def __getattr__(self, name): + field_names = _state_macro_field_names(EmptyStateMacro) + if name in field_names: + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) - The optional ``phase`` rotates the drive axis in the XY plane by - applying a temporary virtual-Z frame rotation before the pulse and - restoring it afterwards. - """ + def __setattr__(self, name, value): + try: + field_names = _state_macro_field_names(EmptyStateMacro) + except TypeError: + field_names = frozenset() + if name in field_names: + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) - reference_pulse_name: str = DrivePulseName.GAUSSIAN.value - reference_angle: float = float(np.pi) - default_angle: float = float(np.pi) - reference_amplitude: float = 1.0 - @property - def reference_pulse(self): - """Return the pulse object backing this macro's XY rotations.""" - name = self._resolve_pulse_name(None) - return self.qubit.xy.operations[name] +@quam_dataclass +class XYDriveMacro(QubitMacro): + """Base macro for XY-plane rotations with switchable pulse families. - def __call__(self, *args, **kwargs): - return self.apply(*args, **kwargs) + The active pulse envelope is determined by ``pulse_family`` combined + with a per-subclass ``_gate_suffix``. Changing ``pulse_family`` + (e.g. from ``"gaussian"`` to ``"kaiser"``) switches the envelope + used by all XY macros simultaneously. + """ - def _resolve_pulse_name(self, pulse_name: str | None) -> str: - if self.qubit.xy is None: - raise ValueError(f"Qubit '{self.qubit.id}' has no XY drive configured.") - - if pulse_name is not None: - if pulse_name not in self.qubit.xy.operations: - raise KeyError( - f"Pulse operation '{pulse_name}' is not defined on qubit '{self.qubit.id}'." - ) - return pulse_name - - for candidate in ( - self.reference_pulse_name, - DrivePulseName.GAUSSIAN, - DrivePulseName.DRAG, - SingleQubitMacroName.X_180, - SingleQubitMacroName.X_90, - ): - if candidate in self.qubit.xy.operations: - return candidate - - raise KeyError( - f"No reference pulse found for qubit '{self.qubit.id}'. " - "Expected one of: " - f"'{self.reference_pulse_name}', " - f"'{SingleQubitMacroName.X_180}', " - f"'{SingleQubitMacroName.X_90}'." - ) + pulse_family: str = DrivePulseName.GAUSSIAN.value + reference_angle: float = None + phase: float = None - def _reference_pulse_length_ns(self, pulse_name: str) -> int | None: - """Reference pulse native length in nanoseconds. + _gate_suffix: ClassVar[str] = "_x90" + _reference_gate_suffix: ClassVar[str] = "_x90" - Used for voltage-sequence hold timing so the hold matches the - pulse's actual waveform length. - """ - pulse = self.qubit.xy.operations[pulse_name] - length = getattr(pulse, "length", None) - return length if isinstance(length, int) else None + @property + def pulse_name(self) -> str: + """Operation name resolved from the active family and gate suffix.""" + return f"{self.pulse_family}{self._gate_suffix}" - def _angle_to_amplitude_scale(self, angle: float) -> float: - """Convert rotation angle to amplitude scale relative to reference. + @property + def reference_pulse_name(self) -> str: + """Reference (x90) operation name for the active family.""" + return f"{self.pulse_family}{self._reference_gate_suffix}" - Returns a multiplicative factor: ``abs(angle) / reference_angle``. - """ - if self.reference_angle <= 0: - raise ValueError("reference_angle must be positive.") - return abs(angle) / self.reference_angle - - @staticmethod - def _normalize_angle_sign_to_phase(angle: float, phase: float) -> tuple[float, float]: - """Encode negative-angle rotations as positive angle + pi phase offset.""" - if angle < 0: - return abs(angle), phase + float(np.pi) - return angle, phase - - def inferred_duration_for_angle(self, angle: float) -> float | None: - """Infer runtime duration (seconds) for a given rotation angle. - - Duration is always the reference pulse's native length regardless - of the angle — only amplitude changes. - """ - try: - pulse_name = self._resolve_pulse_name(None) - length_ns = self._reference_pulse_length_ns(pulse_name) - except Exception: # pragma: no cover - defensive - return None + @property + def pulse(self): + """Return the pulse object backing this macro's XY rotations.""" + return self.qubit.xy.operations[self.reference_pulse_name] - return length_ns * 1e-9 if isinstance(length_ns, int) else None + @property + def pi_pulse(self): + """Return the pi pulse (x180) object for the active family.""" + return self.qubit.xy.operations[f"{self.pulse_family}_x180"] @property def inferred_duration(self) -> float | None: - """Infer runtime duration (seconds) for ``default_angle``.""" - return self.inferred_duration_for_angle(self.default_angle) + return self.pulse.length + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) def update( self, *, - amplitude: float | None = None, + pi_amplitude: float | None = None, amplitude_scale: float | None = None, duration: int | None = None, frequency: float | None = None, @@ -266,194 +351,85 @@ def update( captured by subsequent serialisation (``machine.save``). Args: - amplitude: Set ``reference_amplitude`` to this absolute value. - amplitude_scale: Multiply current ``reference_amplitude`` by - this factor. Mutually exclusive with *amplitude*. + pi_amplitude: Set the x180 pulse amplitude to this value and + the x90 pulse amplitude to half this value. + amplitude_scale: Multiply the current amplitudes of both + pulses by this factor. Mutually exclusive with + *pi_amplitude*. duration: Set the reference pulse length in **nanoseconds** - (quantised to 4 ns). For ``ScalableGaussianPulse`` the - sigma auto-scales via ``sigma_ratio``; for plain - ``GaussianPulse`` sigma is rescaled proportionally. - frequency: Set ``qubit.xy.intermediate_frequency`` to this - absolute value. Mutually exclusive with *frequency_offset*. - frequency_offset: Add this offset to the current - ``qubit.xy.intermediate_frequency``. + (quantised to 4 ns). For Gaussian pulses, sigma + auto-scales via ``sigma_ratio``. + frequency: Set ``qubit.larmor_frequency`` to this absolute + value (Hz). Mutually exclusive with *frequency_offset*. + frequency_offset: Add this offset (Hz) to the current + ``qubit.larmor_frequency``. """ - if amplitude is not None and amplitude_scale is not None: - raise ValueError("Provide either amplitude or amplitude_scale, not both.") - if frequency is not None and frequency_offset is not None: - raise ValueError("Provide either frequency or frequency_offset, not both.") + if pi_amplitude is not None and amplitude_scale is not None: + raise ValueError("pi_amplitude and amplitude_scale are mutually exclusive") + + if pi_amplitude is not None: + self.pulse.amplitude = pi_amplitude / 2 + self.pi_pulse.amplitude = pi_amplitude - if amplitude is not None: - self.reference_amplitude = float(amplitude) - elif amplitude_scale is not None: - self.reference_amplitude *= float(amplitude_scale) + if amplitude_scale is not None: + self.pulse.amplitude = self.pulse.amplitude * amplitude_scale + self.pi_pulse.amplitude = self.pi_pulse.amplitude * amplitude_scale if duration is not None: - pulse = self.reference_pulse - new_length = _quantize_ns(duration) - old_length = int(pulse.length) - - if hasattr(pulse, "sigma_ratio"): - # ScalableGaussianPulse: sigma auto-follows via ratio - pulse.length = new_length - pulse.sigma = pulse.length * pulse.sigma_ratio - else: - # Legacy GaussianPulse: proportionally rescale sigma - sigma = getattr(pulse, "sigma", None) - pulse.length = new_length - if sigma is not None and old_length > 0 and hasattr(pulse, "sigma"): - pulse.sigma = sigma * new_length / old_length + self.pulse.length = duration + self.pi_pulse.length = duration + + if hasattr(self.pulse, "sigma_ratio"): + self.pulse.sigma = duration * self.pulse.sigma_ratio + self.pi_pulse.sigma = duration * self.pi_pulse.sigma_ratio if frequency is not None: - self.qubit.xy.intermediate_frequency = float(frequency) + self.qubit.larmor_frequency = float(frequency) + elif frequency_offset is not None: - self.qubit.xy.intermediate_frequency += float(frequency_offset) + self.qubit.larmor_frequency = float( + self.qubit.larmor_frequency + frequency_offset + ) def apply( - self, - angle: float | None = None, - phase: float = 0.0, - pulse_name: str | None = None, - amplitude_scale: float | None = None, - frequency_offset=None, - duration=None, - restore_frame: bool = True, - **kwargs, + self, + phase: float = 0.0, + amplitude_scale: float | None = None, + duration=None, + **kwargs, ): - """Play a phase-rotated XY drive pulse with compositional scaling. - - The pulse is always played at its native waveform length unless - ``duration`` is provided (in **clock cycles**, 4 ns each). When - a custom duration is given, both the voltage-sequence hold and - the QUA ``play(duration=…)`` use the same value so they stay in - sync. - - Runtime ``amplitude_scale`` multiplies the angle-derived scale - from the reference pulse instead of replacing it. - - Runtime ``frequency_offset`` temporarily shifts the drive - intermediate frequency for this pulse and restores it afterwards. - """ - target_angle = self.default_angle if angle is None else float(angle) - if math.isclose(target_angle, 0.0): - return None - - target_angle, phase = self._normalize_angle_sign_to_phase(target_angle, phase) - resolved_pulse_name = self._resolve_pulse_name(pulse_name) - - auto_amplitude_scale = self._angle_to_amplitude_scale(target_angle) - if math.isclose(auto_amplitude_scale, 0.0): - return None - - drive_scale = _compose_amplitude_scale( - self.reference_amplitude * auto_amplitude_scale, - amplitude_scale, - ) - - # Runtime frequency shift (before pulse) - has_freq_offset = frequency_offset is not None - if has_freq_offset: - self.qubit.xy.update_frequency( - self.qubit.xy.intermediate_frequency + frequency_offset, - ) + phase += self.phase if not math.isclose(phase, 0.0): self.qubit.virtual_z(phase) - - if duration is not None: - hold_duration_ns = duration * 4 - else: - hold_duration_ns = self._reference_pulse_length_ns(resolved_pulse_name) - self.qubit.voltage_sequence.step_to_voltages( - {}, - duration=hold_duration_ns, - ) - - play_kwargs = dict(kwargs) - if duration is not None: - play_kwargs["duration"] = duration self.qubit.xy.play( - pulse_name=resolved_pulse_name, - amplitude_scale=drive_scale, - **play_kwargs, + pulse_name=self.pulse_name, + amplitude_scale=amplitude_scale, + duration=duration ) - if restore_frame and not math.isclose(phase, 0.0): - self.qubit.virtual_z(-phase) - - # Restore frequency (after pulse) - if has_freq_offset: - self.qubit.xy.update_frequency( - self.qubit.xy.intermediate_frequency, - ) - - -class _AxisRotationMacro(QubitMacro): - """Canonical axis-rotation macro delegating to `xy_drive`.""" - - default_angle: float = float(np.pi) - phase: float = 0.0 - - def _xy_drive_macro(self): - macro = self.qubit.macros.get(SingleQubitMacroName.XY_DRIVE) - if macro is None: - raise KeyError(f"Missing canonical macro '{SingleQubitMacroName.XY_DRIVE}' on qubit.") - return macro - - @property - def reference_pulse(self): - """Delegate to the XY-drive macro's reference pulse.""" - return self._xy_drive_macro().reference_pulse - - @property - def inferred_duration(self) -> float | None: - """Infer runtime duration (seconds) for `default_angle`.""" - return self.inferred_duration_for_angle(self.default_angle) - - def inferred_duration_for_angle(self, angle: float) -> float | None: - """Infer runtime duration (seconds) for a given angle via `xy_drive`.""" - macro = self._xy_drive_macro() - infer_fn = getattr(macro, "inferred_duration_for_angle", None) - return infer_fn(angle) if callable(infer_fn) else None - - def update(self, **kwargs) -> None: - """Delegate persistent parameter updates to the XY-drive macro.""" - self._xy_drive_macro().update(**kwargs) - def __call__(self, *args, **kwargs): - return self.apply(*args, **kwargs) - - def apply(self, angle: float | None = None, **kwargs): - """Apply rotation around fixed XY axis by delegating to `xy_drive`.""" - target_angle = self.default_angle if angle is None else float(angle) - phase = _compose_phase(self.phase, kwargs.pop("phase", None)) - runtime_amplitude_scale = kwargs.pop("amplitude_scale", None) - call_kwargs = dict(kwargs) - call_kwargs.update( - angle=target_angle, - phase=phase, - ) - if runtime_amplitude_scale is not None: - call_kwargs["amplitude_scale"] = runtime_amplitude_scale - return self.qubit.macros[SingleQubitMacroName.XY_DRIVE].apply( - **call_kwargs, - ) - - -class XMacro(_AxisRotationMacro): +@quam_dataclass +class XMacro(XYDriveMacro): """Canonical X-axis rotation macro.""" - default_angle: float = float(np.pi) + _gate_suffix: ClassVar[str] = "_x90" + + reference_angle: float = None phase: float = 0.0 -class YMacro(_AxisRotationMacro): +@quam_dataclass +class YMacro(XYDriveMacro): """Canonical Y-axis rotation macro.""" - default_angle: float = float(np.pi) - phase: float = float(np.pi / 2) + _gate_suffix: ClassVar[str] = "_y90" + + reference_angle: float = None + phase: float = -np.pi +@quam_dataclass class ZMacro(QubitMacro): """Canonical virtual-Z rotation macro.""" @@ -473,127 +449,97 @@ def apply(self, angle: float | None = None, **kwargs): self.qubit.virtual_z(target_angle) -class _FixedAxisAngleMacro(QubitMacro): - """Fixed-angle wrapper that delegates to canonical `x`, `y`, or `z` macro.""" +@quam_dataclass +class X180Macro(XYDriveMacro): + """Apply 180-degree rotation around X axis via the dedicated pi pulse.""" + + _gate_suffix: ClassVar[str] = "_x180" - axis_macro_name: str - default_angle: float + axis_macro_name: str = SingleQubitMacroName.X.value + reference_angle: float = float(np.pi) phase: float = 0.0 - @property - def reference_pulse(self): - """Delegate to the axis macro's reference pulse.""" - axis_macro = self.qubit.macros.get(self.axis_macro_name) - if axis_macro is None: - raise KeyError(f"Missing macro '{self.axis_macro_name}' on qubit.") - return axis_macro.reference_pulse - @property - def inferred_duration(self) -> float | None: - axis_macro = self.qubit.macros.get(self.axis_macro_name) - if axis_macro is None: - return None +@quam_dataclass +class X90Macro(XYDriveMacro): + """Apply 90-degree rotation around X axis.""" - infer_fn = getattr(axis_macro, "inferred_duration_for_angle", None) - if callable(infer_fn): - return infer_fn(self.default_angle) + _gate_suffix: ClassVar[str] = "_x90" - duration = getattr(axis_macro, "inferred_duration", None) - return float(duration) if isinstance(duration, (int, float)) else None + axis_macro_name: str = SingleQubitMacroName.X.value + reference_angle: float = float(np.pi / 2) + phase: float = 0.0 - def update(self, **kwargs) -> None: - """Delegate persistent parameter updates to the XY-drive macro.""" - xy_drive = self.qubit.macros.get(SingleQubitMacroName.XY_DRIVE) - if xy_drive is None: - raise KeyError(f"Missing canonical macro '{SingleQubitMacroName.XY_DRIVE}' on qubit.") - xy_drive.update(**kwargs) - def __call__(self, *args, **kwargs): - return self.apply(*args, **kwargs) +@quam_dataclass +class XNeg90Macro(XYDriveMacro): + """Apply -90-degree rotation around X axis via dedicated pulse with axis_angle=pi.""" - def apply(self, angle: float | None = None, **kwargs): - target_angle = self.default_angle if angle is None else float(angle) - extra_phase = kwargs.pop("phase", None) - runtime_amplitude_scale = kwargs.pop("amplitude_scale", None) - phase = _compose_phase(self.phase, extra_phase) - call_kwargs = dict(kwargs) - if extra_phase is not None or not math.isclose(self.phase, 0.0): - call_kwargs["phase"] = phase - if runtime_amplitude_scale is not None: - call_kwargs["amplitude_scale"] = runtime_amplitude_scale - return self.qubit.macros[self.axis_macro_name].apply( - angle=target_angle, - **call_kwargs, - ) - - -class X180Macro(_FixedAxisAngleMacro): - """Apply 180-degree rotation around X axis via canonical `x` macro.""" + _gate_suffix: ClassVar[str] = "_x_neg90" axis_macro_name: str = SingleQubitMacroName.X.value - default_angle: float = float(np.pi) + reference_angle: float = float(np.pi / 2) + phase: float = 0.0 -class X90Macro(_FixedAxisAngleMacro): - """Apply 90-degree rotation around X axis via canonical `x` macro.""" +@quam_dataclass +class Y180Macro(XYDriveMacro): + """Apply 180-degree rotation around Y axis via dedicated pulse with axis_angle=pi/2.""" - axis_macro_name: str = SingleQubitMacroName.X.value - default_angle: float = float(np.pi / 2) + _gate_suffix: ClassVar[str] = "_y180" + axis_macro_name: str = SingleQubitMacroName.Y.value + reference_angle: float = float(np.pi) + phase: float = 0.0 -class XNeg90Macro(_FixedAxisAngleMacro): - """Apply -90-degree rotation around X axis via canonical `x` macro.""" - - axis_macro_name: str = SingleQubitMacroName.X.value - default_angle: float = float(-np.pi / 2) +@quam_dataclass +class Y90Macro(XYDriveMacro): + """Apply 90-degree rotation around Y axis via dedicated pulse with axis_angle=pi/2.""" -class Y180Macro(_FixedAxisAngleMacro): - """Apply 180-degree rotation around Y axis via canonical `y` macro.""" + _gate_suffix: ClassVar[str] = "_y90" axis_macro_name: str = SingleQubitMacroName.Y.value - default_angle: float = float(np.pi) - - -class Y90Macro(_FixedAxisAngleMacro): - """Apply 90-degree rotation around Y axis via canonical `y` macro.""" + reference_angle: float = float(np.pi / 2) + phase: float = 0.0 - axis_macro_name: str = SingleQubitMacroName.Y.value - default_angle: float = float(np.pi / 2) +@quam_dataclass +class YNeg90Macro(XYDriveMacro): + """Apply -90-degree rotation around Y axis via dedicated pulse with axis_angle=-pi/2.""" -class YNeg90Macro(_FixedAxisAngleMacro): - """Apply -90-degree rotation around Y axis via canonical `y` macro.""" + _gate_suffix: ClassVar[str] = "_y_neg90" axis_macro_name: str = SingleQubitMacroName.Y.value - default_angle: float = float(-np.pi / 2) + reference_angle: float = float(np.pi / 2) + phase: float = 0.0 -class Z180Macro(_FixedAxisAngleMacro): +@quam_dataclass +class Z180Macro(ZMacro): """Apply virtual 180-degree Z rotation via canonical `z` macro.""" - axis_macro_name: str = SingleQubitMacroName.Z.value default_angle: float = float(np.pi) -class Z90Macro(_FixedAxisAngleMacro): +@quam_dataclass +class Z90Macro(ZMacro): """Apply virtual 90-degree Z rotation via canonical `z` macro.""" - axis_macro_name: str = SingleQubitMacroName.Z.value - default_angle: float = float(np.pi / 2) + default_angle = float(np.pi / 2) -class ZNeg90Macro(_FixedAxisAngleMacro): +@quam_dataclass +class ZNeg90Macro(ZMacro): """Apply virtual -90-degree Z rotation via canonical `z` macro.""" - axis_macro_name: str = SingleQubitMacroName.Z.value - default_angle: float = float(-np.pi / 2) - + default_angle = float(-np.pi / 2) +@quam_dataclass class IdentityMacro(QubitMacro): """Identity operation implemented as wait.""" - duration: int = 16 + duration: int = DEFAULTS.misc.identity_duration @property def inferred_duration(self) -> float: @@ -604,17 +550,7 @@ def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) def apply(self, duration: int | None = None, **kwargs): - """Implement identity as a quantized wait on qubit channels.""" - duration_ns = self.duration if duration is None else duration - if duration_ns < 0: - raise ValueError("Identity duration must be non-negative.") - duration_ns = max(0, int(round(duration_ns / 4.0)) * 4) - - # Qubit.wait also issues qua.wait but expects clock cycles. Use it when available. - if hasattr(self.qubit, "wait"): - self.qubit.wait(duration_ns // 4) - else: - wait(duration_ns // 4) + self.qubit.idle(duration=duration) SINGLE_QUBIT_MACROS = { diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py index 9cce9428..2b40926d 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/state_macros.py @@ -2,15 +2,18 @@ from __future__ import annotations +from numbers import Integral, Real from typing import Any, Optional +from qm import qua from quam.core import quam_dataclass from quam.core.macro import QuamMacro +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.architecture.quantum_dots.operations.names import ( TwoQubitMacroName, VoltagePointName, ) -from quam_builder.tools.qua_tools import VoltageLevelType +from quam_builder.tools.qua_tools import CLOCK_CYCLE_NS, VoltageLevelType __all__ = [ "InitializeStateMacro", @@ -27,6 +30,18 @@ PointType = str | dict[str, VoltageLevelType] +# ``update()`` sentinel: keyword was omitted (vs. explicitly passed ``None``). +_OMIT_HOLD_UPDATE = object() + + +def _pulse_length_samples_to_ns(length: Any) -> int | None: + """Return pulse length in nanoseconds (1 sample = 1 ns on OPX).""" + if isinstance(length, Integral): + return int(length) + if isinstance(length, Real) and float(length).is_integer(): + return int(length) + return None + def _owner_component(macro: QuamMacro) -> Any: """Resolve the component that owns a macro instance. @@ -48,7 +63,7 @@ def _owner_component(macro: QuamMacro) -> Any: return owner -def _resolve_default_point_duration_ns(owner: Any, point: PointType) -> Optional[int]: +def _resolve_default_point_duration_ns(owner: Any, point: PointType) -> int | None: """Best-effort lookup of a point's default hold duration in nanoseconds.""" if not isinstance(point, str): return None @@ -86,10 +101,15 @@ def _ramp_to_target( @quam_dataclass class InitializeStateMacro(QuamMacro): - """Move component to initialize voltages using a ramp transition.""" + """Move component to initialize voltages using a ramp transition. + + ``hold_duration`` on this macro, if set, overrides the hold time; if ``None``, + the :class:`~quam_builder.architecture.quantum_dots.components.gate_set.VoltageTuningPoint` + ``duration`` is used (via ``ramp_to_*`` with ``duration=None``). + """ point: PointType = VoltagePointName.INITIALIZE.value - ramp_duration: int = 16 + ramp_duration: int = DEFAULTS.state_macro.ramp_duration hold_duration: int | None = None @property @@ -118,10 +138,28 @@ def apply( hold = self.hold_duration if hold_duration is None else hold_duration _ramp_to_target(owner, self.point, ramp_duration=ramp, duration=hold) + def update( + self, + *, + ramp_duration: int | None = None, + hold_duration: int | None | object = _OMIT_HOLD_UPDATE, + point: PointType | None = None, + ) -> None: + """Persistently update calibrated initialization parameters.""" + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if hold_duration is not _OMIT_HOLD_UPDATE: + self.hold_duration = hold_duration # type: ignore[assignment] + if point is not None: + self.point = point + @quam_dataclass class EmptyStateMacro(QuamMacro): - """Move component to empty voltages.""" + """Move component to empty voltages. + + If ``hold_duration`` is ``None``, the hold uses the ``VoltageTuningPoint`` ``duration``. + """ point: PointType = VoltagePointName.EMPTY.value hold_duration: int | None = None @@ -144,6 +182,18 @@ def apply(self, hold_duration: int | None = None, **kwargs): hold = self.hold_duration if hold_duration is None else hold_duration _step_to_target(owner, self.point, duration=hold) + def update( + self, + *, + hold_duration: int | None | object = _OMIT_HOLD_UPDATE, + point: PointType | None = None, + ) -> None: + """Persistently update calibrated empty parameters.""" + if hold_duration is not _OMIT_HOLD_UPDATE: + self.hold_duration = hold_duration # type: ignore[assignment] + if point is not None: + self.point = point + @quam_dataclass class ExchangeStateMacro(QuamMacro): @@ -161,8 +211,8 @@ class ExchangeStateMacro(QuamMacro): point: PointType = VoltagePointName.EXCHANGE.value return_point: PointType = VoltagePointName.INITIALIZE.value - ramp_duration: int = 16 - wait_duration: int = 16 + ramp_duration: int = DEFAULTS.exchange.ramp_duration + wait_duration: int = DEFAULTS.exchange.wait_duration def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) @@ -180,7 +230,9 @@ def apply( ramp = self.ramp_duration if ramp_duration is None else ramp_duration wait = self.wait_duration if wait_duration is None else wait_duration exchange_point = self.point if point is None else point - exchange_return_point = self.return_point if return_point is None else return_point + exchange_return_point = ( + self.return_point if return_point is None else return_point + ) # Ramp to exchange; hold at plateau for wait_duration (ns after ramp completes) _ramp_to_target(owner, exchange_point, ramp_duration=ramp, duration=wait) @@ -197,76 +249,203 @@ class SensorDotMeasureMacro(QuamMacro): returning a QUA boolean suitable for ``Cast.to_int()``. Without a pair ID, falls back to a raw resonator measurement. + + When ``gate_channel_names`` and ``voltage_sequence`` are provided + (typically by ``MeasurePSBPairMacro``), the macro aligns the voltage + gates with the resonator before measuring and tracks the elapsed + readout time on the voltage sequencer so integrated-voltage + bookkeeping stays correct. """ pulse_name: str = "readout" + + def _resolve_pulse_name_for_pair(self, pair_name: Optional[str] = None) -> str | None: + owner = _owner_component(self) + resonator = owner.readout_resonator + if resonator is None: + return None + ops = getattr(resonator, "operations", None) + if pair_name is not None: + pair_pulse_name = f"{self.pulse_name}_{pair_name}" + if ops is not None and pair_pulse_name in ops: + return pair_pulse_name + return self.pulse_name + + def readout_pulse_length_ns_for_pair(self, pair_name: Optional[str] = None) -> int | None: + owner = _owner_component(self) + resonator = owner.readout_resonator + if resonator is None: + return None + pulse_name = self._resolve_pulse_name_for_pair(pair_name) + if pulse_name is None: + return None + pulse = resonator.operations.get(pulse_name) + if pulse is None: + return None + return _pulse_length_samples_to_ns(getattr(pulse, "length", None)) + + @property + def readout_pulse_length_ns(self) -> int | None: + """Length of the readout pulse in nanoseconds, or ``None``.""" + return self.readout_pulse_length_ns_for_pair(None) + + def inferred_duration_for_pair(self, qd_pair_name: Optional[str] = None) -> float | None: + length = self.readout_pulse_length_ns_for_pair(qd_pair_name) + return length * 1e-9 if length is not None else None + + @property + def inferred_duration(self) -> float | None: + """Duration dictated by the readout pulse length (seconds).""" + return self.inferred_duration_for_pair(None) + def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) - def apply(self, *args, quantum_dot_pair_id: Optional[str] = None, **kwargs): + def apply( + self, + *args, + quantum_dot_pair_id: str | None = None, + voltage_sequence=None, + gate_channel_names: list[str] | None = None, + return_iq: bool = False, + **kwargs, + ): """Measure the readout resonator and optionally perform state assignment. Args: quantum_dot_pair_id: If provided, apply the projector and threshold stored on this sensor dot for the given pair, returning a QUA boolean (projected_value > threshold). + voltage_sequence: Voltage sequencer for integrated-voltage tracking. + gate_channel_names: QUA element names of voltage gate channels to + align with the readout resonator before measuring. *args, **kwargs: Forwarded to ``readout_resonator.measure()`` when no pair ID is given. Returns: QUA boolean expression when ``quantum_dot_pair_id`` is set, - otherwise ``None`` (raw measurement stored in qua_vars). + otherwise ``(i_qua, q_qua)`` tuple (raw measurement). """ - from qm.qua import declare, fixed, assign + from qm.qua import declare, fixed owner = _owner_component(self) + resonator = owner.readout_resonator + readout_pulse_name = self._resolve_pulse_name_for_pair(quantum_dot_pair_id) + if readout_pulse_name is None: + raise ValueError("Sensor dot has no readout resonator configured.") + + # if gate_channel_names: + # qua.align(*gate_channel_names, resonator.name) + + # if voltage_sequence is not None: + # pulse_length_ns = self.readout_pulse_length_ns_for_pair(quantum_dot_pair_id) + # if pulse_length_ns is not None: + # voltage_sequence.step_to_voltages({}, pulse_length_ns, ensure_align=False) - I = declare(fixed) - Q = declare(fixed) - owner.readout_resonator.measure(self.pulse_name, qua_vars=(I, Q)) + i_qua = declare(fixed) + q_qua = declare(fixed) + resonator.measure(readout_pulse_name, qua_vars=(i_qua, q_qua)) if quantum_dot_pair_id is None: - return (I, Q) + return (i_qua, q_qua) threshold, projector = owner._readout_params(quantum_dot_pair_id) - wI = projector.get("wI", 1.0) - wQ = projector.get("wQ", 0.0) - offset = projector.get("offset", 0.0) + # wI = float(projector.get("wI", 1.0)) + # wQ = float(projector.get("wQ", 0.0)) + # offset = float(projector.get("offset", 0.0)) - x = declare(fixed) - assign(x, I * wI + Q * wQ + offset) - return x > threshold + # projected = declare(fixed) + # assign(projected, i_qua * wI + q_qua * wQ + offset) + + state = i_qua > threshold # Assuming that the readout projects onto the I axis + if return_iq: + return (i_qua, q_qua, state) + return state @quam_dataclass class MeasurePSBPairMacro(QuamMacro): """PSB measure macro for QuantumDotPair. - Steps to the measure target, then dispatches readout to the - first coupled sensor dot with the pair ID for threshold lookup. + The timing sequence is: + + 1. **Buffer** -- step to the measure voltage point for + ``buffer_duration`` ns (settling time before readout). + 2. **Align** -- synchronize voltage gate channels with the readout + resonator (handled inside the sensor macro). + 3. **Readout + track** -- the sensor macro plays the readout pulse + and calls ``track_sticky_duration`` on the voltage sequencer so + integrated-voltage bookkeeping stays correct. + Returns a QUA boolean for state discrimination. """ point: PointType = VoltagePointName.MEASURE.value - hold_duration: int | None = None + buffer_duration: int | None = DEFAULTS.state_macro.buffer_duration + + @property + def inferred_duration(self) -> float | None: + """Total duration = buffer + sensor readout (seconds).""" + owner = _owner_component(self) + buffer_ns = self.buffer_duration or 0 + if not getattr(owner, "sensor_dots", None): + return None + sensor_dot = owner.sensor_dots[0] + sensor_macro = sensor_dot.macros.get(TwoQubitMacroName.MEASURE) + if sensor_macro is None: + return None + if hasattr(sensor_macro, "inferred_duration_for_pair"): + sensor_dur = sensor_macro.inferred_duration_for_pair(owner.id) + else: + sensor_dur = sensor_macro.inferred_duration + if sensor_dur is None: + return None + return (buffer_ns * 1e-9) + sensor_dur def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) - def apply(self, hold_duration: int | None = None, **kwargs): + def apply(self, buffer_duration: int | None = None, **kwargs): """Step to measure target, then perform PSB readout via sensor dot.""" owner = _owner_component(self) - hold = self.hold_duration if hold_duration is None else hold_duration - _step_to_target(owner, self.point, duration=hold) + buf = self.buffer_duration if buffer_duration is None else buffer_duration + _step_to_target(owner, self.point, duration=buf) if not owner.sensor_dots: - raise ValueError(f"QuantumDotPair '{owner.id}' has no sensor dots for readout.") + raise ValueError( + f"QuantumDotPair '{owner.id}' has no sensor dots for readout." + ) sensor_dot = owner.sensor_dots[0] + + gate_names = [] + vs = getattr(owner, "voltage_sequence", None) + if vs is not None: + gate_set = getattr(vs, "gate_set", None) + if gate_set is not None: + gate_names = [ch.name for ch in gate_set.channels.values()] + + qua.align(sensor_dot.readout_resonator.name, *gate_names) + return sensor_dot.macros[TwoQubitMacroName.MEASURE].apply( quantum_dot_pair_id=owner.id, + voltage_sequence=vs, + gate_channel_names=None, + **kwargs, ) + def update( + self, + *, + buffer_duration: int | None = None, + point: PointType | None = None, + ) -> None: + """Persistently update calibrated measure parameters.""" + if buffer_duration is not None: + self.buffer_duration = buffer_duration + if point is not None: + self.point = point + def _iter_qpu_targets(machine: Any): """Yield components targeted by QPU-level state macros. diff --git a/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py index 9c98249d..b4b9bc49 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py +++ b/quam_builder/architecture/quantum_dots/operations/default_macros/two_qubit_macros.py @@ -1,20 +1,33 @@ + """Two-qubit default macros for quantum-dot qubit pairs.""" # Framework macro base classes introduce deep inheritance chains by design. # pylint: disable=too-many-ancestors -from quam.components.macro import QubitPairMacro +from __future__ import annotations -from quam_builder.architecture.quantum_dots.operations.names import ( - TwoQubitMacroName, - VoltagePointName, -) +import dataclasses + +from qm import qua +from quam.components.macro import QubitPairMacro +from quam.core import quam_dataclass +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( EmptyStateMacro, ExchangeStateMacro, InitializeStateMacro, + MeasurePSBPairMacro, _owner_component, + _resolve_default_point_duration_ns, + _step_to_target, ) +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.tools.qua_tools import DurationType, is_qua_type, validate_duration __all__ = [ "TWO_QUBIT_MACROS", @@ -22,43 +35,288 @@ "Measure2QMacro", "Empty2QMacro", "Exchange2QMacro", + "CROTMacro", "CNOTMacro", "CZMacro", "SwapMacro", "ISwapMacro", + "DispatchInitialize2QMacro" ] -class Initialize2QMacro(InitializeStateMacro, QubitPairMacro): - """Initialize qubit pair by ramping to the `initialize` voltage point.""" +def _state_macro_field_names(macro_cls): + """Return field names specific to a state macro, excluding QuamMacro base fields.""" + from quam.core.macro import QuamMacro + + try: + base_names = {f.name for f in dataclasses.fields(QuamMacro)} + except TypeError: + base_names = set() + try: + all_names = {f.name for f in dataclasses.fields(macro_cls)} + except TypeError: + return set() + return all_names - base_names + + +def _duration_ns_to_cycles(duration_ns: DurationType) -> DurationType: + """Convert a nanosecond duration into OPX clock cycles.""" + validate_duration(duration_ns, "duration") + if is_qua_type(duration_ns): + return duration_ns >> 2 + return int(duration_ns) >> 2 + + +def _runtime_frequency_hz(qubit, esr_frequency): + """Convert an absolute ESR frequency to the channel update frequency.""" + drive = qubit.xy + if drive is None: + raise ValueError(f"Qubit '{qubit.id}' has no XY drive configured.") + + lo_frequency = None + if hasattr(drive, "upconverter_frequency"): + lo_frequency = drive.upconverter_frequency + elif hasattr(drive, "LO_frequency"): + lo_frequency = drive.LO_frequency + + if lo_frequency is None: + return ( + esr_frequency + if is_qua_type(esr_frequency) + else int(round(float(esr_frequency))) + ) + + target_frequency = esr_frequency - lo_frequency + if is_qua_type(target_frequency): + return target_frequency + + target_frequency = int(round(float(target_frequency))) + limit = getattr(drive, "IF_LIMIT", None) + if isinstance(limit, (int, float)) and abs(target_frequency) > limit: + raise ValueError( + f"Requested ESR frequency maps to intermediate frequency " + f"{target_frequency / 1e6:.2f} MHz, exceeding ±{limit / 1e6:.0f} MHz." + ) + + return target_frequency + + +def _point_voltages(owner, point: str | dict) -> dict[str, float]: + """Resolve a voltage point name (or explicit voltage dict) to channel voltages.""" + if isinstance(point, dict): + return point + full_name = owner._create_point_name(point) + tuning_point = owner.voltage_sequence.gate_set.macros.get(full_name) + return dict(tuning_point.voltages) + + +def _x180_pulse_name(drive_qubit, override: str | None = None) -> str: + """Resolve the ESR pulse operation, defaulting to the qubit's calibrated x180. + + Mirrors the single-qubit macros: the pi pulse is ``{pulse_family}_x180``, + resolved from the qubit's own ``x180`` macro so it tracks the active pulse + family (see :class:`X180Macro`). Falls back to ``gaussian_x180`` if the macro + is unavailable. An explicit *override* always wins. + """ + if override is not None: + return override + x180_macro = getattr(drive_qubit, "macros", {}).get(SingleQubitMacroName.X_180.value) + pulse_name = getattr(x180_macro, "pulse_name", None) + if pulse_name is None: + pulse_name = f"{DrivePulseName.GAUSSIAN.value}_x180" + return pulse_name + + +class _CZBalanceCache: + """In-memory record of CZ applications awaiting DC balancing. + + Each :meth:`CZMacro.apply` appends the resolved arguments of the + positive-polarity exchange leg it played. :meth:`CZMacro.balance` later + replays the mirror (negative-polarity) leg for every recorded call via + :meth:`CZMacro.apply_inverse`, then clears the cache, so the net + integrated voltage on every channel returns to zero. + + This is transient program state, not part of the QUAM configuration, so + the field holding it is excluded from serialisation. + """ + + def __init__(self) -> None: + self._calls: list[dict] = [] + + def record(self, **call_kwargs) -> None: + """Store the resolved arguments of a single ``apply`` call.""" + self._calls.append(dict(call_kwargs)) + + def __iter__(self): + return iter(self._calls) + + def __len__(self) -> int: + return len(self._calls) - point: str = VoltagePointName.INITIALIZE.value + def clear(self) -> None: + """Drop all recorded calls.""" + self._calls.clear() + + +class Initialize2QMacro(QubitPairMacro): + """Initialize qubit pair by delegating to QuantumDotPair's initialize macro.""" + + _CANONICAL_MACRO_NAME = "initialize" + + def _resolve_canonical_macro(self): + owner = _owner_component(self) + qd_pair = getattr(owner, "quantum_dot_pair", None) + if qd_pair is None: + raise ValueError( + f"LDQubitPair '{owner.id}' has no quantum_dot_pair for initialization." + ) + return qd_pair.macros[self._CANONICAL_MACRO_NAME] + + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None + + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, qubit_role: str | None = None, **kwargs): + owner = _owner_component(self) + if kwargs.get("qubit_name") is None: + role = "control" if qubit_role is None else qubit_role + if role not in {"target", "control"}: + raise ValueError( + f"Invalid qubit_role '{role}'. Expected 'target' or 'control'." + ) + if role == "control": + kwargs["qubit_name"] = owner.qubit_control.name + else: + kwargs["qubit_name"] = owner.qubit_target.name + if kwargs.get("target_state") is None: + pair_target_state = getattr(owner, "heralded_initialize_target_state", None) + if pair_target_state is not None: + kwargs["target_state"] = pair_target_state + return self._resolve_canonical_macro().apply(xy_channel = owner.xy, **kwargs) + + def __getattr__(self, name): + if name in _state_macro_field_names(InitializeStateMacro): + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + if name in _state_macro_field_names(InitializeStateMacro): + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) + +class DispatchInitialize2QMacro(QubitPairMacro): + """Initialize qubit pair by delegating to QuantumDotPair's initialize macro.""" + + _CANONICAL_MACRO_NAME = "initialize" + + def apply(self, **kwargs): + owner = _owner_component(self) + owner.qubit_target.initialize(**kwargs) + owner.qubit_control.initialize(**kwargs) class Measure2QMacro(QubitPairMacro): """PSB measure macro for LDQubitPair. Delegates to the underlying QuantumDotPair's measure macro, - which performs the full PSB readout chain (voltage step -> sensor - dot readout -> threshold -> QUA boolean). + which performs the full PSB readout chain. """ + _CANONICAL_MACRO_NAME = "measure" + + def _resolve_canonical_macro(self): + owner = _owner_component(self) + qd_pair = getattr(owner, "quantum_dot_pair", None) + if qd_pair is None: + raise ValueError( + f"LDQubitPair '{owner.id}' has no quantum_dot_pair for readout." + ) + return qd_pair.macros[self._CANONICAL_MACRO_NAME] + + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None + + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) + def __call__(self, *args, **kwargs): return self.apply(*args, **kwargs) def apply(self, **kwargs): - """Delegate measurement to the underlying quantum_dot_pair.""" + return self._resolve_canonical_macro().apply(**kwargs) + + def __getattr__(self, name): + if name in _state_macro_field_names(MeasurePSBPairMacro): + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + if name in _state_macro_field_names(MeasurePSBPairMacro): + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) + + +class Empty2QMacro(QubitPairMacro): + """Move qubit pair to empty by delegating to QuantumDotPair's empty macro.""" + + _CANONICAL_MACRO_NAME = "empty" + + def _resolve_canonical_macro(self): owner = _owner_component(self) qd_pair = getattr(owner, "quantum_dot_pair", None) if qd_pair is None: - raise ValueError(f"LDQubitPair '{owner.id}' has no quantum_dot_pair for readout.") - return qd_pair.macros[TwoQubitMacroName.MEASURE].apply(**kwargs) + raise ValueError( + f"LDQubitPair '{owner.id}' has no quantum_dot_pair for empty." + ) + return qd_pair.macros[self._CANONICAL_MACRO_NAME] + @property + def inferred_duration(self) -> float | None: + try: + return self._resolve_canonical_macro().inferred_duration + except (ValueError, KeyError): + return None -class Empty2QMacro(EmptyStateMacro, QubitPairMacro): - """Move qubit pair to the `empty` voltage point.""" + def update(self, **kwargs) -> None: + self._resolve_canonical_macro().update(**kwargs) - point: str = VoltagePointName.EMPTY.value + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply(self, **kwargs): + return self._resolve_canonical_macro().apply(**kwargs) + + def __getattr__(self, name): + if name in _state_macro_field_names(EmptyStateMacro): + return getattr(self._resolve_canonical_macro(), name) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + + def __setattr__(self, name, value): + if name in _state_macro_field_names(EmptyStateMacro): + setattr(self._resolve_canonical_macro(), name, value) + return + super().__setattr__(name, value) class Exchange2QMacro(ExchangeStateMacro, QubitPairMacro): @@ -67,6 +325,292 @@ class Exchange2QMacro(ExchangeStateMacro, QubitPairMacro): point: str = VoltagePointName.EXCHANGE.value +class _CROTBalanceCache: + """In-memory record of CROT exchange legs awaiting DC balancing. + + Each :meth:`CROTMacro.apply` appends the resolved arguments of the + single-polarity exchange leg it played (the leg during which the ESR pulse + drives the qubit). :meth:`CROTMacro.balance` later replays the mirror + (opposite-polarity) leg for every recorded call via + :meth:`CROTMacro.apply_inverse`, then clears the cache, so the net + integrated voltage on every channel returns to zero. + + This is transient program state, not part of the QUAM configuration, so the + field holding it is excluded from serialisation. + """ + + def __init__(self) -> None: + self._calls: list[dict] = [] + + def record(self, **call_kwargs) -> None: + """Store the resolved arguments of a single ``apply`` call.""" + self._calls.append(dict(call_kwargs)) + + def __iter__(self): + return iter(self._calls) + + def __len__(self) -> int: + return len(self._calls) + + def clear(self) -> None: + """Drop all recorded calls.""" + self._calls.clear() + + +@quam_dataclass +class CROTMacro(QubitPairMacro): + """Unbalanced controlled-rotation (CROT) gate macro. + + Ramps the barrier gate from zero to the exchange voltage point (a single + polarity), plays one ESR pulse on the driven qubit during the hold, then + ramps back to zero. The single-polarity excursion accumulates a net DC + voltage on every channel. + + Like :class:`CZMacro`, every ``apply`` records the exchange leg it played in + a transient cache; calling :meth:`balance` replays the mirror + (opposite-polarity) leg via :meth:`apply_inverse` for each recorded call and + clears the cache. ``apply`` followed by ``apply_inverse`` (same arguments) + reproduces the fully balanced pulse shape of :class:`BalancedCROTMacro`. + + By default the ESR pulse is emitted on the target qubit's XY channel; pass + ``drive_target=False`` to drive the control qubit instead (used by the + symmetric branch of CROT spectroscopy). + + The pulse defaults to the driven qubit's calibrated ``x180`` operation (the + same pi pulse used by the single-qubit macros); when ``duration`` and + ``amplitude`` are not given, the x180 pulse's native length and amplitude + are used. + """ + + pulse_name: str | None = None + point: str | None = VoltagePointName.EXCHANGE.value + ramp_duration: int = DEFAULTS.exchange.ramp_duration + """Ramp duration between zero and the exchange voltage point (ns).""" + esr_frequency: float | None = None + amplitude: float | None = None + duration: int | None = None + """ESR pulse / exchange-hold duration (ns). Defaults to the x180 pulse length.""" + + _cache: "_CROTBalanceCache" = dataclasses.field( + default_factory=_CROTBalanceCache, + metadata={"skip_save": True, "exclude": True}, + repr=False, + compare=False, + ) + """Transient record of pending ``apply`` calls, replayed by :meth:`balance`. + + Excluded from serialisation — not part of the QUAM configuration. + """ + + def _drive_qubit(self, drive_target: bool): + """Return the qubit whose XY channel emits the ESR pulse.""" + qubit = ( + self.qubit_pair.qubit_target + if drive_target + else self.qubit_pair.qubit_control + ) + if getattr(qubit, "xy", None) is None: + raise ValueError( + f"Qubit '{qubit.id}' in pair '{self.qubit_pair.id}' has no XY drive configured." + ) + return qubit + + def _pulse_duration_ns(self, drive_qubit, pulse_name: str, duration) -> int: + """Resolve the ESR pulse duration in ns, defaulting to the pulse length.""" + if duration is not None: + return duration + if pulse_name not in drive_qubit.xy.operations: + raise KeyError( + f"Pulse operation '{pulse_name}' is not defined on qubit '{drive_qubit.id}'." + ) + length = getattr(drive_qubit.xy.operations[pulse_name], "length", None) + if length is None: + raise ValueError( + f"Pulse '{pulse_name}' on qubit '{drive_qubit.id}' does not expose a length." + ) + return int(length) + + @property + def inferred_duration(self) -> float | None: + try: + drive_qubit = self._drive_qubit(True) + pulse_duration = self._pulse_duration_ns( + drive_qubit, _x180_pulse_name(drive_qubit, self.pulse_name), self.duration + ) + except (KeyError, ValueError): + return None + if is_qua_type(pulse_duration) or self.ramp_duration is None: + return None + return (pulse_duration + 2 * self.ramp_duration) * 1e-9 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def _play_exchange_leg(self, *, voltages, pulse_duration, ramp) -> None: + """Ramp 0 → ``voltages`` → hold ``pulse_duration`` → ramp back to 0. + + A pure voltage excursion with no drive — used for the balancing leg. + """ + owner = self.qubit_pair + zero = {k: 0.0 for k in voltages} + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + qua.align(*gates) + vs.ramp_to_voltages( + voltages, duration=pulse_duration, ramp_duration=ramp, ensure_align=False + ) + vs.ramp_to_voltages(zero, duration=16, ramp_duration=ramp, ensure_align=False) + + def apply( + self, + *, + point: str | dict | None = None, + ramp_duration: DurationType | None = None, + esr_frequency: float | None = None, + amplitude: float | None = None, + duration: DurationType | None = None, + pulse_name: str | None = None, + drive_target: bool = True, + **kwargs, + ): + """Execute the (unbalanced) CROT pulse sequence. + + Plays the single-polarity exchange leg with the ESR pulse during the + hold, then records the leg so it can be balanced later. + + Parameters + ---------- + point : str or dict, optional + Override for the exchange voltage point name (or explicit channel + voltages). Defaults to the stored ``point``. + ramp_duration : int or QUA variable, optional + Override for the ramp duration (ns). + esr_frequency : float or QUA variable, optional + If given, the driven qubit's XY frequency is set to this value for + the pulse and restored to its Larmor frequency afterwards. + amplitude : float or QUA variable, optional + Per-call amplitude scale for the ESR pulse. + duration : int or QUA variable, optional + ESR pulse / exchange-hold duration (ns). Defaults to the pulse length. + pulse_name : str, optional + Override for the ESR pulse name. + drive_target : bool, optional + Drive the target qubit (default) or the control qubit when ``False``. + """ + target_point = self.point if point is None else point + if target_point is None: + raise ValueError("CROTMacro requires a voltage_point.") + + drive_qubit = self._drive_qubit(drive_target) + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + resolved_pulse_name = _x180_pulse_name( + drive_qubit, self.pulse_name if pulse_name is None else pulse_name + ) + pulse_amplitude = self.amplitude if amplitude is None else amplitude + runtime_esr_frequency = ( + self.esr_frequency if esr_frequency is None else esr_frequency + ) + pulse_duration = self._pulse_duration_ns( + drive_qubit, + resolved_pulse_name, + self.duration if duration is None else duration, + ) + + positive = _point_voltages(self.qubit_pair, target_point) + zero = {k: 0.0 for k in positive} + vs = self.qubit_pair.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + play_kwargs = {"duration": _duration_ns_to_cycles(pulse_duration)} + if pulse_amplitude is not None: + play_kwargs["amplitude_scale"] = pulse_amplitude + + if runtime_esr_frequency is not None: + larmor_frequency = drive_qubit.larmor_frequency + drive_qubit.xy.update_frequency(runtime_esr_frequency) + + qua.align(drive_qubit.xy.name, *gates) + with qua.strict_timing_(): + qua.wait(int((3 * ramp + pulse_duration) // 4), drive_qubit.xy.name) + drive_qubit.xy.play(resolved_pulse_name, **play_kwargs) + vs.ramp_to_voltages( + positive, duration=pulse_duration, ramp_duration=ramp, ensure_align=False + ) + vs.ramp_to_voltages( + zero, duration=16, ramp_duration=ramp, ensure_align=False + ) + + if runtime_esr_frequency is not None: + drive_qubit.xy.update_frequency(larmor_frequency) + + # Record the mirror leg so balance() can cancel the net DC later. + self._cache.record( + point=target_point, ramp_duration=ramp, duration=pulse_duration + ) + + def apply_inverse( + self, + *, + point: str | dict | None = None, + ramp_duration: DurationType | None = None, + duration: DurationType | None = None, + drive_target: bool = True, + **kwargs, + ): + """Play the balancing (opposite-polarity) mirror of :meth:`apply`. + + Ramps to the element-wise negation of the exchange point, holds for the + same duration, then ramps back to zero. No ESR pulse and no frequency + update — this is purely the DC-balancing leg. It is not recorded. + """ + target_point = self.point if point is None else point + if target_point is None: + raise ValueError("CROTMacro requires a voltage_point.") + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + drive_qubit = self._drive_qubit(drive_target) + pulse_duration = self._pulse_duration_ns( + drive_qubit, _x180_pulse_name(drive_qubit, self.pulse_name), duration + ) + + positive = _point_voltages(self.qubit_pair, target_point) + negative = {k: -v for k, v in positive.items()} + self._play_exchange_leg( + voltages=negative, pulse_duration=pulse_duration, ramp=ramp + ) + + def balance(self) -> None: + """Replay :meth:`apply_inverse` for every cached ``apply`` call, in + order, then clear the cache. + + After this the net integrated voltage from all balanced ``apply`` calls + is zero on every channel. + """ + for call in self._cache: + self.apply_inverse(**call) + self._cache.clear() + + def update( + self, + *, + point: str | None = None, + ramp_duration: int | None = None, + esr_frequency: float | None = None, + amplitude: float | None = None, + duration: int | None = None, + ) -> None: + if point is not None: + self.point = point + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if esr_frequency is not None: + self.esr_frequency = esr_frequency + if amplitude is not None: + self.amplitude = amplitude + if duration is not None: + self.duration = duration + + class _Unsupported2QGateMacro(QubitPairMacro): """Default placeholder for two-qubit gates requiring calibration-specific logic.""" @@ -89,10 +633,240 @@ class CNOTMacro(_Unsupported2QGateMacro): gate_name: str = TwoQubitMacroName.CNOT.value -class CZMacro(_Unsupported2QGateMacro): - """Default placeholder for CZ (override required).""" +@quam_dataclass +class CZMacro(QubitPairMacro): + """Geometric CZ gate macro — a single (unbalanced) exchange pulse followed + by virtual Z phase corrections on both qubits. + + The gate ramps the barrier gate to the calibrated CZ voltage point, holds + for a calibrated duration, then ramps back to zero. The exchange produces + a CZ up to single-qubit Z rotations, which are cancelled by virtual Z + (frame) rotations applied after the ramp-back. These corrections are + calibrated by the CZ phase compensation node. + + Unlike :class:`BalancedCz2QMacro`, ``apply`` plays only the positive + exchange leg, so each call accumulates a net DC voltage. To recover DC + balance, every ``apply`` is recorded in a transient cache; calling + :meth:`balance` replays the mirror (negative-polarity) leg via + :meth:`apply_inverse` for each recorded call and clears the cache. + ``apply`` followed by ``apply_inverse`` (same arguments) reproduces the + fully balanced pulse shape. + """ + + point: str = VoltagePointName.CZ.value if hasattr(VoltagePointName, "CZ") else "CZ" + wait_duration: int = DEFAULTS.exchange.cz_duration + """Hold duration at the CZ voltage point (ns).""" + ramp_duration: int = DEFAULTS.exchange.ramp_duration + """Ramp duration between zero and the CZ voltage point (ns).""" + phase_shift_control: float = 0.0 + """Frame rotation on control qubit after CZ (units of 2pi).""" + phase_shift_target: float = 0.0 + """Frame rotation on target qubit after CZ (units of 2pi).""" + + exchange_decay_model: dict | None = None + """Fitted T_2π(V) model from ``18a_swap_oscillations``. + + Serialisable dict with a ``"type"`` key that identifies the function:: + + { + "type": "polynomial", + "coeffs": [c_n, ..., c_0], # highest-degree-first + "degree": int, + } + + ``None`` if not yet calibrated. Consumed by downstream nodes such as + ``18b_geometric_cz_amplitude_phase_calibration`` to evaluate a + per-amplitude CZ duration via :meth:`t_2pi` / :meth:`t_cz`. + """ + + _cache: "_CZBalanceCache" = dataclasses.field( + default_factory=_CZBalanceCache, + metadata={"skip_save": True, "exclude": True}, + repr=False, + compare=False, + ) + """Transient record of pending ``apply`` calls, replayed by :meth:`balance`. + + Excluded from serialisation — not part of the QUAM configuration. + """ + + @property + def inferred_duration(self) -> float | None: + if self.wait_duration is None: + return None + return (self.wait_duration + 2 * self.ramp_duration) * 1e-9 + + def t_2pi(self, amplitude: float) -> float: + """Evaluate the calibrated T_2π(V) model (ns). + + Requires :attr:`exchange_decay_model` to have been populated by + node ``18a_swap_oscillations``. + + Parameters + ---------- + amplitude : float + Barrier gate voltage (V). + + Returns + ------- + float + Full 2π swap oscillation period in nanoseconds. + """ + m = self.exchange_decay_model + if m is None: + raise ValueError( + "T_2π model not calibrated. Run 18a_swap_oscillations first." + ) + model_type = m.get("type", "polynomial") + if model_type == "polynomial": + result = 0.0 + for c in m["coeffs"]: + result = result * amplitude + c + return result + raise ValueError(f"Unknown exchange_decay_model type: {model_type!r}") + + def t_cz(self, amplitude: float) -> float: + """Return T_2π(V) / 2 — the CZ wait_duration for a π conditional phase. + + Parameters + ---------- + amplitude : float + Barrier gate voltage (V). + + Returns + ------- + float + Half-period in nanoseconds (round to 4 ns for the QUA clock). + """ + return self.t_2pi(amplitude) / 2.0 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def _play_exchange_leg(self, *, voltages, wait, ramp) -> None: + """Ramp every channel 0 → ``voltages`` → hold ``wait`` → ramp back to 0.""" + owner = self.qubit_pair + zero = {k: 0.0 for k in voltages} + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + qua.align(*gates) + vs.ramp_to_voltages( + voltages, duration=wait, ramp_duration=ramp, ensure_align=False + ) + vs.ramp_to_voltages( + zero, duration=16, ramp_duration=ramp, ensure_align=False + ) + + def apply( + self, + *, + point: str | dict | None = None, + wait_duration: DurationType | None = None, + ramp_duration: DurationType | None = None, + phase_shift_control=None, + phase_shift_target=None, + **kwargs, + ): + """Execute the (unbalanced) CZ gate pulse sequence. + + Plays the positive-polarity exchange leg, records the call so it can be + balanced later, and applies the virtual Z frame rotations. + + Parameters + ---------- + point : str or dict, optional + Override for the CZ voltage point name (or explicit channel + voltages). Defaults to the stored ``point``. + wait_duration : int or QUA variable, optional + Override for the hold duration (ns). + ramp_duration : int or QUA variable, optional + Override for the ramp duration (ns). + phase_shift_control : float or QUA variable, optional + Per-call override for control qubit frame rotation (units of 2pi). + If None, uses the stored ``phase_shift_control`` attribute. + phase_shift_target : float or QUA variable, optional + Per-call override for target qubit frame rotation (units of 2pi). + If None, uses the stored ``phase_shift_target`` attribute. + """ + cz_point = self.point if point is None else point + wait = self.wait_duration if wait_duration is None else wait_duration + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + ctrl_phase = ( + self.phase_shift_control + if phase_shift_control is None + else phase_shift_control + ) + tgt_phase = ( + self.phase_shift_target + if phase_shift_target is None + else phase_shift_target + ) - gate_name: str = TwoQubitMacroName.CZ.value + positive = _point_voltages(self.qubit_pair, cz_point) + self._play_exchange_leg(voltages=positive, wait=wait, ramp=ramp) + + # Record the mirror leg so balance() can cancel the net DC later. + self._cache.record(point=cz_point, wait_duration=wait, ramp_duration=ramp) + + qua.frame_rotation_2pi(ctrl_phase, self.qubit_pair.qubit_control.xy.name) + qua.frame_rotation_2pi(tgt_phase, self.qubit_pair.qubit_target.xy.name) + + def apply_inverse( + self, + *, + point: str | dict | None = None, + wait_duration: DurationType | None = None, + ramp_duration: DurationType | None = None, + **kwargs, + ): + """Play the balancing (negative-polarity) mirror of :meth:`apply`. + + Ramps to the element-wise negation of the CZ point, holds for the same + duration, then ramps back to zero. No frame rotations are applied — this + is purely the DC-balancing leg. It is not recorded in the cache. + """ + cz_point = self.point if point is None else point + wait = self.wait_duration if wait_duration is None else wait_duration + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + + positive = _point_voltages(self.qubit_pair, cz_point) + negative = {k: -v for k, v in positive.items()} + self._play_exchange_leg(voltages=negative, wait=wait, ramp=ramp) + + def balance(self) -> None: + """Replay :meth:`apply_inverse` for every cached ``apply`` call, in + order, then clear the cache. + + After this the net integrated voltage from all balanced ``apply`` calls + is zero on every channel. + """ + for call in self._cache: + self.apply_inverse(**call) + self._cache.clear() + + def update( + self, + *, + wait_duration: int | None = None, + ramp_duration: int | None = None, + point: str | None = None, + phase_shift_control: float | None = None, + phase_shift_target: float | None = None, + exchange_decay_model: dict | None = None, + ) -> None: + if wait_duration is not None: + self.wait_duration = wait_duration + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if point is not None: + self.point = point + if phase_shift_control is not None: + self.phase_shift_control = phase_shift_control + if phase_shift_target is not None: + self.phase_shift_target = phase_shift_target + if exchange_decay_model is not None: + self.exchange_decay_model = exchange_decay_model class SwapMacro(_Unsupported2QGateMacro): @@ -113,6 +887,7 @@ class ISwapMacro(_Unsupported2QGateMacro): VoltagePointName.EMPTY.value: Empty2QMacro, TwoQubitMacroName.CNOT.value: CNOTMacro, TwoQubitMacroName.CZ.value: CZMacro, + TwoQubitMacroName.CROT.value: CROTMacro, TwoQubitMacroName.SWAP.value: SwapMacro, TwoQubitMacroName.ISWAP.value: ISwapMacro, TwoQubitMacroName.EXCHANGE.value: Exchange2QMacro, diff --git a/quam_builder/architecture/quantum_dots/operations/default_operations.py b/quam_builder/architecture/quantum_dots/operations/default_operations.py index 5259fdc4..1812e68d 100644 --- a/quam_builder/architecture/quantum_dots/operations/default_operations.py +++ b/quam_builder/architecture/quantum_dots/operations/default_operations.py @@ -11,8 +11,8 @@ `component.macros[operation_name]` at runtime. """ +from quam.components.quantum_components import QuantumComponent, Qubit, QubitPair from quam.core import OperationsRegistry -from quam.components.quantum_components import Qubit, QubitPair, QuantumComponent __all__ = [ "operations_registry", @@ -36,6 +36,7 @@ "exchange", "cnot", "cz", + "crot", "swap", "iswap", ] @@ -125,7 +126,7 @@ def z_neg90(component: Qubit, **kwargs): @operations_registry.register_operation -def I(component: Qubit, **kwargs): +def I(component: Qubit, **kwargs): # noqa: E743 """Dispatch to component.macros['I'].""" @@ -139,6 +140,11 @@ def cz(component: QubitPair, **kwargs): """Dispatch to component.macros['cz'].""" +@operations_registry.register_operation +def crot(component: QubitPair, **kwargs): + """Dispatch to component.macros['crot'].""" + + @operations_registry.register_operation def swap(component: QubitPair, **kwargs): """Dispatch to component.macros['swap'].""" diff --git a/quam_builder/architecture/quantum_dots/operations/macro_catalog.py b/quam_builder/architecture/quantum_dots/operations/macro_catalog.py new file mode 100644 index 00000000..101511f1 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/macro_catalog.py @@ -0,0 +1,362 @@ +"""Catalog-based macro registry for quantum-dot components. + +This module provides the ``MacroCatalog`` protocol and concrete implementations +for registering and resolving default macro factories per component type. + +Architecture +------------ +A **catalog** is any object that can provide macro factories for a given +component type. The ``MacroRegistry`` aggregates catalogs and resolves the +effective factory map for a component instance by merging catalogs in +priority order (lowest first, highest wins per key). + +Built-in catalogs: + +- ``UtilityMacroCatalog`` (priority 0): ``align`` and ``wait`` for all + components. +- ``DefaultMacroCatalog`` (priority 100): architecture defaults with + MRO-based inheritance (internal to this catalog only). + +Users supply additional catalogs via ``wire_machine_macros(..., catalogs=[...])``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Dict, Protocol, Type, Union, runtime_checkable + +from quam.core.macro import QuamMacro + +from quam_builder.tools.macros.default_macros import UTILITY_MACRO_FACTORIES + +__all__ = [ + "MacroFactory", + "MacroFactoryMap", + "MacroCatalog", + "MacroRegistry", + "UtilityMacroCatalog", + "DefaultMacroCatalog", + "VoltageBalancedMacroCatalog", + "TypeOverrideCatalog", + "DISABLED", +] + +MacroFactory = Union[Type[QuamMacro], Callable[[], QuamMacro]] +"""A macro class or zero-arg callable that returns a ``QuamMacro`` instance.""" + +MacroFactoryMap = Dict[str, MacroFactory] +"""Mapping from macro name to factory.""" + + +# --------------------------------------------------------------------------- +# Sentinel for disabling/removing macros +# --------------------------------------------------------------------------- + + +class _DisabledSentinel: + """Sentinel indicating a macro should be removed.""" + + _instance: _DisabledSentinel | None = None + + def __new__(cls) -> _DisabledSentinel: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "DISABLED" + + def __bool__(self) -> bool: + return False + + +DISABLED = _DisabledSentinel() + + +# --------------------------------------------------------------------------- +# Protocol +# --------------------------------------------------------------------------- + + +@runtime_checkable +class MacroCatalog(Protocol): + """A source of macro factories for component types. + + Implement this protocol in external packages to provide a complete + or partial set of macro defaults. Return an empty dict from + ``get_factories`` for unsupported component types. + """ + + def get_factories(self, component_type: type) -> MacroFactoryMap: + """Return macro-name -> factory for *component_type*. + + Args: + component_type: The component class to resolve factories for. + + Returns: + Dict mapping macro name strings to factories. Return an + empty dict for unsupported component types. + """ + ... + + @property + def priority(self) -> int: + """Merge priority. Higher values override lower. + + Convention: 0 = utility, 100 = architecture defaults, + 200 = lab/external, 300 = instance overrides. + """ + ... + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + + +class MacroRegistry: + """Aggregates :class:`MacroCatalog` instances and resolves factories.""" + + def __init__(self) -> None: + self._catalogs: list[MacroCatalog] = [] + + def register_catalog(self, catalog: MacroCatalog) -> None: + """Add a catalog. Catalogs are applied in priority order.""" + self._catalogs.append(catalog) + self._catalogs.sort(key=lambda c: c.priority) + + def resolve_factories(self, component: object) -> MacroFactoryMap: + """Resolve effective macro factories for *component*. + + Walks catalogs low-to-high priority; higher priority wins per key. + """ + resolved: MacroFactoryMap = {} + for catalog in self._catalogs: + resolved.update(catalog.get_factories(type(component))) + return resolved + + @property + def catalogs(self) -> tuple[MacroCatalog, ...]: + """Registered catalogs in priority order (read-only).""" + return tuple(self._catalogs) + + +# --------------------------------------------------------------------------- +# Built-in catalogs +# --------------------------------------------------------------------------- + + +class UtilityMacroCatalog: + """Utility macros (``align``, ``wait``) for all macro-dispatch components. + + Returns the same set of utility factories regardless of + *component_type*, so every macro-dispatch component gets + ``align`` and ``wait``. + """ + + priority = 0 + + def get_factories(self, component_type: type) -> MacroFactoryMap: + """Return utility macro factories (same for every component type).""" + return dict(UTILITY_MACRO_FACTORIES) + + +class DefaultMacroCatalog: + """Built-in macro defaults for quantum-dot component types. + + Handles MRO-based inheritance internally: ``LDQubit`` inherits + ``QuantumDot`` defaults, but types marked *authoritative* (e.g. + ``SensorDot``) reset the inherited map. + """ + + priority = 100 + + def __init__(self) -> None: + self._type_factories: dict[type, MacroFactoryMap] = {} + self._authoritative_types: set[type] = set() + self._register_defaults() + + # -- public registration API (also used by _register_defaults) ---------- + + def register( + self, + component_type: type, + factories: Mapping[str, MacroFactory], + *, + authoritative: bool = False, + ) -> None: + """Register macro factories for *component_type*. + + Args: + component_type: The component class. + factories: Macro-name -> factory mapping. + authoritative: If ``True``, MRO resolution resets the + accumulated map when it reaches this type (i.e. the + type does **not** inherit defaults from its bases). + """ + self._type_factories[component_type] = dict(factories) + if authoritative: + self._authoritative_types.add(component_type) + + # -- protocol implementation -------------------------------------------- + + def get_factories(self, component_type: type) -> MacroFactoryMap: + """Resolve factories by walking the MRO (base -> derived). + + Args: + component_type: The component class to resolve factories for. + + Returns: + Merged factory map accumulated across the MRO, with + authoritative types resetting the accumulator. + """ + resolved: MacroFactoryMap = {} + for ancestor in reversed(component_type.mro()): + if ancestor in self._type_factories: + if ancestor in self._authoritative_types: + resolved = dict(self._type_factories[ancestor]) + else: + resolved.update(self._type_factories[ancestor]) + return resolved + + # -- built-in defaults -------------------------------------------------- + + def _register_defaults(self) -> None: + from quam_builder.architecture.quantum_dots.components import QPU + from quam_builder.architecture.quantum_dots.components.quantum_dot import ( + QuantumDot, + ) + from quam_builder.architecture.quantum_dots.components.quantum_dot_pair import ( + QuantumDotPair, + ) + from quam_builder.architecture.quantum_dots.components.sensor_dot import ( + SensorDot, + ) + from quam_builder.architecture.quantum_dots.operations.default_macros import ( + QPU_STATE_MACROS, + SINGLE_QUBIT_MACROS, + STATE_POINT_MACROS, + TWO_QUBIT_MACROS, + ) + from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + MeasurePSBPairMacro, + SensorDotMeasureMacro, + ) + from quam_builder.architecture.quantum_dots.operations.names import ( + VoltagePointName, + ) + from quam_builder.architecture.quantum_dots.qubit import LDQubit + from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair + + self.register(QPU, QPU_STATE_MACROS) + self.register(LDQubit, SINGLE_QUBIT_MACROS) + self.register(LDQubitPair, TWO_QUBIT_MACROS) + self.register(QuantumDot, STATE_POINT_MACROS) + self.register( + QuantumDotPair, + {**STATE_POINT_MACROS, VoltagePointName.MEASURE: MeasurePSBPairMacro}, + ) + self.register( + SensorDot, + {VoltagePointName.MEASURE: SensorDotMeasureMacro}, + authoritative=True, + ) + + +class VoltageBalancedMacroCatalog: + """Macro catalog that registers voltage-balanced implementations. + + At priority 200 this overrides :class:`DefaultMacroCatalog` (100) for: + + * :class:`~quam_builder.architecture.quantum_dots.qubit.LDQubit` -- ``xy_drive`` + → :class:`~quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.single_qubit_macros.BalancedXYDriveMacro` + * :class:`~quam_builder.architecture.quantum_dots.qubit_pair.LDQubitPair` -- ``exchange`` + → :class:`~quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.two_qubit_macros.BalancedExchange2QMacro` + * :class:`~quam_builder.architecture.quantum_dots.components.QuantumDotPair` -- ``initialize``, + ``empty``, ``measure`` → balanced state macros in + :mod:`quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros` + * :class:`~quam_builder.architecture.quantum_dots.components.sensor_dot.SensorDot` -- ``measure`` + → :class:`~quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros.BalancedSensorDotMeasureMacro` + + Tuning points must follow the docstrings of those classes (e.g. positive + ``empty`` / ``measure`` / ``exchange`` entries; ``BalancedXYDriveMacro`` + assumes 0 V idle on all gate channels). + """ + + priority = 200 + + def get_factories(self, component_type: type) -> MacroFactoryMap: + from quam_builder.architecture.quantum_dots.components.quantum_dot_pair import ( + QuantumDotPair, + ) + from quam_builder.architecture.quantum_dots.components.sensor_dot import ( + SensorDot, + ) + from quam_builder.architecture.quantum_dots.qubit import LDQubit + from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair + from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, + ) + from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + CZMacro, CROTMacro + ) + + from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros import ( + BalancedEmptyMacro, + BalancedInitializeMacro, + BalancedMeasurePSBPairMacro, + BalancedSensorDotMeasureMacro, + BalancedHeraldedInitializeMacro, + TwoStageBalancedInitializeMacro, + ) + from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.two_qubit_macros import ( + BalancedCz2QMacro, BalancedCROTMacro + ) + + if component_type is LDQubitPair: + return {TwoQubitMacroName.CZ.value: CZMacro, + TwoQubitMacroName.CROT.value: CROTMacro,} + if component_type is QuantumDotPair: + return { + VoltagePointName.INITIALIZE.value: BalancedHeraldedInitializeMacro, + VoltagePointName.EMPTY.value: BalancedEmptyMacro, + VoltagePointName.MEASURE.value: BalancedMeasurePSBPairMacro, + } + if component_type is SensorDot: + return {VoltagePointName.MEASURE: BalancedSensorDotMeasureMacro} + return {} + + +class TypeOverrideCatalog: + """Ad-hoc type-level overrides without a full catalog class. + + Example:: + + wire_machine_macros( + machine, + catalogs=[ + TypeOverrideCatalog({ + LDQubit: {SingleQubitMacroName.INITIALIZE: LabInitMacro}, + }), + ], + ) + """ + + priority = 200 + + def __init__(self, overrides: Mapping[type, Mapping[str, MacroFactory]]) -> None: + """Create a type-override catalog. + + Args: + overrides: Mapping from component class to macro-name -> factory. + Only exact type matches are used (no MRO walk). + """ + self._overrides: dict[type, MacroFactoryMap] = { + t: dict(m) for t, m in overrides.items() + } + + def get_factories(self, component_type: type) -> MacroFactoryMap: + """Return overrides for *component_type*, or empty dict if none.""" + return dict(self._overrides.get(component_type, {})) diff --git a/quam_builder/architecture/quantum_dots/operations/macro_registry.py b/quam_builder/architecture/quantum_dots/operations/macro_registry.py deleted file mode 100644 index 20c5225b..00000000 --- a/quam_builder/architecture/quantum_dots/operations/macro_registry.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Registry for attaching default macro factories to component types. - -This decouples macro-default wiring from component/base classes while keeping -registration explicit and local to the architecture package. -""" - -from __future__ import annotations - -from typing import Dict, Mapping, Type - -from quam.core.macro import QuamMacro - -from quam_builder.tools.macros.default_macros import UTILITY_MACRO_FACTORIES - -__all__ = [ - "MacroFactoryMap", - "register_component_macro_factories", - "get_default_macro_factories", -] - -MacroFactoryMap = Dict[str, Type[QuamMacro]] -# Type alias for a macro-name to macro-class factory mapping. - -_COMPONENT_MACRO_FACTORIES: Dict[str, MacroFactoryMap] = {} -# Internal registry keyed by fully-qualified component class name. - -_REPLACE_KEYS: set = set() -# Keys registered with replace=True — do not inherit from bases. - - -def _component_key(component_type: type) -> str: - """Create a stable key for component-type registrations.""" - return f"{component_type.__module__}.{component_type.__qualname__}" - - -def register_component_macro_factories( - component_type: type, - macro_factories: Mapping[str, Type[QuamMacro]], - *, - replace: bool = False, -) -> None: - """Register macro factories for a component type. - - Args: - component_type: Target component class. - macro_factories: Macro name -> macro class mapping. - replace: If True, replace any existing mapping for this component type. - If False (default), merge into the existing mapping (new keys win). - """ - key = _component_key(component_type) - if replace: - _REPLACE_KEYS.add(key) - _COMPONENT_MACRO_FACTORIES[key] = dict(macro_factories) - return - if key not in _COMPONENT_MACRO_FACTORIES: - _COMPONENT_MACRO_FACTORIES[key] = dict(macro_factories) - return - - merged = dict(_COMPONENT_MACRO_FACTORIES[key]) - merged.update(macro_factories) - _COMPONENT_MACRO_FACTORIES[key] = merged - - -def get_default_macro_factories(component: object) -> MacroFactoryMap: - """Resolve default macro factories for a component instance. - - Resolution order: - 1. Utility factories (available on all macro-dispatch components) - 2. Registered factories for each type in the component MRO (base -> derived) - - Args: - component: Component instance for which to resolve default macro classes. - - Returns: - MacroFactoryMap with the effective defaults for this component. - """ - resolved: MacroFactoryMap = dict(UTILITY_MACRO_FACTORIES) - - for component_type in reversed(type(component).mro()): - key = _component_key(component_type) - if key in _COMPONENT_MACRO_FACTORIES: - if key in _REPLACE_KEYS: - # Do not inherit from bases — use only this type's factories. - resolved = dict(UTILITY_MACRO_FACTORIES) - resolved.update(_COMPONENT_MACRO_FACTORIES[key]) - else: - resolved.update(_COMPONENT_MACRO_FACTORIES[key]) - - return resolved - - -def _reset_registry() -> None: - """Clear all registered macro factories. FOR TESTING ONLY.""" - _COMPONENT_MACRO_FACTORIES.clear() - _REPLACE_KEYS.clear() diff --git a/quam_builder/architecture/quantum_dots/operations/names.py b/quam_builder/architecture/quantum_dots/operations/names.py index 7094e691..f1b23676 100644 --- a/quam_builder/architecture/quantum_dots/operations/names.py +++ b/quam_builder/architecture/quantum_dots/operations/names.py @@ -27,6 +27,7 @@ class VoltagePointName(StrEnum): MEASURE = "measure" EMPTY = "empty" EXCHANGE = "exchange" + CZ = "CZ" class SingleQubitMacroName(StrEnum): @@ -67,6 +68,7 @@ class TwoQubitMacroName(StrEnum): CNOT = "cnot" CZ = "cz" + CROT = "crot" SWAP = "swap" ISWAP = "iswap" @@ -74,18 +76,21 @@ class TwoQubitMacroName(StrEnum): class DrivePulseName(StrEnum): """Canonical drive pulse operation names for XY drive channels. - Each entry identifies a pulse envelope type registered in - ``qubit.xy.operations``. The ``XYDriveMacro.reference_pulse_name`` - field selects which pulse is used as the single source of truth - for all single-qubit rotations. + Each entry identifies a pulse envelope family registered in + ``qubit.xy.operations``. Operations follow the naming convention + ``{family}_{gate}`` (e.g. ``gaussian_x90``, ``kaiser_x180``). - Users can register multiple pulse types (e.g. both ``gaussian`` - and ``drag``) and switch between them by updating - ``reference_pulse_name`` on the macro. + The active family is selected globally via + ``BaseQuamQD.pulse_family`` and propagated to macros via QuAM + references. """ GAUSSIAN = "gaussian" + SQUARE = "square" + KAISER = "kaiser" + HERMITE = "hermite" DRAG = "drag" + CROT = "crot" X_NEG_90_ALIAS = "-x90" @@ -134,6 +139,7 @@ class DrivePulseName(StrEnum): TwoQubitMacroName.EMPTY.value, TwoQubitMacroName.CNOT.value, TwoQubitMacroName.CZ.value, + TwoQubitMacroName.CROT.value, TwoQubitMacroName.SWAP.value, TwoQubitMacroName.ISWAP.value, TwoQubitMacroName.EXCHANGE.value, diff --git a/quam_builder/architecture/quantum_dots/operations/pulse_catalog.py b/quam_builder/architecture/quantum_dots/operations/pulse_catalog.py new file mode 100644 index 00000000..dac2edcf --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/pulse_catalog.py @@ -0,0 +1,174 @@ +"""Default pulse builders for quantum-dot channels. + +Pulse defaults are materialized by ``PulseWirer`` during the +``wire_machine_macros()`` pass. This module provides the channel-aware +helper builders used by that runtime pass. + +All default parameter values (lengths, amplitudes, sigma ratio) are +imported from :mod:`~quam_builder.architecture.quantum_dots.defaults`. +""" + +from __future__ import annotations +from typing import Optional + +import numpy as np +from quam.components.channels import SingleChannel +from quam.components.pulses import SquareReadoutPulse + +from quam_builder.architecture.quantum_dots.components.pulses import ( + ScalableDragPulse, + ScalableGaussianPulse, + ScalableHermitePulse, + ScalableKaiserPulse, + ScalableSquarePulse, +) +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + TwoQubitMacroName, +) + +__all__ = [ + "PULSE_FAMILIES", + "make_xy_pulse_factories", + "make_readout_pulse", +] + +PULSE_FAMILIES = { + DrivePulseName.GAUSSIAN.value: ScalableGaussianPulse, + DrivePulseName.SQUARE.value: ScalableSquarePulse, + DrivePulseName.KAISER.value: ScalableKaiserPulse, + DrivePulseName.HERMITE.value: ScalableHermitePulse, + DrivePulseName.DRAG.value: ScalableDragPulse, +} + +_AXIS_VARIANTS = { + "x_neg90": {"ref_anchor": "x90", "axis_angle": np.pi}, + "y180": {"ref_anchor": "x180", "axis_angle": np.pi / 2}, + "y90": {"ref_anchor": "x90", "axis_angle": np.pi / 2}, + "y_neg90": {"ref_anchor": "x90", "axis_angle": -np.pi / 2}, +} + + +def _make_family_pulses( + family: str, + pulse_cls: type, + is_single: bool, +) -> dict: + """Generate the 6 standard gate operations for a single pulse family.""" + axis_angle_base = None if is_single else 0.0 + x90_name = f"{family}_x90" + x180_name = f"{family}_x180" + + common_kwargs = {} + if pulse_cls in (ScalableGaussianPulse, ScalableHermitePulse, ScalableDragPulse): + common_kwargs["sigma_ratio"] = DEFAULTS.xy_pulse.sigma_ratio + + x90_amp = DEFAULTS.xy_pulse.amplitude + x180_amp = DEFAULTS.xy_pulse.amplitude * 2 + + ops = { + x90_name: pulse_cls( + id=x90_name, + amplitude=x90_amp, + axis_angle=axis_angle_base, + length=DEFAULTS.xy_pulse.length, + **common_kwargs, + ), + x180_name: pulse_cls( + id=x180_name, + amplitude=x180_amp, + axis_angle=axis_angle_base, + length=DEFAULTS.xy_pulse.length, + **common_kwargs, + ), + } + + for gate_suffix, spec in _AXIS_VARIANTS.items(): + op_name = f"{family}_{gate_suffix}" + anchor_name = f"{family}_{spec['ref_anchor']}" + + ref_kwargs = { + "length": f"#../{anchor_name}/length", + "amplitude": f"#../{anchor_name}/amplitude", + } + if pulse_cls is ScalableGaussianPulse: + ref_kwargs["sigma"] = f"#../{anchor_name}/sigma" + ref_kwargs["sigma_ratio"] = f"#../{anchor_name}/sigma_ratio" + elif pulse_cls is ScalableHermitePulse: + ref_kwargs["sigma_ratio"] = f"#../{anchor_name}/sigma_ratio" + ref_kwargs["hermite_coeff"] = f"#../{anchor_name}/hermite_coeff" + elif pulse_cls is ScalableDragPulse: + ref_kwargs["sigma_ratio"] = f"#../{anchor_name}/sigma_ratio" + ref_kwargs["drag_coefficient"] = f"#../{anchor_name}/drag_coefficient" + + ops[op_name] = pulse_cls( + id=op_name, + axis_angle=None if is_single else spec["axis_angle"], + **ref_kwargs, + ) + + return ops + + +def make_xy_pulse_factories(drive_channel: object) -> dict: + """Build the default XY drive pulses for *drive_channel*. + + Generates operations for all registered pulse families (Gaussian, + Square, Kaiser). Each family produces 6 operations following the + naming convention ``{family}_{gate}``: + + - ``{family}_x90`` -- calibration anchor (x90 amplitude) + - ``{family}_x180`` -- pi pulse (2x amplitude) + - ``{family}_x_neg90``, ``{family}_y180``, ``{family}_y90``, + ``{family}_y_neg90`` -- axis variants referencing anchor params + + Drive-type awareness: + - ``SingleChannel``: ``axis_angle=None`` (real-valued waveforms). + - IQ/MW channels: ``axis_angle`` set per gate (hardware IQ mixing). + + Args: + drive_channel: The XY drive channel instance. + + Returns: + Dict mapping operation names to pulse instances for all families, + plus a ``"cz"`` entry. + """ + is_single = isinstance(drive_channel, SingleChannel) + + all_ops: dict = {} + for family, pulse_cls in PULSE_FAMILIES.items(): + all_ops.update(_make_family_pulses(family, pulse_cls, is_single)) + + all_ops[TwoQubitMacroName.CZ.value] = ScalableGaussianPulse( + id="cz", + length=DEFAULTS.xy_pulse.length, + amplitude=DEFAULTS.xy_pulse.amplitude, + sigma_ratio=DEFAULTS.xy_pulse.sigma_ratio, + axis_angle=None if is_single else 0.0, + ) + + all_ops[DrivePulseName.CROT.value] = ScalableGaussianPulse( + id="crot", + length=DEFAULTS.xy_pulse.length, + amplitude=DEFAULTS.xy_pulse.amplitude, + sigma_ratio=DEFAULTS.xy_pulse.sigma_ratio, + axis_angle=None if is_single else 0.0, + ) + + return all_ops + + +def make_readout_pulse(dot_pair_name: Optional[str] = None) -> SquareReadoutPulse: + """Build default readout pulse for sensor dot resonators. + + Returns: + ``SquareReadoutPulse`` with id ``"readout"``, length from + ``DEFAULTS.readout.length`` (nanoseconds), amplitude from + ``DEFAULTS.readout.amplitude``. + """ + return SquareReadoutPulse( + id="readout" if dot_pair_name is None else f"readout_{dot_pair_name}", + length=DEFAULTS.readout.length, + amplitude=DEFAULTS.readout.amplitude, + ) diff --git a/quam_builder/architecture/quantum_dots/operations/pulse_registry.py b/quam_builder/architecture/quantum_dots/operations/pulse_registry.py deleted file mode 100644 index 26b6db9a..00000000 --- a/quam_builder/architecture/quantum_dots/operations/pulse_registry.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Registry for attaching default pulse factories to component types. - -Mirrors the macro registry pattern -(:mod:`~quam_builder.architecture.quantum_dots.operations.macro_registry`): -pulse-default wiring is decoupled from component/base classes while keeping -registration explicit and local to the architecture package. - -How it works ------------- -1. At import time, :func:`register_component_pulse_factories` is called for - each component type that should carry default pulses (e.g. ``LDQubit``, - ``SensorDot``). The registry stores a mapping of - ``pulse_name -> factory_callable`` keyed by fully-qualified class name. - -2. At wiring time, :func:`get_default_pulse_factories` walks the component's - MRO and merges registered factories (base -> derived, later wins). - -3. The wiring layer (:func:`~quam_builder.architecture.quantum_dots.macro_engine.wiring._ensure_default_pulses`) - calls the factories and writes the resulting ``Pulse`` instances into the - component's ``operations`` dict — but only for pulse names that are not - already present (user-supplied pulses always take precedence). - -Example:: - - from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( - register_component_pulse_factories, - get_default_pulse_factories, - ) - from quam.components.pulses import SquarePulse - - register_component_pulse_factories( - MyCustomQubit, - {"pi": lambda: SquarePulse(length=100, amplitude=0.3)}, - ) - - # Later, at wiring time: - factories = get_default_pulse_factories(my_qubit_instance) - # factories == {"pi": } -""" - -from __future__ import annotations - -from typing import Callable, Dict, Mapping - -from quam.components.pulses import Pulse - -__all__ = [ - "PulseFactoryMap", - "register_component_pulse_factories", - "get_default_pulse_factories", -] - -PulseFactoryMap = Dict[str, Callable[[], Pulse]] -"""Type alias: maps pulse names to no-arg factory callables returning Pulse.""" - -_COMPONENT_PULSE_FACTORIES: Dict[str, PulseFactoryMap] = {} -# Internal registry keyed by fully-qualified component class name. - - -def _component_key(component_type: type) -> str: - """Create a stable key for component-type registrations. - - Uses the fully-qualified module + qualname so that two classes with the - same short name in different modules never collide. - """ - return f"{component_type.__module__}.{component_type.__qualname__}" - - -def register_component_pulse_factories( - component_type: type, - pulse_factories: Mapping[str, Callable[[], Pulse]], -) -> None: - """Register pulse factories for a component type. - - If the component type is already registered, new entries are merged - on top (new keys win, existing keys are preserved). - - Args: - component_type: Target component class (e.g. ``LDQubit``). - pulse_factories: Mapping of pulse name to factory callable. - Each factory should be a no-arg callable returning a - :class:`~quam.components.pulses.Pulse` instance. - - Example:: - - register_component_pulse_factories( - LDQubit, - {"x180": lambda: GaussianPulse(length=1000, amplitude=0.2, sigma=167)}, - ) - """ - key = _component_key(component_type) - if key not in _COMPONENT_PULSE_FACTORIES: - _COMPONENT_PULSE_FACTORIES[key] = dict(pulse_factories) - return - - merged = dict(_COMPONENT_PULSE_FACTORIES[key]) - merged.update(pulse_factories) - _COMPONENT_PULSE_FACTORIES[key] = merged - - -def get_default_pulse_factories(component: object) -> PulseFactoryMap: - """Resolve default pulse factories for a component instance. - - Resolution follows the MRO (base -> derived); later entries win. - This means a derived class can override individual pulse names - registered on a base class. - - Args: - component: Component instance for which to resolve defaults. - - Returns: - Merged ``PulseFactoryMap`` with the effective pulse defaults. - """ - resolved: PulseFactoryMap = {} - - for component_type in reversed(type(component).mro()): - key = _component_key(component_type) - if key in _COMPONENT_PULSE_FACTORIES: - resolved.update(_COMPONENT_PULSE_FACTORIES[key]) - - return resolved - - -def _reset_registry() -> None: - """Clear all registered pulse factories. **FOR TESTING ONLY.**""" - _COMPONENT_PULSE_FACTORIES.clear() diff --git a/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/single_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/single_qubit_macros.py new file mode 100644 index 00000000..ad7fcfca --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/single_qubit_macros.py @@ -0,0 +1,41 @@ +"""Voltage-balanced single-qubit macros for LD qubits. + +In the balanced catalog the idle state is 0 V on every channel, so the +XY drive does not need to hold the voltage gates or update the sticky +voltage-sequence bookkeeping during the microwave pulse. The macro +plays the XY pulse at its native length with angle-to-amplitude and +phase rescaling identical to :class:`XYDriveMacro`. +""" + +# pylint: disable=too-many-ancestors +from __future__ import annotations +from typing import Any + +import math + +from qm import qua +from quam.core import quam_dataclass + +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + XYDriveMacro, + _compose_amplitude_scale, + _quantize_ns, + _resolve_qubit_pair, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + _owner_component, +) +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros import ( + BalancedInitializeMacro, _point_voltages, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.tools.qua_tools import CLOCK_CYCLE_NS, is_qua_type +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from qualang_tools.units import unit + +@quam_dataclass +class BalancedXYDriveMacro(XYDriveMacro): + pass diff --git a/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/state_macros.py b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/state_macros.py new file mode 100644 index 00000000..070e8311 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/state_macros.py @@ -0,0 +1,1010 @@ +"""Voltage-balanced state macros for quantum-dot pair components. + +These macros are designed for wiring onto ``QuantumDotPair`` via +:class:`VoltageBalancedMacroCatalog`. Every macro is constructed so its +net integrated voltage on every gate channel is zero, and the sequence +starts and ends at 0 V. + +Assumptions +----------- +* The ``initialize`` voltage point is defined at 0 V on every channel + (it is the rest state, not a travelled-to location). +* The ``empty`` and ``measure`` voltage points hold the positive-polarity + target voltages; their negative-polarity counterparts are derived by + negating the stored ``VoltageTuningPoint`` entries at runtime. +""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from numpy import False_ +from qm import qua +from quam.core import quam_dataclass +from quam.core.macro import QuamMacro + +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + _owner_component, + _pulse_length_samples_to_ns, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + TwoQubitMacroName, + VoltagePointName, +) +from qualang_tools.units import unit +u = unit(coerce_to_integer=True) + +__all__ = [ + "BalancedInitializeMacro", + "BalancedEmptyMacro", + "BalancedMeasurePSBPairMacro", + "BalancedSensorDotMeasureMacro", + "TwoStageBalancedInitializeMacro", + "BalancedInitializeMacroWithConditionalDrive", +] + + +def _default_target_qubit_name( + owner: Any, + qubit_role: Optional[Literal["target", "control"]] = None, +) -> Optional[str]: + """Resolve default drive qubit name from the owner's associated qubit pair.""" + qubit_pair = _resolve_qubit_pair_for_owner(owner) + if qubit_pair is not None: + role = "control" if qubit_role is None else qubit_role + if role not in {"target", "control"}: + raise ValueError( + f"Invalid qubit_role '{role}'. Expected 'target' or 'control'." + ) + if role == "control": + control = getattr(qubit_pair, "qubit_control", None) + if control is not None: + return control.name + target = getattr(qubit_pair, "qubit_target", None) + if target is not None: + return target.name + return None + + +def _resolve_qubit_pair_for_owner(owner: Any): + """Return the unique LDQubitPair associated with a macro owner. + + Heralded initialize macros are expected to be owned by ``QuantumDotPair`` + components (or directly by ``LDQubitPair`` wrappers). This helper resolves + that relationship and guards against ambiguous matches. + """ + if hasattr(owner, "qubit_target") and hasattr(owner, "qubit_control"): + return owner + + machine = getattr(owner, "machine", None) + owner_id = getattr(owner, "id", None) + if machine is None: + return None + + matches = [] + for qubit_pair in machine.qubit_pairs.values(): + dot_pair = getattr(qubit_pair, "quantum_dot_pair", None) + if dot_pair is owner or ( + dot_pair is not None and getattr(dot_pair, "id", None) == owner_id + ): + matches.append(qubit_pair) + + if len(matches) > 1: + raise ValueError( + "Ambiguous QuantumDotPair->LDQubitPair mapping for owner " + f"'{getattr(owner, 'id', owner)}'." + ) + return matches[0] if matches else None + + +def _default_target_state(owner: Any) -> Optional[int]: + """Resolve heralded-target-state default from the associated qubit pair.""" + qubit_pair = _resolve_qubit_pair_for_owner(owner) + if qubit_pair is None: + return None + return getattr(qubit_pair, "heralded_initialize_target_state", None) + + +def _point_voltages(owner: Any, point: str | dict) -> dict[str, float]: + if isinstance(point, dict): + return point + + full_name = owner._create_point_name(point) + + tuning_point = owner.voltage_sequence.gate_set.macros.get(full_name) + + return dict(tuning_point.voltages) + + +def _zero_voltages(owner: Any) -> dict[str, float]: + return {name: 0.0 for name in owner.voltage_sequence.gate_set.valid_channel_names} + + +@quam_dataclass +class _BalancedRoundTripMacro(QuamMacro): + """Balanced round-trip: ramp 0 → -V → +V → 0 through a named voltage point. + + Shape (per channel): + + 0 ──ramp──▶ -V ──hold── -V ──ramp──▶ +V ──hold── +V ──ramp──▶ 0 + + Ramp 1 and ramp 3 are mirror triangles of each other; ramp 2 is + antisymmetric about 0 V and integrates to zero. The two holds are + equal, so their +V and -V contributions cancel. Net integrated + voltage: zero on every channel. + + Ramp 2 covers twice the voltage of ramps 1 and 3, so its duration is + ``2 * ramp_duration`` to preserve the same slope (consistent dV/dt). + """ + + point: str = VoltagePointName.EMPTY.value + ramp_duration: int = DEFAULTS.state_macro.ramp_duration + hold_duration: int = DEFAULTS.state_macro.hold_duration + + @property + def inferred_duration(self) -> float | None: + return (2 * self.ramp_duration + 2 * self.hold_duration + 16) * 1e-9 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + ramp_duration: int | None = None, + hold_duration: int | None = None, + point: str | dict | None = None, + **kwargs, + ): + owner = _owner_component(self) + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + hold = self.hold_duration if hold_duration is None else hold_duration + target_point = self.point if point is None else point + + positive = _point_voltages(owner, target_point) + negative = {k: -v for k, v in positive.items()} + zero = {k: 0.0 for k, _ in positive.items()} + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + qua.align(*gates) + + # with qua.strict_timing_(): + vs.ramp_to_voltages( + negative, + duration=hold, + ramp_duration=ramp, + ensure_align=False, + ) + vs.ramp_to_voltages( + positive, + duration=hold, + ramp_duration=2*ramp, + ensure_align=False, + ) + vs.ramp_to_voltages( + zero, + duration=DEFAULTS.state_macro.point_duration, + ramp_duration=ramp, + ensure_align=False, + ) + + def update( + self, + *, + ramp_duration: int | None = None, + hold_duration: int | None = None, + point: str | None = None, + ) -> None: + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if hold_duration is not None: + self.hold_duration = hold_duration + if point is not None: + self.point = point + + +@quam_dataclass +class BalancedInitializeMacro(_BalancedRoundTripMacro): + """Voltage-balanced initialize: ramp 0 → -empty → +empty → 0.""" + + point: str = VoltagePointName.INITIALIZE.value + +@quam_dataclass +class BalancedHeraldedInitializeMacro(BalancedInitializeMacro): + def apply( + self, + max_loops: int = 2, + target_state: Optional[Literal[0, 1]] = None, + return_n_loops: bool = False, + conditional_drive: bool = True, + operation: str = "x180", + qubit_role: Optional[Literal["target", "control"]] = None, + qubit_name: Optional[str] = None, + meas_ramp_duration: Optional[int] = None, + meas_buffer_duration: Optional[int] = None, + **kwargs + ): + owner = _owner_component(self) + if qubit_name is None: + qubit_name = _default_target_qubit_name(owner, qubit_role=qubit_role) + if qubit_name is None: + raise ValueError( + "Heralded initialize: no `qubit_name` given and could not " + f"resolve a default target qubit for pair '{getattr(owner, 'id', owner)}'." + ) + if target_state is None: + target_state = _default_target_state(owner) + if target_state is None: + target_state = 0 + loop_start_n, loop_start_bool = 0, True + + n_count = qua.declare(int) + qua.assign(n_count, loop_start_n) + + cond = qua.declare(bool) + qua.assign(cond, loop_start_bool) + + with qua.while_((cond) & (n_count < max_loops)): + # First initialise. super() should be BalancedInitializeMacro + super().apply(**kwargs) + + # Now measure the state + state = owner.measure( + return_iq=False, + ramp_duration=meas_ramp_duration, + buffer_duration=meas_buffer_duration, + ) + + # As long as the state is in the initial value, the loop will continue until max_loops + #qua.assign(cond, qua.Cast.to_bool(state - target_state)) + qua.assign(n_count, n_count + 1) + qubit = owner.machine.qubits[qubit_name] + with qua.if_(cond): + qubit.apply(operation) + + if return_n_loops: + return n_count + return None + + +@quam_dataclass +class BalancedHeraldedInitializeMacroWithMemory(BalancedInitializeMacro): + def apply( + self, + max_loops: int = 1000, + target_state: Optional[Literal[0, 1]] = None, + return_n_loops: bool = False, + conditional_drive: bool = True, + operation: str = "x180", + qubit_role: Optional[Literal["target", "control"]] = None, + qubit_name: Optional[str] = None, + meas_ramp_duration: Optional[int] = None, + meas_buffer_duration: Optional[int] = None, + **kwargs + ): + owner = _owner_component(self) + if qubit_name is None: + qubit_name = _default_target_qubit_name(owner, qubit_role=qubit_role) + if qubit_name is None: + raise ValueError( + "Heralded initialize (memory): no `qubit_name` given and could " + f"not resolve a default target qubit for pair '{getattr(owner, 'id', owner)}'." + ) + if target_state is None: + target_state = _default_target_state(owner) + if target_state is None: + target_state = 1 + loop_start_n, loop_start_bool = 0, True + + n_count = qua.declare(int) + qua.assign(n_count, loop_start_n) + + cond = qua.declare(bool) + qua.assign(cond, loop_start_bool) + + prev_cond = qua.declare(bool) + qua.assign(prev_cond, False) + + drive_success = qua.declare(bool) + qua.assign(drive_success, False) + + # FIRST LOOP: We always initialise, measure, drive. + + # First INIT + super().apply(**kwargs) + # First MEASURE + prev_state = owner.measure( + return_iq=False, + ramp_duration=meas_ramp_duration, + buffer_duration=meas_buffer_duration, + ) + + qua.assign(prev_cond, prev_state) + # First DRIVE + qubit = owner.machine.qubits[qubit_name] + qubit.apply(operation) + + # qua.assign(prev_cond, qua.Cast.to_bool(prev_state - target_state)) + + # Now, we enter the loop. In this loop, we initialise, measure, and conditionally drive. + # The drive condition: + # - Regardless of whether the prev_cond is FALSE (target) or TRUE (non-target), if the loop measures + #  the OPPOSITE condition, then this means that the drive worked. We therefore drive if the state is NOT + # at the target state. Easy. + # - If the newly measured state is the SAME condition, this means that the drive did not work, and that we are stuck. + # In this case, we will continue the loop until we measure the opposite state, and then exit, conditionally driving. + + + with qua.while_((n_count < max_loops) & (cond | ~drive_success)): + # First initialise. super() should be BalancedInitializeMacro + super().apply(**kwargs) + + # Now measure the state + state = owner.measure( + return_iq=False, + ramp_duration=meas_ramp_duration, + buffer_duration=meas_buffer_duration, + ) + + # As long as the state is in the initial value, the loop will continue until max_loops + qua.assign(cond, qua.Cast.to_bool(state - target_state)) + + # If cond != prev_cond, then drive worked. + with qua.if_(state ^ prev_cond): # IF DIFFERENT, THEN TRUE. + qua.assign(drive_success, True) + + # IF SAME, THEN FALSE, drive_success = FALSE. + + qua.assign(n_count, n_count + 1) + qubit = owner.machine.qubits[qubit_name] + with qua.if_(cond): + qubit.apply(operation) + + qua.assign(prev_cond, state) + + if return_n_loops: + return n_count + return None + +@quam_dataclass +class BalancedEmptyMacro(_BalancedRoundTripMacro): + """Voltage-balanced empty: ramp 0 → -empty → +empty → 0. + + Shares the round-trip shape with :class:`BalancedInitializeMacro`; + kept as a separate class so ``empty`` remains an independently- + configurable balanced operation. + """ + + point: str = VoltagePointName.EMPTY.value + + def apply( + self, + measure_and_conditional_drive: bool = False, + state: Literal["less_than_one", "more_than_zero"] = "less_than_one", + drive_at_readout_point: bool = True, + pulse_name: str = "x180", + amplitude_scale: float = 1.0, + frequency_detuning_Hz: int = 0, + ramp_duration: int | None = None, + buffer_duration: int = 0, + hold_duration: int | None = None, + drive_point: str | dict | None = None, + point: str | dict | None = None, + **kwargs, + ): + owner = _owner_component(self) + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + hold = self.hold_duration if hold_duration is None else hold_duration + operation_point = self.point if point is None else point + + if not measure_and_conditional_drive: + return super().apply( + ramp_duration=ramp, + hold_duration=hold, + point=operation_point, + **kwargs, + ) + + pulse = f"{owner.machine.pulse_family}_{pulse_name}" + try: + qubit_pair = [ + qp + for qp in owner.machine.qubit_pairs.values() + if qp.quantum_dot_pair.name == owner.name + ][0] + except IndexError as exc: + raise ValueError(f"Qubit Pair not found for {owner.name}.") from exc + + xy_channel = qubit_pair.xy + xy_channel.update_frequency(xy_channel.intermediate_frequency + frequency_detuning_Hz) + op_length = xy_channel.operations[pulse].length + + def _conditional_drive(result_int): + if state == "less_than_one": + with qua.if_(result_int < 1): + xy_channel.play(pulse, amplitude_scale=amplitude_scale) + with qua.else_(): + qua.wait(op_length // 4, xy_channel.name) + else: + with qua.if_(result_int > 0): + xy_channel.play(pulse, amplitude_scale=amplitude_scale) + with qua.else_(): + qua.wait(op_length // 4, xy_channel.name) + + if not drive_at_readout_point: + qua.align(xy_channel.id, owner.sensor_dots[0].readout_resonator.id, *gates) + result = owner.measure(return_iq=False) + result_int = qua.Cast.to_int(result) + qua.align(xy_channel.id, owner.sensor_dots[0].readout_resonator.id, *gates) + _conditional_drive(result_int) + qua.align(xy_channel.id, owner.sensor_dots[0].readout_resonator.id, *gates) + return result + + if not owner.sensor_dots: + raise ValueError(f"QuantumDotPair '{owner.name}' has no sensor dots for readout") + + sensor_dot = owner.sensor_dots[0] + sensor_macro = sensor_dot.macros[TwoQubitMacroName.MEASURE] + if hasattr(sensor_macro, "readout_pulse_length_ns_for_pair"): + readout_len = sensor_macro.readout_pulse_length_ns_for_pair(owner.id) + else: + readout_len = sensor_macro.readout_pulse_length_ns + if readout_len is None: + raise ValueError( + "Sensor readout pulse length unknown; conditional drive requires " + "a fixed readout duration." + ) + + measure_point = VoltagePointName.MEASURE.value + target_drive_point = measure_point if drive_point is None else drive_point + measure_macro = owner.macros[TwoQubitMacroName.MEASURE] + measure_ramp_duration = getattr(measure_macro, "ramp_duration", ramp) + measure_buffer_duration = getattr(measure_macro, "buffer_duration", buffer_duration) + + measure_positive = _point_voltages(owner, measure_point) + measure_negative = {k: -v for k, v in measure_positive.items()} + drive_positive = _point_voltages(owner, target_drive_point) + drive_negative = {k: -v for k, v in drive_positive.items()} + zero_voltages = _zero_voltages(owner) + + qua.align(sensor_dot.readout_resonator.name, xy_channel.name, *gates) + + with qua.strict_timing_(): + # 1) Ramp to negative drive point and hold for operation window. + vs.ramp_to_voltages( + drive_negative, + duration=op_length + hold, + ramp_duration=ramp, + ensure_align=False, + ) + qua.wait((ramp + op_length + hold) // 4, xy_channel.name) + qua.wait((ramp + op_length + hold) // 4, sensor_dot.readout_resonator.name) + + # 2) Ramp to negative measure point, wait buffer and readout window. + vs.ramp_to_voltages( + measure_negative, + duration=measure_buffer_duration + readout_len, + ramp_duration=measure_ramp_duration, + ensure_align=False, + ) + qua.wait( + (measure_ramp_duration + measure_buffer_duration + readout_len) // 4, + xy_channel.name, + ) + qua.wait( + (measure_ramp_duration + measure_buffer_duration + readout_len) // 4, + sensor_dot.readout_resonator.name, + ) + + # 3) Ramp to positive measure point, wait buffer, then measure. + vs.ramp_to_voltages( + measure_positive, + duration=measure_buffer_duration + readout_len, + ramp_duration=2 * measure_ramp_duration, + ensure_align=False, + ) + qua.wait( + (2 * measure_ramp_duration + measure_buffer_duration) // 4, + sensor_dot.readout_resonator.name, + ) + + result = sensor_macro.apply( + quantum_dot_pair_id=owner.id, + return_iq=False, + ) + result_int = qua.Cast.to_int(result) + qua.wait( + (2 * measure_ramp_duration + measure_buffer_duration + readout_len) // 4, + xy_channel.name, + ) + + # 4) Ramp to drive point, conditionally drive, then hold. + vs.ramp_to_voltages( + drive_positive, + duration=op_length + hold, + ramp_duration=ramp, + ensure_align=False, + ) + qua.wait((ramp + op_length + hold) // 4, sensor_dot.readout_resonator.name) + qua.wait(ramp // 4, xy_channel.name) + _conditional_drive(result_int) + qua.wait(hold // 4, xy_channel.name) + + # 5) Ramp to zero point. + # vs.ramp_to_voltages( + # zero_voltages, + # duration=DEFAULTS.state_macro.point_duration, + # ramp_duration=ramp, + # ensure_align=False, + # ) + vs.ramp_to_zero( + ramp_duration = ramp + ) + + return result + + +@quam_dataclass +class TwoStageBalancedInitializeMacro(QuamMacro): + """Anti-symmetric two-stage ramp for initialization. + + Shape (per channel):: + + 0 ──rd1──▶ -V2 ──rd2──▶ -V1 ──hold── -V1 ──rd_mid──▶ +V1 ──hold── +V1 ──rd2──▶ +V2 ──rd1──▶ 0 + + The positive phase retraces the negative phase in reverse order, + making the waveform anti-symmetric: ``f(t) = -f(T - t)``. This + guarantees the net integrated voltage is exactly zero for any + combination of V1, V2, and ramp durations. + + ``point_1`` supplies the inner (initialization) voltages V1 and + ``point_2`` supplies the outer voltages V2, both resolved from + stored :class:`VoltageTuningPoint` entries. Alternatively, callers + can pass explicit voltage dicts (including QUA variables) via + ``voltages_1`` / ``voltages_2`` to bypass the lookup. + """ + + point_1: str = VoltagePointName.INITIALIZE.value + point_2: str = VoltagePointName.EMPTY.value + ramp_duration_1: int = DEFAULTS.state_macro.ramp_duration + ramp_duration_2: int = DEFAULTS.state_macro.ramp_duration + ramp_duration_mid: int = 16 + hold_duration: int = DEFAULTS.state_macro.hold_duration + + @property + def inferred_duration(self) -> float | None: + rd1 = self.ramp_duration_1 + rd2 = self.ramp_duration_2 + rd_mid = self.ramp_duration_mid + hold = self.hold_duration + total_ns = ( + 2 * rd1 # segments 1 + 5 + + 2 * rd2 # segments 2 + 4 + + rd_mid # segment 3 + + 2 * hold # holds at -V1 and +V1 + + DEFAULTS.state_macro.point_duration # final dwell at 0 + ) + return total_ns * 1e-9 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + ramp_duration_1: int | None = None, + ramp_duration_2: int | None = None, + ramp_duration_mid: int | None = None, + hold_duration: int | None = None, + point_1: str | None = None, + point_2: str | None = None, + voltages_1: dict | None = None, + voltages_2: dict | None = None, + **kwargs, + ): + owner = _owner_component(self) + rd1 = self.ramp_duration_1 if ramp_duration_1 is None else ramp_duration_1 + rd2 = self.ramp_duration_2 if ramp_duration_2 is None else ramp_duration_2 + rd_mid = self.ramp_duration_mid if ramp_duration_mid is None else ramp_duration_mid + hold = self.hold_duration if hold_duration is None else hold_duration + + if voltages_1 is not None: + pos_v1 = voltages_1 + else: + p1 = self.point_1 if point_1 is None else point_1 + pos_v1 = _point_voltages(owner, p1) + + if voltages_2 is not None: + pos_v2 = voltages_2 + else: + p2 = self.point_2 if point_2 is None else point_2 + pos_v2 = _point_voltages(owner, p2) + + neg_v2 = {k: -v for k, v in pos_v2.items()} + neg_v1 = {k: -v for k, v in pos_v1.items()} + zero = {k: 0.0 for k, _ in pos_v2.items()} + + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + qua.align(*gates) + + # with qua.strict_timing_(): + vs.ramp_to_voltages( + neg_v2, duration=16, ramp_duration=rd1, ensure_align=False, + ) + vs.ramp_to_voltages( + neg_v1, duration=hold, ramp_duration=rd2, ensure_align=False, + ) + vs.ramp_to_voltages( + pos_v1, duration=hold, ramp_duration=rd_mid, ensure_align=False, + ) + vs.ramp_to_voltages( + pos_v2, duration=16, ramp_duration=rd2, ensure_align=False, + ) + vs.ramp_to_voltages( + zero, + duration=DEFAULTS.state_macro.point_duration, + ramp_duration=rd1, + ensure_align=False, + ) + + def update( + self, + *, + ramp_duration_1: int | None = None, + ramp_duration_2: int | None = None, + ramp_duration_mid: int | None = None, + hold_duration: int | None = None, + point_1: str | None = None, + point_2: str | None = None, + ) -> None: + if ramp_duration_1 is not None: + self.ramp_duration_1 = ramp_duration_1 + if ramp_duration_2 is not None: + self.ramp_duration_2 = ramp_duration_2 + if ramp_duration_mid is not None: + self.ramp_duration_mid = ramp_duration_mid + if hold_duration is not None: + self.hold_duration = hold_duration + if point_1 is not None: + self.point_1 = point_1 + if point_2 is not None: + self.point_2 = point_2 + + +@quam_dataclass +class BalancedMeasurePSBPairMacro(QuamMacro): + """Voltage-balanced PSB readout for a :class:`QuantumDotPair` (work in progress). + + The step order in :meth:`apply` is ramp to the measure point, readout, then + balance ramps. Optional ``strict_timing_`` on the pre-readout ramp is + commented in code (off by default) for assessment. + + Returns the QUA boolean from the sensor (same idea as + :class:`MeasurePSBPairMacro`). + """ + + point: str = VoltagePointName.MEASURE.value + ramp_duration: int = DEFAULTS.state_macro.ramp_duration + buffer_duration: int = DEFAULTS.state_macro.buffer_duration + + @property + def inferred_duration(self) -> float | None: + owner = _owner_component(self) + if not getattr(owner, "sensor_dots", None): + return None + sensor = owner.sensor_dots[0] + sensor_macro = sensor.macros.get(TwoQubitMacroName.MEASURE) + if sensor_macro is None: + return None + if hasattr(sensor_macro, "readout_pulse_length_ns_for_pair"): + readout_len = sensor_macro.readout_pulse_length_ns_for_pair(owner.id) + else: + readout_len = sensor_macro.readout_pulse_length_ns + if readout_len is None: + return None + h = self.buffer_duration + readout_len + r = self.ramp_duration + # Match apply(): (r+buf) + readout + (2*r+h) + (r+return_hold) ns + return (16 + 2 * r + 2 * h + DEFAULTS.state_macro.point_duration) * 1e-9 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + ramp_duration: int | None = None, + buffer_duration: int | None = None, + point: str | None = None, + return_iq: bool = False, + **kwargs, + ): + owner = _owner_component(self) + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + buf = self.buffer_duration if buffer_duration is None else buffer_duration + target_point = self.point if point is None else point + + if not owner.sensor_dots: + raise ValueError(f"QuantumDotPair '{owner.id}' has no sensor dots for readout.") + sensor_dot = owner.sensor_dots[0] + sensor_macro = sensor_dot.macros[TwoQubitMacroName.MEASURE] + if hasattr(sensor_macro, "readout_pulse_length_ns_for_pair"): + readout_len = sensor_macro.readout_pulse_length_ns_for_pair(owner.id) + else: + readout_len = sensor_macro.readout_pulse_length_ns + if readout_len is None: + raise ValueError( + "Sensor readout pulse length unknown; balanced measurement " + "requires a fixed readout duration." + ) + + hold = buf + readout_len + # `ramp` / `buf` may be QUA variables (e.g. when scanned via input streams), + # so avoid Python int() casting. Right-shift by 2 == integer division by 4 + # (clock cycles), and works for both Python ints and QUA int expressions. + if isinstance(ramp, (int, float)) and isinstance(buf, (int, float)): + wait_cycles = int((ramp + buf) // 4) + else: + wait_cycles = (ramp + buf) >> 2 + positive = _point_voltages(owner, target_point) + negative = {k: -v for k, v in positive.items()} + zero = {k: 0.0 for k, _ in positive.items()} + + vs = owner.voltage_sequence + + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + qua.align(sensor_dot.readout_resonator.name, *gates) + + # with qua.strict_timing_(): + vs.ramp_to_voltages( + positive, + duration=hold, + ramp_duration=ramp, + ensure_align=False, + ) + qua.wait(wait_cycles, sensor_dot.readout_resonator.name) + + result = sensor_macro.apply( + quantum_dot_pair_id=owner.id, + return_iq=return_iq, + ) + + vs.ramp_to_voltages( + negative, + duration=hold, + ramp_duration=16, + ensure_align=False, + ) + vs.ramp_to_voltages( + zero, + duration=16, + ramp_duration=ramp, + ensure_align=False, + ) + + return result + + def update( + self, + *, + ramp_duration: int | None = None, + buffer_duration: int | None = None, + point: str | None = None, + ) -> None: + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if buffer_duration is not None: + self.buffer_duration = buffer_duration + if point is not None: + self.point = point + + +@quam_dataclass +class BalancedSensorDotMeasureMacro(QuamMacro): + """PSB readout via the sensor readout resonator (voltage-balanced catalog). + + Copy of :class:`SensorDotMeasureMacro` for use with + :class:`~quam_builder.architecture.quantum_dots.operations.macro_catalog.VoltageBalancedMacroCatalog` + (priority 200), so readout can diverge from architecture defaults without + editing the default catalog. Behavior matches the original until + specialized. + + When called with a ``quantum_dot_pair_id``, applies the stored projector and + threshold. With ``gate_channel_names`` and ``voltage_sequence``, aligns LF + gates to the resonator and tracks readout time on the sequencer. + """ + + pulse_name: str = "readout" + + def _resolve_pulse_name_for_pair(self, pair_name: str | None = None) -> str | None: + owner = _owner_component(self) + resonator = owner.readout_resonator + if resonator is None: + return None + ops = getattr(resonator, "operations", None) + if pair_name is not None: + pair_pulse_name = f"{self.pulse_name}_{pair_name}" + if ops is not None and pair_pulse_name in ops: + return pair_pulse_name + return self.pulse_name + + def readout_pulse_length_ns_for_pair(self, pair_name: str | None = None) -> int | None: + owner = _owner_component(self) + resonator = owner.readout_resonator + if resonator is None: + return None + pulse_name = self._resolve_pulse_name_for_pair(pair_name) + if pulse_name is None: + return None + pulse = resonator.operations.get(pulse_name) + if pulse is None: + return None + return _pulse_length_samples_to_ns(getattr(pulse, "length", None)) + + @property + def readout_pulse_length_ns(self) -> int | None: + return self.readout_pulse_length_ns_for_pair(None) + + def inferred_duration_for_pair(self, qd_pair_name: str | None = None) -> float | None: + length = self.readout_pulse_length_ns_for_pair(qd_pair_name) + return length * 1e-9 if length is not None else None + + @property + def inferred_duration(self) -> float | None: + return self.inferred_duration_for_pair(None) + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + *args, + quantum_dot_pair_id: str | None = None, + return_iq: bool = False, + **kwargs, + ): + from qm.qua import align as qua_align # noqa: I001 + from qm.qua import declare, fixed + + owner = _owner_component(self) + resonator = owner.readout_resonator + readout_pulse_name = self._resolve_pulse_name_for_pair(quantum_dot_pair_id) + if readout_pulse_name is None: + raise ValueError("Sensor dot has no readout resonator configured.") + + i_qua = declare(fixed) + q_qua = declare(fixed) + resonator.measure(readout_pulse_name, qua_vars=(i_qua, q_qua)) + + if quantum_dot_pair_id is None: + return (i_qua, q_qua) + + threshold, _projector = owner._readout_params(quantum_dot_pair_id) + state = i_qua > threshold + if return_iq: + return (i_qua, q_qua, state) + return state + + +@quam_dataclass +class BalancedInitializeMacroWithConditionalDrive(BalancedInitializeMacro): + + point: str = VoltagePointName.MEASURE.value + ramp_duration: int = DEFAULTS.state_macro.ramp_duration + buffer_duration: int = DEFAULTS.state_macro.buffer_duration + hold_duration: int = DEFAULTS.state_macro.hold_duration + + def apply( + self, + ramp_duration: int = None, + buffer_duration: int = None, + hold_duration: int = None, + point: str | dict = None, + pulse_name: str = "gaussian_x180", + return_iq: bool = False, + xy_channel: Any = None, + amplitude_scale: float = 1.0, + frequency_detuning_Hz: int = 0, + ): + owner = _owner_component(self) + + xy_exists = xy_channel is not None + + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + buf = self.buffer_duration if buffer_duration is None else buffer_duration + hold_dur = self.hold_duration if hold_duration is None else hold_duration + target_point = self.point if point is None else point + + if not owner.sensor_dots: + raise ValueError(f"QuantumDotPair '{owner.name}' has no sensor dots for readout") + + sensor_dot = owner.sensor_dots[0] + sensor_macro = sensor_dot.macros[TwoQubitMacroName.MEASURE] + if hasattr(sensor_macro, "readout_pulse_length_ns_for_pair"): + readout_len = sensor_macro.readout_pulse_length_ns_for_pair(owner.id) + else: + readout_len = sensor_macro.readout_pulse_length_ns + if readout_len is None: + raise ValueError( + "Sensor readout pulse length unknown; balanced measurement " + "requires a fixed readout duration." + ) + + hold = buf + readout_len + # `ramp` / `buf` may be QUA variables (e.g. when scanned via input streams), + # so avoid Python int() casting. Right-shift by 2 == integer division by 4 + # (clock cycles), and works for both Python ints and QUA int expressions. + if isinstance(ramp, (int, float)) and isinstance(buf, (int, float)): + wait_cycles = int((ramp + buf) // 4) + else: + wait_cycles = (ramp + buf) >> 2 + positive = _point_voltages(owner, target_point) + negative = {k: -v for k, v in positive.items()} + zero = {k: 0.0 for k, _ in positive.items()} + + vs = owner.voltage_sequence + + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + elements_to_align = [sensor_dot.readout_resonator.name, *gates] + if xy_exists: + elements_to_align.extend([xy_channel.id]) + qua.align(*elements_to_align) + + if xy_exists: + op_frequency = xy_channel.intermediate_frequency + new_freq = op_frequency + frequency_detuning_Hz + + # Update the frequency outside of the strict timing + xy_channel.update_frequency(new_freq) + op_length = xy_channel.operations[pulse_name].length + else: + op_length = 0 + + with qua.strict_timing_(): + vs.ramp_to_voltages( + negative, + duration=hold + op_length + hold_dur, + ramp_duration=ramp, + ensure_align=False, + ) + qua.wait(wait_cycles + readout_len//4 + hold_dur//4, sensor_dot.readout_resonator.name) + + if xy_exists: + qua.wait(wait_cycles + readout_len//4, xy_channel.id) + qua.wait(op_length//4 , xy_channel.id) + qua.wait(hold_dur//4, xy_channel.id) + qua.wait(op_length//4, sensor_dot.readout_resonator.name) + + vs.ramp_to_voltages( + positive, + duration=hold + op_length + hold_dur, + ramp_duration=2 * ramp, + ensure_align=False, + ) + + qua.wait(wait_cycles + ramp//4, sensor_dot.readout_resonator.name) + + result = sensor_macro.apply( + quantum_dot_pair_id=owner.id, + return_iq=return_iq, + ) + if xy_exists: + qua.wait(wait_cycles + ramp//4 + readout_len//4 , xy_channel.id) + with qua.if_(result < 1): + xy_channel.play(pulse_name, amplitude_scale = amplitude_scale) + with qua.else_(): + qua.wait(op_length//4 , xy_channel.id) + qua.wait(hold_dur//4, xy_channel.id) + + qua.wait(hold_dur//4, sensor_dot.readout_resonator.name) + + vs.ramp_to_voltages( + zero, + duration=ramp, + ramp_duration=ramp, + ensure_align=False, + ) + + return result diff --git a/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/two_qubit_macros.py b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/two_qubit_macros.py new file mode 100644 index 00000000..46313c01 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/operations/voltage_balanced_macros/two_qubit_macros.py @@ -0,0 +1,380 @@ +"""Voltage-balanced two-qubit macros for LD qubit pairs.""" + +# pylint: disable=too-many-ancestors +from __future__ import annotations + +from typing import Any, Dict, Optional + +from qm import qua +from quam.components.macro import QubitPairMacro +from quam.core import quam_dataclass + +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.architecture.quantum_dots.operations.names import ( + DrivePulseName, + SingleQubitMacroName, + VoltagePointName, +) + +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros import ( + _point_voltages, + _zero_voltages, +) + +__all__ = ["BalancedCz2QMacro", "BalancedDCz2QMacro", "BalancedCROTMacro"] + + +@quam_dataclass +class BalancedCz2QMacro(QubitPairMacro): + """Voltage-balanced exchange pulse. + + Shape (per channel): + + 0 ──step──▶ -V_ex ──hold(T)── -V_ex + ──step──▶ +V_ex ──hold(T)── +V_ex ──step──▶ 0 + + ``V_ex`` is the voltage stored at the pair's ``exchange`` point (the + positive-polarity barrier configuration). The negative-polarity leg + uses an element-wise negation of that point. Equal hold times at the + two polarities give a zero net integrated voltage on every channel. + """ + + point: str = VoltagePointName.EXCHANGE.value + wait_duration: int = DEFAULTS.exchange.wait_duration + ramp_duration: int = DEFAULTS.exchange.ramp_duration + + phase_shift_control: float = 0.0 + phase_shift_target: float = 0.0 + + exchange_decay_model: Optional[Dict[str, Any]] = None + """Fitted T_2π(V) model from swap oscillations. + + Serialisable dict with a ``"type"`` key that identifies the function:: + + { + "type": "polynomial", + "coeffs": [c_n, ..., c_0], # highest-degree-first + "degree": int, + } + + ``None`` if not yet calibrated (run ``18a_swap_oscillations``). + """ + + @property + def inferred_duration(self) -> float | None: + return 2 * self.wait_duration * 1e-9 + 4 * self.ramp_duration * 1e-9 + + def t_2pi(self, amplitude: float) -> float: + """Evaluate the calibrated T_2π(V) model (ns). + + Requires :attr:`exchange_decay_model` to have been populated by + node ``18a_swap_oscillations``. + + Parameters + ---------- + amplitude : float + Barrier gate voltage (V). + + Returns + ------- + float + Full 2π swap oscillation period in nanoseconds. + """ + m = self.exchange_decay_model + if m is None: + raise ValueError( + "T_2π model not calibrated. Run 18a_swap_oscillations first." + ) + model_type = m.get("type", "polynomial") + if model_type == "polynomial": + result = 0.0 + for c in m["coeffs"]: + result = result * amplitude + c + return result + raise ValueError(f"Unknown exchange_decay_model type: {model_type!r}") + + def t_cz(self, amplitude: float) -> float: + """Return T_2π(V) / 2 — the CZ wait_duration for a π conditional phase. + + Parameters + ---------- + amplitude : float + Barrier gate voltage (V). + + Returns + ------- + float + Half-period in nanoseconds (round to 4 ns for QUA clock). + """ + return self.t_2pi(amplitude) / 2.0 + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def update( + self, + *, + wait_duration: int | None = None, + point: str | None = None, + ramp_duration: int | None = None, + phase_shift_control: float | None = None, + phase_shift_target: float | None = None, + exchange_decay_model: dict | None = None, + ) -> None: + if wait_duration is not None: + self.wait_duration = wait_duration + if point is not None: + self.point = point + if ramp_duration is not None: + self.ramp_duration = ramp_duration + if phase_shift_control is not None: + self.phase_shift_control = phase_shift_control + if phase_shift_target is not None: + self.phase_shift_target = phase_shift_target + if exchange_decay_model is not None: + self.exchange_decay_model = exchange_decay_model + + def apply( + self, + wait_duration: int | None = None, + ramp_duration: int | None = None, + point: str | None = None, + phase_shift_control: float | None = None, + phase_shift_target: float | None = None, + **kwargs, + ): + wait = self.wait_duration if wait_duration is None else wait_duration + ramp = self.ramp_duration if ramp_duration is None else ramp_duration + phase_shift_control = ( + self.phase_shift_control + if phase_shift_control is None + else phase_shift_control + ) + phase_shift_target = ( + self.phase_shift_target + if phase_shift_target is None + else phase_shift_target + ) + + owner = self.qubit_pair + positive = _point_voltages(owner, self.point) if point is None else point + + negative = {k: -v for k, v in positive.items()} + zero = {k: 0.0 for k, _ in positive.items()} + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + qua.align(*gates) + # with qua.strict_timing_(): + vs.ramp_to_voltages( + negative, duration=wait, ramp_duration=ramp, ensure_align=False + ) + vs.ramp_to_voltages( + positive, duration=wait, ramp_duration=2 * ramp, ensure_align=False + ) + vs.ramp_to_voltages( + zero, + duration=16, + ramp_duration=ramp, + ensure_align=False, + ) + + qua.frame_rotation_2pi( + phase_shift_control, self.qubit_pair.qubit_control.xy.name + ) + qua.frame_rotation_2pi(phase_shift_target, self.qubit_pair.qubit_target.xy.name) + + def balance(self) -> None: + """No-op: each ``apply`` already plays both polarities (self-balancing). + + Provided so calibration scripts can unconditionally call + ``qubit_pair.cz.balance()`` and work whether the wired macro is this + balanced variant or the unbalanced :class:`CZMacro` that requires it. + """ + + +@quam_dataclass +class BalancedDCz2QMacro(BalancedCz2QMacro): + """Dynamically decoupled CZ: CZ – X180(control, target) – CZ. + + The two CZ halves sandwich simultaneous X180 pulses on both qubits, + refocusing low-frequency noise while accumulating twice the exchange phase. + Phase shifts are applied only after the second CZ half. + """ + + def apply( + self, + wait_duration: int | None = None, + ramp_duration: int | None = None, + point: str | None = None, + phase_shift_control: float | None = None, + phase_shift_target: float | None = None, + **kwargs, + ): + # First CZ half — no phase shifts yet + super().apply( + wait_duration=wait_duration, + ramp_duration=ramp_duration, + point=point, + phase_shift_control=0.0, + phase_shift_target=0.0, + **kwargs, + ) + + # Refocusing X180 on both qubits simultaneously + control = self.qubit_pair.qubit_control + target = self.qubit_pair.qubit_target + owner = self.qubit_pair + vs = owner.voltage_sequence + + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + qua.align(control.xy.name, *gates) + + control.x180() + + qua.align(control.xy.name, target.xy.name) + + target.x180() + + qua.align(target.xy.name, *gates) + + # Second CZ half — apply accumulated phase shifts here + super().apply( + wait_duration=wait_duration, + ramp_duration=ramp_duration, + point=point, + phase_shift_control=phase_shift_control, + phase_shift_target=phase_shift_target, + **kwargs, + ) + + +def _x180_pulse_name(drive_qubit, override: str | None = None) -> str: + """Resolve the ESR pulse operation, defaulting to the qubit's calibrated x180. + + Mirrors the single-qubit macros: the pi pulse is ``{pulse_family}_x180``, + resolved from the qubit's own ``x180`` macro so it tracks the active pulse + family (see :class:`X180Macro`). Falls back to ``gaussian_x180`` if the macro + is unavailable. An explicit *override* always wins. + """ + if override is not None: + return override + x180_macro = getattr(drive_qubit, "macros", {}).get(SingleQubitMacroName.X_180.value) + pulse_name = getattr(x180_macro, "pulse_name", None) + if pulse_name is None: + pulse_name = f"{DrivePulseName.GAUSSIAN.value}_x180" + return pulse_name + + +@quam_dataclass +class BalancedCROTMacro(QubitPairMacro): + """Step a qubit pair to a point, play one ESR pulse, then return to initialize. + + The ESR pulse is emitted on the driven qubit's XY channel. During the pulse + the macro schedules an empty voltage-sequence segment on the pair so + sticky-voltage tracking stays aligned with the microwave pulse duration. + + The pulse defaults to the driven qubit's calibrated ``x180`` operation (the + same pi pulse used by the single-qubit macros); when ``duration`` and + ``amplitude`` are not given, the x180 pulse's native length and amplitude + are used. + """ + + pulse_name: str | None = None + point: str | None = VoltagePointName.EXCHANGE.value + ramp_duration: int | None = DEFAULTS.exchange.ramp_duration + return_point: str = VoltagePointName.INITIALIZE.value + esr_frequency: float | None = None + + @property + def drive_qubit(self): + """Return the qubit whose XY channel should emit the ESR pulse.""" + drive_qubit = self.qubit_pair.qubit_target + return drive_qubit + + @property + def inferred_duration(self) -> float | None: + pass + + def __call__(self, *args, **kwargs): + return self.apply(*args, **kwargs) + + def apply( + self, + *, + ramp_duration: int | None = None, + point: str | None = None, + esr_frequency: float | None = None, + amplitude: float | None = None, + duration: int | None = None, + pulse_name: str | None = None, + drive_target: bool = True, + **kwargs, + ): + """Execute the CROT sequence with runtime overrides. + + By default the ESR pulse is emitted on the target qubit's XY channel. + Set ``drive_target=False`` to instead drive the control qubit (used by + the symmetric branch of CROT spectroscopy, where the roles of target + and control are swapped). + """ + target_point = self.point if point is None else point + + if target_point is None: + raise ValueError("CROTMacro requires a voltage_point.") + + drive_qubit = self.drive_qubit if drive_target else self.qubit_pair.qubit_control + ramp_duration = self.ramp_duration if ramp_duration is None else ramp_duration + pulse_name = _x180_pulse_name( + drive_qubit, self.pulse_name if pulse_name is None else pulse_name + ) + + esr_frequency = self.esr_frequency if esr_frequency is None else esr_frequency + + if esr_frequency is not None: + larmor_frequency = drive_qubit.larmor_frequency + drive_qubit.xy.update_frequency(esr_frequency) + + owner = self.qubit_pair + + positive = _point_voltages(owner, target_point) + negative = {k: -v for k, v in positive.items()} + zero = {k: 0.0 for k, _ in positive.items()} + vs = owner.voltage_sequence + gates = [ch_name for ch_name in vs.gate_set.channels.keys()] + + pulse_duration = drive_qubit.xy.operations[pulse_name].length if duration is None else duration + qua.align(drive_qubit.xy.name, *gates) + with qua.strict_timing_(): + qua.wait(int((3*ramp_duration + pulse_duration)//4), drive_qubit.xy.name) + + drive_qubit.xy.play( + pulse_name, + amplitude_scale=amplitude, + duration=int(pulse_duration // 4), + ) + + vs.ramp_to_voltages( + negative, duration=pulse_duration, ramp_duration=ramp_duration, ensure_align=False + ) + vs.ramp_to_voltages( + positive, duration=pulse_duration, ramp_duration=2 * ramp_duration, ensure_align=False + ) + vs.ramp_to_voltages( + zero, + duration=16, + ramp_duration=ramp_duration, + ensure_align=False, + ) + + if esr_frequency is not None: + drive_qubit.xy.update_frequency(larmor_frequency) + + def balance(self) -> None: + """No-op: each ``apply`` already plays both polarities (self-balancing). + + Provided so calibration scripts can unconditionally call + ``qubit_pair.crot.balance()`` and work whether the wired macro is this + balanced variant or the unbalanced :class:`CROTMacro` that requires it. + """ diff --git a/quam_builder/architecture/quantum_dots/qpu/__init__.py b/quam_builder/architecture/quantum_dots/qpu/__init__.py index 0d62927a..0ae2250f 100644 --- a/quam_builder/architecture/quantum_dots/qpu/__init__.py +++ b/quam_builder/architecture/quantum_dots/qpu/__init__.py @@ -2,11 +2,8 @@ from .base_quam_qd import * from . import loss_divincenzo_quam from .loss_divincenzo_quam import * -from typing import Union __all__ = [ *base_quam_qd.__all__, *loss_divincenzo_quam.__all__, ] - -AnyQuamQD = Union[BaseQuamQD, LossDiVincenzoQuam] diff --git a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py index 974525de..ec9495e2 100644 --- a/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py +++ b/quam_builder/architecture/quantum_dots/qpu/base_quam_qd.py @@ -6,22 +6,24 @@ ClassVar, Optional, Literal, + Tuple, + Callable, Sequence, - Mapping, - Any, ) from dataclasses import field import numpy as np +from collections import defaultdict import importlib +import json -from qm import QuantumMachinesManager +from qm import QuantumMachinesManager, QuantumMachine from qm.octave import QmOctaveConfig from qm.qua.type_hints import QuaVariable, StreamType from qm.qua import declare, fixed, declare_stream from quam.serialisation import JSONSerialiser from quam.components import Octave, FrequencyConverter -from quam.components import Channel, pulses +from quam.components import Channel from quam.components.ports import FEMPortsContainer, OPXPlusPortsContainer from quam.core import quam_dataclass, QuamRoot, QuamBase @@ -41,7 +43,6 @@ ) from quam_builder.architecture.quantum_dots.components.global_gate import GlobalGate -from quam_builder.architecture.quantum_dots.components.dac_spec import QdacSpec from quam_builder.tools.voltage_sequence import VoltageSequence from quam_builder.architecture.quantum_dots.qubit import AnySpinQubit @@ -64,8 +65,6 @@ class BaseQuamQD(QuamRoot): global_gates (Dict[str, GlobalGate]): Global gate components associated with back gate, reservoirs, or splitter gates. wiring (dict): The wiring configuration. network (dict): The network configuration. - dac_config (dict): Per-DAC driver settings (same structure as passed to :meth:`set_dac_config`), - serialised alongside ``wiring`` and ``network`` in ``wiring.json``. ports (Union[FEMPortsContainer, OPXPlusPortsContainer]): The ports container. _data_handler (ClassVar[DataHandler]): The data handler. qmm (ClassVar[Optional[QuantumMachinesManager]]): The Quantum Machines Manager. @@ -108,11 +107,39 @@ class BaseQuamQD(QuamRoot): mixers: Dict[str, FrequencyConverter] = field(default_factory=dict) wiring: dict = field(default_factory=dict) network: dict = field(default_factory=dict) - dac_config: Dict[str, Any] = field(default_factory=dict) + ports: Union[FEMPortsContainer, OPXPlusPortsContainer] = None + pulse_family: str = "gaussian" + qmm: ClassVar[Optional[QuantumMachinesManager]] = None + track_integrated_voltage: bool = False + limit_play_commands: bool = False + + def set_pulse_family(self, family: str) -> None: + """Switch all XY-drive macros to a new pulse envelope family. + + Updates ``self.pulse_family`` and propagates to every qubit's + XY-drive macros that carry a ``pulse_family`` attribute. + + Args: + family: One of ``"gaussian"``, ``"square"``, ``"kaiser"``, + ``"hermite"``. + """ + from quam_builder.architecture.quantum_dots.operations.names import DrivePulseName + + valid = {e.value for e in DrivePulseName} + if family not in valid: + raise ValueError( + f"Unknown pulse family '{family}'. Choose from: {sorted(valid)}" + ) + self.pulse_family = family + for qubit in getattr(self, "qubits", {}).values(): + for macro in getattr(qubit, "macros", {}).values(): + if hasattr(macro, "pulse_family"): + macro.pulse_family = family + @classmethod def get_serialiser(cls) -> JSONSerialiser: """Get the serialiser for the QuamRoot class, which is the JSONSerialiser. @@ -120,21 +147,21 @@ def get_serialiser(cls) -> JSONSerialiser: This method can be overridden by subclasses to provide a custom serialiser. """ return JSONSerialiser( - content_mapping={ - "wiring": "wiring.json", - "network": "wiring.json", - "dac_config": "wiring.json", - } + content_mapping={"wiring": "wiring.json", "network": "wiring.json"} ) def get_voltage_sequence(self, gate_set_id: str) -> VoltageSequence: if gate_set_id not in self.voltage_sequences: gate_set = self.virtual_gate_sets[gate_set_id] - seq = gate_set.new_sequence() + seq = gate_set.new_sequence( + track_integrated_voltage=self.track_integrated_voltage, enforce_qua_calcs=self.track_integrated_voltage, limit_play_commands=self.limit_play_commands + ) for qd_id, qd in self.quantum_dots.items(): try: - qd_gate_set_name = self._get_virtual_gate_set(qd.physical_channel).id + qd_gate_set_name = self._get_virtual_gate_set( + qd.physical_channel + ).id if qd_gate_set_name == gate_set.id: seq.state_trackers[qd.id].current_level = qd.current_voltage except (AttributeError, ValueError, KeyError): @@ -143,7 +170,15 @@ def get_voltage_sequence(self, gate_set_id: str) -> VoltageSequence: self.voltage_sequences[gate_set_id] = seq return self.voltage_sequences[gate_set_id] - def get_component(self, name: str) -> Union[AnySpinQubit, QuantumDot, SensorDot, BarrierGate]: + def declare_voltage_variables(self): + """Call inside QUA programme to initialize the voltage variables.""" + for gate_set_id in self.virtual_gate_sets: + seq = self.get_voltage_sequence(gate_set_id) + seq.declare_qua_variables() + + def get_component( + self, name: str + ) -> Union[AnySpinQubit, QuantumDot, SensorDot, BarrierGate]: """ Retrieve a component object by name from qubits, qubit_pairs, quantum_dots, quantum_dot_pairs, sensor_dots, or barrier_gates @@ -172,29 +207,24 @@ def connect_to_external_source( Args: reset_voltages (bool): Whether to reset the voltages of each of the channels to the last-applied voltage, saved in the Quam state. - DAC entries must be set on :attr:`dac_config` (e.g. via :meth:`set_dac_config`) and are - stored in ``wiring.json`` when saving. Example entry: - + The dac configuration must be saved in the same directory as the quam state_old.json file, as a dac_api.json. The format must be as follows: { "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", "driver_class": "QDac2", "connection": {"visalib": "@py", "address": "TCPIP::172.16.33.101::5025::SOCKET"}, "channel_method": "channel", - "accessor": "dc_constant_V", - "is_qdac": true + "accessor": "dc_constant_V" } """ - if not self.dac_config: + if self._dac_config is None: raise ValueError( - "No DAC configurations found. Set them with set_dac_config(...) or load from wiring.json." + "No DAC configurations found. Please save a directory of DACs with the Quam state." ) dac_instances = self.dacs - for dac_name, config in self.dac_config.items(): + for dac_name, config in self._dac_config.items(): module = importlib.import_module(config["driver_module"]) dac_class = getattr(module, config["driver_class"]) - if dac_name in dac_instances: - dac_instances[dac_name]["driver"].close() dac_instances[dac_name] = { "driver": dac_class(dac_name, **config["connection"]), "channel_method": config["channel_method"], @@ -205,7 +235,9 @@ def connect_to_external_source( for ch in self.physical_channels.values(): dac_name = getattr(ch.dac_spec, "dac_name", "main") if dac_name not in dac_instances: - print(f"WARNING: {ch.id} references {dac_name}, but no config found. Skipping") + print( + f"WARNING: {ch.id} references {dac_name}, but no config found. Skipping" + ) continue dac_info = dac_instances[dac_name] dac_channel = getattr(dac_info["driver"], dac_info["channel_method"])( @@ -249,14 +281,22 @@ def _get_virtual_name(self, channel: Channel) -> str: break # Found it, exit loop if physical_name is None: - raise ValueError(f"Channel {channel.id} not associated with VirtualGateSet {vgs_name}") + raise ValueError( + f"Channel {channel.id} not associated with VirtualGateSet {vgs_name}" + ) - virtual_name = vgs.layers[0].source_gates[vgs.layers[0].target_gates.index(physical_name)] + virtual_name = vgs.layers[0].source_gates[ + vgs.layers[0].target_gates.index(physical_name) + ] return virtual_name def reset_voltage_sequence(self, gate_set_id) -> None: - self.voltage_sequences[gate_set_id] = self.virtual_gate_sets[gate_set_id].new_sequence( - track_integrated_voltage=True + self.voltage_sequences[gate_set_id] = self.virtual_gate_sets[ + gate_set_id + ].new_sequence( + track_integrated_voltage=self.track_integrated_voltage, + enforce_qua_calcs=self.track_integrated_voltage, + limit_play_commands=self.limit_play_commands ) return @@ -267,12 +307,7 @@ def register_global_gates( if isinstance(global_channels, VoltageGate): global_channels = [global_channels] for ch in global_channels: - try: - virtual_name = self._get_virtual_name(ch) - except ValueError: - # Channels intentionally excluded from VirtualGateSet (e.g. QDAC-only) - # are still valid global gates and are keyed by their physical id. - virtual_name = ch.id + virtual_name = self._get_virtual_name(ch) global_gate = GlobalGate( id=virtual_name, physical_channel=ch.get_reference(), @@ -334,8 +369,8 @@ def register_sensor_dots( id=virtual_name, physical_channel=ch.get_reference(), ) - self.sensor_dots[virtual_name] = sensor_dot sensor_dot.physical_channel.readout = res + self.sensor_dots[virtual_name] = sensor_dot if sensor_drain_mappings is not None: for ch, drain in sensor_drain_mappings.items(): @@ -361,99 +396,26 @@ def register_barrier_gates(self, barrier_channels: List[Channel]) -> None: ) self.barrier_gates[virtual_name] = barrier_gate - def wire_voltage_gate_qdac( - self, - voltage_gate: Union[VoltageGate, Channel], - *, - qdac_output_port: int, - dac_name: str = "qdac", - with_trigger_channel: bool = False, - digital_output_key: str = "qdac_trig", - qdac_trigger_in: Optional[int] = None, - ) -> None: - """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. - - Setting ``digital_outputs[...].parent = None`` before the line is registered under - the Quam tree causes QUAM to resolve references on an orphan - ``DigitalOutputChannel`` and emit ``get_root()`` warnings. This method registers - ``QdacSpec`` (and the optional OPX trigger ``Channel``) **first**, then moves the - existing digital line in a minimal second step. - - Args: - voltage_gate: Physical gate (e.g. from ``virtual_gate_sets[id].channels``). - qdac_output_port: QDAC channel index. - dac_name: Key under ``machine.dacs`` for the driver. - with_trigger_channel: If True, move ``digital_output_key`` into a wrapper - ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. - digital_output_key: Name of the digital output on the gate (default wiring - uses ``qdac_trig``). - qdac_trigger_in: Optional QDAC external trigger port. - """ - if with_trigger_channel: - if digital_output_key not in voltage_gate.digital_outputs: - raise KeyError( - f"{digital_output_key!r} missing from digital_outputs of " - f"{getattr(voltage_gate, 'name', voltage_gate)!r}" - ) - dig = voltage_gate.digital_outputs[digital_output_key] - trigger_channel = Channel( - id=f"{getattr(voltage_gate, 'id', 'voltage_gate')}_qdac_trigger", - digital_outputs={"trigger": dig.get_reference()}, - operations={"trigger": pulses.Pulse(length=100, digital_marker="ON")}, - ) - - voltage_gate.dac_spec = QdacSpec( - dac_name=dac_name, - qdac_output_port=qdac_output_port, - opx_trigger_out=trigger_channel, - qdac_trigger_in=qdac_trigger_in, - ) - - else: - voltage_gate.dac_spec = QdacSpec( - dac_name=dac_name, - qdac_output_port=qdac_output_port, - opx_trigger_out=None, - qdac_trigger_in=qdac_trigger_in, - ) - - def apply_qdac_channel_mapping( + def _ensure_default_detuning_axis_for_quantum_dot_pair( self, - gate_set_id: str, - qdac_mapping: Mapping[str, Mapping[str, Any]], - *, - digital_output_key: str = "qdac_trig", - dac_name: str = "qdac", - qdac_trigger_in: Optional[int] = None, + pair: QuantumDotPair, + gate_set: VirtualGateSet, ) -> None: - """Apply :meth:`wire_voltage_gate_qdac` to every ``VoltageGate`` listed in ``qdac_mapping``. - - Keys of ``qdac_mapping`` must match :attr:`VoltageGate.name` (same as the prior - ``quam_config`` loop). Values are dicts with at least ``"ch"`` (QDAC port index, - or ``None`` to skip) and optional ``"trigger"`` (bool, default ``False``). + """Register the canonical inter-dot detuning axis on the pair's virtual gate set. - Channel entries are resolved via ``virtual_gate_sets[gate_set_id].channels[key]`` - so ``#/`` references are followed while the gate set is already under this root. + Calls :meth:`QuantumDotPair.define_detuning_axis` with ``matrix=[[1.0, -1.0]]`` + and ``set_dc_virtual_axis=False`` (same defaults as test/example scripts). + Skips if ``pair.detuning_axis_name`` is already a channel on ``gate_set``. + Refreshes the cached :class:`VoltageSequence` for that gate set afterward. """ - vgs = self.virtual_gate_sets[gate_set_id] - for channel_name in list(vgs.channels.keys()): - gate = vgs.channels[channel_name] - if not isinstance(gate, VoltageGate): - continue - if gate.name not in qdac_mapping: - continue - entry = qdac_mapping[gate.name] - ch_nb = entry.get("ch") - if ch_nb is None: - continue - self.wire_voltage_gate_qdac( - gate, - qdac_output_port=ch_nb, - dac_name=dac_name, - with_trigger_channel=bool(entry.get("trigger", False)), - digital_output_key=digital_output_key, - qdac_trigger_in=qdac_trigger_in, - ) + if pair.detuning_axis_name in getattr(gate_set, "valid_channel_names", []): + return + pair.define_detuning_axis( + matrix=[[1.0, -1.0]], + detuning_axis_name=pair.detuning_axis_name, + set_dc_virtual_axis=False, + ) + self.reset_voltage_sequence(gate_set.id) def register_quantum_dot_pair( self, @@ -464,30 +426,38 @@ def register_quantum_dot_pair( dot_coupling: float = 0.0, ) -> None: """ - Creates QuantumDotPair objects given a list of Channels - Args: - quantum_dots (List[QuantumDot]): A list of two Channel objects which are already registered as QuantumDots. + Create and register a :class:`~quam_builder.architecture.quantum_dots.components.QuantumDotPair`. + After registration, applies the default detuning axis for the pair (see + :meth:`_ensure_default_detuning_axis_for_quantum_dot_pair`). """ if len(quantum_dot_ids) != 2: - raise ValueError(f"Must be 2 QuantumDot objects. Received {len(quantum_dot_ids)}") + raise ValueError( + f"Must be 2 QuantumDot objects. Received {len(quantum_dot_ids)}" + ) qd_names = quantum_dot_ids name_check = self.find_quantum_dot_pair(qd_names[0], qd_names[1]) if name_check is not None: - raise ValueError(f"Quantum Dot Pairing already exists with name {name_check}") + raise ValueError( + f"Quantum Dot Pairing already exists with name {name_check}" + ) for name in qd_names: if name not in self.quantum_dots: - raise ValueError(f"Quantum Dot {name} not registered. Please register first") + raise ValueError( + f"Quantum Dot {name} not registered. Please register first" + ) if id is None: id = f"{qd_names[0]}_{qd_names[1]}" sensor_names = sensor_dot_ids for name in sensor_names: if name not in self.sensor_dots: - raise ValueError(f"Sensor Dot {name} not registered. Please register first") + raise ValueError( + f"Sensor Dot {name} not registered. Please register first" + ) if barrier_gate_id is not None: barrier_name = barrier_gate_id @@ -496,6 +466,9 @@ def register_quantum_dot_pair( f"Barrier Gate {barrier_name} not registered. Please register first" ) + first_dot = self.quantum_dots[qd_names[0]] + gate_set = self._get_virtual_gate_set(first_dot.physical_channel) + quantum_dot_pair = QuantumDotPair( id=id, quantum_dots=[self.quantum_dots[m].get_reference() for m in qd_names], @@ -510,6 +483,11 @@ def register_quantum_dot_pair( self.quantum_dot_pairs[id] = quantum_dot_pair + self._ensure_default_detuning_axis_for_quantum_dot_pair( + quantum_dot_pair, + gate_set, + ) + def find_quantum_dot_pair(self, dot1_name: str, dot2_name: str) -> Optional[str]: target_dots = {dot1_name, dot2_name} for pair_name, dot_pair in self.quantum_dot_pairs.items(): @@ -555,10 +533,12 @@ def add_point( processed_voltages = {} for gate_name, voltage in voltages.items(): if gate_name in self.qubits: - gate_name = self.qubits[gate_name].id + gate_name = self.qubits[gate_name].quantum_dot.id processed_voltages[gate_name] = voltage - return self.virtual_gate_sets[gate_set_id].add_point(name, processed_voltages, duration) + return self.virtual_gate_sets[gate_set_id].add_point( + name, processed_voltages, duration + ) def create_virtual_gate_set( self, @@ -571,18 +551,6 @@ def create_virtual_gate_set( if gate_set_id is None: gate_set_id = f"virtual_gate_set_{len(self.virtual_gate_sets.keys())}" - # Always register channels under the machine, even when excluded from VGS. - for ch in virtual_channel_mapping.values(): - if ch not in list(self.physical_channels.values()): - self.physical_channels[ch.id] = ch - - # VirtualGateSet should contain only OPX-backed channels. - virtual_channel_mapping = { - virtual_name: ch - for virtual_name, ch in virtual_channel_mapping.items() - if getattr(ch, "opx_output", None) is not None - } - virtual_gate_names, physical_gate_names = [], [] channel_mapping = {} for virtual_name, ch in virtual_channel_mapping.items(): @@ -593,40 +561,41 @@ def create_virtual_gate_set( physical_name = ch.id physical_gate_names.append(physical_name) + # Add the channel to self.physical_channels if it does not already exist + if ch not in list(self.physical_channels.values()): + self.physical_channels[ch.id] = ch + # Add to the channel mapping, which (for the VirtualGateSet) maps the physical channel names to the physical channel objects channel_mapping[physical_name] = ch.get_reference() - # Register an empty gate set first, then fill ``channels`` while ``parent`` is set. - # Otherwise QUAM may resolve ``#/`` references during construction while this - # VirtualGateSet is still detached from the QuamRoot, triggering get_root() warnings. self.virtual_gate_sets[gate_set_id] = VirtualGateSet( id=gate_set_id, - channels={}, + channels=channel_mapping, allow_rectangular_matrices=allow_rectangular_matrices, adjust_for_attenuation=adjust_for_attenuation, ) - vgs = self.virtual_gate_sets[gate_set_id] - for physical_name, ref in channel_mapping.items(): - vgs.channels[physical_name] = ref if compensation_matrix is None: compensation_matrix = np.eye(len(virtual_gate_names)).tolist() - vgs.add_to_layer( + self.virtual_gate_sets[gate_set_id].add_to_layer( layer_id="compensation_layer", source_gates=virtual_gate_names, target_gates=physical_gate_names, matrix=compensation_matrix, ) - self.voltage_sequences[gate_set_id] = vgs.new_sequence( - track_integrated_voltage=True + self.voltage_sequences[gate_set_id] = self.virtual_gate_sets[ + gate_set_id + ].new_sequence( + track_integrated_voltage=self.track_integrated_voltage, + enforce_qua_calcs=self.track_integrated_voltage, + limit_play_commands=self.limit_play_commands ) def create_virtual_dc_set( self, gate_set_id: str, matrix: List[List[float]] = None, - include_all_dac_voltage_gates: bool = True, ) -> None: """ Method to create a VirtualDCSet, using the same structure as the VirtualGateSet. @@ -639,26 +608,15 @@ def create_virtual_dc_set( ) vgs = self.virtual_gate_sets[gate_set_id] - # VDS is for external DC control: include only DAC-backed VoltageGates. - source_channels = self.physical_channels - if not include_all_dac_voltage_gates: - source_channels = {name: ch for name, ch in vgs.channels.items()} - - channel_mapping = {} - for physical_name, ch in source_channels.items(): - if not isinstance(ch, VoltageGate): - continue - if getattr(ch, "dac_spec", None) is None: - continue - channel_mapping[physical_name] = ch.get_reference() + channel_mapping = { + name: ch.get_reference() for name, ch in vgs.channels.items() + } - # Keep VirtualDCSet compensation layer synced to the first VGS layer - # for the subset of physical targets present in the DC set. - layer = vgs.layers[0] - indices = [i for i, name in enumerate(layer.target_gates) if name in channel_mapping] - virtual_names = [layer.source_gates[i] for i in indices] - physical_names = [layer.target_gates[i] for i in indices] - gate_set_matrix = [[layer.matrix[i][j] for j in indices] for i in indices] + virtual_names = list(vgs.layers[0].source_gates) + physical_names = list(vgs.layers[0].target_gates) + gate_set_matrix = [ + row[:] for row in vgs.layers[0].matrix + ] # Copy to avoid any mutability issues, just incase allow_rectangular_matrices = vgs.allow_rectangular_matrices @@ -667,14 +625,13 @@ def create_virtual_dc_set( channels=channel_mapping, allow_rectangular_matrices=allow_rectangular_matrices, ) - if virtual_names and physical_names: - self.virtual_dc_sets[gate_set_id].add_to_layer( - layer_id="compensation_layer", - source_gates=virtual_names, - target_gates=physical_names, - matrix=gate_set_matrix, - ) - if matrix and self.virtual_dc_sets[gate_set_id].layers: + self.virtual_dc_sets[gate_set_id].add_to_layer( + layer_id="compensation_layer", + source_gates=virtual_names, + target_gates=physical_names, + matrix=gate_set_matrix, + ) + if matrix: self.virtual_dc_sets[gate_set_id].layers[0].matrix = matrix def update_cross_compensation_submatrix( @@ -723,7 +680,9 @@ def create_new_matrix(full_matrix): full_matrix_i = source_index[virtual_name] # Replace the matrix elemeent - full_matrix[full_matrix_i][full_matrix_j] = matrix[subspace_i][subspace_j] + full_matrix[full_matrix_i][full_matrix_j] = matrix[subspace_i][ + subspace_j + ] return full_matrix if target == "opx" or target == "both": @@ -752,7 +711,9 @@ def update_full_cross_compensation( virtual_gate_set_name is not None and virtual_gate_set_name not in self.virtual_gate_sets ): - raise ValueError(f"No such VirtualGateSet. Received {virtual_gate_set_name}") + raise ValueError( + f"No such VirtualGateSet. Received {virtual_gate_set_name}" + ) if virtual_gate_set_name is None: virtual_gate_set_name = next(iter(self.virtual_gate_sets.keys())) @@ -773,16 +734,20 @@ def step_to_voltage( If default_to_zero = True, then all the unnamed qubit values will be defaulted to zero. If default_to_zero = False, then unnamed qubits will be kept at their last tracked level. """ - if gate_set_name is not None and gate_set_name not in list(self.virtual_gate_sets.keys()): + if gate_set_name is not None and gate_set_name not in list( + self.virtual_gate_sets.keys() + ): raise ValueError("Gate Set not found in Quam") if gate_set_name is None: gate_set_name = list(self.virtual_gate_sets.keys())[0] - new_sequence = self.virtual_gate_sets[gate_set_name].new_sequence() + new_sequence = self.virtual_gate_sets[gate_set_name].new_sequence( + track_integrated_voltage=self.track_integrated_voltage, enforce_qua_calcs=self.track_integrated_voltage, limit_play_commands=self.limit_play_commands + ) actual_voltages = {} for name, value in voltages.items(): if name in self.qubits: - actual_voltages[self.qubits[name].id] = value + actual_voltages[self.qubits[name].quantum_dot.id] = value else: actual_voltages[name] = value @@ -806,12 +771,14 @@ def initialise(self, qubit_name: str) -> None: except: raise RuntimeError(f"Failed to initialise qubit {qubit_name}") - def connect(self, reset_voltages: bool = False, skip_dacs: bool=False) -> QuantumMachinesManager: + def connect(self, timeout: Optional[float] = None) -> QuantumMachinesManager: """Open a Quantum Machine Manager with the credentials ("host" and "cluster_name") as defined in the network file. Args: - reset_voltages (bool): Whether to reset the voltages of each of the channels to the last-applied voltage, saved in the Quam state. - skip_dacs (bool): Whether to connect to the registered DACs. + timeout: Timeout in seconds for gRPC API calls including program compilation. + Defaults to the QM SDK default (120 s). Increase for programs with many + QUA variables that take longer to compile. + Returns: QuantumMachinesManager: The opened Quantum Machine Manager. """ @@ -820,12 +787,11 @@ def connect(self, reset_voltages: bool = False, skip_dacs: bool=False) -> Quantu cluster_name=self.network["cluster_name"], octave=self.get_octave_config(), ) - if "port" in self.network: settings["port"] = self.network["port"] + if timeout is not None: + settings["timeout"] = timeout self.qmm = QuantumMachinesManager(**settings) - if self.dac_config and not skip_dacs: - self.connect_to_external_source(reset_voltages) return self.qmm def get_octave_config(self) -> QmOctaveConfig: @@ -875,10 +841,15 @@ def to_dict(self, follow_references=False, include_defaults=False): # Ensure that all the current_external_voltage values in VoltageGates are synchronised to the actual value, right before serialisation. This # ensures that the right value is saved. for ch in self.physical_channels.values(): - if hasattr(ch, "current_external_voltage") and ch.offset_parameter is not None: + if ( + hasattr(ch, "current_external_voltage") + and ch.offset_parameter is not None + ): ch.current_external_voltage = float(ch.offset_parameter()) - d = super().to_dict(follow_references=follow_references, include_defaults=include_defaults) + d = super().to_dict( + follow_references=follow_references, include_defaults=include_defaults + ) # We treat the voltage_sequences as a runtime helper, and not as a Quam component. That way, it does not get serialised. # All the relevant information about the sequence (points, macros) are stored on the QuantumDot/Qubit level and/or the VirtualGateSet level. @@ -886,22 +857,15 @@ def to_dict(self, follow_references=False, include_defaults=False): d.pop("dacs", None) return d - def set_dac_config(self, config: Optional[Dict[str, Any]]) -> None: - """Set DAC driver entries by name. Persisted in ``wiring.json`` (with ``wiring`` / ``network``). - - Pass a dict mapping logical DAC names (e.g. ``qdac1``, ``main``) to driver specs. If you - pass a single-driver flat dict (with top-level ``driver_module``), it is wrapped as - ``{"main": config}``. - """ - if not config: - self.dac_config = {} + def set_dac_config(self, config: Dict) -> None: + """Set the DAC configuration(s). This will not be serialised as a Quam field""" + if config is None: + object.__setattr__(self, "_dac_config", None) return + if "driver_module" in config: - self.dac_config = {"main": dict(config)} - else: - self.dac_config = { - k: dict(v) if isinstance(v, Mapping) else v for k, v in config.items() - } + config = {"main": config} + object.__setattr__(self, "_dac_config", config) @classmethod def load( @@ -921,14 +885,39 @@ def load( # Recreate voltage sequences for each virtual gate set for gate_set_id, vgs in instance.virtual_gate_sets.items(): instance.voltage_sequences[gate_set_id] = vgs.new_sequence( - track_integrated_voltage=True + track_integrated_voltage=instance.track_integrated_voltage, + enforce_qua_calcs=instance.track_integrated_voltage, + limit_play_commands=instance.limit_play_commands ) + # load the dac_api from the same directory too + if not isinstance(filepath_or_dict, dict): + if filepath_or_dict is not None: + state_dir = Path(filepath_or_dict) + if state_dir.is_file(): + state_dir = state_dir.parent + else: + state_path = Path(instance.serialiser._get_state_path()) + state_dir = state_path.parent if state_path.is_file() else state_path + dac_dir = state_dir / "dac" + if dac_dir.is_dir(): + dac_configs = {} + for f in sorted(dac_dir.glob("*.jsonc")): + with open(f) as fh: + dac_configs[f.stem] = json.load(fh) + instance.set_dac_config(dac_configs if dac_configs else None) + else: + instance.set_dac_config(None) + else: + instance.set_dac_config(None) + # We can also update the state_tracker here to hold the value held by QuantumDot.current_voltage. - from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + ) - wire_machine_macros(instance) + wire_machine_macros(instance, fill_only=True) return instance @@ -945,3 +934,18 @@ def save( include_defaults, ignore, ) + + # Save the DAC driver API as a separate JSON file + if getattr(self, "_dac_config", None) is not None: + if path is not None: + state_dir = Path(path) + if state_dir.is_file(): + state_dir = state_dir.parent + else: + state_path = Path(self.serialiser._get_state_path()) + state_dir = state_path.parent if state_path.is_file() else state_path + dac_dir = state_dir / "dac" + dac_dir.mkdir(exist_ok=True) + for name, config in self._dac_config.items(): + with open(dac_dir / f"{name}.jsonc", "w") as f: + json.dump(config, f, indent=2) diff --git a/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py b/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py index 75e1f98e..f3e51c3a 100644 --- a/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py +++ b/quam_builder/architecture/quantum_dots/qpu/loss_divincenzo_quam.py @@ -97,13 +97,17 @@ def load( if not hasattr(instance, "active_qubit_pair_names"): instance.active_qubit_pair_names = [] - from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + ) - wire_machine_macros(instance) + wire_machine_macros(instance, fill_only=True) return instance - def get_component(self, name: str) -> Union[AnySpinQubit, QuantumDot, SensorDot, BarrierGate]: + def get_component( + self, name: str + ) -> Union[AnySpinQubit, QuantumDot, SensorDot, BarrierGate]: """ Retrieve a component object by name from qubits, qubit_pairs, quantum_dots, quantum_dot_pairs, sensor_dots, or barrier_gates @@ -138,7 +142,7 @@ def register_qubit( d = quantum_dot_id dot = self.quantum_dots[d] # Assume a single quantum dot for a LD Qubit qubit = LDQubit( - id=d, + id=qubit_name, quantum_dot=dot.get_reference(), xy=xy, preferred_readout_quantum_dot=readout_quantum_dot, @@ -193,7 +197,9 @@ def calibrate_octave_ports(self, QM: QuantumMachine) -> None: try: qubit.calibrate_octave(QM) except NoCalibrationElements: - print(f"No calibration elements found for {qubit.id}. Skipping calibration.") + print( + f"No calibration elements found for {qubit.id}. Skipping calibration." + ) @property def active_qubits(self) -> List[AnySpinQubit]: diff --git a/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py b/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py index 72d382bb..b9c9184b 100644 --- a/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py +++ b/quam_builder/architecture/quantum_dots/qubit/ld_qubit.py @@ -1,7 +1,7 @@ # Quantum component inheritance depth is framework-driven for QuAM dataclasses. # pylint: disable=too-many-ancestors -from typing import Dict, Tuple, Union, Literal, TYPE_CHECKING, Optional +from typing import Any, Dict, Tuple, Union, Literal, TYPE_CHECKING, Optional from dataclasses import field import numpy as np @@ -10,6 +10,7 @@ from quam.core import quam_dataclass from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from qm.octave.octave_mixer_calibration import MixerCalibrationResults from qm import logger from qm import QuantumMachine @@ -39,6 +40,7 @@ class LDQubit(VoltageMacroMixin, Qubit): # pylint: disable=too-many-ancestors T2ramsey (float): The qubit T2* in seconds. T2echo (float): The qubit T2 in seconds. thermalization_time_factor (int): Thermalization time in units of T1. Default is 5. + gate_fidelity (Dict[str, Any]): Collection of single qubit gate fidelity metrics. points (Dict[str, Dict[str, float]]): A dictionary of instantiated macro points. Methods: @@ -66,10 +68,18 @@ class LDQubit(VoltageMacroMixin, Qubit): # pylint: disable=too-many-ancestors T1: float = None T2ramsey: float = None T2echo: float = None - thermalization_time_factor: int = 5 + thermalization_time_factor: int = DEFAULTS.qubit.thermalization_time_factor + gate_fidelity: Dict[str, Any] = field(default_factory=dict) points: Dict[str, Dict[str, float]] = field(default_factory=dict) + def __post_init__(self): + super().__post_init__() + if isinstance(self.quantum_dot, str): + return + if self.id is None: + self.id = self.quantum_dot.id + @property def physical_channel(self) -> Channel: return self.quantum_dot.physical_channel @@ -84,12 +94,30 @@ def thermalization_time(self): if self.T1 is not None: return int(self.thermalization_time_factor * self.T1 * 1e9 / 4) * 4 else: - return int(self.thermalization_time_factor * 10e-6 * 1e9 / 4) * 4 + return ( + int( + self.thermalization_time_factor + * DEFAULTS.qubit.fallback_t1 + * 1e9 + / 4 + ) + * 4 + ) @property def voltage_sequence(self): return self.quantum_dot.voltage_sequence + @property + def drive_IF(self) -> float: + """Current intermediate frequency of the XY drive (derived, read-only).""" + return self.xy.intermediate_frequency + + @property + def drive_LO(self) -> float: + """Current LO frequency of the XY drive.""" + return self.xy.LO_frequency + def _get_component_id_for_voltages(self) -> str: """Target the quantum_dot for voltage operations.""" return self.quantum_dot.id @@ -98,7 +126,9 @@ def calibrate_octave( self, QM: QuantumMachine, calibrate_drive: bool = True, - ) -> Tuple[Union[None, MixerCalibrationResults], Union[None, MixerCalibrationResults]]: + ) -> Tuple[ + Union[None, MixerCalibrationResults], Union[None, MixerCalibrationResults] + ]: """Calibrate the Octave channels (EDSR and possible resonator) linked to this qubit for the LO frequency, intermediate frequency and Octave gain as defined in the state. @@ -160,38 +190,22 @@ def wait(self, duration: int): def add_xy_pulse(self, pulse_name: str, pulse) -> None: self.xy.add_pulse(name=pulse_name, pulse=pulse) - def set_xy_frequency(self, frequency: float, recenter_LO: bool = True): - """ - Configure the LO+IF of xy. Use this function to update the drive frequency to the calibrated Larmor frequency - """ - if self.xy is None: - raise ValueError(f"No XY configured on Qubit {self.id}") - - LO_frequency = self.xy.LO_frequency - intermediate_frequency = frequency - LO_frequency - - if abs(intermediate_frequency) > 400e6: - if recenter_LO: - print( - f"Intermediate Frequency exceeds ±400MHz ({intermediate_frequency/1e6 : .2f}MHz). Setting LO to {frequency/1e9: .4f}GHz" - ) - self.xy.LO_frequency = frequency - self.xy.intermediate_frequency = 0 - else: - raise ValueError( - f"Intermediate Frequency ({intermediate_frequency/1e6 : .2f}MHz) exceeds ±400MHz" - ) - else: - self.xy.intermediate_frequency = intermediate_frequency - def virtual_z(self, phase: float) -> None: """Apply a virtual Z rotation""" frame_rotation_2pi(phase / (2 * np.pi), self.xy.name) + def idle(self, duration: int) -> None: + wait( + duration, + self.physical_channel.name, self.xy.name + ) + def _validate_readout_quantum_dot(self, qd_name): """Validate that the preferred quantum dot for readout actually exists in Quam, and forms a QuantumDotPair with the QuantumDot in this LDQubit.""" if qd_name not in self.machine.quantum_dots: - raise ValueError(f"Quantum Dot {qd_name} not a registered Quantum Dot in Quam. ") + raise ValueError( + f"Quantum Dot {qd_name} not a registered Quantum Dot in Quam. " + ) qd_pair = self.machine.find_quantum_dot_pair(self.quantum_dot.id, qd_name) if qd_pair is None: raise ValueError( @@ -202,4 +216,19 @@ def __setattr__(self, name, value): if name == "preferred_readout_quantum_dot" and value is not None: if hasattr(self, "quantum_dot") and not isinstance(self.quantum_dot, str): self._validate_readout_quantum_dot(value) + # if name == "larmor_frequency" and value is not None: + # if hasattr(self, "xy") and self.xy is not None: + # try: + # lo = self.xy.LO_frequency + # if isinstance(lo, (int, float)) and isinstance(value, (int, float)): + # if_freq = value - lo + # limit = self.xy.IF_LIMIT + # if abs(if_freq) > limit: + # raise ValueError( + # f"Intermediate frequency {if_freq / 1e6:.2f} MHz " + # f"exceeds \u00b1{limit / 1e6:.0f} MHz. Adjust " + # f"LO_frequency or larmor_frequency." + # ) + # except AttributeError: + # pass super().__setattr__(name, value) diff --git a/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py b/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py index 31e8eb93..3aaede55 100644 --- a/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py +++ b/quam_builder/architecture/quantum_dots/qubit_pair/ld_qubit_pair.py @@ -1,7 +1,9 @@ # Quantum component inheritance depth is framework-driven for QuAM dataclasses. # pylint: disable=too-many-ancestors -from typing import Union, TYPE_CHECKING +from typing import Any, Dict, Optional, Union, TYPE_CHECKING +from dataclasses import field +from qm.qua import wait from quam.core import quam_dataclass from quam.components import QubitPair @@ -9,7 +11,7 @@ from quam_builder.architecture.quantum_dots.components import ( QuantumDotPair, ) -from quam_builder.architecture.quantum_dots.components import VoltageGate +from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveBase from quam_builder.architecture.quantum_dots.components.mixins import VoltageMacroMixin from quam_builder.architecture.quantum_dots.qubit import LDQubit @@ -28,6 +30,7 @@ class LDQubitPair(VoltageMacroMixin, QubitPair): Attributes: qubit_control (LDQubit): The first Loss-DiVincenzo Qubit instance qubit_target (LDQubit): The second Loss-DiVincenzo Qubit instance + gate_fidelity (Dict[str, Any]): Collection of two-qubit gate fidelity metrics. points (Dict[str, Dict[str, float]]): A dictionary of instantiated macro points. Methods: @@ -43,6 +46,10 @@ class LDQubitPair(VoltageMacroMixin, QubitPair): qubit_target: LDQubit quantum_dot_pair: QuantumDotPair = None + gate_fidelity: Dict[str, Any] = field(default_factory=dict) + + xy: XYDriveBase = None + heralded_initialize_target_state: Optional[int] = None def __post_init__(self): super().__post_init__() @@ -78,4 +85,17 @@ def _get_component_id_for_voltages(self) -> str: """ return self.detuning_axis_name - # Voltage point methods (add_point, step_to_point, ramp_to_point) are now provided by VoltageMacroMixin + def _create_point_name(self, point_name: str) -> str: + """Delegate point naming to the quantum_dot_pair so keys match the gate set.""" + return self.quantum_dot_pair._create_point_name(point_name) + + def idle(self, duration) -> None: + wait( + duration, + self.physical_channel.name, + self.qubit_target.physical_channel.name, + self.qubit_target.xy.name, + self.qubit_control.physical_channel.name, + self.qubit_control.xy.name, + + ) diff --git a/quam_builder/architecture/quantum_dots/virtual_gates/virtualisation_layer.py b/quam_builder/architecture/quantum_dots/virtual_gates/virtualisation_layer.py index f2b32ee9..6176856e 100644 --- a/quam_builder/architecture/quantum_dots/virtual_gates/virtualisation_layer.py +++ b/quam_builder/architecture/quantum_dots/virtual_gates/virtualisation_layer.py @@ -1,5 +1,7 @@ """Compatibility wrapper exposing VirtualizationLayer.""" -from quam_builder.architecture.quantum_dots.components.virtual_gate_set import VirtualizationLayer +from quam_builder.architecture.quantum_dots.components.virtual_gate_set import ( + VirtualizationLayer, +) __all__ = ["VirtualizationLayer"] diff --git a/quam_builder/architecture/superconducting/README.md b/quam_builder/architecture/superconducting/README.md index 71115f99..0552ca7a 100644 --- a/quam_builder/architecture/superconducting/README.md +++ b/quam_builder/architecture/superconducting/README.md @@ -29,7 +29,7 @@ Defines the auxiliary hardware components and control elements associated with q - **`FluxLine`**: Defines the parameters for the flux bias line used to tune qubit frequency. Includes various attributes suc as the different flux bias offsets (`joint_offset`, `independent_offset`...), the corresponding flux points (`flux_point`), and the flux voltage settle time (`settle_time`) and methods to go to the specified bias points (`to_independent_idle`, `to_joint_idle`...) for instance. - **`Mixer`**: Defines mixer calibration parameters, typically including the correction matrix (`correction_matrix`) to compensate for IQ imbalance and skewness. - **`TunableCoupler`**: Models a tunable coupling element between qubits. Includes various attributes suc as the different flux bias offsets (`decouple_offset`, `interaction_offset`...), the corresponding flux points (`flux_point`), and the flux voltage settle time (`settle_time`) and methods to go to the specified bias points (`to_decouple_idle`, `to_interaction_idle`...) for instance. -- **`CrossResonance`**: Defines parameters specific to cross-resonance (CR) based two-qubit gates. +- **`CrossResonance`**: Defines parameters specific to cross-resonance (CR) based two-qubit gates. - **`ZZDrive`**: Represents drives used for dynamical decoupling or ZZ interaction cancellation. ## 4. Qubit Pair (`architecture/superconducting/qubit_pair/`) diff --git a/quam_builder/architecture/superconducting/__init__.py b/quam_builder/architecture/superconducting/__init__.py index 49990dff..51b31a39 100644 --- a/quam_builder/architecture/superconducting/__init__.py +++ b/quam_builder/architecture/superconducting/__init__.py @@ -1,4 +1,12 @@ -from .components import cross_resonance, flux_line, mixer, readout_resonator, tunable_coupler, xy_drive, zz_drive +from .components import ( + cross_resonance, + flux_line, + mixer, + readout_resonator, + tunable_coupler, + xy_drive, + zz_drive, +) from .custom_gates import CZGate from .qpu import BaseQuam, FixedFrequencyQuam, FluxTunableQuam from .qubit import BaseTransmon, FixedFrequencyTransmon, FluxTunableTransmon diff --git a/quam_builder/architecture/superconducting/components/twpa.py b/quam_builder/architecture/superconducting/components/twpa.py index 8402df1f..dec775a5 100644 --- a/quam_builder/architecture/superconducting/components/twpa.py +++ b/quam_builder/architecture/superconducting/components/twpa.py @@ -16,23 +16,23 @@ class TWPA(QuamComponent): Args: id (str, int): The id of the TWPA, used to generate the name. Can be a string, or an integer in which case it will add`Channel._default_label`. - pump (IQChannel): The pump component(sticky element) used for continuous output. + pump (IQChannel): The pump component(sticky element) used for continuous output. pump_ (IQChannel): The pump component(non sticky element)used for TWPA calibration spectroscopy (IQChannel): Probe tone used for calibrating the saturation power of the TWPA max_avg_gain (float): The maximum average gain around the readout resonators related to the TWPA max_avg_snr_improvement (float): The maximum average SNR improvement around the readout resonators related to the TWPA - pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement - pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement + pump_frequency (float): calibrated pump frequency at which twpa gives the maximum average snr improvement + pump_amplitude (float): calibrated pump amplitude at which twpa gives the maximum average snr improvement mltpx_pump_frequency (float): calibrated pump frequency at which twpa gives proper snr improvement for multiplexed readout mltpx_pump_amplitude (float): calibrated pump amplitude at which twpa gives proper snr improvement for multiplexed readout - p_saturation (float): calibrated saturation power of the twpa + p_saturation (float): calibrated saturation power of the twpa avg_std_gain (float): standard deviation of the average gain around the readout resonators related to the TWPA avg_std_snr_improvement (float): standard deviation of the average snr improvement around the readout resonators related to the TWPA - + dispersive_feature (float): dispersive feature of the twpa defined from it's designed parameters qubits (list): list of qubits of which the signals are amplified by the twpa - + initialization (bool): whether to use the twpa in the QUA program or not _initialized_ids (ClassVar[set]): A class-level set to track initialized twpa object IDs externally. This won't be serialized since it's not an instance attribute. @@ -47,48 +47,43 @@ class TWPA(QuamComponent): max_avg_gain: float = None max_avg_snr_improvement: float = None - pump_frequency : float = None - pump_amplitude : float = None - mltpx_pump_frequency : float = None - mltpx_pump_amplitude : float = None + pump_frequency: float = None + pump_amplitude: float = None + mltpx_pump_frequency: float = None + mltpx_pump_amplitude: float = None p_saturation: float = None - avg_std_gain: float=None - avg_std_snr_improvement: float= None + avg_std_gain: float = None + avg_std_snr_improvement: float = None dispersive_feature: float = None qubits: list = None - + initialization: bool = True _initialized_ids: ClassVar[set] = set() - @property def name(self): """The name of the twpa""" return self.id if isinstance(self.id, str) else f"twpa{self.id}" - - def initialize(self): # dont use twpa for the QUA program if initialization is set to False if not self.initialization: - return + return # Check initialization state using object ID (memory address) - # Initialize TWPA pump only when it hasn't been initialized yet + # Initialize TWPA pump only when it hasn't been initialized yet # This won't be serialized since it's stored in a class-level set obj_id = id(self) if obj_id in self._initialized_ids: return - + f_p = self.pump_frequency p_p = self.pump_amplitude update_frequency( self.pump.name, - f_p+ self.pump.intermediate_frequency, + f_p + self.pump.intermediate_frequency, ) self.pump.play("pump", amplitude_scale=p_p) # Store object ID externally (won't be serialized) # guarantee initializing twpa pump only once per QUA program execution self._initialized_ids.add(obj_id) - - diff --git a/quam_builder/architecture/superconducting/custom_gates/flux_tunable_transmon_pair/two_qubit_gates.py b/quam_builder/architecture/superconducting/custom_gates/flux_tunable_transmon_pair/two_qubit_gates.py index ffca1c44..d2b161ee 100644 --- a/quam_builder/architecture/superconducting/custom_gates/flux_tunable_transmon_pair/two_qubit_gates.py +++ b/quam_builder/architecture/superconducting/custom_gates/flux_tunable_transmon_pair/two_qubit_gates.py @@ -6,9 +6,8 @@ from quam.components.macro import QubitPairMacro from quam.components.pulses import Pulse from quam.core import quam_dataclass -from quam.utils.qua_types import ( - ScalarInt -) +from quam.utils.qua_types import ScalarInt + __all__ = ["CZGate"] @@ -65,7 +64,7 @@ class CZGate(QubitPairMacro): unwanted frequency shifts during the CZ gate. - Maintaining qubit states: keeping spectator qubits in specific states during the gate. - Multi-qubit gate synchronization: ensuring all qubits are properly aligned and synchronized. - + The three spectator qubit parameters work together: - ``spectator_qubits``: Dictionary mapping qubit names (str) to qubit objects. These are the qubit instances that will be controlled during the gate. @@ -75,7 +74,7 @@ class CZGate(QubitPairMacro): - ``spectator_qubits_phase_shift``: Dictionary mapping qubit names (str) to phase shift values (float, in units of 2π). These frame rotations are applied to the spectator qubits after the flux pulses, similar to phase_shift_control and phase_shift_target. - + Usage Example: ```python # Configure spectator qubits for crosstalk compensation @@ -101,7 +100,7 @@ class CZGate(QubitPairMacro): "q1": 0.01, # Small phase correction for q1 (0.01 * 2π) "q2": 0.0 # No phase correction needed for q2 } - + # When apply() is called, spectator qubits will: # 1. Be aligned with control and target qubits # 2. Have their flux pulses played in parallel with the control qubit pulse @@ -110,7 +109,7 @@ class CZGate(QubitPairMacro): ``` Note: The keys in all three dictionaries must match (same qubit names). Only qubits listed in both ``spectator_qubits`` and ``spectator_qubits_control`` will have flux pulses applied. - + spectator_qubits: dict[str, Any] Optional dictionary of spectator qubit objects. spectator_qubits_control: dict[str, Pulse] @@ -175,7 +174,7 @@ class CZGate(QubitPairMacro): spectator_qubits: dict[str, Any] = field(default_factory=dict) spectator_qubits_control: dict[str, Pulse] = field(default_factory=dict) spectator_qubits_phase_shift: dict[str, float] = field(default_factory=dict) - + fidelity: dict[str, Any] = field(default_factory=dict) extras: dict[str, Any] = field(default_factory=dict) duration_control: ScalarInt = None @@ -207,9 +206,8 @@ def apply( phase_shift_control=None, phase_shift_target=None, **kwargs, - ) -> None: - + # Build list of spectator qubits and their pulse names spectator_qubits_list = [] spectator_pulse_names = {} @@ -224,17 +222,19 @@ def apply( self.qubit_pair.qubit_control.align(align_list) # Spectator qubit flux pulses - for qubit_name, spectator_qubit in zip(self.spectator_qubits.keys(), spectator_qubits_list): + for qubit_name, spectator_qubit in zip( + self.spectator_qubits.keys(), spectator_qubits_list + ): if qubit_name in spectator_pulse_names: spectator_qubit.z.play(spectator_pulse_names[qubit_name]) - + # Control qubit flux self.qubit_pair.qubit_control.z.play( self.flux_pulse_control_label, amplitude_scale=amplitude_scale_control, - duration=duration_control + duration=duration_control, ) - + # Coupler flux if self.coupler_flux_pulse is not None: self.qubit_pair.coupler.play( @@ -244,8 +244,10 @@ def apply( ) # Align all resources after playing pulses - self.qubit_pair.qubit_control.align([self.qubit_pair.qubit_target] + spectator_qubits_list) - + self.qubit_pair.qubit_control.align( + [self.qubit_pair.qubit_target] + spectator_qubits_list + ) + # Apply phase shifts if phase_shift_control is not None: self.qubit_pair.qubit_control.xy.frame_rotation_2pi(phase_shift_control) @@ -264,4 +266,6 @@ def apply( self.spectator_qubits[qubit_name].xy.frame_rotation_2pi(phase_shift) # Final alignment - self.qubit_pair.qubit_control.align([self.qubit_pair.qubit_target] + spectator_qubits_list) \ No newline at end of file + self.qubit_pair.qubit_control.align( + [self.qubit_pair.qubit_target] + spectator_qubits_list + ) diff --git a/quam_builder/architecture/superconducting/qpu/base_quam.py b/quam_builder/architecture/superconducting/qpu/base_quam.py index 3631cd44..ab275cc5 100644 --- a/quam_builder/architecture/superconducting/qpu/base_quam.py +++ b/quam_builder/architecture/superconducting/qpu/base_quam.py @@ -76,7 +76,7 @@ def get_serialiser(cls) -> JSONSerialiser: This method can be overridden by subclasses to provide a custom serialiser. """ return JSONSerialiser( - content_mapping={"wiring": "wiring.json", "network": "wiring.json"} + content_mapping={"wiring": "wiring_old.json", "network": "wiring_old.json"} ) def get_octave_config(self) -> Optional[QmOctaveConfig]: diff --git a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py index 744d383e..efa49f11 100644 --- a/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py +++ b/quam_builder/architecture/superconducting/qpu/fixed_frequency_quam.py @@ -24,7 +24,7 @@ class FixedFrequencyQuam(BaseQuam): qubit_pairs (Dict[str, FixedFrequencyTransmonPair]): A dictionary of qubit pairs composing the QUAM. Methods: - load: Loads the QUAM from the state.json file. + load: Loads the QUAM from the state_old.json file. """ qubit_type: ClassVar[Type[FixedFrequencyTransmon]] = FixedFrequencyTransmon diff --git a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py index fb94fd6e..afd28b74 100644 --- a/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py +++ b/quam_builder/architecture/superconducting/qpu/flux_tunable_quam.py @@ -24,7 +24,7 @@ class FluxTunableQuam(BaseQuam): qubit_pairs (Dict[str, FixedFrequencyTransmonPair]): A dictionary of qubit pairs composing the QUAM. Methods: - load: Loads the QUAM from the state.json file. + load: Loads the QUAM from the state_old.json file. apply_all_couplers_to_min: Apply the offsets that bring all the active qubit pairs to a decoupled point. apply_all_flux_to_joint_idle: Apply the offsets that bring all the active qubits to the joint sweet spot. apply_all_flux_to_min: Apply the offsets that bring all the active qubits to the minimum frequency point. @@ -124,7 +124,6 @@ def set_all_fluxes( target.align() return target_bias - def initialize_qpu(self, **kwargs): """Initialize the QPU with the calibrated TWPA pumping points and with the specified flux point and target @@ -134,14 +133,7 @@ def initialize_qpu(self, **kwargs): target: The qubit under study. """ for twpa in self.twpas.values(): - twpa.initialize() + twpa.initialize() flux_point = kwargs.get("flux_point", "joint") target = kwargs.get("target", None) self.set_all_fluxes(flux_point, target) - - - - - - - \ No newline at end of file diff --git a/quam_builder/architecture/superconducting/qubit/base_transmon.py b/quam_builder/architecture/superconducting/qubit/base_transmon.py index 4ab996ad..b3520275 100644 --- a/quam_builder/architecture/superconducting/qubit/base_transmon.py +++ b/quam_builder/architecture/superconducting/qubit/base_transmon.py @@ -430,3 +430,5 @@ def wait(self, duration: int): """ channel_names = [channel.name for channel in self.channels.values()] wait(duration, *channel_names) + + diff --git a/quam_builder/builder/nv_center/add_nv_spcm_component.py b/quam_builder/builder/nv_center/add_nv_spcm_component.py index feaf60fb..6cdc3eae 100644 --- a/quam_builder/builder/nv_center/add_nv_spcm_component.py +++ b/quam_builder/builder/nv_center/add_nv_spcm_component.py @@ -22,7 +22,7 @@ def add_nv_spcm_component( Raises: ValueError: If the port keys do not match any implemented mapping. """ - time_of_flight: int = 32 # 4ns above default so that it appears in state.json + time_of_flight: int = 32 # 4ns above default so that it appears in state_old.json signal_threshold: float = 800 / 4096 # The signal threshold in volts signal_polarity: Literal["above", "below"] = ( diff --git a/quam_builder/builder/qop_connectivity/build_quam_wiring.py b/quam_builder/builder/qop_connectivity/build_quam_wiring.py index 1395c81f..c05ef7f0 100644 --- a/quam_builder/builder/qop_connectivity/build_quam_wiring.py +++ b/quam_builder/builder/qop_connectivity/build_quam_wiring.py @@ -1,16 +1,15 @@ from pathlib import Path -from typing import Any, Dict, Optional, Union +from typing import Optional, Union from qualang_tools.wirer import Connectivity from quam.components.ports import FEMPortsContainer, OPXPlusPortsContainer from quam_builder.architecture.superconducting.qpu import AnyQuam as AnyQuamSC from quam_builder.architecture.nv_center.qpu import AnyQuamNV -from quam_builder.architecture.quantum_dots.qpu import AnyQuamQD from quam_builder.builder.qop_connectivity.create_wiring import create_wiring -AnyQuam = Union[AnyQuamSC, AnyQuamNV, AnyQuamQD] +AnyQuam = Union[AnyQuamSC, AnyQuamNV] def build_quam_wiring( @@ -20,7 +19,6 @@ def build_quam_wiring( quam_instance: AnyQuam, port: Optional[int] = None, path: Optional[Union[str, Path]] = None, - dac_config: Optional[Dict[str, Any]] = None, ) -> AnyQuam: """Builds the QUAM wiring configuration and saves the machine setup. @@ -32,10 +30,6 @@ def build_quam_wiring( port (Optional[int]): The port number. Defaults to None. path (Optional[Union[str, Path]]): Directory to save the machine state. Defaults to None (uses the machine's existing save path). - dac_config (Optional[Dict[str, Any]]): Optional map of DAC name → driver spec - (same structure as :meth:`~quam_builder.architecture.quantum_dots.qpu.base_quam_qd.BaseQuamQD.set_dac_config`). - Stored in ``wiring.json`` next to ``wiring`` and ``network`` on quantum-dot roots - that support it. Returns: AnyQuam: The configured QUAM instance. @@ -43,10 +37,6 @@ def build_quam_wiring( machine = quam_instance add_ports_container(connectivity, machine) add_name_and_ip(machine, host_ip, cluster_name, port) - if dac_config is not None: - setter = getattr(machine, "set_dac_config", None) - if callable(setter): - setter(dac_config) machine.wiring = create_wiring(connectivity) machine.save(path) return machine @@ -69,13 +59,11 @@ def add_ports_container(connectivity: Connectivity, machine: AnyQuam): machine.ports = FEMPortsContainer() elif channel.instrument_id in ["opx+"]: machine.ports = OPXPlusPortsContainer() - elif channel.instrument_id == "qdac2" and machine.ports is None: - # No LF/OPX channels on this element: still attach a ports container so QUAM - # machine state is valid (QDAC DC lines use integer wiring keys, not #/ports/...). - machine.ports = OPXPlusPortsContainer() -def add_name_and_ip(machine: AnyQuam, host_ip: str, cluster_name: str, port: Union[int, None]): +def add_name_and_ip( + machine: AnyQuam, host_ip: str, cluster_name: str, port: Union[int, None] +): """Stores the minimal information to connect to a QuantumMachinesManager. Args: diff --git a/quam_builder/builder/qop_connectivity/create_wiring.py b/quam_builder/builder/qop_connectivity/create_wiring.py index 35df5561..811e34ad 100644 --- a/quam_builder/builder/qop_connectivity/create_wiring.py +++ b/quam_builder/builder/qop_connectivity/create_wiring.py @@ -3,11 +3,7 @@ from qualang_tools.wirer import Connectivity from qualang_tools.wirer.connectivity.element import QubitPairReference, QubitReference from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from qualang_tools.wirer.instruments.instrument_channel import ( - AnyInstrumentChannel, - InstrumentChannelQdac2DigitalInput, - InstrumentChannelQdac2Output, -) +from qualang_tools.wirer.instruments.instrument_channel import AnyInstrumentChannel from quam_builder.builder.qop_connectivity.create_analog_ports import ( create_octave_port, create_mw_fem_port, @@ -18,12 +14,7 @@ create_digital_output_port, ) from quam_builder.builder.qop_connectivity.paths import * -from quam_builder.builder.qop_connectivity.qdac_wiring import ( - QDAC_OUTPUT_KEY, - QDAC_TRIGGER_KEY, - qdac_output_wiring_entry, - qdac_trigger_wiring_entry, -) + def create_wiring(connectivity: Connectivity) -> dict: """Generates a dictionary containing QUAM-compatible JSON references which can be used to generate QUAM `port` objects. @@ -108,12 +99,10 @@ def qubit_wiring( qubit_line_wiring = {} for channel in channels: if channel.instrument_id == "external-mixer": - key, reference = create_external_mixer_reference(channel, element_id, line_type) + key, reference = create_external_mixer_reference( + channel, element_id, line_type + ) qubit_line_wiring[key] = reference - elif isinstance(channel, InstrumentChannelQdac2Output): - qubit_line_wiring[QDAC_OUTPUT_KEY] = qdac_output_wiring_entry(channel) - elif isinstance(channel, InstrumentChannelQdac2DigitalInput): - qubit_line_wiring[QDAC_TRIGGER_KEY] = qdac_trigger_wiring_entry(channel) elif not (channel.signal_type == "digital" and channel.io_type == "input"): key, reference = get_channel_port(channel, channels) qubit_line_wiring[key] = reference @@ -121,7 +110,9 @@ def qubit_wiring( return qubit_line_wiring -def qubit_pair_wiring(channels: List[AnyInstrumentChannel], element_id: QubitPairReference) -> dict: +def qubit_pair_wiring( + channels: List[AnyInstrumentChannel], element_id: QubitPairReference +) -> dict: """Generates a dictionary containing QUAM-compatible JSON references for a list of channels from a single qubit pair and the same line type. Args: @@ -136,18 +127,16 @@ def qubit_pair_wiring(channels: List[AnyInstrumentChannel], element_id: QubitPai "target_qubit": f"{QUBITS_BASE_JSON_PATH}/q{element_id.target_index}", } for channel in channels: - if isinstance(channel, InstrumentChannelQdac2Output): - qubit_pair_line_wiring[QDAC_OUTPUT_KEY] = qdac_output_wiring_entry(channel) - elif isinstance(channel, InstrumentChannelQdac2DigitalInput): - qubit_pair_line_wiring[QDAC_TRIGGER_KEY] = qdac_trigger_wiring_entry(channel) - elif not (channel.signal_type == "digital" and channel.io_type == "input"): + if not (channel.signal_type == "digital" and channel.io_type == "input"): key, reference = get_channel_port(channel, channels) qubit_pair_line_wiring[key] = reference return qubit_pair_line_wiring -def get_channel_port(channel: AnyInstrumentChannel, channels: List[AnyInstrumentChannel]) -> tuple: +def get_channel_port( + channel: AnyInstrumentChannel, channels: List[AnyInstrumentChannel] +) -> tuple: """Determines the key and JSON reference for a given channel. Args: diff --git a/quam_builder/builder/qop_connectivity/get_digital_outputs.py b/quam_builder/builder/qop_connectivity/get_digital_outputs.py index e152967e..843f8efd 100644 --- a/quam_builder/builder/qop_connectivity/get_digital_outputs.py +++ b/quam_builder/builder/qop_connectivity/get_digital_outputs.py @@ -16,21 +16,13 @@ def get_digital_outputs( dict[str, DigitalOutputChannel]: A dictionary of digital output channels. """ digital_outputs = dict() - digital_output_ports = [port for port in ports if "digital_output" in port] - num_digital_output_ports = len(digital_output_ports) - - for i, item in enumerate(digital_output_ports): - key = ( - digital_marker_name - if num_digital_output_ports == 1 - else f"{digital_marker_name}_{i}" - ) + for i, item in enumerate([port for port in ports if "digital_output" in port]): if digital_marker_name == "octave_switch": if isinstance( DigitalOutputChannel(opx_output=f"{wiring_path}/{item}").opx_output, FEMDigitalOutputPort, ): - digital_outputs[key] = DigitalOutputChannel( + digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=14, # 14ns for QOP333 and above buffer=13, # 13ns for QOP333 and above @@ -39,13 +31,13 @@ def get_digital_outputs( DigitalOutputChannel(opx_output=f"{wiring_path}/{item}").opx_output, OPXPlusDigitalOutputPort, ): - digital_outputs[key] = DigitalOutputChannel( + digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=57, # 57ns for QOP222 and above buffer=18, # 18ns for QOP222 and above ) else: - digital_outputs[key] = DigitalOutputChannel( + digital_outputs[f"{digital_marker_name}_{i}"] = DigitalOutputChannel( opx_output=f"{wiring_path}/{item}", delay=0, buffer=0, diff --git a/quam_builder/builder/qop_connectivity/qdac_wiring.py b/quam_builder/builder/qop_connectivity/qdac_wiring.py deleted file mode 100644 index 8c5ed39a..00000000 --- a/quam_builder/builder/qop_connectivity/qdac_wiring.py +++ /dev/null @@ -1,112 +0,0 @@ -"""QDAC-II wiring helpers (quam-builder only; no QUAM fork). - -QUAM resolves any string starting with ``#/`` as a JSON pointer into the Quam root (see -:func:`quam.utils.string_reference.is_reference`). QDAC outputs are not components on the root, -so there is no live ``#/qdac/...`` target to resolve. We therefore store **logical** ids in -``ref`` **without** a ``#/`` prefix (e.g. ``qdac/analog_outputs/qdac1/3``) so traversal of -``machine.wiring`` does not emit missing-reference warnings. Use ``unit_index`` and ``port`` -for programmatic use; ``ref`` is for humans and external tooling. -""" - -from __future__ import annotations - -from typing import Any, Dict, Mapping, Optional - -from qualang_tools.wirer.instruments.instrument_channel import ( - InstrumentChannelQdac2DigitalInput, - InstrumentChannelQdac2Output, -) - -__all__ = [ - "QDAC_OUTPUT_KEY", - "QDAC_TRIGGER_KEY", - "qdac_analog_output_ref", - "qdac_digital_input_ref", - "qdac_output_wiring_entry", - "qdac_trigger_wiring_entry", - "extract_qdac_output_port", - "extract_qdac_unit_index", - "extract_qdac_trigger_port", - "extract_qdac_trigger_unit_index", -] - -QDAC_OUTPUT_KEY = "qdac_output" -QDAC_TRIGGER_KEY = "qdac_trigger" - - -def qdac_analog_output_ref(unit_index: int, port: int) -> str: - """Stable logical path for a QDAC DC output (not a QUAM ``#/`` reference).""" - return f"qdac/analog_outputs/qdac{int(unit_index)}/{int(port)}" - - -def qdac_digital_input_ref(unit_index: int, port: int) -> str: - """Stable logical path for a QDAC external trigger input (not a QUAM ``#/`` reference).""" - return f"qdac/digital_inputs/qdac{int(unit_index)}/{int(port)}" - - -def qdac_output_wiring_entry(channel: InstrumentChannelQdac2Output) -> Dict[str, Any]: - u, p = int(channel.con), int(channel.port) - return { - "unit_index": u, - "port": p, - "ref": qdac_analog_output_ref(u, p), - } - - -def qdac_trigger_wiring_entry(channel: InstrumentChannelQdac2DigitalInput) -> Dict[str, Any]: - u, p = int(channel.con), int(channel.port) - return { - "unit_index": u, - "port": p, - "ref": qdac_digital_input_ref(u, p), - } - - -def extract_qdac_output_port(wiring_dict: Mapping[str, Any]) -> Optional[int]: - """Analog output port (1–24) on the QDAC unit, or ``None``.""" - block = wiring_dict.get(QDAC_OUTPUT_KEY) - if isinstance(block, Mapping): - port = block.get("port") - if port is not None: - return int(port) - legacy = wiring_dict.get("qdac_channel") - if isinstance(legacy, int): - return legacy - if isinstance(legacy, Mapping) and legacy.get("port") is not None: - return int(legacy["port"]) - return None - - -def extract_qdac_unit_index(wiring_dict: Mapping[str, Any]) -> Optional[int]: - """Wirer ``con`` / QDAC unit index if present.""" - block = wiring_dict.get(QDAC_OUTPUT_KEY) - if isinstance(block, Mapping): - u = block.get("unit_index") - if u is not None: - return int(u) - legacy = wiring_dict.get("qdac_channel") - if isinstance(legacy, Mapping) and legacy.get("unit_index") is not None: - return int(legacy["unit_index"]) - return None - - -def extract_qdac_trigger_port(wiring_dict: Mapping[str, Any]) -> Optional[int]: - """External trigger input port (1–4), or ``None``.""" - block = wiring_dict.get(QDAC_TRIGGER_KEY) - if isinstance(block, Mapping): - port = block.get("port") - if port is not None: - return int(port) - legacy = wiring_dict.get("qdac_trigger_in") - if isinstance(legacy, int): - return legacy - return None - - -def extract_qdac_trigger_unit_index(wiring_dict: Mapping[str, Any]) -> Optional[int]: - block = wiring_dict.get(QDAC_TRIGGER_KEY) - if isinstance(block, Mapping): - u = block.get("unit_index") - if u is not None: - return int(u) - return None \ No newline at end of file diff --git a/quam_builder/builder/quantum_dots/__init__.py b/quam_builder/builder/quantum_dots/__init__.py index 36833d46..ad6ebc96 100644 --- a/quam_builder/builder/quantum_dots/__init__.py +++ b/quam_builder/builder/quantum_dots/__init__.py @@ -1,6 +1,10 @@ """Quantum dot builder module for constructing QuAM configurations.""" -from . import build_utils as _build_utils, build_qpu as _build_qpu, build_quam as _build_quam +from . import ( + build_utils as _build_utils, + build_qpu as _build_qpu, + build_quam as _build_quam, +) from .build_utils import * from .build_qpu import * from .build_quam import * diff --git a/quam_builder/builder/quantum_dots/build_qpu.py b/quam_builder/builder/quantum_dots/build_qpu.py index 59d428e8..3201da78 100644 --- a/quam_builder/builder/quantum_dots/build_qpu.py +++ b/quam_builder/builder/quantum_dots/build_qpu.py @@ -28,11 +28,8 @@ from quam_builder.architecture.superconducting.qpu import AnyQuam from quam_builder.builder.quantum_dots.build_utils import ( + _FALLBACK_INTERMEDIATE_FREQUENCY, DEFAULT_GATE_SET_ID, - DEFAULT_INTERMEDIATE_FREQUENCY, - DEFAULT_READOUT_AMPLITUDE, - DEFAULT_READOUT_LENGTH, - DEFAULT_STICKY_DURATION, _build_virtual_mapping, _extract_qubit_number, _make_resonator, @@ -87,8 +84,12 @@ class QpuAssembly: # pylint: disable=too-few-public-methods,too-many-instance-a plunger_id_to_channel: Dict[str, VoltageGate] = field(default_factory=dict) barrier_id_to_channel: Dict[str, VoltageGate] = field(default_factory=dict) sensor_id_to_channel: Dict[str, VoltageGate] = field(default_factory=dict) - sensor_id_to_resonator: Dict[str, ReadoutResonatorSingle] = field(default_factory=dict) - qubit_id_to_xy_info: Dict[str, Tuple[str, str, Mapping[str, Any]]] = field(default_factory=dict) + sensor_id_to_resonator: Dict[str, ReadoutResonatorSingle] = field( + default_factory=dict + ) + qubit_id_to_xy_info: Dict[str, Tuple[str, str, Mapping[str, Any]]] = field( + default_factory=dict + ) qubit_pair_id_to_barrier_id: Dict[str, str] = field(default_factory=dict) plunger_virtual_names: Dict[str, str] = field(default_factory=dict) @@ -149,7 +150,9 @@ def build(self) -> AnyQuam: self.machine.active_qubit_pair_names = [] self._wiring_by_type = self._normalize_wiring(self.machine.wiring or {}) - self._normalized_pair_sensor_map = self._normalize_sensor_map(self._qubit_pair_sensor_map) + self._normalized_pair_sensor_map = self._normalize_sensor_map( + self._qubit_pair_sensor_map + ) self._collect_physical_channels() self._create_virtual_gate_set() self._register_channels() @@ -157,7 +160,9 @@ def build(self) -> AnyQuam: self._register_qubit_pairs() return self.machine - def _normalize_wiring(self, wiring: Mapping[str, Any]) -> Dict[str, Mapping[str, Any]]: + def _normalize_wiring( + self, wiring: Mapping[str, Any] + ) -> Dict[str, Mapping[str, Any]]: normalized: Dict[str, Mapping[str, Any]] = {} for element_type, wiring_by_element in wiring.items(): canonical_type = _normalize_element_type(element_type) @@ -217,9 +222,13 @@ def _collect_global_gates(self, wiring_by_element: Mapping[str, Any]): for line_type, ports in _sorted_items(wiring_by_line_type): _validate_line_type(element_type, line_type) wiring_path = f"#/wiring/{element_type}/{global_gate_id}/{line_type}" - self.assembly.global_gates.append(_make_voltage_gate(global_gate_id, wiring_path)) + self.assembly.global_gates.append( + _make_voltage_gate(global_gate_id, wiring_path) + ) - def _collect_sensor_dots(self, wiring_by_element: Mapping[str, Any], resonator_cls: Any): + def _collect_sensor_dots( + self, wiring_by_element: Mapping[str, Any], resonator_cls: Any + ): element_type = "readout" for sensor_dot_id, wiring_by_line_type in _sorted_items(wiring_by_element): sensor_number = _extract_qubit_number(sensor_dot_id) @@ -245,9 +254,15 @@ def _collect_qubits(self, wiring_by_element: Mapping[str, Any]): wiring_path = f"#/wiring/{element_type}/{qubit_id}/{line_type}" if line_type == WiringLineType.DRIVE.value: drive_type = _validate_drive_ports(qubit_id, ports) - self.assembly.qubit_id_to_xy_info[qubit_id] = (drive_type, wiring_path, ports) + self.assembly.qubit_id_to_xy_info[qubit_id] = ( + drive_type, + wiring_path, + ports, + ) elif line_type == WiringLineType.PLUNGER_GATE.value: - plunger_gate = _make_voltage_gate(f"plunger_{qubit_index}", wiring_path) + plunger_gate = _make_voltage_gate( + f"plunger_{qubit_index}", wiring_path + ) self.assembly.plunger_channels.append(plunger_gate) self.assembly.plunger_id_to_channel[plunger_gate.id] = plunger_gate @@ -264,7 +279,9 @@ def _collect_qubit_pairs(self, wiring_by_element: Mapping[str, Any]): self.assembly.barrier_counter += 1 self.assembly.barrier_channels.append(barrier_gate) self.assembly.barrier_id_to_channel[barrier_gate.id] = barrier_gate - self.assembly.qubit_pair_id_to_barrier_id[qubit_pair_id] = barrier_gate.id + self.assembly.qubit_pair_id_to_barrier_id[qubit_pair_id] = ( + barrier_gate.id + ) def _create_virtual_gate_set(self): virtual_channel_mapping: Dict[str, VoltageGate] = {} @@ -313,14 +330,18 @@ def _register_channels(self): for sensor_id, channel in self.assembly.sensor_id_to_channel.items(): resonator = self.assembly.sensor_id_to_resonator.get(sensor_id) if resonator is None: - raise ValueError(f"Missing resonator wiring for sensor gate '{sensor_id}'") + raise ValueError( + f"Missing resonator wiring for sensor gate '{sensor_id}'" + ) sensor_resonator_mappings[channel] = resonator self.machine.register_channel_elements( plunger_channels=self.assembly.plunger_channels, sensor_resonator_mappings=sensor_resonator_mappings, barrier_channels=self.assembly.barrier_channels, - global_gates=self.assembly.global_gates if self.assembly.global_gates else None, + global_gates=( + self.assembly.global_gates if self.assembly.global_gates else None + ), ) self.machine.active_sensor_dot_names = [ @@ -333,7 +354,9 @@ def _register_channels(self): def _register_qubits(self): if not self.assembly.plunger_channels: if self._wiring_by_type.get("qubits"): - raise ValueError("No plunger gates collected while qubit wiring is defined") + raise ValueError( + "No plunger gates collected while qubit wiring is defined" + ) return number_of_qubits = len(self.assembly.plunger_channels) @@ -361,7 +384,7 @@ def _register_qubits(self): elif xy_type == "Single": xy = XYDriveSingle( id=f"{qubit_name}_xy", - RF_frequency=int(DEFAULT_INTERMEDIATE_FREQUENCY), + RF_frequency=int(_FALLBACK_INTERMEDIATE_FREQUENCY), opx_output=f"{wiring_path}/opx_output", ) @@ -405,12 +428,18 @@ def _register_qubit_pairs_by_name(self, qc_name, qt_name, qubit_pair_id): for sensor in requested_sensors ] else: - sensor_dot_ids = [name for _, name in _sorted_items(self.assembly.sensor_virtual_names)] + sensor_dot_ids = [ + name for _, name in _sorted_items(self.assembly.sensor_virtual_names) + ] barrier_gate_id = None - physical_barrier_id = self.assembly.qubit_pair_id_to_barrier_id.get(qubit_pair_id) + physical_barrier_id = self.assembly.qubit_pair_id_to_barrier_id.get( + qubit_pair_id + ) if physical_barrier_id: - barrier_gate_id = self.assembly.barrier_virtual_names.get(physical_barrier_id) + barrier_gate_id = self.assembly.barrier_virtual_names.get( + physical_barrier_id + ) if barrier_gate_id is None: raise ValueError( f"Barrier gate '{physical_barrier_id}' has no registered virtual mapping" @@ -447,9 +476,7 @@ def _register_qubit_pairs_by_name(self, qc_name, qt_name, qubit_pair_id): self.machine.active_qubit_pair_names.append(f"{qc_name}_{qt_name}") def _resolve_sensor_virtual_name(self, pair_id: str, sensor: str) -> str: - allowed_formats = ( - "virtual_sensor_, sensor_, or s (e.g., virtual_sensor_1, sensor_1, s1)" - ) + allowed_formats = "virtual_sensor_, sensor_, or s (e.g., virtual_sensor_1, sensor_1, s1)" if not isinstance(sensor, str): raise ValueError( f"Sensor mapping for pair '{pair_id}' must contain string identifiers; " @@ -501,9 +528,5 @@ def _resolve_sensor_virtual_name(self, pair_id: str, sensor: str) -> str: "_build_virtual_mapping", "_parse_qubit_pair_ids", "DEFAULT_GATE_SET_ID", - "DEFAULT_STICKY_DURATION", - "DEFAULT_INTERMEDIATE_FREQUENCY", - "DEFAULT_READOUT_LENGTH", - "DEFAULT_READOUT_AMPLITUDE", ] # pylint: enable=undefined-all-variable diff --git a/quam_builder/builder/quantum_dots/build_qpu_stage1.py b/quam_builder/builder/quantum_dots/build_qpu_stage1.py index dd7bc91c..64d21ab5 100644 --- a/quam_builder/builder/quantum_dots/build_qpu_stage1.py +++ b/quam_builder/builder/quantum_dots/build_qpu_stage1.py @@ -87,7 +87,9 @@ def build(self) -> BaseQuamQD: return self.machine - def _normalize_wiring(self, wiring: Mapping[str, Any]) -> Dict[str, Mapping[str, Any]]: + def _normalize_wiring( + self, wiring: Mapping[str, Any] + ) -> Dict[str, Mapping[str, Any]]: """Normalize wiring structure by element type.""" normalized = {} for element_type_raw, elements in wiring.items(): @@ -99,41 +101,50 @@ def _normalize_wiring(self, wiring: Mapping[str, Any]) -> Dict[str, Mapping[str, def _collect_physical_channels(self): """Collect all physical channels from wiring.""" self._collect_global_gates(self._wiring_by_type.get("globals", {})) - self._collect_sensor_dots(self._wiring_by_type.get("readout", {}), self.resonator_cls) + self._collect_sensor_dots( + self._wiring_by_type.get("readout", {}), self.resonator_cls + ) self._collect_qubits(self._wiring_by_type.get("qubits", {})) self._collect_qubit_pairs(self._wiring_by_type.get("qubit_pairs", {})) def _collect_global_gates(self, wiring_by_element: Mapping[str, Any]): """Collect global gate channels with QDAC support.""" - for global_id, wiring_by_line_type in wiring_by_element.items(): - for line_type, ports in wiring_by_line_type.items(): + for global_id, line_types in wiring_by_element.items(): + for line_type, line_wiring in line_types.items(): wiring_path = f"#/wiring/globals/{global_id}/{line_type}" # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(ports) - gate = _make_voltage_gate_with_qdac(global_id, wiring_path, ports, qdac_channel) + qdac_channel = _extract_qdac_channel(line_wiring) + + gate = _make_voltage_gate_with_qdac( + global_id, wiring_path, qdac_channel + ) self.assembly.global_gates.append(gate) - def _collect_sensor_dots(self, wiring_by_element: Mapping[str, Any], resonator_cls: Any): + def _collect_sensor_dots( + self, wiring_by_element: Mapping[str, Any], resonator_cls: Any + ): """Collect sensor gates and resonators with QDAC support.""" - for sensor_id, wiring_by_line_type in wiring_by_element.items(): + for sensor_id, line_types in wiring_by_element.items(): sensor_channel = None resonator = None sensor_number = _extract_qubit_number(sensor_id) sensor_gate_id = f"sensor_{sensor_number}" - for line_type, ports in wiring_by_line_type.items(): + for line_type, line_wiring in line_types.items(): _validate_line_type("readout", line_type) wiring_path = f"#/wiring/readout/{sensor_id}/{line_type}" if line_type == WiringLineType.SENSOR_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(ports) + qdac_channel = _extract_qdac_channel(line_wiring) sensor_channel = _make_voltage_gate_with_qdac( - sensor_gate_id, wiring_path, ports, qdac_channel + sensor_gate_id, wiring_path, qdac_channel ) elif line_type == WiringLineType.RF_RESONATOR.value: - resonator = _make_resonator(sensor_gate_id, wiring_path, resonator_cls) + resonator = _make_resonator( + sensor_gate_id, wiring_path, resonator_cls + ) if sensor_channel: self.assembly.sensor_channels.append(sensor_channel) @@ -147,19 +158,21 @@ def _collect_qubits(self, wiring_by_element: Mapping[str, Any]): Note: XY drives are NOT collected in Stage 1. """ - for qubit_id, wiring_by_line_type in wiring_by_element.items(): - for line_type, ports in wiring_by_line_type.items(): + for qubit_id, line_types in wiring_by_element.items(): + for line_type, line_wiring in line_types.items(): _validate_line_type("qubits", line_type) wiring_path = f"#/wiring/qubits/{qubit_id}/{line_type}" if line_type == WiringLineType.PLUNGER_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(ports) + qdac_channel = _extract_qdac_channel(line_wiring) plunger_number = _extract_qubit_number(qubit_id) plunger_id = f"plunger_{plunger_number}" - plunger = _make_voltage_gate_with_qdac(plunger_id, wiring_path, ports, qdac_channel) + plunger = _make_voltage_gate_with_qdac( + plunger_id, wiring_path, qdac_channel + ) self.assembly.plunger_channels.append(plunger) self.assembly.plunger_id_to_channel[qubit_id] = plunger @@ -167,19 +180,21 @@ def _collect_qubits(self, wiring_by_element: Mapping[str, Any]): def _collect_qubit_pairs(self, wiring_by_element: Mapping[str, Any]): """Collect barrier gates with QDAC support.""" - for pair_id, wiring_by_line_type in wiring_by_element.items(): - for line_type, ports in wiring_by_line_type.items(): + for pair_id, line_types in wiring_by_element.items(): + for line_type, line_wiring in line_types.items(): _validate_line_type("qubit_pairs", line_type) wiring_path = f"#/wiring/qubit_pairs/{pair_id}/{line_type}" if line_type == WiringLineType.BARRIER_GATE.value: # Extract QDAC channel if present - qdac_channel = _extract_qdac_channel(ports) + qdac_channel = _extract_qdac_channel(line_wiring) barrier_id = f"barrier_{self.assembly.barrier_counter}" self.assembly.barrier_counter += 1 - barrier = _make_voltage_gate_with_qdac(barrier_id, wiring_path, ports, qdac_channel) + barrier = _make_voltage_gate_with_qdac( + barrier_id, wiring_path, qdac_channel + ) self.assembly.barrier_channels.append(barrier) self.assembly.barrier_id_to_channel[barrier_id] = barrier self.assembly.qubit_pair_id_to_barrier_id[pair_id] = barrier_id @@ -243,7 +258,9 @@ def _register_channels(self): for sensor_id, channel in self.assembly.sensor_id_to_channel.items(): resonator = self.assembly.sensor_id_to_resonator.get(sensor_id) if resonator is None: - raise ValueError(f"Missing resonator wiring for sensor gate '{sensor_id}'") + raise ValueError( + f"Missing resonator wiring for sensor gate '{sensor_id}'" + ) sensor_resonator_mappings[channel] = resonator # Register all channel elements (creates quantum dots, sensor dots, barrier gates) @@ -251,7 +268,9 @@ def _register_channels(self): plunger_channels=self.assembly.plunger_channels, sensor_resonator_mappings=sensor_resonator_mappings, barrier_channels=self.assembly.barrier_channels, - global_gates=self.assembly.global_gates if self.assembly.global_gates else None, + global_gates=( + self.assembly.global_gates if self.assembly.global_gates else None + ), ) def _register_quantum_dot_pairs(self): @@ -274,7 +293,9 @@ def _register_quantum_dot_pairs(self): continue if not barrier_virtual: - logger.warning(f"Skipping quantum dot pair {pair_id}: barrier gate not found") + logger.warning( + f"Skipping quantum dot pair {pair_id}: barrier gate not found" + ) continue # Find associated sensor dots (if any) diff --git a/quam_builder/builder/quantum_dots/build_qpu_stage2.py b/quam_builder/builder/quantum_dots/build_qpu_stage2.py index 8c1e15c5..59c905a5 100644 --- a/quam_builder/builder/quantum_dots/build_qpu_stage2.py +++ b/quam_builder/builder/quantum_dots/build_qpu_stage2.py @@ -13,8 +13,9 @@ from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.builder.quantum_dots.build_utils import ( - DEFAULT_INTERMEDIATE_FREQUENCY, + _FALLBACK_INTERMEDIATE_FREQUENCY, _create_xy_drive_from_wiring, _extract_qubit_number, _implicit_qubit_to_dot_mapping, @@ -106,7 +107,8 @@ def build(self) -> LossDiVincenzoQuam: # Validate quantum dots exist if not self.machine.quantum_dots: raise ValueError( - "No quantum dots found in machine. " "Please run Stage 1 (build_base_quam) first." + "No quantum dots found in machine. " + "Please run Stage 1 (build_base_quam) first." ) # Extract XY drive wiring if not provided @@ -165,7 +167,7 @@ def _extract_xy_drive_wiring(self) -> Dict[str, Dict]: "type": drive_type, # "IQ", "MW", or "Single" "wiring_path": wiring_path, # JSON reference to ports "intermediate_frequency": drive_wiring.get( - "intermediate_frequency", DEFAULT_INTERMEDIATE_FREQUENCY + "intermediate_frequency", _FALLBACK_INTERMEDIATE_FREQUENCY ), } @@ -216,10 +218,95 @@ def _create_xy(self, qubit_id: str) -> Optional[Any]: drive_type=xy_info["type"], wiring_path=xy_info["wiring_path"], intermediate_frequency=xy_info.get( - "intermediate_frequency", DEFAULT_INTERMEDIATE_FREQUENCY + "intermediate_frequency", _FALLBACK_INTERMEDIATE_FREQUENCY ), ) + def _set_initial_larmor_frequency(self, qubit_name: str, qubit_id: str) -> None: + """Set larmor_frequency on a newly registered qubit. + + Priority: + 1. ``DEFAULTS.frequency.larmor_frequency`` if set. + 2. ``LO + desired_IF`` derived from the wiring port's + upconverter_frequency (read directly from the port object + to avoid QuAM reference resolution on not-yet-attached + components). + """ + qubit = self.machine.qubits[qubit_name] + + if DEFAULTS.frequency.larmor_frequency is not None: + qubit.larmor_frequency = float(DEFAULTS.frequency.larmor_frequency) + return + + xy_info = self.xy_drive_wiring.get(qubit_id, {}) + desired_if = xy_info.get( + "intermediate_frequency", _FALLBACK_INTERMEDIATE_FREQUENCY + ) + + lo = self._resolve_lo_from_wiring(qubit_id) + if isinstance(lo, (int, float)): + qubit.larmor_frequency = float(lo + desired_if) + else: + logger.warning( + "Could not determine LO frequency for %s. " + "Set qubit.larmor_frequency manually.", + qubit_name, + ) + + def _resolve_lo_from_wiring(self, qubit_id: str): + """Read the LO/upconverter frequency directly from the wiring port. + + Avoids going through the QuAM reference chain (which requires the + XYDriveMW to be fully attached to the component tree). + + The wiring dict may store the port as either a direct object or a + QuAM reference string (e.g. ``#/ports/mw_outputs/con1/4/1``). In + the latter case we traverse ``machine.ports`` manually. + """ + try: + wiring_xy = ( + self.machine.wiring.get("qubits", {}).get(qubit_id, {}).get("xy", {}) + ) + port = wiring_xy.get("opx_output") + default_lo = DEFAULTS.frequency.lo_frequency + + if port is not None and hasattr(port, "upconverter_frequency"): + if default_lo is not None and port.upconverter_frequency is None: + port.upconverter_frequency = float(default_lo) + return port.upconverter_frequency + + # Port stored as a reference string — resolve via machine.ports + if isinstance(port, str) and port.startswith("#/ports/"): + port_obj = self._traverse_port_reference(port) + if port_obj is not None and hasattr(port_obj, "upconverter_frequency"): + if ( + default_lo is not None + and port_obj.upconverter_frequency is None + ): + port_obj.upconverter_frequency = float(default_lo) + return port_obj.upconverter_frequency + except (AttributeError, KeyError, TypeError): + pass + return DEFAULTS.frequency.lo_frequency + + def _traverse_port_reference(self, ref: str): + """Manually resolve a port reference like ``#/ports/mw_outputs/con1/4/1``.""" + parts = ref.lstrip("#/").split("/") + obj = self.machine + for part in parts: + if isinstance(obj, Mapping): + if part in obj: + obj = obj[part] + elif part.isdigit() and int(part) in obj: + obj = obj[int(part)] + else: + return None + elif hasattr(obj, part): + obj = getattr(obj, part) + else: + return None + return obj + def _register_qubits(self): """Register qubits with the machine using implicit mapping.""" # Get qubit IDs from wiring or XY drive wiring @@ -233,14 +320,18 @@ def _register_qubits(self): # If no qubit IDs found, infer from quantum dots if not qubit_ids: - logger.info("No qubit IDs found in wiring. Inferring from quantum dot names.") + logger.info( + "No qubit IDs found in wiring. Inferring from quantum dot names." + ) # Extract numbers from virtual_dot_N to create qN for dot_id in self.machine.quantum_dots.keys(): try: number = _extract_qubit_number(dot_id) qubit_ids.add(f"q{number}") except ValueError: - logger.warning(f"Could not infer qubit ID from quantum dot: {dot_id}") + logger.warning( + f"Could not infer qubit ID from quantum dot: {dot_id}" + ) # Register each qubit for qubit_id in sorted(qubit_ids, key=_natural_sort_key): @@ -259,6 +350,9 @@ def _register_qubits(self): readout_quantum_dot=None, # TODO: Add readout dot support ) + if xy is not None: + self._set_initial_larmor_frequency(qubit_name, qubit_id) + logger.info( f"Registered qubit {qubit_name} → quantum_dot {quantum_dot_id} " f"(XY drive: {xy is not None})" @@ -273,7 +367,9 @@ def _register_qubit_pairs(self): # If no qubit pair IDs in wiring, infer from quantum dot pairs if not qubit_pair_ids: - logger.info("No qubit pair IDs found in wiring. Inferring from quantum dot pairs.") + logger.info( + "No qubit pair IDs found in wiring. Inferring from quantum dot pairs." + ) for dot_pair_id in self.machine.quantum_dot_pairs.keys(): # Parse quantum dot pair ID to get qubit numbers # e.g., "virtual_dot_1_virtual_dot_2_pair" → "q1_q2" @@ -324,8 +420,14 @@ def _register_qubit_pairs(self): logger.error(f"Failed to register qubit pair {pair_id}: {e}") continue - def _wire_sensor_dots_to_pairs(self): - """Populate quantum_dot_pair.sensor_dots from qubit_pair_sensor_map.""" + def _wire_sensor_dots_to_pairs(self) -> None: + """Populate quantum_dot_pair.sensor_dots from qubit_pair_sensor_map. + + Also initialises placeholder readout discrimination parameters + (threshold=0, identity projector) on each sensor dot for each + pair it is wired to, so the PSB measurement chain is compilable + before calibration. + """ for pair_id, sensor_names in self.qubit_pair_sensor_map.items(): qp = self.machine.qubit_pairs.get(pair_id) if qp is None: @@ -337,6 +439,13 @@ def _wire_sensor_dots_to_pairs(self): ref = f"#/sensor_dots/{sid}" if ref not in qdp.sensor_dots: qdp.sensor_dots.append(ref) + sd = self.machine.sensor_dots[sid] + if qdp.id not in sd.readout_thresholds: + sd._add_readout_params( + qdp.id, + threshold=0.0, + projector={"wI": 1.0, "wQ": 0.0, "offset": 0.0}, + ) def _set_preferred_readout_quantum_dots(self): """Set preferred_readout_quantum_dot using cross-pair topology. diff --git a/quam_builder/builder/quantum_dots/build_quam.py b/quam_builder/builder/quantum_dots/build_quam.py index a541d5ac..5a4c93c7 100644 --- a/quam_builder/builder/quantum_dots/build_quam.py +++ b/quam_builder/builder/quantum_dots/build_quam.py @@ -14,134 +14,37 @@ import warnings from pathlib import Path -from typing import Optional, Union, Mapping, Any +from typing import Optional, Sequence, Union from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from quam_builder.builder.quantum_dots.build_utils import ( - _extract_qubit_number, - _normalize_element_type, -) -from quam_builder.builder.qop_connectivity.qdac_wiring import ( - extract_qdac_output_port, - extract_qdac_trigger_port, - extract_qdac_unit_index, -) -from quam.components import Channel, FrequencyConverter, LocalOscillator, Octave, pulses +from quam.components import FrequencyConverter, LocalOscillator, Octave from quam_builder.architecture.superconducting.components.mixer import StandaloneMixer from quam_builder.builder.quantum_dots.build_qpu import ( + QpuAssembly, _QpuBuilder, _set_default_grid_location, ) from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder from quam_builder.architecture.quantum_dots.components import QPU -from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam, AnyQuamQD +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros -from quam_builder.architecture.quantum_dots.components import VoltageGate, QdacSpec - - -def _to_plain_mapping(obj: Any, max_depth: int = 6) -> Any: - """Recursively unwrap QUAM wiring containers into plain dict/list structures.""" - if max_depth <= 0: - return obj - if obj is None or isinstance(obj, (str, bytes, int, float, bool)): - return obj - if isinstance(obj, Mapping): - return {k: _to_plain_mapping(v, max_depth - 1) for k, v in obj.items()} - if isinstance(obj, (list, tuple)): - return type(obj)(_to_plain_mapping(x, max_depth - 1) for x in obj) - if hasattr(obj, "get_unreferenced_value") and hasattr(obj, "keys"): - return { - k: _to_plain_mapping(obj.get_unreferenced_value(k), max_depth - 1) - for k in obj.keys() - } - return obj - - -def _dac_entry_from_wiring_plain(plain: Mapping[str, Any]) -> Optional[dict[str, Any]]: - """Build one :func:`add_dacs` entry from flattened port mapping, or ``None`` if no QDAC DC port.""" - ch = extract_qdac_output_port(plain) - if ch is None: - return None - trigger = any("digital_output" in str(k) for k in plain) - qti = extract_qdac_trigger_port(plain) - u = extract_qdac_unit_index(plain) - dac_name = "qdac" if u is None else f"qdac{u}" - return { - "ch": ch, - "trigger": trigger, - "qdac_trigger_in": qti, - "dac_name": dac_name, - } - - -def _dac_mapping_from_wiring(machine: BaseQuamQD) -> dict[str, dict[str, Any]]: - """Mirror :class:`_BaseQpuBuilder` gate ids and barrier ordering from ``machine.wiring``.""" - wiring = machine.wiring or {} - normalized: dict[str, Any] = {} - for element_type_raw, elements in wiring.items(): - et = _normalize_element_type(element_type_raw) - if et: - normalized[et] = elements - - result: dict[str, dict[str, Any]] = {} - - for global_id, wiring_by_line_type in normalized.get("globals", {}).items(): - for _line_type, ports in wiring_by_line_type.items(): - plain = _to_plain_mapping(ports) - if not isinstance(plain, dict): - continue - entry = _dac_entry_from_wiring_plain(plain) - if entry: - result[global_id] = entry - - for sensor_id, wiring_by_line_type in normalized.get("readout", {}).items(): - sensor_gate_id = f"sensor_{_extract_qubit_number(sensor_id)}" - for line_type, ports in wiring_by_line_type.items(): - if line_type != WiringLineType.SENSOR_GATE.value: - continue - plain = _to_plain_mapping(ports) - if not isinstance(plain, dict): - continue - entry = _dac_entry_from_wiring_plain(plain) - if entry: - result[sensor_gate_id] = entry - - for qubit_id, wiring_by_line_type in normalized.get("qubits", {}).items(): - for line_type, ports in wiring_by_line_type.items(): - if line_type != WiringLineType.PLUNGER_GATE.value: - continue - plain = _to_plain_mapping(ports) - if not isinstance(plain, dict): - continue - entry = _dac_entry_from_wiring_plain(plain) - if entry: - result[f"plunger_{_extract_qubit_number(qubit_id)}"] = entry - - barrier_counter = 0 - for _pair_id, wiring_by_line_type in normalized.get("qubit_pairs", {}).items(): - for line_type, ports in wiring_by_line_type.items(): - if line_type != WiringLineType.BARRIER_GATE.value: - continue - plain = _to_plain_mapping(ports) - if not isinstance(plain, dict): - continue - entry = _dac_entry_from_wiring_plain(plain) - if entry: - result[f"barrier_{barrier_counter}"] = entry - barrier_counter += 1 - - return result +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + MacroCatalog, + MacroFactoryMap, +) +from quam_builder.architecture.superconducting.qpu import AnyQuam def build_base_quam( machine: BaseQuamQD, calibration_db_path: Optional[Union[Path, str]] = None, + qdac_ip: Optional[str] = None, connect_qdac: bool = False, - macro_profile_path: Optional[Union[Path, str]] = None, - component_overrides: Optional[dict] = None, - instance_overrides: Optional[dict] = None, + catalogs: Optional[Sequence[MacroCatalog]] = None, + instance_overrides: Optional[dict[str, MacroFactoryMap]] = None, save: bool = True, + path: Optional[Union[Path, str]] = None, ) -> BaseQuamQD: """Build Stage 1: BaseQuamQD with physical quantum dots. @@ -162,13 +65,15 @@ def build_base_quam( machine: BaseQuamQD instance with wiring defined. calibration_db_path: Path to Octave calibration database. If None, uses the machine's state directory. + qdac_ip: IP address for QDAC connection. If provided with connect_qdac=True, + connects to QDAC for external voltage control. connect_qdac: If True, connects to QDAC using qdac_ip or machine.network['qdac_ip']. - macro_profile_path: Optional TOML file with macro override definitions. - component_overrides: Typed overrides keyed by component class. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. - instance_overrides: Typed overrides keyed by component path string. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + catalogs: Optional list of ``MacroCatalog`` instances (e.g. lab packages). + instance_overrides: Per-component-path overrides keyed by path string. save: If True, saves the machine state after building. + path: Optional directory or ``state_old.json`` file path passed to + :meth:`~quam.core.quam_classes.QuamRoot.save`. If ``None``, uses QUAM + default state path (env / config). Ignored when ``save`` is False. Returns: Configured BaseQuamQD ready for Stage 2 conversion. @@ -192,48 +97,18 @@ def build_base_quam( # Build Stage 1: Quantum dots only _BaseQpuBuilder(machine).build() + if getattr(machine, "qpu", None) is None: machine.qpu = QPU() - # Map QDAC output ports from machine.wiring onto VoltageGate.dac_spec - add_dacs(machine) - # Minimal fill-in for QDAC-only gates excluded from VirtualGateSet. - for ch in machine.physical_channels.values(): - if not isinstance(ch, VoltageGate): - continue - if getattr(ch, "dac_spec", None) is not None: - continue - qdac_ch = getattr(ch, "qdac_channel", None) - if qdac_ch is None: - continue - qdac_unit = getattr(ch, "qdac_unit_index", None) - qdac_name = "qdac" if qdac_unit is None else f"qdac{qdac_unit}" - ch.dac_spec = QdacSpec( - dac_name=qdac_name, - qdac_output_port=qdac_ch, - qdac_trigger_in=getattr(ch, "qdac_trigger_in", None), - ) - # Keep an always-available DC control layer in sync with OPX virtual gates. - has_dac_voltage_gate = any( - isinstance(ch, VoltageGate) and getattr(ch, "dac_spec", None) is not None - for ch in machine.physical_channels.values() - ) - if has_dac_voltage_gate: - for gate_set_id in machine.virtual_gate_sets.keys(): - machine.create_virtual_dc_set(gate_set_id=gate_set_id, include_all_dac_voltage_gates=True) - - wire_machine_macros( - machine, - macro_profile_path=macro_profile_path, - component_overrides=component_overrides, - instance_overrides=instance_overrides, - ) # Optional QDAC connection if connect_qdac: - machine.connect_to_external_source() + if qdac_ip: + machine.network["qdac_ip"] = qdac_ip + machine.connect_to_external_source(external_qdac=True) if save: - machine.save() + machine.save(path) return machine @@ -259,46 +134,36 @@ def build_loss_divincenzo_quam( xy_drive_wiring: Optional[dict] = None, qubit_pair_sensor_map: Optional[dict] = None, implicit_mapping: bool = True, - macro_profile_path: Optional[Union[Path, str]] = None, - component_overrides: Optional[dict] = None, - instance_overrides: Optional[dict] = None, + catalogs: Optional[Sequence[MacroCatalog]] = None, + instance_overrides: Optional[dict[str, MacroFactoryMap]] = None, save: bool = True, + path: Optional[Union[Path, str]] = None, ) -> LossDiVincenzoQuam: """Build Stage 2: Convert BaseQuamQD to LossDiVincenzoQuam with qubits. This is INDEPENDENT of Stage 1 and works with any BaseQuamQD (file or memory). Creates: - - LDQubits (mapped to quantum dots via implicit numbering: q1 → virtual_dot_1) + - LDQubits (mapped to quantum dots via implicit numbering: q1 -> virtual_dot_1) - XY drive channels for each qubit - Qubit pairs (mapped to quantum dot pairs) - Default pulses Args: machine: BaseQuamQD, LossDiVincenzoQuam, or path to saved BaseQuamQD state. - xy_drive_wiring: Optional dict mapping qubit_id → XY drive configuration. - Format: { - "q1": { - "type": "IQ" | "MW" | "Single", - "wiring_path": "#/wiring/qubits/q1/xy", - "intermediate_frequency": 500e6 # optional - }, - ... - } + xy_drive_wiring: Optional dict mapping qubit_id -> XY drive configuration. If None (default), automatically extracts XY drives from - machine.wiring if available. Only provide this if: - - Loading from file without wiring information, OR - - Need to override automatic extraction + machine.wiring if available. qubit_pair_sensor_map: Sensor mapping for qubit pairs. Format: {"q1_q2": ["sensor_1", "sensor_2"], ...} - implicit_mapping: If True, uses q1→virtual_dot_1 mapping. If False, + implicit_mapping: If True, uses q1->virtual_dot_1 mapping. If False, requires explicit mapping configuration. - macro_profile_path: Optional TOML file with macro override definitions. - component_overrides: Typed overrides keyed by component class. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. - instance_overrides: Typed overrides keyed by component path string. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + catalogs: Optional list of ``MacroCatalog`` instances (e.g. lab packages). + instance_overrides: Per-component-path overrides keyed by path string. save: If True, saves the machine state after building. + path: Optional directory or ``state_old.json`` file path passed to + :meth:`~quam.core.quam_classes.QuamRoot.save`. If ``None``, uses QUAM + default state path (env / config). Ignored when ``save`` is False. Returns: LossDiVincenzoQuam with qubits registered. @@ -325,6 +190,10 @@ def build_loss_divincenzo_quam( This function implements Stage 2 only and requires quantum dots to be already registered. If starting from scratch, first call build_base_quam(). """ + # Materialise ports before qubit registration so that Stage 2 can + # resolve LO/upconverter frequencies from the port objects. + add_ports(machine) + # Build Stage 2: Qubits from quantum dots builder = _LDQubitBuilder( machine, @@ -336,16 +205,15 @@ def build_loss_divincenzo_quam( if getattr(machine, "qpu", None) is None: machine.qpu = QPU() - add_ports(machine) wire_machine_macros( machine, - macro_profile_path=macro_profile_path, - component_overrides=component_overrides, + catalogs=catalogs, instance_overrides=instance_overrides, + fill_only=False, ) if save: - machine.save() + machine.save(path) return machine @@ -355,12 +223,15 @@ def build_quam( machine: Union[BaseQuamQD, LossDiVincenzoQuam], calibration_db_path: Optional[Union[Path, str]] = None, qubit_pair_sensor_map: Optional[dict] = None, + qdac_ip: Optional[str] = None, connect_qdac: bool = False, - macro_profile_path: Optional[Union[Path, str]] = None, - component_overrides: Optional[dict] = None, - instance_overrides: Optional[dict] = None, + catalogs: Optional[Sequence[MacroCatalog]] = None, + instance_overrides: Optional[dict[str, MacroFactoryMap]] = None, save: bool = True, -) -> LossDiVincenzoQuam: # pylint: disable=too-many-arguments,too-many-positional-arguments + path: Optional[Union[Path, str]] = None, +) -> ( + LossDiVincenzoQuam +): # pylint: disable=too-many-arguments,too-many-positional-arguments """Build complete QuAM configuration using two-stage process. This is a convenience wrapper that executes both stages: @@ -373,13 +244,14 @@ def build_quam( machine: BaseQuamQD or LossDiVincenzoQuam with wiring defined. calibration_db_path: Path to Octave calibration database. qubit_pair_sensor_map: Sensor mapping for qubit pairs. + qdac_ip: IP address for QDAC connection. connect_qdac: If True, connects to QDAC for external voltage control. - macro_profile_path: Optional TOML file with macro override definitions. - component_overrides: Typed overrides keyed by component class. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. - instance_overrides: Typed overrides keyed by component path string. See - :func:`~quam_builder.architecture.quantum_dots.macro_engine.overrides.overrides`. + catalogs: Optional list of ``MacroCatalog`` instances (e.g. lab packages). + instance_overrides: Per-component-path overrides keyed by path string. save: If True, saves the machine state after building. + path: Optional directory or ``state_old.json`` file path passed to each stage's + :meth:`~quam.core.quam_classes.QuamRoot.save`. If ``None``, uses QUAM + default state path (env / config). Ignored when ``save`` is False. Returns: Fully configured LossDiVincenzoQuam with qubits. @@ -405,21 +277,22 @@ def build_quam( machine = build_base_quam( machine, calibration_db_path=calibration_db_path, + qdac_ip=qdac_ip, connect_qdac=connect_qdac, - macro_profile_path=macro_profile_path, - component_overrides=component_overrides, + catalogs=catalogs, instance_overrides=instance_overrides, - save=False, # Don't save yet + save=save, + path=path, ) # Stage 2: Convert to LossDiVincenzoQuam machine = build_loss_divincenzo_quam( machine, qubit_pair_sensor_map=qubit_pair_sensor_map, - macro_profile_path=macro_profile_path, - component_overrides=component_overrides, + catalogs=catalogs, instance_overrides=instance_overrides, save=save, + path=path, ) return machine @@ -439,7 +312,7 @@ class _OrchestratedQuamBuilder: def __init__( self, - machine: AnyQuamQD, + machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]], qubit_pair_sensor_map: Optional[dict], ) -> None: @@ -464,27 +337,36 @@ def add_qpu(self) -> None: add_qpu(self.machine, qubit_pair_sensor_map=self.qubit_pair_sensor_map) -def add_ports(machine: AnyQuamQD) -> None: +_RF_LINE_TYPES = {WiringLineType.RF_RESONATOR.value} + + +def add_ports(machine: AnyQuam) -> None: """Register all I/O ports referenced in wiring specifications. Scans the wiring configuration and creates port objects for all - referenced inputs and outputs. + referenced inputs and outputs. LF-FEM analog output ports that are + not RF resonator lines get upsampling_mode="pulse" (gate channels). Args: machine: QuAM instance with wiring defined. """ - if machine.ports is None: - return for wiring_by_element in machine.wiring.values(): for wiring_by_line_type in wiring_by_element.values(): - for ports in wiring_by_line_type.values(): + for line_type, ports in wiring_by_line_type.items(): for port in ports: - raw = ports.get_unreferenced_value(port) - if isinstance(raw, str) and "ports" in raw: - machine.ports.reference_to_port(raw, create=True) + port_ref = ports.get_unreferenced_value(port) + if "ports" in port_ref: + created_port = machine.ports.reference_to_port( + port_ref, create=True + ) + if ( + hasattr(created_port, "upsampling_mode") + and line_type not in _RF_LINE_TYPES + ): + created_port.upsampling_mode = "pulse" -def add_qpu(machine: AnyQuamQD, qubit_pair_sensor_map: Optional[dict] = None) -> None: +def add_qpu(machine: AnyQuam, qubit_pair_sensor_map: Optional[dict] = None) -> None: """Build and register QPU elements from wiring specifications. Creates and registers: @@ -503,7 +385,7 @@ def add_qpu(machine: AnyQuamQD, qubit_pair_sensor_map: Optional[dict] = None) -> def _resolve_calibration_db_path( - machine: AnyQuamQD, calibration_db_path: Optional[Union[Path, str]] + machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] ) -> Path: """Resolve and normalize Octave calibration database path. @@ -525,8 +407,8 @@ def _resolve_calibration_db_path( def add_octaves( - machine: AnyQuamQD, calibration_db_path: Optional[Union[Path, str]] = None -) -> AnyQuamQD: + machine: AnyQuam, calibration_db_path: Optional[Union[Path, str]] = None +) -> AnyQuam: """Scan wiring for Octaves and initialize frequency converters. Creates Octave component instances for each Octave found in the wiring @@ -546,7 +428,9 @@ def add_octaves( for line_type, references in wiring_by_line_type.items(): for reference in references: if "octaves" in references.get_unreferenced_value(reference): - octave_name = references.get_unreferenced_value(reference).split("/")[2] + octave_name = references.get_unreferenced_value( + reference + ).split("/")[2] octave = Octave( name=octave_name, calibration_db_path=str(calibration_db_path), @@ -557,7 +441,7 @@ def add_octaves( return machine -def add_external_mixers(machine: AnyQuamQD) -> AnyQuamQD: +def add_external_mixers(machine: AnyQuam) -> AnyQuam: """Scan wiring for external mixers and create frequency converter components. Creates mixer components with local oscillators for each external mixer @@ -574,7 +458,9 @@ def add_external_mixers(machine: AnyQuamQD) -> AnyQuamQD: for line_type, references in wiring_by_line_type.items(): for reference in references: if "mixers" in references.get_unreferenced_value(reference): - mixer_name = references.get_unreferenced_value(reference).split("/")[2] + mixer_name = references.get_unreferenced_value(reference).split( + "/" + )[2] ldv_qubit_channel = { WiringLineType.DRIVE.value: "xy", WiringLineType.RESONATOR.value: "resonator", @@ -612,105 +498,6 @@ def add_pulses(machine: LossDiVincenzoQuam) -> None: DeprecationWarning, stacklevel=2, ) - from quam_builder.architecture.quantum_dots.macro_engine.wiring import _ensure_default_pulses - - _ensure_default_pulses(machine) - + from quam_builder.architecture.quantum_dots.macro_engine.wiring import PulseWirer -def _wire_voltage_gate_qdac( - voltage_gate: VoltageGate, - *, - qdac_output_port: int, - dac_name: str = "qdac", - with_trigger_channel: bool = False, - digital_output_key: str = "qdac_trig", - qdac_trigger_in: Optional[int] = None, -) -> None: - """Attach QDAC metadata and optionally move a digital trigger under a wrapper ``Channel``. - - Setting ``digital_outputs[...].parent = None`` before the line is registered under - the Quam tree causes QUAM to resolve references on an orphan - ``DigitalOutputChannel`` and emit ``get_root()`` warnings. This method registers - ``QdacSpec`` (and the optional OPX trigger ``Channel``) **first**, then moves the - existing digital line in a minimal second step. - - Args: - voltage_gate: Physical gate (e.g. from ``virtual_gate_sets[id].channels``). - qdac_output_port: QDAC channel index. - dac_name: Key under ``machine.dacs`` for the driver. - with_trigger_channel: If True, move ``digital_output_key`` into a wrapper - ``Channel`` referenced by ``QdacSpec.opx_trigger_out``. - digital_output_key: Name of the digital output on the gate (default wiring - uses ``qdac_trig``). - qdac_trigger_in: Optional QDAC external trigger port. - """ - if with_trigger_channel: - if digital_output_key not in voltage_gate.digital_outputs: - raise KeyError( - f"{digital_output_key!r} missing from digital_outputs of " - f"{getattr(voltage_gate, 'name', voltage_gate)!r}" - ) - dig = voltage_gate.digital_outputs[digital_output_key] - trigger_channel = Channel( - id=f"{getattr(voltage_gate, 'id', 'voltage_gate')}_qdac_trigger", - digital_outputs={"trigger": dig.get_reference()}, - operations={"trigger": pulses.Pulse(length=100, digital_marker="ON")}, - ) - voltage_gate.dac_spec = QdacSpec( - dac_name=dac_name, - qdac_output_port=qdac_output_port, - opx_trigger_out=trigger_channel, - qdac_trigger_in=qdac_trigger_in, - ) - else: - voltage_gate.dac_spec = QdacSpec( - dac_name=dac_name, - qdac_output_port=qdac_output_port, - ) - -def add_dacs( - machine: BaseQuamQD, - *, - digital_output_key: str = "qdac_trig", - dac_name: str = "qdac", - qdac_trigger_in: Optional[int] = None, - trigger_pulse_length_ns: int = 100, -) -> None: - """ - Attach ``QdacSpec`` to gates using QDAC ports from ``machine.wiring``. - - Uses :func:`_dac_mapping_from_wiring`. No-op when wiring defines no QDAC DC outputs. - """ - by_gate = _dac_mapping_from_wiring(machine) - if not by_gate: - return - for vgs_key in machine.virtual_gate_sets.keys(): - vgs = machine.virtual_gate_sets[vgs_key] - for channel_name in list(vgs.channels.keys()): - gate = vgs.channels[channel_name] - if not isinstance(gate, VoltageGate): - continue - entry = None - for cand in ( - channel_name, - getattr(gate, "id", None), - getattr(gate, "name", None), - ): - if cand and cand in by_gate: - entry = by_gate[cand] - break - if entry is None: - continue - ch_nb = entry.get("ch") - if ch_nb is None: - continue - entry_dac = entry.get("dac_name", dac_name) - entry_qti = entry.get("qdac_trigger_in", qdac_trigger_in) - _wire_voltage_gate_qdac( - gate, - qdac_output_port=ch_nb, - dac_name=entry_dac, - with_trigger_channel=bool(entry.get("trigger", False)), - digital_output_key=digital_output_key, - qdac_trigger_in=entry_qti, - ) + PulseWirer().wire(machine) diff --git a/quam_builder/builder/quantum_dots/build_utils.py b/quam_builder/builder/quantum_dots/build_utils.py index 6b4a3f1b..b5274899 100644 --- a/quam_builder/builder/quantum_dots/build_utils.py +++ b/quam_builder/builder/quantum_dots/build_utils.py @@ -26,21 +26,15 @@ XYDriveMW, XYDriveSingle, ) -from quam_builder.builder.qop_connectivity.get_digital_outputs import ( - get_digital_outputs, -) +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.architecture.superconducting.qpu import AnyQuam from quam_builder.builder.qop_connectivity.channel_ports import ( iq_out_channel_ports, mw_out_channel_ports, ) -# Default configuration constants for quantum dot systems DEFAULT_GATE_SET_ID = "main_qpu" -DEFAULT_STICKY_DURATION = 16 -DEFAULT_INTERMEDIATE_FREQUENCY = 500e6 -DEFAULT_RESONATOR_INTERMEDIATE_FREQUENCY = 0 -DEFAULT_READOUT_LENGTH = 200 -DEFAULT_READOUT_AMPLITUDE = 0.01 +_FALLBACK_INTERMEDIATE_FREQUENCY = DEFAULTS.frequency.intermediate_frequency _ELEMENT_TYPE_ALIASES = { "globals": "globals", @@ -107,7 +101,9 @@ def _normalize_element_type(element_type: str) -> str: try: return _ELEMENT_TYPE_ALIASES[element_type] except KeyError as exc: - raise ValueError(f"Unsupported element type '{element_type}' in wiring") from exc + raise ValueError( + f"Unsupported element type '{element_type}' in wiring" + ) from exc def _validate_line_type(element_type: str, line_type: str) -> None: @@ -156,9 +152,9 @@ def _make_sticky_channel() -> StickyChannelAddon: """Create a sticky channel addon with default duration. Returns: - StickyChannelAddon configured with DEFAULT_STICKY_DURATION. + StickyChannelAddon configured with ``DEFAULTS.misc.sticky_duration``. """ - return StickyChannelAddon(duration=DEFAULT_STICKY_DURATION, digital=False) + return StickyChannelAddon(duration=DEFAULTS.misc.sticky_duration, digital=False) def _make_voltage_gate(gate_id: str, wiring_path: str) -> VoltageGate: @@ -178,7 +174,9 @@ def _make_voltage_gate(gate_id: str, wiring_path: str) -> VoltageGate: ) -def _make_resonator(sensor_id: str, wiring_path: str, resonator_cls: Any) -> ReadoutResonatorSingle: +def _make_resonator( + sensor_id: str, wiring_path: str, resonator_cls: Any +) -> ReadoutResonatorSingle: """Create a readout resonator with default configuration and readout pulse. Args: @@ -192,11 +190,13 @@ def _make_resonator(sensor_id: str, wiring_path: str, resonator_cls: Any) -> Rea sensor_number = _extract_qubit_number(sensor_id) return resonator_cls( id=f"readout_resonator_{sensor_number}", - frequency_bare=0, - intermediate_frequency=DEFAULT_RESONATOR_INTERMEDIATE_FREQUENCY, + frequency_bare=DEFAULTS.readout.frequency, + intermediate_frequency=DEFAULTS.readout.frequency, operations={ "readout": pulses.SquareReadoutPulse( - length=DEFAULT_READOUT_LENGTH, id="readout", amplitude=DEFAULT_READOUT_AMPLITUDE + length=DEFAULTS.readout.length, + id="readout", + amplitude=DEFAULTS.readout.amplitude, ) }, opx_output=f"{wiring_path}/opx_output", @@ -317,84 +317,38 @@ def _ensure_q_prefix(qubit_token: str) -> str: return control, target -_OPX_ANALOG_WIRING_KEYS = frozenset({"opx_output", "opx_output_I", "opx_output_Q"}) - - -def _wiring_has_opx_analog_output(ports: Any) -> bool: - """True if the line wiring includes an OPX/LF analog port reference (not QDAC-only).""" - if not ports or not hasattr(ports, "keys"): - return False - for key in ports.keys(): - if key not in _OPX_ANALOG_WIRING_KEYS: - continue - if hasattr(ports, "get_unreferenced_value"): - raw = ports.get_unreferenced_value(key) - elif hasattr(ports, "get"): - raw = ports.get(key) - else: - raw = ports[key] - if raw is not None and raw != "": - return True - return False - - def _extract_qdac_channel(wiring_dict: Dict[str, Any]) -> int | None: - """Extract QDAC DC output port index from wiring (legacy int or structured block). + """Extract QDAC channel number from wiring configuration. - Prefer the structured ``qdac_output`` dict from :mod:`quam_builder.builder.qop_connectivity.qdac_wiring`; - fall back to legacy ``qdac_channel`` integer. - """ - from quam_builder.builder.qop_connectivity.qdac_wiring import extract_qdac_output_port + Args: + wiring_dict: Wiring dictionary that may contain 'qdac_channel' key. - return extract_qdac_output_port(wiring_dict) + Returns: + QDAC channel number if present, None otherwise. + """ + return wiring_dict.get("qdac_channel") def _make_voltage_gate_with_qdac( - gate_id: str, wiring_path: str, ports: Mapping[str, Any], qdac_channel: int | None = None + gate_id: str, wiring_path: str, qdac_channel: int | None = None ) -> VoltageGate: """Create a voltage gate component with sticky channel and optional QDAC mapping. Args: gate_id: Identifier for the gate. wiring_path: JSON path to wiring configuration. - ports: Port mapping for this line (OPX refs, ``qdac_output``, digitals, etc.). qdac_channel: Optional QDAC channel number for external voltage control. Returns: Configured VoltageGate instance with QDAC channel if provided. - - When wiring is **QDAC-only** (no OPX/LF analog port keys), :attr:`VoltageGate.opx_output` - is set to ``None`` so QUAM does not resolve a missing ``#/wiring/.../opx_output`` path. - Sticky is omitted so :meth:`~quam.core.quam_classes.QuamRoot.generate_config` does not touch OPX. """ - - digital_outputs = get_digital_outputs(wiring_path, ports, "qdac_trig") - opx_out = f"{wiring_path}/opx_output" if _wiring_has_opx_analog_output(ports) else None - sticky = _make_sticky_channel() if opx_out is not None else None - gate = VoltageGate( id=gate_id, - opx_output=opx_out, - sticky=sticky, - digital_outputs=digital_outputs, + opx_output=f"{wiring_path}/opx_output", + sticky=_make_sticky_channel(), ) if qdac_channel is not None: gate.qdac_channel = qdac_channel - from quam_builder.builder.qop_connectivity.qdac_wiring import ( - extract_qdac_trigger_port, - extract_qdac_trigger_unit_index, - extract_qdac_unit_index, - ) - - qdac_unit = extract_qdac_unit_index(ports) - if qdac_unit is not None: - gate.qdac_unit_index = qdac_unit - qdac_trigger_in = extract_qdac_trigger_port(ports) - if qdac_trigger_in is not None: - gate.qdac_trigger_in = qdac_trigger_in - qdac_trig_unit = extract_qdac_trigger_unit_index(ports) - if qdac_trig_unit is not None: - gate.qdac_trigger_unit_index = qdac_trig_unit return gate @@ -450,7 +404,7 @@ def _create_xy_drive_from_wiring( qubit_id: str, drive_type: str, wiring_path: str, - intermediate_frequency: float = DEFAULT_INTERMEDIATE_FREQUENCY, + intermediate_frequency: float = _FALLBACK_INTERMEDIATE_FREQUENCY, ) -> Any: # Returns XYDriveIQ, XYDriveMW, or XYDriveSingle """Create an XY drive channel from wiring specification. @@ -480,12 +434,10 @@ def _create_xy_drive_from_wiring( opx_output_I=f"{wiring_path}/opx_output_I", opx_output_Q=f"{wiring_path}/opx_output_Q", frequency_converter_up=f"{wiring_path}/frequency_converter_up", - RF_frequency=None, ) if drive_type == "MW": return XYDriveMW( id=drive_id, - RF_frequency=None, opx_output=f"{wiring_path}/opx_output", ) if drive_type == "Single": @@ -495,16 +447,14 @@ def _create_xy_drive_from_wiring( opx_output=f"{wiring_path}/opx_output", ) - raise ValueError(f"Unknown drive type: {drive_type}. Expected 'IQ', 'MW', or 'Single'.") + raise ValueError( + f"Unknown drive type: {drive_type}. Expected 'IQ', 'MW', or 'Single'." + ) # pylint: disable=undefined-all-variable __all__ = [ "DEFAULT_GATE_SET_ID", - "DEFAULT_STICKY_DURATION", - "DEFAULT_INTERMEDIATE_FREQUENCY", - "DEFAULT_READOUT_LENGTH", - "DEFAULT_READOUT_AMPLITUDE", "_natural_sort_key", "_sorted_items", "_normalize_element_type", @@ -522,4 +472,4 @@ def _create_xy_drive_from_wiring( "_implicit_qubit_to_dot_mapping", "_create_xy_drive_from_wiring", ] -# pylint: enable=undefined-all-variable \ No newline at end of file +# pylint: enable=undefined-all-variable diff --git a/quam_builder/builder/quantum_dots/pulses.py b/quam_builder/builder/quantum_dots/pulses.py index 94183276..36c67359 100644 --- a/quam_builder/builder/quantum_dots/pulses.py +++ b/quam_builder/builder/quantum_dots/pulses.py @@ -4,19 +4,19 @@ This module is superseded by the macro-engine pulse wiring system. Use ``wire_machine_macros()`` from ``quam_builder.architecture.quantum_dots.macro_engine`` instead. - Pulse defaults are now registered via ``component_pulse_catalog`` and + Pulse defaults are now registered via ``pulse_catalog`` and applied automatically during ``wire_machine_macros()``. """ import warnings -from typing import Any, Literal +from typing import Any, Union import numpy as np -from quam.components.pulses import GaussianPulse, SquareReadoutPulse, SquarePulse +from quam.components.pulses import GaussianPulse, SquareReadoutPulse, DragPulse from quam.components.channels import SingleChannel from qualang_tools.addons.calibration.calibrations import unit +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.components import ANY_READOUT_RESONATOR, VoltageGate -from quam_builder.tools.voltage_sequence import DEFAULT_PULSE_NAME, MIN_PULSE_DURATION_NS +from quam_builder.architecture.quantum_dots.components import ReadoutResonatorBase u = unit(coerce_to_integer=True) @@ -38,9 +38,9 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: ) # ESR/MW drive pulses (if xy exists) if hasattr(qubit, "xy") and qubit.xy is not None: - pulse_length = 1000 # ns - pulse_amp = 0.2 - sigma = pulse_length / 6 + pulse_length = DEFAULTS.xy_pulse.length + pulse_amp = DEFAULTS.xy_pulse.amplitude + sigma = pulse_length * DEFAULTS.xy_pulse.sigma_ratio # SingleChannel (XYDriveSingle) uses real-valued waveforms only. # IQ/MW channels use axis_angle for hardware IQ mixing. @@ -99,7 +99,7 @@ def add_default_ldv_qubit_pulses(qubit: LDQubit) -> None: ) -def add_default_resonator_pulses(resonator: ANY_READOUT_RESONATOR) -> None: +def add_default_resonator_pulses(resonator: ReadoutResonatorBase) -> None: """Add default square readout pulse to sensor resonator. .. deprecated:: @@ -114,13 +114,11 @@ def add_default_resonator_pulses(resonator: ANY_READOUT_RESONATOR) -> None: DeprecationWarning, stacklevel=2, ) - readout_length = 2000 # ns - readout_amp = 0.1 - if isinstance(resonator, ANY_READOUT_RESONATOR): + if isinstance(resonator, ReadoutResonatorBase): resonator.operations["readout"] = SquareReadoutPulse( id="readout", - length=readout_length, - amplitude=readout_amp, + length=DEFAULTS.readout.length, + amplitude=DEFAULTS.readout.amplitude, ) @@ -140,55 +138,3 @@ def add_default_ldv_qubit_pair_pulses(qubit_pair: Any) -> None: stacklevel=2, ) pass - - -def add_default_baseband_pulse(voltage_gate: VoltageGate): - """Add default square pulse to the voltage gate. - Note that the amplitude of the default pulse is chosen based on the output mode: - - if OPX1000 & output_mode is "amplified", then the waveform amplitude is 1.25V - - if OPX1000 & output_mode is "direct", then the waveform amplitude is 0.25V - - if OPX+, then the waveform amplitude is 0.25V. - - Args: - voltage_gate: VoltageGate instance to configure. - """ - if not isinstance(voltage_gate, str): - if hasattr(voltage_gate.opx_output, "output_mode"): - if voltage_gate.opx_output.output_mode == "amplified": - voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( - amplitude=1.25, length=MIN_PULSE_DURATION_NS - ) - else: - voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( - amplitude=0.25, length=MIN_PULSE_DURATION_NS - ) - else: - voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( - amplitude=0.25, length=MIN_PULSE_DURATION_NS - ) - # Add trigger pulse for the external DAC - if len(voltage_gate.digital_outputs) > 0: - voltage_gate.operations["trigger"] = SquarePulse(amplitude=0.0, length=1000, digital_marker="ON") - -def update_output_mode_and_default_baseband_pulse(voltage_gate: VoltageGate, output_mode: Literal["amplified", "direct"]): - """Update default square pulse of the voltage gate. - Note that the amplitude of the default pulse is chosen based on the output mode: - - if OPX1000 & output_mode is "amplified", then the waveform amplitude is 1.25V - - if OPX1000 & output_mode is "direct", then the waveform amplitude is 0.25V - - if OPX+, then the waveform amplitude is 0.25V. - - Args: - voltage_gate: VoltageGate instance to configure. - output_mode: The OPX1000 LF-FEM output mode. Can be "amplified" or "direct". - """ - if not isinstance(voltage_gate, str): - if hasattr(voltage_gate.opx_output, "output_mode"): - voltage_gate.opx_output.output_mode = output_mode - if voltage_gate.opx_output.output_mode == "amplified": - voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( - amplitude=1.25, length=MIN_PULSE_DURATION_NS - ) - else: - voltage_gate.operations[DEFAULT_PULSE_NAME] = SquarePulse( - amplitude=0.25, length=MIN_PULSE_DURATION_NS - ) diff --git a/quam_builder/builder/superconducting/add_transmon_resonator_component.py b/quam_builder/builder/superconducting/add_transmon_resonator_component.py index fa392b68..3f627d92 100644 --- a/quam_builder/builder/superconducting/add_transmon_resonator_component.py +++ b/quam_builder/builder/superconducting/add_transmon_resonator_component.py @@ -32,7 +32,7 @@ def add_transmon_resonator_component( digital_outputs = get_digital_outputs(wiring_path, ports) depletion_time = 1 * u.us - time_of_flight = 32 # 4ns above default so that it appears in state.json + time_of_flight = 32 # 4ns above default so that it appears in state_old.json if all(key in ports for key in iq_in_out_channel_ports): transmon.resonator = ReadoutResonatorIQ( diff --git a/quam_builder/builder/superconducting/build_quam.py b/quam_builder/builder/superconducting/build_quam.py index 91e7936a..cfea6e01 100644 --- a/quam_builder/builder/superconducting/build_quam.py +++ b/quam_builder/builder/superconducting/build_quam.py @@ -136,7 +136,9 @@ def add_transmons(machine: AnyQuam): transmon_pair, wiring_path, ports ) elif line_type == WiringLineType.ZZ_DRIVE.value: - add_transmon_pair_zz_drive_component(transmon_pair, wiring_path, ports) + add_transmon_pair_zz_drive_component( + transmon_pair, wiring_path, ports + ) else: raise ValueError(f"Unknown line type: {line_type}") machine.qubit_pairs[transmon_pair.name] = transmon_pair @@ -182,7 +184,9 @@ def add_octaves( for line_type, references in wiring_by_line_type.items(): for reference in references: if "octaves" in references.get_unreferenced_value(reference): - octave_name = references.get_unreferenced_value(reference).split("/")[2] + octave_name = references.get_unreferenced_value( + reference + ).split("/")[2] octave = Octave( name=octave_name, calibration_db_path=str(calibration_db_path), @@ -207,7 +211,9 @@ def add_external_mixers(machine: AnyQuam) -> AnyQuam: for line_type, references in wiring_by_line_type.items(): for reference in references: if "mixers" in references.get_unreferenced_value(reference): - mixer_name = references.get_unreferenced_value(reference).split("/")[2] + mixer_name = references.get_unreferenced_value(reference).split( + "/" + )[2] transmon_channel = { WiringLineType.DRIVE.value: "xy", WiringLineType.RESONATOR.value: "resonator", diff --git a/quam_builder/tools/macros/measure_macros.py b/quam_builder/tools/macros/measure_macros.py index e8827a71..cfa0a823 100644 --- a/quam_builder/tools/macros/measure_macros.py +++ b/quam_builder/tools/macros/measure_macros.py @@ -34,4 +34,4 @@ def apply(self, **kwargs) -> QuaVariableBool: I, Q = self.component.measure("readout") state = qua.declare(bool) qua.assign(state, I > self.threshold) - return state \ No newline at end of file + return state diff --git a/quam_builder/tools/voltage_sequence/sequence_state_tracker.py b/quam_builder/tools/voltage_sequence/sequence_state_tracker.py index 2d682d89..9bb2122f 100644 --- a/quam_builder/tools/voltage_sequence/sequence_state_tracker.py +++ b/quam_builder/tools/voltage_sequence/sequence_state_tracker.py @@ -93,10 +93,13 @@ def __init__( self._element_name: str = element_name # Initialize state variables directly for the single element self._current_level_internal: Scalar[float] = 0.0 - if enforce_qua_calcs: - self._current_level_internal = declare( - fixed, value=self._current_level_internal - ) + self._enforce_qua_calcs = enforce_qua_calcs + + # ### Keep the declarations out, lazily done in the QUA program ### + # if enforce_qua_calcs: + # self._current_level_internal = declare( + # fixed, value=self._current_level_internal + # ) # Whether to track integrated voltage self._track_integrated_voltage: bool = track_integrated_voltage # Stores accumulated voltage*duration*scale_factor @@ -105,6 +108,20 @@ def __init__( self._integrated_voltage_qua_var: Optional[QuaVariable] = None self._current_py_val_before_promotion = None + def initialize_qua_vars(self): + # Reset integrated voltage tracking — stale QUA refs from previous program + self._integrated_voltage_internal = 0 + self._integrated_voltage_qua_var = None + self._current_py_val_before_promotion = None + + if self._enforce_qua_calcs: + val = ( + self._current_level_internal + if not is_qua_type(self._current_level_internal) + else 0.0 + ) + self._current_level_internal = declare(fixed, value=val) + @property def element_name(self) -> str: """Gets the name of the element tracked by this instance.""" @@ -263,12 +280,18 @@ def update_integrated_voltage( # --- Calculate contribution from the constant level (flat top) part --- if needs_qua_calc: int_v_var = self._ensure_qua_integrated_voltage_var() - level_contribution = Cast.mul_int_by_fixed( - duration - << INTEGRATED_VOLTAGE_BITSHIFT, # duration * INTEGRATED_VOLTAGE_SCALING_FACTOR - level, - ) - assign(int_v_var, int_v_var + level_contribution) + if is_qua_type(level): + level_contribution = Cast.mul_int_by_fixed( + duration + << INTEGRATED_VOLTAGE_BITSHIFT, # duration * INTEGRATED_VOLTAGE_SCALING_FACTOR + level, + ) + assign(int_v_var, int_v_var + level_contribution) + else: + # level is a Python float; pre-scale to avoid Cast.mul_int_by_fixed + # requiring a QUA fixed argument + level_scaled = int(round(float(level) * INTEGRATED_VOLTAGE_SCALING_FACTOR)) + assign(int_v_var, int_v_var + duration * level_scaled) else: # All inputs and current state are Python types level_contribution = int( np.round((level * duration) * INTEGRATED_VOLTAGE_SCALING_FACTOR) @@ -290,12 +313,20 @@ def update_integrated_voltage( "not initialized during QUA ramp calculation." ) - ramp_contribution = Cast.mul_int_by_fixed( - ramp_duration - << INTEGRATED_VOLTAGE_BITSHIFT, # ramp_duration * INTEGRATED_VOLTAGE_SCALING_FACTOR - avg_ramp_level, - ) - assign(int_v_var, int_v_var + ramp_contribution) + if is_qua_type(avg_ramp_level): + ramp_contribution = Cast.mul_int_by_fixed( + ramp_duration + << INTEGRATED_VOLTAGE_BITSHIFT, # ramp_duration * INTEGRATED_VOLTAGE_SCALING_FACTOR + avg_ramp_level, + ) + assign(int_v_var, int_v_var + ramp_contribution) + else: + # avg_ramp_level is a Python float; pre-scale to avoid Cast.mul_int_by_fixed + # requiring a QUA fixed argument + avg_ramp_level_scaled = int( + round(float(avg_ramp_level) * INTEGRATED_VOLTAGE_SCALING_FACTOR) + ) + assign(int_v_var, int_v_var + ramp_duration * avg_ramp_level_scaled) else: # All inputs for ramp part are Python types ramp_contribution = int( np.round( diff --git a/quam_builder/tools/voltage_sequence/voltage_sequence.py b/quam_builder/tools/voltage_sequence/voltage_sequence.py index fb122d63..d00691a1 100644 --- a/quam_builder/tools/voltage_sequence/voltage_sequence.py +++ b/quam_builder/tools/voltage_sequence/voltage_sequence.py @@ -21,7 +21,6 @@ ) from quam.components.channels import SingleChannel -from quam.components.pulses import SquarePulse from quam_builder.architecture.quantum_dots.components.gate_set import ( GateSet, @@ -47,8 +46,6 @@ __all__ = [ "VoltageTuningPoint", "VoltageSequence", - "DEFAULT_PULSE_NAME", - "MIN_PULSE_DURATION_NS", ] # --- Constants --- @@ -106,7 +103,8 @@ def __init__( gate_set: GateSet, track_integrated_voltage: bool = True, keep_levels: bool = True, - enforce_qua_calcs: bool = False, + enforce_qua_calcs: bool = True, + limit_play_commands: bool = False, ): """ Initializes the VoltageSequence. @@ -118,12 +116,14 @@ def __init__( keep_levels: without keep_levels, the default behaviour for resolving voltages will be that any unspecified voltages will be treated as 0, with keep_levels, unspecified voltages instead use the latest value - enforce_qua_calcs: Enforcing qua calcs can be required to correctly - track the current level for certain programs, defaults to False. + enforce_qua_calcs: When True, all voltage and integrated-voltage + tracking uses QUA variables, ensuring correct runtime behaviour + inside QUA loops and switch/case blocks. Defaults to True. + Set to False only if you need pure Python-mode tracking for + a program with no QUA control flow around voltage operations. """ self.gate_set: GateSet = gate_set - self._update_baseband_pulse_amplitude() self.state_trackers: Dict[str, SequenceStateTracker] = { ch_name: SequenceStateTracker( ch_name, @@ -135,68 +135,51 @@ def __init__( self._temp_qua_vars: Dict[str, QuaVariable] = {} # For ramp_rate etc. self._track_integrated_voltage: bool = track_integrated_voltage self._keep_levels: bool = keep_levels - self._channel_max_voltage: Dict[str, float] = {} - - for ch_name, channel_obj in self.gate_set.channels.items(): - opx_voltage_limit = ( - 2.5 - if hasattr(channel_obj.opx_output, "output_mode") - and channel_obj.opx_output.output_mode == "amplified" - else 0.5 - ) - if self.gate_set.adjust_for_attenuation and hasattr(channel_obj, "attenuation"): - attenuation_scale = 10 ** (channel_obj.attenuation / 20) - self._channel_max_voltage[ch_name] = opx_voltage_limit / attenuation_scale - else: - self._channel_max_voltage[ch_name] = opx_voltage_limit if self._keep_levels: self._keep_levels_tracker = KeepLevels(self.gate_set) self._batched_voltages = None self._prog_id = None + self.limit_play_commands: bool = limit_play_commands - def _update_baseband_pulse_amplitude(self): - for ch in self.gate_set.channels.values(): - if ch.opx_output is not None: - if DEFAULT_PULSE_NAME not in ch.operations: - ch.operations[DEFAULT_PULSE_NAME] = SquarePulse( - id=DEFAULT_PULSE_NAME, - length=MIN_PULSE_DURATION_NS, - amplitude=0.25, - ) - output_mode = getattr(ch.opx_output, "output_mode", None) - if output_mode is None: - ch.operations[DEFAULT_PULSE_NAME].amplitude = 0.25 - elif output_mode == "direct": - ch.operations[DEFAULT_PULSE_NAME].amplitude = 0.25 - elif output_mode == "amplified": - ch.operations[DEFAULT_PULSE_NAME].amplitude = 1.25 - - def _initialise_attenuation_qua_vars(self) -> None: - """Lazy initiation of QUA variables that runs only at the start of the QUA program.""" + def _check_for_new_program(self, update_prog_id: bool = False) -> bool: current_program_scope = id(scopes_manager.program_scope) - if self._prog_id != current_program_scope: + is_new = self._prog_id != current_program_scope + if update_prog_id: self._prog_id = current_program_scope + return is_new - self.attenuation_qua_variables = { - ch_name: ( - declare( - fixed, - value=10 ** (ch.attenuation / 20) / (1 << ATTENUATION_BITSHIFT), - ) - if hasattr(ch, "attenuation") - else declare(fixed, value=1 / (1 << ATTENUATION_BITSHIFT)) + def _initialise_attenuation_qua_vars(self) -> None: + """Lazy initiation of QUA variables that runs only at the start of the QUA program.""" + # current_program_scope = id(scopes_manager.program_scope) + # if self._prog_id != current_program_scope: + # self._prog_id = current_program_scope + + # if self._check_for_new_program(update_prog_id = False): + self.attenuation_qua_variables = { + ch_name: ( + declare( + fixed, + value=10 ** (ch.attenuation / 20) / (1 << ATTENUATION_BITSHIFT), ) - for (ch_name, ch) in self.gate_set.channels.items() + if hasattr(ch, "attenuation") + else declare(fixed, value=1 / (1 << ATTENUATION_BITSHIFT)) + ) + for (ch_name, ch) in self.gate_set.channels.items() + } + if self.gate_set.adjust_for_attenuation: + self._attenuated_delta_v_vars: Dict[str, QuaVariable] = { + ch_name: declare(fixed) for ch_name in self.gate_set.channels.keys() } - if self.gate_set.adjust_for_attenuation: - self._attenuated_delta_v_vars: Dict[str, QuaVariable] = { - ch_name: declare(fixed) for ch_name in self.gate_set.channels.keys() - } - else: - return + # else: + # return + + def declare_qua_variables(self): + """Declare all deferred QUA variables. Must be called inside a program scope.""" + for tracker in self.state_trackers.values(): + tracker.initialize_qua_vars() @contextmanager def simultaneous(self, duration: int = 16, ramp_duration: int = None): @@ -231,7 +214,10 @@ def _adjust_for_attenuation(self, channel, delta_v): attenuation_scale = self.attenuation_qua_variables[ch_name] if is_qua_type(delta_v): unattenuated_delta_v = self._attenuated_delta_v_vars[ch_name] - assign(unattenuated_delta_v, (delta_v * attenuation_scale) << ATTENUATION_BITSHIFT) + assign( + unattenuated_delta_v, + (delta_v * attenuation_scale) << ATTENUATION_BITSHIFT, + ) else: unattenuated_delta_v = delta_v * ( 10 ** (channel.attenuation / 20) if hasattr(channel, "attenuation") else 1 @@ -246,12 +232,9 @@ def _play_step_on_channel( ): """Plays a scaled step on a single channel.""" DEFAULT_WF_AMPLITUDE = channel.operations[DEFAULT_PULSE_NAME].amplitude - + DEFAULT_AMPLITUDE_BITSHIFT = int(np.log2(1 / DEFAULT_WF_AMPLITUDE)) MIN_PULSE_DURATION_NS = channel.operations[DEFAULT_PULSE_NAME].length - inv_wf_amplitude = float(np.round(1.0 / DEFAULT_WF_AMPLITUDE, 10)) - log2_inv_wf = np.log2(1.0 / DEFAULT_WF_AMPLITUDE) - if self.gate_set.adjust_for_attenuation: delta_v = self._adjust_for_attenuation(channel, delta_v) @@ -263,14 +246,9 @@ def _play_step_on_channel( return if is_qua_type(delta_v): - # Bit-shift only matches scaling when 1/DEFAULT_WF_AMPLITUDE is a power of two - # (e.g. 0.25 V baseband). Amplified LF-FEM uses 1.25 V → use explicit multiply. - if log2_inv_wf == int(log2_inv_wf) and int(log2_inv_wf) >= 0: - scaled_amp = delta_v << int(log2_inv_wf) - else: - scaled_amp = delta_v * inv_wf_amplitude + scaled_amp = delta_v << DEFAULT_AMPLITUDE_BITSHIFT else: - scaled_amp = np.round(delta_v * inv_wf_amplitude, 10) + scaled_amp = np.round(delta_v * (1.0 / (DEFAULT_WF_AMPLITUDE)), 10) duration_cycles = duration >> 2 # Convert ns to clock cycles if is_qua_type(duration): @@ -362,8 +340,13 @@ def _common_voltages_change( ensure_align: bool = True, ): """Common logic for step_to_voltages and ramp_to_voltages.""" - if self.gate_set.adjust_for_attenuation: - self._initialise_attenuation_qua_vars() + # Redeclare the QUA variables for every new programme, on the first common voltages change call + + if self._check_for_new_program(update_prog_id=True): + self._temp_qua_vars.clear() + self.declare_qua_variables() + if self.gate_set.adjust_for_attenuation: + self._initialise_attenuation_qua_vars() if self._batched_voltages is not None: self._batched_voltages.update(target_voltages_dict) @@ -377,6 +360,7 @@ def _common_voltages_change( "Guidance: Using QUA variable for `ramp_duration`. " "Ensure hold `duration` is sufficient." ) + changed_gates = set(target_voltages_dict.keys()) if self._keep_levels: target_voltages_dict = self._keep_levels_tracker.update_voltage_dict_with_current( @@ -384,6 +368,13 @@ def _common_voltages_change( ) full_target_voltages_dict = self.gate_set.resolve_voltages(target_voltages_dict) + if hasattr(self.gate_set, "influence_map") and self.limit_play_commands: + affected = set() + for gate in changed_gates: + affected = affected | self.gate_set.influence_map.get(gate, {gate}) + full_target_voltages_dict = { + ch: v for ch, v in full_target_voltages_dict.items() if ch in affected + } if ensure_align: # this align is need for general use, as "step_to_voltages" adds math that can offset pulses in time # ensure_align allows to overwrite this, (currently only set to False inside apply_compensation_pulse) @@ -414,8 +405,12 @@ def _common_voltages_change( duration, ramp_duration, ) + if not is_qua_type(delta_v) and delta_v == 0.0: + # Skip the play command if the delta_v is zero. Also no need to update tracker. + # Integrated voltage is already tracked above. + continue - if ramp_duration is None or ( + elif ramp_duration is None or ( not is_qua_type(ramp_duration) and int(float(str(ramp_duration))) == 0 ): self._play_step_on_channel(channel_obj, delta_v, duration) @@ -427,8 +422,24 @@ def _common_voltages_change( duration, ) tracker.current_level = target_voltage + if ( + self._track_integrated_voltage + and hasattr(self.gate_set, "influence_map") + and self.limit_play_commands + ): + for ch_name in self.gate_set.channels: + if ch_name not in affected: + tracker = self.state_trackers[ch_name] + # Channels outside the affected set do not ramp in this call; + # they only hold their current level for `duration`. + tracker.update_integrated_voltage(tracker.current_level, duration, None) - def step_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: DurationType): + def step_to_voltages( + self, + voltages: Dict[str, VoltageLevelType], + duration: DurationType, + ensure_align: bool = True, + ): """ Steps all specified channels directly to the given voltage levels. @@ -450,6 +461,9 @@ def step_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: Dura Each voltage level can be a fixed value or a QUA variable. duration: The duration (ns) to hold the voltages, must be >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. + ensure_align: If False, skip the internal align() call. Set to + False inside strict_timing_() blocks where alignment is + managed externally. Example: >>> with qua.program() as prog: @@ -459,13 +473,16 @@ def step_to_voltages(self, voltages: Dict[str, VoltageLevelType], duration: Dura ... # Any channel not specified (e.g., "B1") will be set to 0.0V ... voltage_seq.step_to_voltages({"P1": 0.5}, duration=500) """ - self._common_voltages_change(voltages, duration, ramp_duration=None) + self._common_voltages_change( + voltages, duration, ramp_duration=None, ensure_align=ensure_align + ) def ramp_to_voltages( self, voltages: Dict[str, VoltageLevelType], duration: DurationType, ramp_duration: DurationType, + ensure_align: bool = True, ): """ Ramps all specified channels to the given voltage levels, then holds. @@ -490,6 +507,9 @@ def ramp_to_voltages( >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. ramp_duration: The duration (ns) of the ramp. Can be a fixed value or a QUA variable. + ensure_align: If False, skip the internal align() call. Set to + False inside strict_timing_() blocks where alignment is + managed externally. Example: >>> with qua.program() as prog: @@ -507,7 +527,9 @@ def ramp_to_voltages( ... voltages={"P2": 0.2}, duration=1000, ramp_duration=ramp_time ... ) """ - self._common_voltages_change(voltages, duration, ramp_duration=ramp_duration) + self._common_voltages_change( + voltages, duration, ramp_duration=ramp_duration, ensure_align=ensure_align + ) def track_sticky_duration(self, duration_ns: int) -> None: """Track hold time at current channel levels without emitting pulses. @@ -517,16 +539,16 @@ def track_sticky_duration(self, duration_ns: int) -> None: """ if not self._track_integrated_voltage: return - if not isinstance(duration_ns, int): - raise TypeError("duration_ns must be an integer number of nanoseconds.") - if duration_ns < 0: - raise TypeError("duration_ns must be non-negative.") - if duration_ns % CLOCK_CYCLE_NS != 0: - raise TypeError( - f"duration_ns ({duration_ns}ns) must be a multiple of {CLOCK_CYCLE_NS}ns." - ) - if duration_ns == 0: - return + # if not isinstance(duration_ns, int): + # raise TypeError("duration_ns must be an integer number of nanoseconds.") + # if duration_ns < 0: + # raise TypeError("duration_ns must be non-negative.") + # if duration_ns % CLOCK_CYCLE_NS != 0: + # raise TypeError( + # f"duration_ns ({duration_ns}ns) must be a multiple of {CLOCK_CYCLE_NS}ns." + # ) + # if duration_ns == 0: + # return for tracker in self.state_trackers.values(): tracker.update_integrated_voltage( @@ -535,7 +557,12 @@ def track_sticky_duration(self, duration_ns: int) -> None: ramp_duration=None, ) - def step_to_point(self, name: str, duration: Optional[DurationType] = None): + def step_to_point( + self, + name: str, + duration: Optional[DurationType] = None, + ensure_align: bool = True, + ): """ Steps all channels to the voltages defined in a predefined tuning point. @@ -549,6 +576,9 @@ def step_to_point(self, name: str, duration: Optional[DurationType] = None): If None, the default duration from the VoltageTuningPoint is used. Must be >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. + ensure_align: If False, skip the internal align() call. Set to + False inside strict_timing_() blocks where alignment is + managed externally. Example: >>> # First, define tuning points on the GateSet @@ -568,13 +598,19 @@ def step_to_point(self, name: str, duration: Optional[DurationType] = None): ) tuning_point: VoltageTuningPoint = tuning_point_macro effective_duration = duration if duration is not None else tuning_point.duration - self._common_voltages_change(tuning_point.voltages, effective_duration, ramp_duration=None) + self._common_voltages_change( + tuning_point.voltages, + effective_duration, + ramp_duration=None, + ensure_align=ensure_align, + ) def ramp_to_point( self, name: str, ramp_duration: DurationType, duration: Optional[DurationType] = None, + ensure_align: bool = True, ): """ Ramps all channels to the voltages defined in a predefined tuning point. @@ -591,6 +627,9 @@ def ramp_to_point( If None, the default duration from the VoltageTuningPoint is used. Must be >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. + ensure_align: If False, skip the internal align() call. Set to + False inside strict_timing_() blocks where alignment is + managed externally. Example: >>> # First, define tuning points on the GateSet @@ -615,6 +654,7 @@ def ramp_to_point( tuning_point.voltages, effective_duration, ramp_duration=ramp_duration, + ensure_align=ensure_align, ) def _calculate_python_compensation_params( @@ -739,8 +779,8 @@ def apply_compensation_pulse( raise ValueError( "apply_compensation_pulse is not supported when integrated voltage is not tracked." ) - if self.gate_set.adjust_for_attenuation: - self._initialise_attenuation_qua_vars() + # if self.gate_set.adjust_for_attenuation: + # self._initialise_attenuation_qua_vars() if max_voltage <= 0: raise ValueError("max_voltage must be positive.") @@ -754,14 +794,21 @@ def apply_compensation_pulse( self._common_voltages_change(target_voltages_dict=zero_dict, duration=16) for ch_name, channel_obj in self.gate_set.channels.items(): - channel_limit = self._channel_max_voltage[ch_name] - channel_max_voltage = min(max_voltage, channel_limit) - if max_voltage > channel_limit: - print( - f"Channel '{ch_name}': supplied max_voltage ({max_voltage:.4f}) exceeds channel max_voltage " - f"({channel_limit:.4f}). Using reduced max_voltage of {channel_max_voltage:.4f}." - ) - + DEFAULT_WF_AMPLITUDE = channel_obj.operations[DEFAULT_PULSE_NAME].amplitude + DEFAULT_AMPLITUDE_BITSHIFT = int(np.log2(1 / DEFAULT_WF_AMPLITUDE)) + opx_voltage_limit = ( + 2.5 + if hasattr(channel_obj.opx_output, "output_mode") + and channel_obj.opx_output.output_mode == "amplified" + else 0.5 + ) + + if self.gate_set.adjust_for_attenuation and hasattr(channel_obj, "attenuation"): + attenuation_scale = 10 ** (channel_obj.attenuation / 20) + if max_voltage * attenuation_scale > opx_voltage_limit: + raise ValueError( + f"Channel '{ch_name}' attenuation-corrected max_voltage of {max_voltage * attenuation_scale:.2f} exceeds OPX output limit of {opx_voltage_limit}" + ) tracker = self.state_trackers[ch_name] current_v = tracker.current_level @@ -769,7 +816,7 @@ def apply_compensation_pulse( if not is_qua_type(tracker.integrated_voltage) and not is_qua_type(current_v): py_comp_amp, py_comp_dur = self._calculate_python_compensation_params( - tracker, channel_max_voltage + tracker, max_voltage ) if py_comp_dur == 0: # No pulse needed tracker.current_level = py_comp_amp # Should be 0.0 @@ -784,7 +831,7 @@ def apply_compensation_pulse( comp_amp_val, comp_dur_val = py_comp_amp, py_comp_dur else: q_comp_amp, q_comp_dur_4ns = self._calculate_qua_compensation_params( - tracker, channel_max_voltage, channel_obj.name + tracker, max_voltage, channel_obj.name ) delta_v_q = q_comp_amp - current_v with if_(q_comp_dur_4ns > 0): @@ -868,9 +915,7 @@ def ramp_to_zero( Args: ramp_duration: Optional. The duration (ns) of the ramp to zero. - If None, uses QUA's ``ramp_to_zero`` on each element when - ``adjust_for_attenuation`` is off; when it is on, uses an explicit - ramp so OPX scaling matches ``step_to_voltages`` / ``ramp_to_voltages``. + If None, QUA's `ramp_to_zero` command is used for an immediate ramp. Must be >16ns and a multiple of 4ns. Can be a fixed value or a QUA variable. reset_tracker: Optional. Reset integrated voltage tracking @@ -890,38 +935,12 @@ def ramp_to_zero( """ if ramp_duration is None: - if self.gate_set.adjust_for_attenuation: - # QUA ramp_to_zero() does not apply the same OPX scaling as _play_*_on_channel - # when adjust_for_attenuation is enabled; use an explicit ramp so attenuation - # and default-pulse amplitude stay consistent (e.g. amplified LF-FEM). - sticky_durations = [ - int(sticky.duration) - for sticky in ( - getattr(ch, "sticky", None) for ch in self.gate_set.channels.values() - ) - if sticky is not None and getattr(sticky, "duration", None) is not None - ] - ramp_ns = max(sticky_durations) if sticky_durations else MIN_PULSE_DURATION_NS - self.ramp_to_voltages( - voltages={ch_name: 0.0 for ch_name in self.gate_set.channels}, - duration=0, - ramp_duration=ramp_ns, + for ch_name, channel_obj in self.gate_set.channels.items(): + tracker = self.state_trackers[ch_name] + ramp_to_zero(channel_obj.name) + tracker.update_integrated_voltage( + level=0.0, duration=0, ramp_duration=channel_obj.sticky.duration ) - if not self._track_integrated_voltage: - for ch_name, channel_obj in self.gate_set.channels.items(): - tracker = self.state_trackers[ch_name] - tracker.update_integrated_voltage( - level=0.0, - duration=0, - ramp_duration=channel_obj.sticky.duration, - ) - else: - for ch_name, channel_obj in self.gate_set.channels.items(): - tracker = self.state_trackers[ch_name] - ramp_to_zero(channel_obj.name) - tracker.update_integrated_voltage( - level=0.0, duration=0, ramp_duration=channel_obj.sticky.duration - ) else: self.ramp_to_voltages( diff --git a/tests/architecture/quantum_dots/components/test_base_quam_qd.py b/tests/architecture/quantum_dots/components/test_base_quam_qd.py index 2180c8ef..d01311a4 100644 --- a/tests/architecture/quantum_dots/components/test_base_quam_qd.py +++ b/tests/architecture/quantum_dots/components/test_base_quam_qd.py @@ -34,7 +34,11 @@ def _resonator() -> ReadoutResonatorSingle: id="rr", frequency_bare=0, intermediate_frequency=500e6, - operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + operations={ + "readout": pulses.SquareReadoutPulse( + length=200, id="readout", amplitude=0.01 + ) + }, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), sticky=StickyChannelAddon(duration=16, digital=False), @@ -289,7 +293,9 @@ def test_declare_qua_variables(self, qd_machine): def test_declare_qua_variables_custom_count(self, qd_machine): with qua.program() as _prog: - iq_i, _i_st, iq_q, _q_st, _n, _n_st = qd_machine.declare_qua_variables(num_IQ_pairs=2) + iq_i, _i_st, iq_q, _q_st, _n, _n_st = qd_machine.declare_qua_variables( + num_IQ_pairs=2 + ) assert len(iq_i) == 2 assert len(iq_q) == 2 diff --git a/tests/architecture/quantum_dots/components/test_ld_qubit.py b/tests/architecture/quantum_dots/components/test_ld_qubit.py index e26c7f10..d55ce5fd 100644 --- a/tests/architecture/quantum_dots/components/test_ld_qubit.py +++ b/tests/architecture/quantum_dots/components/test_ld_qubit.py @@ -3,9 +3,17 @@ All objects are real — no mocks or stubs. """ +import pytest from qm import qua +from quam.components import StickyChannelAddon +from quam.components.ports import LFFEMAnalogOutputPort, MWFEMAnalogOutputPort -from quam_builder.architecture.quantum_dots.components import QuantumDot +from quam_builder.architecture.quantum_dots.components import ( + QuantumDot, + VoltageGate, + XYDriveMW, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam class TestLDQubitProperties: @@ -77,3 +85,69 @@ def test_thermal_reset_in_qua(self, qd_machine): qubit.reset("thermal") assert prog is not None + + +def _make_qubit_with_xy(): + """Helper: build a minimal machine with one qubit wired to an XYDriveMW.""" + machine = LossDiVincenzoQuam() + + gate = VoltageGate( + id="plunger_1", + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=1), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + machine.create_virtual_gate_set( + virtual_channel_mapping={"vdot1": gate}, + gate_set_id="main", + ) + machine.register_channel_elements( + plunger_channels=[gate], + barrier_channels=[], + sensor_resonator_mappings={}, + ) + machine.register_qubit(quantum_dot_id="vdot1", qubit_name="Q1") + + qubit = machine.qubits["Q1"] + qubit.larmor_frequency = 5.1e9 + + xy = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + ) + qubit.xy = xy + return machine, qubit + + +class TestLDQubitDriveFrequency: + def test_drive_IF_property(self): + _, qubit = _make_qubit_with_xy() + assert qubit.drive_IF == pytest.approx(0.1e9) + + def test_drive_LO_property(self): + _, qubit = _make_qubit_with_xy() + assert qubit.drive_LO == int(5e9) + + def test_larmor_frequency_propagates_to_IF(self): + _, qubit = _make_qubit_with_xy() + qubit.larmor_frequency = 5.3e9 + assert qubit.drive_IF == pytest.approx(0.3e9) + + def test_if_validation_rejects_over_400mhz(self): + _, qubit = _make_qubit_with_xy() + with pytest.raises(ValueError, match="exceeds"): + qubit.larmor_frequency = 6e9 # IF = 1 GHz, over 400 MHz + + def test_if_validation_allows_within_limit(self): + _, qubit = _make_qubit_with_xy() + qubit.larmor_frequency = 5.3e9 # IF = 300 MHz, within limit + + def test_set_xy_frequency_removed(self): + _, qubit = _make_qubit_with_xy() + assert not hasattr(qubit, "set_xy_frequency") diff --git a/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py b/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py index f6b397f2..dbfca0a7 100644 --- a/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py +++ b/tests/architecture/quantum_dots/components/test_ld_qubit_pair.py @@ -23,7 +23,7 @@ def test_quantum_dot_pair_linked(self, qd_machine): def test_detuning_axis_name(self, qd_machine): pair = qd_machine.qubit_pairs["Q1_Q2"] - assert pair.detuning_axis_name == "dot1_dot2_epsilon" + assert pair.detuning_axis_name == "dot1_dot2_pair_epsilon" def test_voltage_sequence_accessible(self, qd_machine): pair = qd_machine.qubit_pairs["Q1_Q2"] @@ -41,13 +41,15 @@ def test_physical_channel(self, qd_machine): class TestLDQubitPairVoltageOps: def test_add_point(self, qd_machine): pair = qd_machine.qubit_pairs["Q1_Q2"] - full_name = pair.add_point("exchange", {"dot1_dot2_epsilon": 0.5}, duration=100) + full_name = pair.add_point( + "exchange", {"dot1_dot2_pair_epsilon": 0.5}, duration=100 + ) macros = pair.voltage_sequence.gate_set.get_macros() assert full_name in macros def test_step_to_point_in_qua(self, qd_machine): pair = qd_machine.qubit_pairs["Q1_Q2"] - pair.add_point("exchange", {"dot1_dot2_epsilon": 0.5}, duration=100) + pair.add_point("exchange", {"dot1_dot2_pair_epsilon": 0.5}, duration=100) with qua.program() as prog: pair.step_to_point("exchange") @@ -56,7 +58,7 @@ def test_step_to_point_in_qua(self, qd_machine): def test_ramp_to_point_in_qua(self, qd_machine): pair = qd_machine.qubit_pairs["Q1_Q2"] - pair.add_point("ramp_target", {"dot1_dot2_epsilon": 0.3}, duration=100) + pair.add_point("ramp_target", {"dot1_dot2_pair_epsilon": 0.3}, duration=100) with qua.program() as prog: pair.ramp_to_point("ramp_target", ramp_duration=20) diff --git a/tests/architecture/quantum_dots/components/test_quantum_dot.py b/tests/architecture/quantum_dots/components/test_quantum_dot.py index 30612d58..89cad194 100644 --- a/tests/architecture/quantum_dots/components/test_quantum_dot.py +++ b/tests/architecture/quantum_dots/components/test_quantum_dot.py @@ -80,18 +80,20 @@ def test_play_in_qua(self, qd_machine): class TestQuantumDotCatalog: """Verify QuantumDot receives state macros after wire_machine_macros().""" - def test_has_initialize_macro(self, qd_machine, reset_catalog): + def test_has_initialize_macro(self, qd_machine): wire_machine_macros(qd_machine) for qd in qd_machine.quantum_dots.values(): assert "initialize" in qd.macros, f"{qd.id} missing 'initialize' macro" - def test_no_generic_measure_macro_on_quantum_dot(self, qd_machine, reset_catalog): + def test_no_generic_measure_macro_on_quantum_dot(self, qd_machine): """QuantumDot should not have a generic measure macro; measurement is component-specific.""" wire_machine_macros(qd_machine) for qd in qd_machine.quantum_dots.values(): - assert "measure" not in qd.macros, f"{qd.id} should not have generic 'measure' macro" + assert ( + "measure" not in qd.macros + ), f"{qd.id} should not have generic 'measure' macro" - def test_has_empty_macro(self, qd_machine, reset_catalog): + def test_has_empty_macro(self, qd_machine): wire_machine_macros(qd_machine) for qd in qd_machine.quantum_dots.values(): assert "empty" in qd.macros, f"{qd.id} missing 'empty' macro" diff --git a/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py b/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py index 73686d82..04378749 100644 --- a/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py +++ b/tests/architecture/quantum_dots/components/test_quantum_dot_pair.py @@ -45,16 +45,16 @@ def test_machine_reference(self, qd_machine): class TestDetuningAxis: def test_detuning_axis_name_set(self, qd_machine): pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] - assert pair.detuning_axis_name == "dot1_dot2_epsilon" + assert pair.detuning_axis_name == "dot1_dot2_pair_epsilon" def test_detuning_axis_in_virtual_gate_set(self, qd_machine): vgs = qd_machine.virtual_gate_sets["main_qpu"] - assert "dot1_dot2_epsilon" in vgs.valid_channel_names + assert "dot1_dot2_pair_epsilon" in vgs.valid_channel_names def test_two_detuning_axes_coexist(self, qd_machine): vgs = qd_machine.virtual_gate_sets["main_qpu"] - assert "dot1_dot2_epsilon" in vgs.valid_channel_names - assert "dot3_dot4_epsilon" in vgs.valid_channel_names + assert "dot1_dot2_pair_epsilon" in vgs.valid_channel_names + assert "dot3_dot4_pair_epsilon" in vgs.valid_channel_names class TestDetuningControl: @@ -103,17 +103,17 @@ def test_step_to_point_in_qua(self, qd_machine): class TestQuantumDotPairCatalog: """Verify QuantumDotPair receives state macros after wire_machine_macros().""" - def test_has_initialize_macro(self, qd_machine, reset_catalog): + def test_has_initialize_macro(self, qd_machine): wire_machine_macros(qd_machine) for pair in qd_machine.quantum_dot_pairs.values(): assert "initialize" in pair.macros, f"{pair.id} missing 'initialize' macro" - def test_has_measure_macro(self, qd_machine, reset_catalog): + def test_has_measure_macro(self, qd_machine): wire_machine_macros(qd_machine) for pair in qd_machine.quantum_dot_pairs.values(): assert "measure" in pair.macros, f"{pair.id} missing 'measure' macro" - def test_has_empty_macro(self, qd_machine, reset_catalog): + def test_has_empty_macro(self, qd_machine): wire_machine_macros(qd_machine) for pair in qd_machine.quantum_dot_pairs.values(): assert "empty" in pair.macros, f"{pair.id} missing 'empty' macro" diff --git a/tests/architecture/quantum_dots/components/test_readout_resonator.py b/tests/architecture/quantum_dots/components/test_readout_resonator.py index 7376fbfa..ee9d7c74 100644 --- a/tests/architecture/quantum_dots/components/test_readout_resonator.py +++ b/tests/architecture/quantum_dots/components/test_readout_resonator.py @@ -21,7 +21,9 @@ def test_basic_creation(self): frequency_bare=5e9, intermediate_frequency=100e6, operations={ - "readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) + "readout": pulses.SquareReadoutPulse( + length=200, id="readout", amplitude=0.01 + ) }, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), @@ -35,7 +37,11 @@ def test_with_sticky_addon(self): id="rr_sticky", frequency_bare=0, intermediate_frequency=200e6, - operations={"readout": pulses.SquareReadoutPulse(length=100, id="ro", amplitude=0.05)}, + operations={ + "readout": pulses.SquareReadoutPulse( + length=100, id="ro", amplitude=0.05 + ) + }, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), sticky=StickyChannelAddon(duration=16, digital=False), diff --git a/tests/architecture/quantum_dots/components/test_sensor_dot.py b/tests/architecture/quantum_dots/components/test_sensor_dot.py index b83b0b1a..94174d85 100644 --- a/tests/architecture/quantum_dots/components/test_sensor_dot.py +++ b/tests/architecture/quantum_dots/components/test_sensor_dot.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock +from qm import qua + from quam_builder.architecture.quantum_dots.components import SensorDot from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros from quam_builder.architecture.quantum_dots.components.sensor_dot import Projector @@ -114,26 +116,31 @@ def test_sensor_dot_measure_macro_apply_calls_readout_resonator_measure(self): mock_sd.readout_resonator = mock_resonator macro = SensorDotMeasureMacro() macro.parent = mock_sd - macro.apply(foo="bar") - mock_resonator.measure.assert_called_once_with(foo="bar") + with qua.program(): + macro.apply() + mock_resonator.measure.assert_called_once() + call_kw = mock_resonator.measure.call_args.kwargs + assert "qua_vars" in call_kw class TestSensorDotCatalog: """Verify SensorDot receives measure-only macro after wire_machine_macros().""" - def test_has_measure_macro(self, qd_machine, reset_catalog): + def test_has_measure_macro(self, qd_machine): wire_machine_macros(qd_machine) for sd in qd_machine.sensor_dots.values(): assert "measure" in sd.macros, f"{sd.id} missing 'measure' macro" - def test_no_initialize_macro(self, qd_machine, reset_catalog): + def test_no_initialize_macro(self, qd_machine): wire_machine_macros(qd_machine) for sd in qd_machine.sensor_dots.values(): assert ( "initialize" not in sd.macros ), f"{sd.id} must not have 'initialize' macro (CAT-03)" - def test_no_empty_macro(self, qd_machine, reset_catalog): + def test_no_empty_macro(self, qd_machine): wire_machine_macros(qd_machine) for sd in qd_machine.sensor_dots.values(): - assert "empty" not in sd.macros, f"{sd.id} must not have 'empty' macro (CAT-03)" + assert ( + "empty" not in sd.macros + ), f"{sd.id} must not have 'empty' macro (CAT-03)" diff --git a/tests/architecture/quantum_dots/components/test_virtual_gate_set.py b/tests/architecture/quantum_dots/components/test_virtual_gate_set.py index 51af4e19..537c3733 100644 --- a/tests/architecture/quantum_dots/components/test_virtual_gate_set.py +++ b/tests/architecture/quantum_dots/components/test_virtual_gate_set.py @@ -62,19 +62,17 @@ def test_virtual_gate_set_resolve_single_layer_square(gate_set): source_vector = np.array([0.5, 0.2]) expected = np.linalg.inv(np.asarray(matrix)) @ source_vector - resolved = gate_set.resolve_voltages({"Vx": source_vector[0], "Vy": source_vector[1]}) + resolved = gate_set.resolve_voltages( + {"Vx": source_vector[0], "Vy": source_vector[1]} + ) assert np.isclose(resolved["P1"], expected[0]) assert np.isclose(resolved["P2"], expected[1]) def test_virtual_gate_set_resolve_stacked_layers_square(gate_set): - gate_set.add_layer( - source_gates=["V_mid"], target_gates=["P1"], matrix=[[2.0]] - ) - gate_set.add_layer( - source_gates=["V_top"], target_gates=["V_mid"], matrix=[[0.5]] - ) + gate_set.add_layer(source_gates=["V_mid"], target_gates=["P1"], matrix=[[2.0]]) + gate_set.add_layer(source_gates=["V_top"], target_gates=["V_mid"], matrix=[[0.5]]) resolved = gate_set.resolve_voltages({"V_top": 1.2}) @@ -83,9 +81,7 @@ def test_virtual_gate_set_resolve_stacked_layers_square(gate_set): def test_virtual_gate_set_unknown_channel_rejected(gate_set): - gate_set.add_layer( - source_gates=["Vx"], target_gates=["P1"], matrix=[[1.0]] - ) + gate_set.add_layer(source_gates=["Vx"], target_gates=["P1"], matrix=[[1.0]]) with pytest.raises(ValueError): gate_set.resolve_voltages({"Vx": 0.2, "unknown": 0.1}) @@ -113,7 +109,9 @@ def test_add_to_layer_matches_direct_matrix_sources_only(): vgs_direct.allow_rectangular_matrices = True vgs_direct.add_layer(source_all, target_all, matrix_full) - vgs_incremental = VirtualGateSet(id="incremental_sources", channels=channels_incremental) + vgs_incremental = VirtualGateSet( + id="incremental_sources", channels=channels_incremental + ) vgs_incremental.allow_rectangular_matrices = True vgs_incremental.add_layer(source_all[:2], target_all, matrix_full[:2]) vgs_incremental.add_to_layer( @@ -152,7 +150,9 @@ def test_add_to_layer_adds_targets_and_sources(): vgs_direct.allow_rectangular_matrices = True vgs_direct.add_layer(source_all, target_all, matrix_full) - vgs_incremental = VirtualGateSet(id="incremental_targets", channels=channels_incremental) + vgs_incremental = VirtualGateSet( + id="incremental_targets", channels=channels_incremental + ) vgs_incremental.allow_rectangular_matrices = True vgs_incremental.add_layer( source_all[:2], @@ -176,7 +176,11 @@ def test_add_to_layer_adds_targets_and_sources(): resolved_incremental = vgs_incremental.resolve_voltages(sample_voltages) np.testing.assert_allclose( [resolved_direct["P1"], resolved_direct["P2"], resolved_direct["P3"]], - [resolved_incremental["P1"], resolved_incremental["P2"], resolved_incremental["P3"]], + [ + resolved_incremental["P1"], + resolved_incremental["P2"], + resolved_incremental["P3"], + ], ) diff --git a/tests/architecture/quantum_dots/components/test_xy_drive.py b/tests/architecture/quantum_dots/components/test_xy_drive.py index 684526ab..8b43f22e 100644 --- a/tests/architecture/quantum_dots/components/test_xy_drive.py +++ b/tests/architecture/quantum_dots/components/test_xy_drive.py @@ -5,17 +5,20 @@ All objects are real — no mocks or stubs. """ +import pytest from qm import qua -from quam.components import pulses +from quam.components import StickyChannelAddon, pulses from quam.components.ports import ( LFFEMAnalogOutputPort, MWFEMAnalogOutputPort, ) from quam_builder.architecture.quantum_dots.components import ( - XYDriveSingle, + VoltageGate, XYDriveMW, + XYDriveSingle, ) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam # --------------------------------------------------------------------------- @@ -42,7 +45,7 @@ def test_no_default_pulses_on_creation(self): opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), ) # No pulses added at construction time — they come from wire_machine_macros - assert "gaussian" not in drive.operations + assert "gaussian_x90" not in drive.operations assert "pi" not in drive.operations def test_add_custom_pulse(self): @@ -51,7 +54,9 @@ def test_add_custom_pulse(self): RF_frequency=int(100e6), opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), ) - drive.add_pulse("rabi", pulses.GaussianPulse(length=100, amplitude=0.2, sigma=20)) + drive.add_pulse( + "rabi", pulses.GaussianPulse(length=100, amplitude=0.2, sigma=20) + ) assert "rabi" in drive.operations assert drive.operations["rabi"].length == 100 @@ -63,9 +68,11 @@ def test_play_pulse_compiles(self): RF_frequency=int(100e6), opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), ) - drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + drive.operations["gaussian_x90"] = pulses.GaussianPulse( + length=100, amplitude=0.2, sigma=40 + ) with qua.program() as prog: - drive.play("gaussian") + drive.play("gaussian_x90") assert prog is not None def test_play_with_amplitude_scale(self): @@ -85,11 +92,13 @@ def test_play_with_duration_override(self): RF_frequency=int(100e6), opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), ) - drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + drive.operations["gaussian_x90"] = pulses.GaussianPulse( + length=100, amplitude=0.2, sigma=40 + ) with qua.program() as prog: t = qua.declare(int) qua.assign(t, 100) - drive.play("gaussian", duration=t) + drive.play("gaussian_x90", duration=t) assert prog is not None @@ -137,3 +146,108 @@ def test_add_pulse_and_play(self): with qua.program() as prog: drive.play("drive") assert prog is not None + + +class TestXYDriveValidation: + def _make_mw_drive(self, upconverter_freq: int, if_freq: int) -> XYDriveMW: + return XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=upconverter_freq, + full_scale_power_dbm=10, + ), + intermediate_frequency=if_freq, + ) + + def test_valid_if_passes(self): + drive = self._make_mw_drive(int(5e9), int(100e6)) + drive.validate_intermediate_frequency() + + def test_mw_if_at_500mhz_passes(self): + drive = self._make_mw_drive(int(5e9), int(500e6)) + drive.validate_intermediate_frequency() # MW FEM limit is 500 MHz + + def test_if_exceeds_limit_raises(self): + drive = self._make_mw_drive(int(5e9), int(600e6)) + with pytest.raises(ValueError, match="exceeds"): + drive.validate_intermediate_frequency() + + def test_negative_if_within_limit_passes(self): + drive = self._make_mw_drive(int(5e9), int(-200e6)) + drive.validate_intermediate_frequency() + + def test_negative_if_exceeds_limit_raises(self): + drive = self._make_mw_drive(int(5e9), int(-600e6)) + with pytest.raises(ValueError, match="exceeds"): + drive.validate_intermediate_frequency() + + +class TestXYDriveRFReference: + def _make_machine_with_qubit(self): + """Build minimal machine with one qubit, no XY drive yet.""" + machine = LossDiVincenzoQuam() + gate = VoltageGate( + id="plunger_1", + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=1), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + machine.create_virtual_gate_set( + virtual_channel_mapping={"vdot1": gate}, + gate_set_id="main", + ) + machine.register_channel_elements( + plunger_channels=[gate], + barrier_channels=[], + sensor_resonator_mappings={}, + ) + machine.register_qubit(quantum_dot_id="vdot1", qubit_name="Q1") + return machine + + def test_mw_rf_frequency_references_larmor(self): + """XYDriveMW.RF_frequency should resolve to qubit.larmor_frequency.""" + machine = self._make_machine_with_qubit() + qubit = machine.qubits["Q1"] + qubit.larmor_frequency = 5.1e9 + + xy = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + ) + qubit.xy = xy + + assert xy.RF_frequency == 5.1e9 + assert xy.intermediate_frequency == pytest.approx(0.1e9) + + def test_mw_rf_tracks_larmor_change(self): + """Changing larmor_frequency should be reflected in xy.RF_frequency.""" + machine = self._make_machine_with_qubit() + qubit = machine.qubits["Q1"] + qubit.larmor_frequency = 5.1e9 + + xy = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + ) + qubit.xy = xy + + qubit.larmor_frequency = 5.2e9 + assert xy.RF_frequency == 5.2e9 + assert xy.intermediate_frequency == pytest.approx(0.2e9) diff --git a/tests/architecture/quantum_dots/conftest.py b/tests/architecture/quantum_dots/conftest.py index bc5cf7b1..8bce0575 100644 --- a/tests/architecture/quantum_dots/conftest.py +++ b/tests/architecture/quantum_dots/conftest.py @@ -39,7 +39,11 @@ def qd_machine(): id="readout_resonator", frequency_bare=0, intermediate_frequency=500e6, - operations={"readout": pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01)}, + operations={ + "readout": pulses.SquareReadoutPulse( + length=200, id="readout", amplitude=0.01 + ) + }, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), sticky=StickyChannelAddon(duration=16, digital=False), @@ -83,20 +87,13 @@ def qd_machine(): barrier_gate_id="virtual_barrier_3", ) - machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot1_dot2_epsilon", - set_dc_virtual_axis=False, + machine.register_qubit_pair( + qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2" ) - machine.quantum_dot_pairs["dot3_dot4_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot3_dot4_epsilon", - set_dc_virtual_axis=False, + machine.register_qubit_pair( + qubit_control_name="Q3", qubit_target_name="Q4", id="Q3_Q4" ) - machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") - machine.register_qubit_pair(qubit_control_name="Q3", qubit_target_name="Q4", id="Q3_Q4") - # QuantumDotPair.__post_init__ eagerly creates the VoltageSequence (cached) # before detuning axes are added, so the KeepLevels tracker is stale. machine.reset_voltage_sequence("main_qpu") diff --git a/tests/architecture/quantum_dots/operations/test_alignment_fixes.py b/tests/architecture/quantum_dots/operations/test_alignment_fixes.py index 43606837..0d8a5c01 100644 --- a/tests/architecture/quantum_dots/operations/test_alignment_fixes.py +++ b/tests/architecture/quantum_dots/operations/test_alignment_fixes.py @@ -44,8 +44,8 @@ from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( Measure2QMacro, ) -from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( - _make_xy_pulse_factories, +from quam_builder.architecture.quantum_dots.operations.pulse_catalog import ( + make_xy_pulse_factories, ) from quam_builder.architecture.quantum_dots.qubit import LDQubit @@ -69,24 +69,24 @@ def test_single_channel_pulse_has_no_axis_angle(self): opx_output=LFFEMAnalogOutputPort("con1", 3, port_id=1), RF_frequency=100_000_000, ) - default_pulses = _make_xy_pulse_factories(xy) + default_pulses = make_xy_pulse_factories(xy) for name, pulse in default_pulses.items(): xy.operations[name] = pulse - pulse = xy.operations["gaussian"] + pulse = xy.operations["gaussian_x90"] assert ( pulse.axis_angle is None - ), "Pulse 'gaussian' should have axis_angle=None for SingleChannel" + ), "Pulse 'gaussian_x90' should have axis_angle=None for SingleChannel" def test_iq_channel_pulse_has_axis_angle(self): """IQ channels should still get axis_angle=0.0.""" xy = MagicMock(spec=XYDriveIQ) xy.operations = {} - default_pulses = _make_xy_pulse_factories(xy) + default_pulses = make_xy_pulse_factories(xy) for name, pulse in default_pulses.items(): xy.operations[name] = pulse - pulse = xy.operations["gaussian"] + pulse = xy.operations["gaussian_x90"] assert pulse.axis_angle == 0.0 @@ -106,7 +106,7 @@ def test_z_neg90_default_angle(self): macro = ZNeg90Macro() assert macro.default_angle == pytest.approx(-np.pi / 2) - def test_z_neg90_wired_to_qubit(self, reset_catalog): + def test_z_neg90_wired_to_qubit(self): """After wiring, qubit should have z_neg90 macro.""" machine = _make_wired_machine() wire_machine_macros(machine) @@ -123,25 +123,25 @@ def test_z_neg90_wired_to_qubit(self, reset_catalog): class TestMeasureMacroRegistration: """Verify macro types are correctly registered in the catalog.""" - def test_measure1q_macro_type(self, reset_catalog): + def test_measure1q_macro_type(self): machine = _make_wired_machine() wire_machine_macros(machine) q = machine.qubits["Q1"] assert isinstance(q.macros["measure"], Measure1QMacro) - def test_measure_psb_pair_macro_type(self, reset_catalog): + def test_measure_psb_pair_macro_type(self): machine = _make_wired_machine() wire_machine_macros(machine) pair = machine.quantum_dot_pairs["dot1_dot2_pair"] assert isinstance(pair.macros["measure"], MeasurePSBPairMacro) - def test_sensor_dot_measure_macro_type(self, reset_catalog): + def test_sensor_dot_measure_macro_type(self): machine = _make_wired_machine() wire_machine_macros(machine) sd = machine.sensor_dots["virtual_sensor_1"] assert isinstance(sd.macros["measure"], SensorDotMeasureMacro) - def test_measure2q_delegates(self, reset_catalog): + def test_measure2q_delegates(self): machine = _make_wired_machine() wire_machine_macros(machine) qp = machine.qubit_pairs["Q1_Q2"] @@ -151,7 +151,7 @@ def test_measure2q_delegates(self, reset_catalog): class TestMeasure1QNavigation: """Test that Measure1QMacro navigates to the correct QuantumDotPair.""" - def test_raises_without_preferred_readout(self, reset_catalog): + def test_raises_without_preferred_readout(self): machine = _make_wired_machine() wire_machine_macros(machine) q = machine.qubits["Q1"] @@ -160,7 +160,7 @@ def test_raises_without_preferred_readout(self, reset_catalog): with pytest.raises(ValueError, match="preferred_readout_quantum_dot"): q.macros["measure"].apply() - def test_raises_with_invalid_pair(self, reset_catalog): + def test_raises_with_invalid_pair(self): """Setting preferred_readout_quantum_dot to a non-existent dot should fail at the setter.""" machine = _make_wired_machine() wire_machine_macros(machine) @@ -172,14 +172,14 @@ def test_raises_with_invalid_pair(self, reset_catalog): class TestMeasurePSBPairNavigation: """Test MeasurePSBPairMacro structure and error paths.""" - def test_pair_macro_has_correct_point(self, reset_catalog): + def test_pair_macro_has_correct_point(self): machine = _make_wired_machine() wire_machine_macros(machine) pair = machine.quantum_dot_pairs["dot1_dot2_pair"] macro = pair.macros["measure"] assert macro.point == VoltagePointName.MEASURE.value - def test_pair_has_sensor_dots(self, reset_catalog): + def test_pair_has_sensor_dots(self): machine = _make_wired_machine() wire_machine_macros(machine) pair = machine.quantum_dot_pairs["dot1_dot2_pair"] @@ -204,7 +204,9 @@ def test_state_macro_signature_uses_point_keyword(self): assert "point_name" not in measure_pair_sig.parameters def test_initialize_state_macro_ramps_to_named_point(self): - macro = InitializeStateMacro(point="custom_init", ramp_duration=48, hold_duration=64) + macro = InitializeStateMacro( + point="custom_init", ramp_duration=48, hold_duration=64 + ) owner = MagicMock() with patch( @@ -213,7 +215,9 @@ def test_initialize_state_macro_ramps_to_named_point(self): ): macro.apply() - owner.ramp_to_point.assert_called_once_with("custom_init", ramp_duration=48, duration=64) + owner.ramp_to_point.assert_called_once_with( + "custom_init", ramp_duration=48, duration=64 + ) owner.ramp_to_voltages.assert_not_called() def test_initialize_state_macro_ramps_to_voltage_dict(self): @@ -227,7 +231,9 @@ def test_initialize_state_macro_ramps_to_voltage_dict(self): ): macro.apply() - owner.ramp_to_voltages.assert_called_once_with(voltages, duration=64, ramp_duration=48) + owner.ramp_to_voltages.assert_called_once_with( + voltages, duration=64, ramp_duration=48 + ) owner.ramp_to_point.assert_not_called() def test_exchange_state_macro_accepts_voltage_dict_targets(self): @@ -258,11 +264,12 @@ def test_exchange_state_macro_accepts_voltage_dict_targets(self): def test_measure_psb_pair_macro_steps_to_voltage_dict(self): voltages = {"virtual_dot_1": -0.1} - macro = MeasurePSBPairMacro(point=voltages, hold_duration=96) + macro = MeasurePSBPairMacro(point=voltages, buffer_duration=96) sensor_dot = MagicMock() owner = MagicMock() owner.id = "dot1_dot2_pair" owner.sensor_dots = [sensor_dot] + owner.voltage_sequence = None with patch( "quam_builder.architecture.quantum_dots.operations.default_macros.state_macros._owner_component", @@ -274,19 +281,21 @@ def test_measure_psb_pair_macro_steps_to_voltage_dict(self): owner.step_to_point.assert_not_called() sensor_dot.macros[VoltagePointName.MEASURE.value].apply.assert_called_once_with( quantum_dot_pair_id="dot1_dot2_pair", + voltage_sequence=None, + gate_channel_names=None, ) class TestMeasure2QDelegation: """Test Measure2QMacro delegates to quantum_dot_pair.""" - def test_qubit_pair_has_quantum_dot_pair(self, reset_catalog): + def test_qubit_pair_has_quantum_dot_pair(self): machine = _make_wired_machine() wire_machine_macros(machine) qp = machine.qubit_pairs["Q1_Q2"] assert qp.quantum_dot_pair is not None - def test_measure2q_raises_without_quantum_dot_pair(self, reset_catalog): + def test_measure2q_raises_without_quantum_dot_pair(self): """Directly test Measure2QMacro.apply with no quantum_dot_pair.""" macro = Measure2QMacro() owner = MagicMock() @@ -328,7 +337,9 @@ def _make_wired_machine() -> LossDiVincenzoQuam: frequency_bare=0, intermediate_frequency=500e6, operations={ - "readout": quam_pulses.SquareReadoutPulse(length=200, id="readout", amplitude=0.01) + "readout": quam_pulses.SquareReadoutPulse( + length=200, id="readout", amplitude=0.01 + ) }, opx_output=LFFEMAnalogOutputPort("con1", 5, port_id=1), opx_input=LFFEMAnalogInputPort("con1", 5, port_id=2), @@ -361,13 +372,9 @@ def _make_wired_machine() -> LossDiVincenzoQuam: barrier_gate_id="virtual_barrier_1", ) - machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot1_dot2_epsilon", - set_dc_virtual_axis=False, + machine.register_qubit_pair( + qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2" ) - - machine.register_qubit_pair(qubit_control_name="Q1", qubit_target_name="Q2", id="Q1_Q2") machine.reset_voltage_sequence("main_qpu") return machine diff --git a/tests/architecture/quantum_dots/operations/test_canonical_dot_pair_delegation.py b/tests/architecture/quantum_dots/operations/test_canonical_dot_pair_delegation.py new file mode 100644 index 00000000..d29b697f --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_canonical_dot_pair_delegation.py @@ -0,0 +1,570 @@ +"""Tests for canonical QuantumDotPair macro delegation.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + Empty1QMacro, + Initialize1QMacro, + Measure1QMacro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + Empty2QMacro, + Initialize2QMacro, + Measure2QMacro, +) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + EmptyStateMacro, + InitializeStateMacro, + MeasurePSBPairMacro, +) +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD + + +class TestInitializeStateMacroUpdate: + def test_update_ramp_duration(self): + macro = InitializeStateMacro() + macro.update(ramp_duration=32) + assert macro.ramp_duration == 32 + + def test_update_hold_duration(self): + macro = InitializeStateMacro() + macro.update(hold_duration=200) + assert macro.hold_duration == 200 + + def test_update_point(self): + macro = InitializeStateMacro() + macro.update(point="custom_init") + assert macro.point == "custom_init" + + def test_update_multiple_params(self): + macro = InitializeStateMacro() + macro.update(ramp_duration=64, hold_duration=400) + assert macro.ramp_duration == 64 + assert macro.hold_duration == 400 + + def test_update_rejects_unknown_kwargs(self): + macro = InitializeStateMacro() + with pytest.raises(TypeError): + macro.update(nonexistent_param=42) + + def test_update_hold_duration_none_clears_override(self): + macro = InitializeStateMacro() + macro.update(hold_duration=200) + assert macro.hold_duration == 200 + macro.update(hold_duration=None) + assert macro.hold_duration is None + + +class TestEmptyStateMacroUpdate: + def test_update_hold_duration(self): + macro = EmptyStateMacro() + macro.update(hold_duration=300) + assert macro.hold_duration == 300 + + def test_update_point(self): + macro = EmptyStateMacro() + macro.update(point="custom_empty") + assert macro.point == "custom_empty" + + def test_update_multiple_params(self): + macro = EmptyStateMacro() + macro.update(hold_duration=300, point="custom_empty") + assert macro.hold_duration == 300 + assert macro.point == "custom_empty" + + def test_update_rejects_unknown_kwargs(self): + macro = EmptyStateMacro() + with pytest.raises(TypeError): + macro.update(nonexistent_param=42) + + def test_update_hold_duration_none_clears_override(self): + macro = EmptyStateMacro() + macro.update(hold_duration=300) + assert macro.hold_duration == 300 + macro.update(hold_duration=None) + assert macro.hold_duration is None + + +class TestMeasurePSBPairMacroUpdate: + def test_update_buffer_duration(self): + macro = MeasurePSBPairMacro() + macro.update(buffer_duration=500) + assert macro.buffer_duration == 500 + + def test_update_point(self): + macro = MeasurePSBPairMacro() + macro.update(point="custom_measure") + assert macro.point == "custom_measure" + + def test_update_multiple_params(self): + macro = MeasurePSBPairMacro() + macro.update(buffer_duration=500, point="custom_measure") + assert macro.buffer_duration == 500 + assert macro.point == "custom_measure" + + def test_update_rejects_unknown_kwargs(self): + macro = MeasurePSBPairMacro() + with pytest.raises(TypeError): + macro.update(nonexistent_param=42) + + +def _mock_qubit_with_pair(pair_macros=None): + """Build a mock qubit whose preferred readout dot resolves to a pair.""" + pair = MagicMock() + if pair_macros is not None: + pair.macros = pair_macros + else: + pair.macros = { + "initialize": MagicMock(), + "empty": MagicMock(), + "measure": MagicMock(), + } + + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = "dot2" + qubit.quantum_dot.id = "dot1" + qubit.machine.find_quantum_dot_pair.return_value = "dot1_dot2_pair" + qubit.machine.quantum_dot_pairs = {"dot1_dot2_pair": pair} + return qubit, pair + + +class TestInitialize1QMacroDelegation: + def test_apply_delegates_to_pair(self): + qubit, pair = _mock_qubit_with_pair() + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.apply(ramp_duration=32) + + pair.macros["initialize"].apply.assert_called_once_with(ramp_duration=32) + + def test_apply_raises_without_preferred_dot(self): + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = None + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + with pytest.raises(ValueError, match="preferred_readout_quantum_dot"): + macro.apply() + + def test_apply_raises_when_pair_not_found(self): + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = "dot2" + qubit.quantum_dot.id = "dot1" + qubit.machine.find_quantum_dot_pair.return_value = None + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + with pytest.raises(ValueError, match="No QuantumDotPair"): + macro.apply() + + def test_update_delegates_to_pair(self): + init_macro = InitializeStateMacro() + qubit, pair = _mock_qubit_with_pair({"initialize": init_macro}) + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.update(ramp_duration=64) + + assert init_macro.ramp_duration == 64 + + def test_callable_dispatches_apply(self): + qubit, pair = _mock_qubit_with_pair() + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro() + + pair.macros["initialize"].apply.assert_called_once() + + def test_inferred_duration_delegates(self): + init_macro = MagicMock() + init_macro.inferred_duration = 5e-7 + qubit, pair = _mock_qubit_with_pair({"initialize": init_macro}) + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.inferred_duration == pytest.approx(5e-7) + + +class TestEmpty1QMacroDelegation: + def test_apply_delegates_to_pair(self): + qubit, pair = _mock_qubit_with_pair() + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.apply(hold_duration=100) + + pair.macros["empty"].apply.assert_called_once_with(hold_duration=100) + + def test_apply_raises_without_preferred_dot(self): + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = None + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + with pytest.raises(ValueError, match="preferred_readout_quantum_dot"): + macro.apply() + + def test_update_delegates_to_pair(self): + empty_macro = EmptyStateMacro() + qubit, pair = _mock_qubit_with_pair({"empty": empty_macro}) + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.update(hold_duration=200) + + assert empty_macro.hold_duration == 200 + + def test_callable_dispatches_apply(self): + qubit, pair = _mock_qubit_with_pair() + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro() + + pair.macros["empty"].apply.assert_called_once() + + def test_inferred_duration_delegates(self): + empty_macro = MagicMock() + empty_macro.inferred_duration = 3e-7 + qubit, pair = _mock_qubit_with_pair({"empty": empty_macro}) + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.inferred_duration == pytest.approx(3e-7) + + +class TestMeasure1QMacroProxy: + def test_update_delegates_to_pair(self): + measure_macro = MeasurePSBPairMacro() + qubit, pair = _mock_qubit_with_pair({"measure": measure_macro}) + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.update(buffer_duration=500) + + assert measure_macro.buffer_duration == 500 + + def test_getattr_reads_from_pair_macro(self): + measure_macro = MeasurePSBPairMacro(buffer_duration=250) + qubit, pair = _mock_qubit_with_pair({"measure": measure_macro}) + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.buffer_duration == 250 + + def test_setattr_writes_to_pair_macro(self): + measure_macro = MeasurePSBPairMacro(buffer_duration=100) + qubit, pair = _mock_qubit_with_pair({"measure": measure_macro}) + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.buffer_duration = 999 + + assert measure_macro.buffer_duration == 999 + + +_2Q_OWNER_PATCH = ( + "quam_builder.architecture.quantum_dots.operations.default_macros" + ".two_qubit_macros._owner_component" +) + + +def _mock_qubit_pair_owner(pair_macros=None): + """Build a mock LDQubitPair owner with a quantum_dot_pair.""" + qd_pair = MagicMock() + if pair_macros is not None: + qd_pair.macros = pair_macros + else: + qd_pair.macros = { + "initialize": MagicMock(), + "empty": MagicMock(), + "measure": MagicMock(), + } + + owner = MagicMock() + owner.quantum_dot_pair = qd_pair + owner.id = "Q1_Q2" + return owner, qd_pair + + +class TestInitialize2QMacroDelegation: + def test_apply_delegates_to_pair(self): + owner, qd_pair = _mock_qubit_pair_owner() + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.apply(ramp_duration=32) + + qd_pair.macros["initialize"].apply.assert_called_once_with(ramp_duration=32) + + def test_apply_raises_without_quantum_dot_pair(self): + owner = MagicMock() + owner.quantum_dot_pair = None + owner.id = "Q1_Q2" + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + with pytest.raises(ValueError, match="quantum_dot_pair"): + macro.apply() + + def test_update_delegates_to_pair(self): + init_macro = InitializeStateMacro() + owner, qd_pair = _mock_qubit_pair_owner({"initialize": init_macro}) + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.update(ramp_duration=128) + + assert init_macro.ramp_duration == 128 + + def test_callable_dispatches_apply(self): + owner, qd_pair = _mock_qubit_pair_owner() + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro() + + qd_pair.macros["initialize"].apply.assert_called_once() + + def test_inferred_duration_delegates(self): + init_macro = MagicMock() + init_macro.inferred_duration = 1e-6 + owner, qd_pair = _mock_qubit_pair_owner({"initialize": init_macro}) + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration == pytest.approx(1e-6) + + +class TestEmpty2QMacroDelegation: + def test_apply_delegates_to_pair(self): + owner, qd_pair = _mock_qubit_pair_owner() + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.apply(hold_duration=50) + + qd_pair.macros["empty"].apply.assert_called_once_with(hold_duration=50) + + def test_apply_raises_without_quantum_dot_pair(self): + owner = MagicMock() + owner.quantum_dot_pair = None + owner.id = "Q1_Q2" + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + with pytest.raises(ValueError, match="quantum_dot_pair"): + macro.apply() + + def test_update_delegates_to_pair(self): + empty_macro = EmptyStateMacro() + owner, qd_pair = _mock_qubit_pair_owner({"empty": empty_macro}) + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.update(hold_duration=400) + + assert empty_macro.hold_duration == 400 + + def test_callable_dispatches_apply(self): + owner, qd_pair = _mock_qubit_pair_owner() + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro() + + qd_pair.macros["empty"].apply.assert_called_once() + + +class TestMeasure2QMacroProxy: + def test_update_delegates_to_pair(self): + measure_macro = MeasurePSBPairMacro() + owner, qd_pair = _mock_qubit_pair_owner({"measure": measure_macro}) + macro = Measure2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.update(buffer_duration=800) + + assert measure_macro.buffer_duration == 800 + + +class TestParameterProxy1Q: + """Verify __getattr__/__setattr__ proxy for 1Q macros.""" + + def test_init_getattr_reads_ramp_duration(self): + init_macro = InitializeStateMacro(ramp_duration=48) + qubit, pair = _mock_qubit_with_pair({"initialize": init_macro}) + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.ramp_duration == 48 + + def test_init_setattr_writes_ramp_duration(self): + init_macro = InitializeStateMacro(ramp_duration=16) + qubit, pair = _mock_qubit_with_pair({"initialize": init_macro}) + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.ramp_duration = 96 + + assert init_macro.ramp_duration == 96 + + def test_empty_getattr_reads_hold_duration(self): + empty_macro = EmptyStateMacro(hold_duration=300) + qubit, pair = _mock_qubit_with_pair({"empty": empty_macro}) + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.hold_duration == 300 + + def test_empty_setattr_writes_hold_duration(self): + empty_macro = EmptyStateMacro(hold_duration=100) + qubit, pair = _mock_qubit_with_pair({"empty": empty_macro}) + macro = Empty1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + macro.hold_duration = 500 + + assert empty_macro.hold_duration == 500 + + def test_getattr_raises_for_unknown_attr(self): + qubit, pair = _mock_qubit_with_pair() + macro = Initialize1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + with pytest.raises(AttributeError, match="no_such_field"): + _ = macro.no_such_field + + +class TestParameterProxy2Q: + """Verify __getattr__/__setattr__ proxy for 2Q macros.""" + + def test_init_getattr_reads_ramp_duration(self): + init_macro = InitializeStateMacro(ramp_duration=64) + owner, qd_pair = _mock_qubit_pair_owner({"initialize": init_macro}) + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + assert macro.ramp_duration == 64 + + def test_init_setattr_writes_ramp_duration(self): + init_macro = InitializeStateMacro(ramp_duration=16) + owner, qd_pair = _mock_qubit_pair_owner({"initialize": init_macro}) + macro = Initialize2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.ramp_duration = 128 + + assert init_macro.ramp_duration == 128 + + def test_empty_getattr_reads_hold_duration(self): + empty_macro = EmptyStateMacro(hold_duration=250) + owner, qd_pair = _mock_qubit_pair_owner({"empty": empty_macro}) + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + assert macro.hold_duration == 250 + + def test_empty_setattr_writes_hold_duration(self): + empty_macro = EmptyStateMacro(hold_duration=100) + owner, qd_pair = _mock_qubit_pair_owner({"empty": empty_macro}) + macro = Empty2QMacro() + + with patch(_2Q_OWNER_PATCH, return_value=owner): + macro.hold_duration = 600 + + assert empty_macro.hold_duration == 600 + + +class TestSerializationPersistence: + """Verify that update() changes persist through save/load round-trips.""" + + def test_initialize_update_persists(self, qd_machine, tmp_path): + wire_machine_macros(qd_machine) + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + pair.macros["initialize"].update(ramp_duration=64, hold_duration=400) + + qd_machine.save(tmp_path) + loaded = BaseQuamQD.load(tmp_path) + + loaded_pair = loaded.quantum_dot_pairs["dot1_dot2_pair"] + assert loaded_pair.macros["initialize"].ramp_duration == 64 + assert loaded_pair.macros["initialize"].hold_duration == 400 + + def test_empty_update_persists(self, qd_machine, tmp_path): + wire_machine_macros(qd_machine) + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + pair.macros["empty"].update(hold_duration=300) + + qd_machine.save(tmp_path) + loaded = BaseQuamQD.load(tmp_path) + + loaded_pair = loaded.quantum_dot_pairs["dot1_dot2_pair"] + assert loaded_pair.macros["empty"].hold_duration == 300 + + def test_measure_update_persists(self, qd_machine, tmp_path): + wire_machine_macros(qd_machine) + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + pair.macros["measure"].update(buffer_duration=500) + + qd_machine.save(tmp_path) + loaded = BaseQuamQD.load(tmp_path) + + loaded_pair = loaded.quantum_dot_pairs["dot1_dot2_pair"] + assert loaded_pair.macros["measure"].buffer_duration == 500 + + def test_direct_setattr_persists(self, qd_machine, tmp_path): + wire_machine_macros(qd_machine) + pair = qd_machine.quantum_dot_pairs["dot1_dot2_pair"] + pair.macros["initialize"].ramp_duration = 128 + + qd_machine.save(tmp_path) + loaded = BaseQuamQD.load(tmp_path) + + loaded_pair = loaded.quantum_dot_pairs["dot1_dot2_pair"] + assert loaded_pair.macros["initialize"].ramp_duration == 128 diff --git a/tests/architecture/quantum_dots/operations/test_catalog_reset.py b/tests/architecture/quantum_dots/operations/test_catalog_reset.py index 2986ce2b..c50d4a9f 100644 --- a/tests/architecture/quantum_dots/operations/test_catalog_reset.py +++ b/tests/architecture/quantum_dots/operations/test_catalog_reset.py @@ -1,37 +1,68 @@ -"""Tests for catalog/registry reset helpers (FOR TESTING ONLY).""" +"""Tests for catalog-based macro registration (no global registry state).""" -import pytest +from quam.components import StickyChannelAddon +from quam.components.ports import LFFEMAnalogOutputPort -from quam_builder.architecture.quantum_dots.operations import component_macro_catalog -from quam_builder.architecture.quantum_dots.operations import macro_registry +from quam_builder.architecture.quantum_dots.components import QuantumDot, VoltageGate +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + DefaultMacroCatalog, + MacroRegistry, + UtilityMacroCatalog, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + ZNeg90Macro, +) +from quam_builder.architecture.quantum_dots.operations.names import SingleQubitMacroName +from quam_builder.architecture.quantum_dots.qubit import LDQubit -def test_reset_registration_sets_registered_false(): - """_reset_registration() sets _REGISTERED = False so registration can re-run.""" - component_macro_catalog.register_default_component_macro_factories() - component_macro_catalog._reset_registration() - assert component_macro_catalog._REGISTERED is False +def test_default_macro_catalog_fresh_instances_are_independent(): + """Each DefaultMacroCatalog keeps its own type table.""" + a = DefaultMacroCatalog() + b = DefaultMacroCatalog() + assert a is not b + assert a.get_factories(LDQubit) == b.get_factories(LDQubit) -def test_reset_registry_clears_factories(): - """_reset_registry() empties _COMPONENT_MACRO_FACTORIES.""" - component_macro_catalog.register_default_component_macro_factories() - assert len(macro_registry._COMPONENT_MACRO_FACTORIES) > 0 - macro_registry._reset_registry() - assert len(macro_registry._COMPONENT_MACRO_FACTORIES) == 0 +def test_macro_registry_resolve_merges_catalogs_in_priority_order(): + """Later (higher-priority) catalogs override macro keys from earlier ones.""" + low = UtilityMacroCatalog() + high = DefaultMacroCatalog() + reg = MacroRegistry() + reg.register_catalog(low) + reg.register_catalog(high) + gate = VoltageGate( + id="g1", + opx_output=LFFEMAnalogOutputPort("con1", 1, port_id=1), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + qd = QuantumDot(id="qd1", physical_channel=gate) + qubit = LDQubit(id="Q1", quantum_dot=qd) -def test_reset_allows_fresh_registration(): - """After both resets, register_default_component_macro_factories() can re-run.""" - component_macro_catalog.register_default_component_macro_factories() - component_macro_catalog._reset_registration() - macro_registry._reset_registry() - # Should not raise; idempotent re-registration - component_macro_catalog.register_default_component_macro_factories() + merged = reg.resolve_factories(qubit) + assert SingleQubitMacroName.Z_NEG_90.value in merged + assert merged[SingleQubitMacroName.Z_NEG_90.value] is ZNeg90Macro -def test_reset_catalog_fixture_provides_fresh_state(reset_catalog): - """Tests using reset_catalog fixture see fresh registration state.""" - # After reset_catalog runs, registry should be empty and _REGISTERED False - assert component_macro_catalog._REGISTERED is False - assert len(macro_registry._COMPONENT_MACRO_FACTORIES) == 0 +def test_default_macro_catalog_register_custom_factory(): + """register() on a fresh catalog adds or replaces entries for a type.""" + + class CustomZ(ZNeg90Macro): + pass + + catalog = DefaultMacroCatalog() + catalog.register( + LDQubit, + { + **catalog.get_factories(LDQubit), + SingleQubitMacroName.Z_NEG_90.value: CustomZ, + }, + ) + factories = catalog.get_factories(LDQubit) + assert factories[SingleQubitMacroName.Z_NEG_90.value] is CustomZ + + +def test_macro_registry_fresh_instance_has_no_catalogs(): + reg = MacroRegistry() + assert reg.catalogs == () diff --git a/tests/architecture/quantum_dots/operations/test_measure_duration_chain.py b/tests/architecture/quantum_dots/operations/test_measure_duration_chain.py new file mode 100644 index 00000000..2d00ccd7 --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_measure_duration_chain.py @@ -0,0 +1,318 @@ +"""Tests for readout duration inference chain and apply() wiring. + +Covers: +- SensorDotMeasureMacro.inferred_duration converts pulse samples to ns +- SensorDotMeasureMacro.apply() calls qua.align + track_sticky_duration +- MeasurePSBPairMacro.inferred_duration = buffer + sensor duration +- MeasurePSBPairMacro.buffer_duration rename (from hold_duration) +- Measure1QMacro.inferred_duration navigates full chain +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + Measure1QMacro, +) +from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( + MeasurePSBPairMacro, + SensorDotMeasureMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + VoltagePointName, +) + +_OWNER_PATCH = ( + "quam_builder.architecture.quantum_dots.operations.default_macros" + ".state_macros._owner_component" +) + + +def _mock_sensor_owner(pulse_length: int = 2000) -> MagicMock: + """Build a mock sensor dot owner with a readout resonator and pulse.""" + owner = MagicMock() + pulse = MagicMock() + pulse.length = pulse_length + owner.readout_resonator.operations = {"readout": pulse} + owner.readout_resonator.name = "rr1" + return owner + + +# --------------------------------------------------------------------------- # +# SensorDotMeasureMacro.inferred_duration +# --------------------------------------------------------------------------- # + + +class TestSensorDotMeasureInferredDuration: + def test_returns_pulse_length_in_seconds(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=2000) + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration == pytest.approx(2000 * 1e-9) + + def test_returns_none_when_no_resonator(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = MagicMock() + owner.readout_resonator = None + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration is None + + def test_returns_none_when_pulse_missing(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = MagicMock() + owner.readout_resonator.operations = {} + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration is None + + def test_returns_none_when_pulse_has_no_length(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = MagicMock() + pulse = MagicMock(spec=[]) + owner.readout_resonator.operations = {"readout": pulse} + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration is None + + def test_readout_pulse_length_ns_property(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=1600) + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.readout_pulse_length_ns == 1600 + + +# --------------------------------------------------------------------------- # +# SensorDotMeasureMacro.apply() — alignment and voltage tracking +# --------------------------------------------------------------------------- # + + +def _qua_mock_context(): + """Context manager that mocks ``qm.qua`` imports used by ``apply()``.""" + mock_mod = MagicMock() + mock_mod.declare.return_value = 0.0 + mock_mod.fixed = "fixed_sentinel" + mock_mod.assign = MagicMock() + return patch.dict("sys.modules", {"qm": MagicMock(), "qm.qua": mock_mod}), mock_mod + + +class TestSensorDotMeasureApply: + def test_aligns_gates_with_resonator(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=2000) + vs = MagicMock() + gate_names = ["gate_P1", "gate_P2"] + + ctx, qua_mod = _qua_mock_context() + with patch(_OWNER_PATCH, return_value=owner), ctx: + macro.apply( + voltage_sequence=vs, + gate_channel_names=gate_names, + ) + + qua_mod.align.assert_called_once_with("gate_P1", "gate_P2", "rr1") + + def test_holds_gates_during_readout(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=2000) + vs = MagicMock() + + ctx, _ = _qua_mock_context() + with patch(_OWNER_PATCH, return_value=owner), ctx: + macro.apply( + voltage_sequence=vs, + gate_channel_names=["gate_P1"], + ) + + vs.track_sticky_duration.assert_called_once_with(2000) + + def test_no_align_without_gate_names(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=2000) + vs = MagicMock() + + ctx, qua_mod = _qua_mock_context() + with patch(_OWNER_PATCH, return_value=owner), ctx: + macro.apply(voltage_sequence=vs) + + qua_mod.align.assert_not_called() + + def test_no_track_without_voltage_sequence(self): + macro = SensorDotMeasureMacro(pulse_name="readout") + owner = _mock_sensor_owner(pulse_length=2000) + + ctx, _ = _qua_mock_context() + with patch(_OWNER_PATCH, return_value=owner), ctx: + result = macro.apply(gate_channel_names=["gate_P1"]) + + assert result is not None + + +# --------------------------------------------------------------------------- # +# MeasurePSBPairMacro — buffer_duration + inferred_duration +# --------------------------------------------------------------------------- # + + +class TestMeasurePSBPairDuration: + def test_buffer_duration_field_exists(self): + macro = MeasurePSBPairMacro() + assert hasattr(macro, "buffer_duration") + assert macro.buffer_duration is None + + def test_buffer_duration_settable(self): + macro = MeasurePSBPairMacro(buffer_duration=500) + assert macro.buffer_duration == 500 + + def test_inferred_duration_buffer_plus_sensor(self): + macro = MeasurePSBPairMacro(buffer_duration=200) + sensor_macro = MagicMock() + sensor_macro.inferred_duration = 2e-6 + + sensor_dot = MagicMock() + sensor_dot.macros = {VoltagePointName.MEASURE.value: sensor_macro} + + owner = MagicMock() + owner.sensor_dots = [sensor_dot] + + with patch(_OWNER_PATCH, return_value=owner): + dur = macro.inferred_duration + + assert dur == pytest.approx(200e-9 + 2e-6) + + def test_inferred_duration_zero_buffer(self): + """When buffer_duration is None, buffer contribution is 0.""" + macro = MeasurePSBPairMacro(buffer_duration=None) + sensor_macro = MagicMock() + sensor_macro.inferred_duration = 2e-6 + + sensor_dot = MagicMock() + sensor_dot.macros = {VoltagePointName.MEASURE.value: sensor_macro} + + owner = MagicMock() + owner.sensor_dots = [sensor_dot] + + with patch(_OWNER_PATCH, return_value=owner): + dur = macro.inferred_duration + + assert dur == pytest.approx(2e-6) + + def test_inferred_duration_none_without_sensor(self): + macro = MeasurePSBPairMacro(buffer_duration=100) + owner = MagicMock() + owner.sensor_dots = [] + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration is None + + def test_inferred_duration_none_when_sensor_has_no_duration(self): + macro = MeasurePSBPairMacro(buffer_duration=100) + sensor_macro = MagicMock() + sensor_macro.inferred_duration = None + + sensor_dot = MagicMock() + sensor_dot.macros = {VoltagePointName.MEASURE.value: sensor_macro} + + owner = MagicMock() + owner.sensor_dots = [sensor_dot] + + with patch(_OWNER_PATCH, return_value=owner): + assert macro.inferred_duration is None + + def test_apply_passes_voltage_context_to_sensor(self): + """Verify apply() forwards voltage_sequence and gate_channel_names.""" + macro = MeasurePSBPairMacro(buffer_duration=100) + sensor_dot = MagicMock() + + ch1 = MagicMock() + ch1.name = "gate_P1" + ch2 = MagicMock() + ch2.name = "gate_P2" + + owner = MagicMock() + owner.id = "pair1" + owner.sensor_dots = [sensor_dot] + owner.voltage_sequence.gate_set.channels.values.return_value = [ch1, ch2] + + with patch(_OWNER_PATCH, return_value=owner): + macro.apply() + + sensor_dot.macros[VoltagePointName.MEASURE.value].apply.assert_called_once_with( + quantum_dot_pair_id="pair1", + voltage_sequence=owner.voltage_sequence, + gate_channel_names=["gate_P1", "gate_P2"], + ) + + +# --------------------------------------------------------------------------- # +# Measure1QMacro.inferred_duration +# --------------------------------------------------------------------------- # + + +class TestMeasure1QInferredDuration: + def test_inferred_duration_navigates_chain(self): + pair_macro = MagicMock() + pair_macro.inferred_duration = 2.2e-6 + + pair = MagicMock() + pair.macros = {"measure": pair_macro} + + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = "dot2" + qubit.quantum_dot.id = "dot1" + qubit.machine.find_quantum_dot_pair.return_value = "dot1_dot2_pair" + qubit.machine.quantum_dot_pairs = {"dot1_dot2_pair": pair} + + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + dur = macro.inferred_duration + + assert dur == pytest.approx(2.2e-6) + + def test_inferred_duration_none_without_preferred_dot(self): + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = None + + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.inferred_duration is None + + def test_inferred_duration_none_when_pair_not_found(self): + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = "dot2" + qubit.quantum_dot.id = "dot1" + qubit.machine.find_quantum_dot_pair.return_value = None + + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.inferred_duration is None + + def test_inferred_duration_none_when_pair_has_no_measure_macro(self): + pair = MagicMock() + pair.macros = {} + + qubit = MagicMock() + qubit.preferred_readout_quantum_dot = "dot2" + qubit.quantum_dot.id = "dot1" + qubit.machine.find_quantum_dot_pair.return_value = "dot1_dot2_pair" + qubit.machine.quantum_dot_pairs = {"dot1_dot2_pair": pair} + + macro = Measure1QMacro() + + with patch.object( + type(macro), "qubit", new_callable=lambda: property(lambda self: qubit) + ): + assert macro.inferred_duration is None diff --git a/tests/architecture/quantum_dots/operations/test_pulse_catalog.py b/tests/architecture/quantum_dots/operations/test_pulse_catalog.py index d59a0f20..2ce3405c 100644 --- a/tests/architecture/quantum_dots/operations/test_pulse_catalog.py +++ b/tests/architecture/quantum_dots/operations/test_pulse_catalog.py @@ -1,31 +1,22 @@ -"""Tests for the pulse catalog and pulse registry.""" +"""Tests for the pulse builder helpers.""" import pytest -from quam.components.channels import SingleChannel -from quam.components.pulses import GaussianPulse, SquareReadoutPulse +from quam.components.pulses import SquareReadoutPulse -from quam_builder.architecture.quantum_dots.components.pulses import ScalableGaussianPulse - -from quam_builder.architecture.quantum_dots.operations.component_pulse_catalog import ( - _make_xy_pulse_factories, - _make_readout_pulse, - register_default_component_pulse_factories, - _reset_registration, -) -from quam_builder.architecture.quantum_dots.operations.pulse_registry import ( - _reset_registry, +from quam_builder.architecture.quantum_dots.components.pulses import ( + ScalableDragPulse, + ScalableGaussianPulse, + ScalableHermitePulse, + ScalableKaiserPulse, + ScalableSquarePulse, ) - -@pytest.fixture(autouse=True) -def reset_pulse_state(): - """Reset pulse registry and catalog state for each test.""" - _reset_registry() - _reset_registration() - yield - _reset_registry() - _reset_registration() +from quam_builder.architecture.quantum_dots.operations.pulse_catalog import ( + PULSE_FAMILIES, + make_xy_pulse_factories, + make_readout_pulse, +) class TestMakeXYPulseFactories: @@ -34,17 +25,19 @@ class TestMakeXYPulseFactories: def test_single_channel_no_axis_angle(self): """SingleChannel drives should produce pulse with axis_angle=None.""" from quam.components.ports import LFFEMAnalogOutputPort - from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveSingle + from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveSingle, + ) xy = XYDriveSingle( id="test_xy", RF_frequency=100_000_000, opx_output=LFFEMAnalogOutputPort("con1", 3, port_id=1), ) - pulses = _make_xy_pulse_factories(xy) + pulses = make_xy_pulse_factories(xy) - assert set(pulses.keys()) == {"gaussian"} - pulse = pulses["gaussian"] + assert "gaussian_x90" in pulses + pulse = pulses["gaussian_x90"] assert isinstance(pulse, ScalableGaussianPulse) assert pulse.axis_angle is None @@ -54,49 +47,340 @@ def test_iq_channel_has_axis_angle(self): from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ xy = MagicMock(spec=XYDriveIQ) - # Not a SingleChannel - pulses = _make_xy_pulse_factories(xy) + pulses = make_xy_pulse_factories(xy) - assert pulses["gaussian"].axis_angle == 0.0 + assert pulses["gaussian_x90"].axis_angle == 0.0 def test_pulse_parameters(self): - """Verify default pulse parameters.""" + """Verify pulse structure (not specific default lengths which are configurable).""" from unittest.mock import MagicMock from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + from quam_builder.architecture.quantum_dots.defaults import DEFAULTS xy = MagicMock(spec=XYDriveIQ) - pulses = _make_xy_pulse_factories(xy) + pulses = make_xy_pulse_factories(xy) - gaussian = pulses["gaussian"] + gaussian = pulses["gaussian_x90"] assert isinstance(gaussian, ScalableGaussianPulse) - assert gaussian.length == 1000 - assert gaussian.amplitude == 1.0 - assert gaussian.sigma == pytest.approx(1000 / 6) + assert gaussian.amplitude == DEFAULTS.xy_pulse.amplitude assert gaussian.sigma_ratio == pytest.approx(1 / 6) + assert gaussian.sigma == pytest.approx(gaussian.length * gaussian.sigma_ratio) + def test_all_families_present(self): + """All registered pulse families should produce operations.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ -class TestMakeReadoutPulse: - """Test readout pulse factory.""" + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) - def test_readout_pulse_type(self): - pulse = _make_readout_pulse() - assert isinstance(pulse, SquareReadoutPulse) + for family in PULSE_FAMILIES: + assert f"{family}_x90" in pulses + assert f"{family}_x180" in pulses + assert f"{family}_x_neg90" in pulses + assert f"{family}_y180" in pulses + assert f"{family}_y90" in pulses + assert f"{family}_y_neg90" in pulses + + def test_square_pulse_type(self): + """Square family should use ScalableSquarePulse.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + assert isinstance(pulses["square_x90"], ScalableSquarePulse) + + def test_kaiser_pulse_type(self): + """Kaiser family should use ScalableKaiserPulse.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + assert isinstance(pulses["kaiser_x90"], ScalableKaiserPulse) + + def test_hermite_pulse_type(self): + """Hermite family should use ScalableHermitePulse.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + assert isinstance(pulses["hermite_x90"], ScalableHermitePulse) + + def test_drag_pulse_type(self): + """DRAG family should use ScalableDragPulse.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + assert isinstance(pulses["drag_x90"], ScalableDragPulse) + + def test_cz_pulse_still_present(self): + """CZ pulse should still be generated.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + assert "cz" in pulses + assert isinstance(pulses["cz"], ScalableGaussianPulse) + + +class TestPulseFamilySwitching: + """Test that XYDriveMacro resolves pulse_name dynamically from pulse_family.""" + + def test_default_pulse_family_is_gaussian(self): + from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + XYDriveMacro, + X180Macro, + Y90Macro, + ) + + macro = XYDriveMacro.__new__(XYDriveMacro) + macro.pulse_family = "gaussian" + assert macro.pulse_name == "gaussian_x90" + assert macro.reference_pulse_name == "gaussian_x90" + + def test_x180_resolves_to_family_x180(self): + from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + X180Macro, + ) + + macro = X180Macro.__new__(X180Macro) + macro.pulse_family = "gaussian" + assert macro.pulse_name == "gaussian_x180" + + macro.pulse_family = "kaiser" + assert macro.pulse_name == "kaiser_x180" + + def test_y90_resolves_to_family_y90(self): + from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + Y90Macro, + ) + + macro = Y90Macro.__new__(Y90Macro) + macro.pulse_family = "square" + assert macro.pulse_name == "square_y90" + + def test_switching_family_changes_all_suffixes(self): + from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( + XYDriveMacro, + X180Macro, + XNeg90Macro, + Y180Macro, + YNeg90Macro, + ) + + macros = { + "xy_drive": XYDriveMacro.__new__(XYDriveMacro), + "x180": X180Macro.__new__(X180Macro), + "x_neg90": XNeg90Macro.__new__(XNeg90Macro), + "y180": Y180Macro.__new__(Y180Macro), + "y_neg90": YNeg90Macro.__new__(YNeg90Macro), + } + + for macro in macros.values(): + macro.pulse_family = "kaiser" + + assert macros["xy_drive"].pulse_name == "kaiser_x90" + assert macros["x180"].pulse_name == "kaiser_x180" + assert macros["x_neg90"].pulse_name == "kaiser_x_neg90" + assert macros["y180"].pulse_name == "kaiser_y180" + assert macros["y_neg90"].pulse_name == "kaiser_y_neg90" + + +class TestKaiserPulseWaveform: + """Test Kaiser pulse waveform properties.""" - def test_readout_pulse_parameters(self): - pulse = _make_readout_pulse() - assert pulse.length == 2000 - assert pulse.amplitude == 1.0 + def test_kaiser_peak_equals_amplitude(self): + """Kaiser peak should equal amplitude (peak normalization).""" + import numpy as np + kaiser = ScalableKaiserPulse(length=100, amplitude=0.25) + waveform = kaiser.waveform_function() -class TestRegistration: - """Test pulse catalog registration.""" + assert np.max(np.abs(waveform)) == pytest.approx(0.25, rel=1e-10) - def test_idempotent(self): - register_default_component_pulse_factories() - register_default_component_pulse_factories() # should not raise + def test_kaiser_peak_within_bounds(self): + """Kaiser waveform should never exceed [-1, 1] when amplitude <= 1.""" + import numpy as np - def test_reset(self): - register_default_component_pulse_factories() - _reset_registration() - # After reset, re-registration should work - register_default_component_pulse_factories() + kaiser = ScalableKaiserPulse(length=1000, amplitude=1.0) + waveform = kaiser.waveform_function() + + assert np.max(np.abs(waveform)) <= 1.0 + + def test_kaiser_is_symmetric(self): + """Kaiser window should be symmetric around its center.""" + import numpy as np + + kaiser = ScalableKaiserPulse(length=100, amplitude=1.0) + waveform = kaiser.waveform_function() + + np.testing.assert_allclose(waveform, waveform[::-1], atol=1e-12) + + +class TestHermitePulseWaveform: + """Test Hermite pulse waveform properties.""" + + def test_hermite_peak_equals_amplitude(self): + """Hermite peak should equal amplitude (peak normalization).""" + import numpy as np + + hermite = ScalableHermitePulse(length=100, amplitude=0.25) + waveform = hermite.waveform_function() + + assert np.max(np.abs(waveform)) == pytest.approx(0.25, rel=1e-10) + + def test_hermite_is_symmetric(self): + """Hermite window should be symmetric around its center.""" + import numpy as np + + hermite = ScalableHermitePulse(length=100, amplitude=1.0) + waveform = hermite.waveform_function() + + np.testing.assert_allclose(waveform, waveform[::-1], atol=1e-12) + + def test_hermite_with_axis_angle(self): + """Hermite pulse with axis_angle should produce a complex waveform.""" + import numpy as np + + hermite = ScalableHermitePulse(length=100, amplitude=0.5, axis_angle=np.pi / 2) + waveform = hermite.waveform_function() + + assert np.iscomplexobj(waveform) + assert np.max(np.abs(waveform)) == pytest.approx(0.5, rel=1e-10) + + def test_hermite_coeff_zero_gives_gaussian_shape(self): + """hermite_coeff=0 reduces the envelope to exp(-x²/2) (pure Gaussian shape). + + The Hermite waveform peak-normalises by the highest sample, so it is + proportional to a Gaussian but not bit-for-bit equal to + ScalableGaussianPulse for even-length arrays where no sample lands + exactly at centre. We verify shape proportionality instead. + """ + import numpy as np + + length = 101 # odd so one sample is exactly at centre (x=0) + sigma_ratio = 1 / 6 + hermite = ScalableHermitePulse( + length=length, amplitude=1.0, sigma_ratio=sigma_ratio, hermite_coeff=0.0 + ) + gauss = ScalableGaussianPulse( + length=length, amplitude=1.0, sigma_ratio=sigma_ratio, subtracted=False + ) + wh = hermite.waveform_function() + wg = gauss.waveform_function() + + # With an odd-length array the centre sample has x=0, so exp(-0/2)=1 + # and peak normalisation is exact. Both waveforms should coincide. + np.testing.assert_allclose(np.real(wh), np.real(wg), rtol=1e-6) + + def test_hermite_coeff_affects_shape(self): + """Different hermite_coeff values should produce distinct waveforms.""" + import numpy as np + + h1 = ScalableHermitePulse(length=100, amplitude=1.0, hermite_coeff=0.2) + h2 = ScalableHermitePulse(length=100, amplitude=1.0, hermite_coeff=0.8) + + assert not np.allclose(h1.waveform_function(), h2.waveform_function()) + + def test_hermite_axis_variants_reference_sigma_ratio(self): + """Axis variant pulses should carry sigma_ratio QuAM reference to the anchor.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + # Axis variants should have their sigma_ratio as a QuAM reference string. + for gate in ("x_neg90", "y90", "y180", "y_neg90"): + pulse = pulses[f"hermite_{gate}"] + assert isinstance(pulse.sigma_ratio, str), ( + f"hermite_{gate}.sigma_ratio should be a QuAM reference, got {pulse.sigma_ratio!r}" + ) + assert "hermite_x90" in pulse.sigma_ratio or "hermite_x180" in pulse.sigma_ratio + + def test_hermite_axis_variants_reference_hermite_coeff(self): + """Axis variant pulses should carry hermite_coeff QuAM reference to the anchor.""" + from unittest.mock import MagicMock + from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ + + xy = MagicMock(spec=XYDriveIQ) + pulses = make_xy_pulse_factories(xy) + + for gate in ("x_neg90", "y90", "y180", "y_neg90"): + pulse = pulses[f"hermite_{gate}"] + assert isinstance(pulse.hermite_coeff, str), ( + f"hermite_{gate}.hermite_coeff should be a QuAM reference, got {pulse.hermite_coeff!r}" + ) + + +class TestSquarePulseWaveform: + """Test Square pulse waveform properties.""" + + def test_square_is_constant(self): + """Square pulse should be constant amplitude.""" + import numpy as np + + sq = ScalableSquarePulse(length=100, amplitude=0.5) + waveform = sq.waveform_function() + + np.testing.assert_allclose(waveform, 0.5 * np.ones(100)) + + def test_square_with_axis_angle(self): + """Square pulse with axis_angle should produce complex waveform.""" + import numpy as np + + sq = ScalableSquarePulse(length=100, amplitude=0.5, axis_angle=np.pi / 2) + waveform = sq.waveform_function() + + assert np.iscomplexobj(waveform) + np.testing.assert_allclose(np.abs(waveform), 0.5 * np.ones(100)) + + +class TestDragPulseWaveform: + """Test DRAG pulse waveform properties.""" + + def test_drag_single_channel_is_real(self): + """Single-channel DRAG should emit only the I-component.""" + import numpy as np + + drag = ScalableDragPulse(length=101, amplitude=0.4, axis_angle=None) + waveform = drag.waveform_function() + + assert not np.iscomplexobj(waveform) + assert np.max(np.abs(waveform)) == pytest.approx(0.4, rel=1e-10) + + def test_drag_iq_has_quadrature_component(self): + """IQ DRAG should contain both I and Q components.""" + import numpy as np + + drag = ScalableDragPulse( + length=101, amplitude=1.0, axis_angle=0.0, drag_coefficient=0.35 + ) + waveform = drag.waveform_function() + + assert np.iscomplexobj(waveform) + i_component = np.real(waveform) + q_component = np.imag(waveform) + + assert np.max(i_component) == pytest.approx(1.0, rel=1e-10) + assert np.max(np.abs(q_component)) == pytest.approx(0.35, rel=1e-10) + np.testing.assert_allclose(q_component, -q_component[::-1], atol=1e-12) + + +class TestMakeReadoutPulse: + """Test readout pulse factory.""" + + def test_readout_pulse_type(self): + pulse = make_readout_pulse() + assert isinstance(pulse, SquareReadoutPulse) diff --git a/tests/architecture/quantum_dots/operations/test_voltage_balanced_macro_catalog.py b/tests/architecture/quantum_dots/operations/test_voltage_balanced_macro_catalog.py new file mode 100644 index 00000000..804df98a --- /dev/null +++ b/tests/architecture/quantum_dots/operations/test_voltage_balanced_macro_catalog.py @@ -0,0 +1,47 @@ +"""Tests for :class:`VoltageBalancedMacroCatalog`.""" + +from quam_builder.architecture.quantum_dots.components.quantum_dot_pair import ( + QuantumDotPair, +) +from quam_builder.architecture.quantum_dots.components.sensor_dot import SensorDot +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + VoltageBalancedMacroCatalog, +) +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + TwoQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.single_qubit_macros import ( + BalancedXYDriveMacro, +) +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.state_macros import ( + BalancedEmptyMacro, + BalancedInitializeMacro, + BalancedMeasurePSBPairMacro, + BalancedSensorDotMeasureMacro, +) +from quam_builder.architecture.quantum_dots.operations.voltage_balanced_macros.two_qubit_macros import ( + BalancedExchange2QMacro, +) +from quam_builder.architecture.quantum_dots.qubit import LDQubit +from quam_builder.architecture.quantum_dots.qubit_pair import LDQubitPair + + +def test_voltage_balanced_catalog_maps_expected_types() -> None: + cat = VoltageBalancedMacroCatalog() + assert cat.priority == 200 + + q1 = cat.get_factories(LDQubit) + assert q1[SingleQubitMacroName.XY_DRIVE.value] is BalancedXYDriveMacro + + qp2 = cat.get_factories(LDQubitPair) + assert qp2[TwoQubitMacroName.EXCHANGE.value] is BalancedExchange2QMacro + + qdp = cat.get_factories(QuantumDotPair) + assert qdp[VoltagePointName.INITIALIZE.value] is BalancedInitializeMacro + assert qdp[VoltagePointName.EMPTY.value] is BalancedEmptyMacro + assert qdp[VoltagePointName.MEASURE.value] is BalancedMeasurePSBPairMacro + + sensor = cat.get_factories(SensorDot) + assert sensor[VoltagePointName.MEASURE] is BalancedSensorDotMeasureMacro diff --git a/tests/architecture/quantum_dots/test_crot_macro.py b/tests/architecture/quantum_dots/test_crot_macro.py new file mode 100644 index 00000000..bbafa922 --- /dev/null +++ b/tests/architecture/quantum_dots/test_crot_macro.py @@ -0,0 +1,195 @@ +"""Tests for the default (unbalanced, cache-and-balance) two-qubit CROT macro. + +Like :class:`CZMacro`, ``CROTMacro.apply`` plays a single-polarity exchange leg +(with the ESR pulse during the hold) and records it; ``balance`` later replays +the opposite-polarity mirror leg via ``apply_inverse`` to cancel the net DC. +""" + +from unittest.mock import call, patch + +import pytest + +from qm import qua +from quam.components.ports import MWFEMAnalogOutputPort +from quam_builder.architecture.quantum_dots.components import XYDriveMW +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + CROTMacro, +) +from quam_builder.architecture.quantum_dots.operations.names import VoltagePointName + +RAMP = DEFAULTS.exchange.ramp_duration +EXCHANGE_VOLTAGES = {"virtual_dot_1": 0.09, "virtual_dot_2": 0.16} +ZERO_VOLTAGES = {"virtual_dot_1": 0.0, "virtual_dot_2": 0.0} + + +def _add_drive(qubit, drive_id, fem_id, larmor=5.1e9): + qubit.larmor_frequency = larmor + qubit.xy = XYDriveMW( + id=drive_id, + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=fem_id, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + ) + + +@pytest.fixture +def pair_with_crot(qd_machine): + """LD qubit pair with target + control MW drives and a default-wired CROT macro.""" + _add_drive(qd_machine.qubits["Q2"], "q2_xy", 1, larmor=5.1e9) + _add_drive(qd_machine.qubits["Q1"], "q1_xy", 2, larmor=5.0e9) + + pair = qd_machine.qubit_pairs["Q1_Q2"] + pair.add_point( + VoltagePointName.INITIALIZE, + {"virtual_dot_1": 0.05, "virtual_dot_2": 0.06}, + duration=200, + ) + pair.add_point( + VoltagePointName.EXCHANGE, + EXCHANGE_VOLTAGES, + duration=180, + ) + + wire_machine_macros(qd_machine) + return pair + + +def test_crot_apply_plays_pulse_and_records_positive_leg(pair_with_crot): + """apply() drives the target, ramps the positive exchange leg, and caches it.""" + pair = pair_with_crot + target_qubit = pair.qubit_target + crot = pair.macros["crot"] + + with qua.program(): + with ( + patch.object(pair.voltage_sequence, "ramp_to_voltages") as mock_ramp, + patch.object(target_qubit.xy, "update_frequency") as mock_update_frequency, + patch.object(target_qubit.xy, "play") as mock_play, + ): + crot.apply( + point=VoltagePointName.EXCHANGE.value, + esr_frequency=5.12e9, + amplitude=0.4, + duration=400, + ) + + mock_play.assert_called_once_with("gaussian_x180", duration=100, amplitude_scale=0.4) + assert mock_ramp.call_args_list == [ + call(EXCHANGE_VOLTAGES, duration=400, ramp_duration=RAMP, ensure_align=False), + call(ZERO_VOLTAGES, duration=16, ramp_duration=RAMP, ensure_align=False), + ] + # Frequency is set for the pulse, then restored to the Larmor frequency. + assert mock_update_frequency.call_args_list == [call(5.12e9), call(5.1e9)] + # A single pending leg awaits balancing. + assert len(crot._cache) == 1 + + +def test_crot_balance_replays_negative_leg_and_clears_cache(pair_with_crot): + """balance() mirrors every cached apply() with the opposite polarity.""" + pair = pair_with_crot + target_qubit = pair.qubit_target + crot = pair.macros["crot"] + + negative = {k: -v for k, v in EXCHANGE_VOLTAGES.items()} + + with qua.program(): + with ( + patch.object(pair.voltage_sequence, "ramp_to_voltages") as mock_ramp, + patch.object(target_qubit.xy, "play"), + ): + crot.apply(point=VoltagePointName.EXCHANGE.value, duration=400) + assert len(crot._cache) == 1 + mock_ramp.reset_mock() + + crot.balance() + + assert mock_ramp.call_args_list == [ + call(negative, duration=400, ramp_duration=RAMP, ensure_align=False), + call(ZERO_VOLTAGES, duration=16, ramp_duration=RAMP, ensure_align=False), + ] + assert len(crot._cache) == 0 + + +def test_crot_drive_target_false_drives_control(pair_with_crot): + """drive_target=False emits the ESR pulse on the control qubit's XY channel.""" + pair = pair_with_crot + crot = pair.macros["crot"] + + with qua.program(): + with ( + patch.object(pair.voltage_sequence, "ramp_to_voltages"), + patch.object(pair.qubit_target.xy, "play") as mock_target_play, + patch.object(pair.qubit_control.xy, "play") as mock_control_play, + ): + crot.apply( + point=VoltagePointName.EXCHANGE.value, + duration=400, + drive_target=False, + ) + + mock_target_play.assert_not_called() + mock_control_play.assert_called_once_with("gaussian_x180", duration=100) + + +def test_crot_defaults_to_exchange_point_and_native_pulse_length(pair_with_crot): + """Without overrides, apply() uses the exchange point and native pulse length.""" + pair = pair_with_crot + target_qubit = pair.qubit_target + crot = pair.macros["crot"] + + native_pulse_ns = target_qubit.xy.operations["gaussian_x180"].length + + with qua.program(): + with ( + patch.object(pair.voltage_sequence, "ramp_to_voltages") as mock_ramp, + patch.object(target_qubit.xy, "play") as mock_play, + ): + crot.apply() + + mock_play.assert_called_once_with("gaussian_x180", duration=native_pulse_ns // 4) + assert mock_ramp.call_args_list[0] == call( + EXCHANGE_VOLTAGES, duration=native_pulse_ns, ramp_duration=RAMP, ensure_align=False + ) + + +def test_crot_inferred_duration(pair_with_crot): + """Inferred duration = pulse duration + two ramps.""" + crot = CROTMacro(point=VoltagePointName.EXCHANGE.value, duration=400) + pair_with_crot.set_macro("crot_duration", crot) + assert crot.inferred_duration == pytest.approx((400 + 2 * RAMP) * 1e-9) + + +def test_crot_builds_a_valid_qua_program(pair_with_crot): + """CROT should compile inside a QUA program with real pair and channel objects.""" + with qua.program() as prog: + pair_with_crot.macros["crot"].apply( + point=VoltagePointName.EXCHANGE.value, + esr_frequency=5.12e9, + amplitude=0.4, + duration=400, + ) + pair_with_crot.macros["crot"].balance() + + assert prog is not None + + +def test_crot_requires_drive_qubit_xy(qd_machine): + """CROT should fail when the driven qubit has no XY drive configured.""" + pair = qd_machine.qubit_pairs["Q1_Q2"] + pair.add_point( + VoltagePointName.INITIALIZE, + {"virtual_dot_1": 0.05, "virtual_dot_2": 0.06}, + duration=200, + ) + pair.add_point(VoltagePointName.EXCHANGE, EXCHANGE_VOLTAGES, duration=180) + + wire_machine_macros(qd_machine) + with pytest.raises(ValueError, match="has no XY drive configured"): + pair.macros["crot"].apply(point=VoltagePointName.EXCHANGE.value, duration=400) \ No newline at end of file diff --git a/tests/architecture/quantum_dots/test_macro_persistence.py b/tests/architecture/quantum_dots/test_macro_persistence.py index 0780726e..8e509da1 100644 --- a/tests/architecture/quantum_dots/test_macro_persistence.py +++ b/tests/architecture/quantum_dots/test_macro_persistence.py @@ -1,12 +1,10 @@ """Tests for macro persistence across save/load round-trips.""" -import pytest - from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD -def test_macro_instances_survive_save_load_roundtrip(qd_machine, reset_catalog, tmp_path): +def test_macro_instances_survive_save_load_roundtrip(qd_machine, tmp_path): """QuantumDot, QuantumDotPair, SensorDot macros persist across save/load.""" wire_machine_macros(qd_machine) qd_machine.save(tmp_path) diff --git a/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py b/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py index 42268b08..22c5bf47 100644 --- a/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py +++ b/tests/architecture/quantum_dots/test_rabi_chevron_e2e.py @@ -14,9 +14,8 @@ All objects are real — no mocks or stubs. """ -from typing import Tuple - import pytest + from qm import qua from quam.components import pulses from quam.components.channels import StickyChannelAddon @@ -26,15 +25,12 @@ ) from quam.core import quam_dataclass from quam.core.macro.quam_macro import QuamMacro - from quam_builder.architecture.quantum_dots.components import ( ReadoutResonatorSingle, VoltageGate, XYDriveSingle, ) from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam -from quam_builder.architecture.quantum_dots.qubit import LDQubit - # ----------------------------------------------------------------------- # Helpers — mirror rabi_chevron.py sections 1-3 @@ -49,7 +45,9 @@ def _make_gate(port: int, gate_id: str) -> VoltageGate: ) -def create_rabi_machine() -> Tuple[LossDiVincenzoQuam, XYDriveSingle, ReadoutResonatorSingle]: +def create_rabi_machine() -> ( + tuple[LossDiVincenzoQuam, XYDriveSingle, ReadoutResonatorSingle] +): """Set up a minimal machine that mirrors the rabi_chevron example.""" machine = LossDiVincenzoQuam() @@ -73,7 +71,9 @@ def create_rabi_machine() -> Tuple[LossDiVincenzoQuam, XYDriveSingle, ReadoutRes opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=5, output_mode="direct"), ) # Add the pulses that were previously added by XYDriveSingle.__post_init__ - xy_drive.operations["gaussian"] = pulses.GaussianPulse(length=100, amplitude=0.2, sigma=40) + xy_drive.operations["gaussian_x90"] = pulses.GaussianPulse( + length=100, amplitude=0.2, sigma=40 + ) xy_drive.operations["pi"] = pulses.SquarePulse(length=104, amplitude=0.2) xy_drive.operations["pi_half"] = pulses.SquarePulse(length=52, amplitude=0.2) xy_drive.operations["drive"] = pulses.GaussianPulse( @@ -126,7 +126,7 @@ def inferred_duration(self) -> float | None: try: parent_qubit = self.parent.parent pulse = parent_qubit.xy.operations[self.pulse_name] - return pulse.length * 1e-9 + return pulse.length * 4e-9 except Exception: return None @@ -153,7 +153,7 @@ def inferred_duration(self) -> float | None: "virtual_sensor_1" ].readout_resonator pulse = resonator.operations[self.pulse_name] - return (64 * 4 + pulse.length) * 1e-9 + return (64 + pulse.length) * 4e-9 except Exception: return None @@ -189,8 +189,12 @@ def rabi_setup(): qubit = machine.qubits["Q1"] qubit.add_point(point_name="init", voltages={"virtual_dot_1": 0.05}, duration=500) - qubit.add_point(point_name="operate", voltages={"virtual_dot_1": 0.15}, duration=2000) - qubit.add_point(point_name="readout", voltages={"virtual_dot_1": -0.05}, duration=2000) + qubit.add_point( + point_name="operate", voltages={"virtual_dot_1": 0.15}, duration=2000 + ) + qubit.add_point( + point_name="readout", voltages={"virtual_dot_1": -0.05}, duration=2000 + ) qubit.macros["drive"] = DriveMacro(pulse_name="drive") qubit.macros["measure"] = MeasureMacro(pulse_name="readout") @@ -241,7 +245,7 @@ def test_xy_set(self, rabi_setup): assert qubit.xy is not None assert qubit.xy.id == "Q1_xy" assert "drive" in qubit.xy.operations - assert "gaussian" in qubit.xy.operations + assert "gaussian_x90" in qubit.xy.operations def test_qubit_has_quantum_dot(self, rabi_setup): _, qubit = rabi_setup diff --git a/tests/architecture/quantum_dots/test_xy_drive_macro_frequency.py b/tests/architecture/quantum_dots/test_xy_drive_macro_frequency.py new file mode 100644 index 00000000..3848ba1a --- /dev/null +++ b/tests/architecture/quantum_dots/test_xy_drive_macro_frequency.py @@ -0,0 +1,91 @@ +"""Tests for XYDriveMacro frequency update/apply behaviour.""" + +import pytest +from quam.components import StickyChannelAddon +from quam.components.ports import LFFEMAnalogOutputPort, MWFEMAnalogOutputPort + +from quam_builder.architecture.quantum_dots.components import VoltageGate, XYDriveMW +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.operations.names import ( + SingleQubitMacroName, + VoltagePointName, +) +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam + + +@pytest.fixture +def wired_qubit(): + """Qubit with XY drive and macros wired.""" + machine = LossDiVincenzoQuam() + + gate = VoltageGate( + id="plunger_1", + opx_output=LFFEMAnalogOutputPort("con1", 2, port_id=1), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + machine.create_virtual_gate_set( + virtual_channel_mapping={"vdot1": gate}, + gate_set_id="main", + ) + machine.register_channel_elements( + plunger_channels=[gate], + barrier_channels=[], + sensor_resonator_mappings={}, + ) + machine.register_qubit(quantum_dot_id="vdot1", qubit_name="Q1") + + qubit = machine.qubits["Q1"] + qubit.larmor_frequency = 5.1e9 + + xy = XYDriveMW( + id="xy_mw", + opx_output=MWFEMAnalogOutputPort( + controller_id="con1", + fem_id=1, + port_id=1, + band=2, + upconverter_frequency=int(5e9), + full_scale_power_dbm=10, + ), + ) + qubit.xy = xy + + machine.reset_voltage_sequence("main") + qubit.add_point(VoltagePointName.INITIALIZE, {"vdot1": 0.1}, duration=200) + qubit.add_point(VoltagePointName.MEASURE, {"vdot1": 0.15}, duration=200) + qubit.add_point(VoltagePointName.EMPTY, {"vdot1": 0.0}, duration=200) + + wire_machine_macros(machine) + return qubit + + +class TestXYDriveMacroFrequencyUpdate: + def test_update_frequency_sets_larmor(self, wired_qubit): + qubit = wired_qubit + xy_macro = qubit.macros[SingleQubitMacroName.XY_DRIVE] + + xy_macro.update(frequency=5.2e9) + assert qubit.larmor_frequency == 5.2e9 + + def test_update_frequency_offset_adjusts_larmor(self, wired_qubit): + qubit = wired_qubit + xy_macro = qubit.macros[SingleQubitMacroName.XY_DRIVE] + + original = qubit.larmor_frequency + xy_macro.update(frequency_offset=10e6) + assert qubit.larmor_frequency == pytest.approx(original + 10e6) + + def test_update_frequency_and_offset_raises(self, wired_qubit): + qubit = wired_qubit + xy_macro = qubit.macros[SingleQubitMacroName.XY_DRIVE] + + with pytest.raises(ValueError, match="either frequency or frequency_offset"): + xy_macro.update(frequency=5.2e9, frequency_offset=10e6) + + def test_update_no_recenter_lo_parameter(self, wired_qubit): + """recenter_LO parameter should no longer exist.""" + qubit = wired_qubit + xy_macro = qubit.macros[SingleQubitMacroName.XY_DRIVE] + + with pytest.raises(TypeError): + xy_macro.update(frequency=5.2e9, recenter_LO=True) diff --git a/tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py b/tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py index 11dbdbbe..e1a07d32 100644 --- a/tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py +++ b/tests/architecture/quantum_dots/virtual_gates/test_virtual_gates.py @@ -49,7 +49,9 @@ def test_add_layer_success_second_layer(virtual_gate_set_fixture): """Test adding a valid second layer targeting the first layer's source.""" vgs = virtual_gate_set_fixture vgs.add_layer(source_gates=["virt_L0"], target_gates=["P1"], matrix=[[1.0]]) - layer2 = vgs.add_layer(source_gates=["virt_L1"], target_gates=["virt_L0"], matrix=[[0.5]]) + layer2 = vgs.add_layer( + source_gates=["virt_L1"], target_gates=["virt_L0"], matrix=[[0.5]] + ) assert len(vgs.layers) == 2 assert vgs.layers[1] == layer2 assert layer2.target_gates == ["virt_L0"] # Targets source of layer 0 @@ -60,7 +62,9 @@ def test_add_layer_second_layer_targets_physical(virtual_gate_set_fixture): vgs = virtual_gate_set_fixture vgs.add_layer(source_gates=["virt_L0"], target_gates=["P1"], matrix=[[1.0]]) # virt_L1 targets P2 (physical), which is allowed - layer2 = vgs.add_layer(source_gates=["virt_L1"], target_gates=["P2"], matrix=[[0.5]]) + layer2 = vgs.add_layer( + source_gates=["virt_L1"], target_gates=["P2"], matrix=[[0.5]] + ) assert len(vgs.layers) == 2 assert vgs.layers[1].target_gates == ["P2"] @@ -187,8 +191,12 @@ def test_resolve_voltages_allow_extra_entries_false_error(virtual_gate_set_fixtu # "unknown_gate" is not a defined physical or virtual channel with pytest.raises(ValueError) as excinfo: - vgs.resolve_voltages({"v_g1": 1.0, "unknown_gate": 0.5}, allow_extra_entries=False) - assert "Channels {'unknown_gate'} in voltages that are not part" in str(excinfo.value) + vgs.resolve_voltages( + {"v_g1": 1.0, "unknown_gate": 0.5}, allow_extra_entries=False + ) + assert "Channels {'unknown_gate'} in voltages that are not part" in str( + excinfo.value + ) def test_resolve_voltages_allow_extra_entries_true_ignored(virtual_gate_set_fixture): @@ -243,7 +251,9 @@ def test_matrix_validation_non_square_matrix(virtual_gate_set_fixture): with pytest.raises(ValueError) as excinfo: vgs.add_layer( - source_gates=["v1", "v2"], target_gates=["P1", "P2"], matrix=non_square_matrix + source_gates=["v1", "v2"], + target_gates=["P1", "P2"], + matrix=non_square_matrix, ) assert "Matrix must be square" in str(excinfo.value) @@ -254,7 +264,9 @@ def test_matrix_validation_non_invertible_matrix(virtual_gate_set_fixture): singular_matrix = [[1.0, 2.0], [2.0, 4.0]] # Determinant = 0 with pytest.raises(ValueError) as excinfo: - vgs.add_layer(source_gates=["v1", "v2"], target_gates=["P1", "P2"], matrix=singular_matrix) + vgs.add_layer( + source_gates=["v1", "v2"], target_gates=["P1", "P2"], matrix=singular_matrix + ) assert "Matrix is not invertible" in str(excinfo.value) @@ -263,6 +275,8 @@ def test_matrix_validation_valid_matrix(virtual_gate_set_fixture): vgs = virtual_gate_set_fixture valid_matrix = [[1.0, 0.5], [0.0, 1.0]] # Square and invertible - layer = vgs.add_layer(source_gates=["v1", "v2"], target_gates=["P1", "P2"], matrix=valid_matrix) + layer = vgs.add_layer( + source_gates=["v1", "v2"], target_gates=["P1", "P2"], matrix=valid_matrix + ) assert len(vgs.layers) == 1 assert layer.matrix == valid_matrix diff --git a/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py b/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py index 61ff8f13..7879258a 100644 --- a/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py +++ b/tests/architecture/quantum_dots/virtual_gates/test_virtual_voltage_sequence.py @@ -47,7 +47,7 @@ def test_vgs_go_to_virtual_point(virtual_gate_set: VirtualGateSet): ) with qua.program() as prog: - seq = vgs.new_sequence() + seq = vgs.new_sequence(enforce_qua_calcs=False) seq.step_to_point("virt_p1") # Calculate expected physical voltages (assuming initial state is 0V for ch1, ch2) @@ -72,7 +72,7 @@ def test_vgs_step_to_virtual_level(virtual_gate_set: VirtualGateSet): add_default_virtual_layer(vgs) with qua.program() as prog: - seq = vgs.new_sequence() + seq = vgs.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"v_g1": -0.5, "v_g2": 0.2}, duration=80) # ns # Calculate expected physical voltages (initial state 0V) @@ -97,7 +97,7 @@ def test_vgs_step_to_virtual_level_qua_voltage(virtual_gate_set: VirtualGateSet) add_default_virtual_layer(vgs) with qua.program() as prog: - seq = vgs.new_sequence(track_integrated_voltage=False) + seq = vgs.new_sequence(track_integrated_voltage=False, enforce_qua_calcs=False) qua_v_g1_level = qua.declare(qua.fixed) qua.assign(qua_v_g1_level, 0.8) # Target for v_g1 @@ -127,7 +127,8 @@ def test_vgs_step_to_virtual_level_qua_voltage(virtual_gate_set: VirtualGateSet) duration=30, ) qua.play( - DEFAULT_PULSE_NAME * qua.amp((((0.0 + ((0.0 * exp_qua_v_g1_level) + 0.6)) - 0.0) << 2)), + DEFAULT_PULSE_NAME + * qua.amp((((0.0 + ((0.0 * exp_qua_v_g1_level) + 0.6)) - 0.0) << 2)), "ch2", duration=30, ) @@ -142,7 +143,7 @@ def test_vgs_ramp_to_zero_after_virtual_step(virtual_gate_set: VirtualGateSet): add_default_virtual_layer(vgs) with qua.program() as prog: - seq = vgs.new_sequence() + seq = vgs.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"v_g1": 0.2, "v_g2": 0.1}, duration=40) # ns seq.ramp_to_zero() @@ -167,7 +168,9 @@ def test_vgs_ramp_to_zero_after_virtual_step(virtual_gate_set: VirtualGateSet): def test_ramp_to_voltages_simple(machine): """Tests a simple ramp_to_voltages operation.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) seq.ramp_to_voltages( voltages={"ch1": 0.2, "ch2": -0.1}, duration=120, @@ -188,9 +191,13 @@ def test_ramp_to_voltages_simple(machine): def test_ramp_to_point_simple(machine): """Tests a simple ramp_to_point operation.""" - machine.gate_set.add_point("p_ramp", voltages={"ch1": 0.15, "ch2": 0.05}, duration=200) + machine.gate_set.add_point( + "p_ramp", voltages={"ch1": 0.15, "ch2": 0.05}, duration=200 + ) with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) seq.ramp_to_point("p_ramp", ramp_duration=80) ast = ProgramTreeBuilder().build(prog) @@ -207,11 +214,17 @@ def test_ramp_to_point_simple(machine): def test_step_then_ramp(machine): """Tests a step operation followed by a ramp operation.""" - machine.gate_set.add_point("p_step", voltages={"ch1": 0.1, "ch2": 0.1}, duration=100) + machine.gate_set.add_point( + "p_step", voltages={"ch1": 0.1, "ch2": 0.1}, duration=100 + ) with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) seq.step_to_point("p_step") # Current level: ch1=0.1, ch2=0.1 - seq.ramp_to_voltages(voltages={"ch1": 0.3, "ch2": -0.1}, duration=160, ramp_duration=80) + seq.ramp_to_voltages( + voltages={"ch1": 0.3, "ch2": -0.1}, duration=160, ramp_duration=80 + ) ast = ProgramTreeBuilder().build(prog) with qua.program() as expected_program: @@ -231,9 +244,13 @@ def test_step_then_ramp(machine): def test_ramp_then_step(machine): """Tests a ramp operation followed by a step operation.""" - machine.gate_set.add_point("p_final", voltages={"ch1": 0.05, "ch2": -0.05}, duration=500) + machine.gate_set.add_point( + "p_final", voltages={"ch1": 0.05, "ch2": -0.05}, duration=500 + ) with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) seq.ramp_to_voltages( voltages={"ch1": 0.2, "ch2": 0.2}, duration=100, ramp_duration=40 ) # Current level: ch1=0.2, ch2=0.2 @@ -258,7 +275,9 @@ def test_ramp_then_step(machine): def test_ramp_to_voltages_with_qua_voltage(machine): """Tests ramp_to_voltages using a QUA variable for the voltage level.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) qua_level = qua.declare(qua.fixed) qua.assign(qua_level, 0.15) seq.ramp_to_voltages( @@ -288,10 +307,14 @@ def test_ramp_to_voltages_with_qua_voltage(machine): def test_ramp_to_voltages_with_qua_ramp_duration(machine): """Tests ramp_to_voltages using a QUA variable for the ramp duration.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) qua_ramp_dur = qua.declare(int) qua.assign(qua_ramp_dur, 80) # ns - seq.ramp_to_voltages(voltages={"ch1": 0.2}, duration=160, ramp_duration=qua_ramp_dur) + seq.ramp_to_voltages( + voltages={"ch1": 0.2}, duration=160, ramp_duration=qua_ramp_dur + ) ast = ProgramTreeBuilder().build(prog) with qua.program() as expected_program: @@ -301,14 +324,18 @@ def test_ramp_to_voltages_with_qua_ramp_duration(machine): qua.assign(expected_qua_ramp_dur, 80) # ch1: 0.0 -> 0.2 (delta=0.2), ramp=80(20), hold=160(40) -> 40 # ch2: 0.0 -> 0.0 (delta=0.0), ramp=80(20), hold=160(40) -> 40 - qua.assign(_vseq_tmp_ch1_ramp_rate, 0.2 * qua.Math.div(1.0, expected_qua_ramp_dur)) + qua.assign( + _vseq_tmp_ch1_ramp_rate, 0.2 * qua.Math.div(1.0, expected_qua_ramp_dur) + ) qua.play( qua.ramp(_vseq_tmp_ch1_ramp_rate), "ch1", duration=expected_qua_ramp_dur >> 2, ) qua.wait(40, "ch1") - qua.assign(_vseq_tmp_ch2_ramp_rate, 0.0 * qua.Math.div(1.0, expected_qua_ramp_dur)) + qua.assign( + _vseq_tmp_ch2_ramp_rate, 0.0 * qua.Math.div(1.0, expected_qua_ramp_dur) + ) qua.play( qua.ramp(_vseq_tmp_ch2_ramp_rate), "ch2", diff --git a/tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py b/tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py index c890ecb5..b9f9fb55 100644 --- a/tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py +++ b/tests/architecture/quantum_dots/virtual_gates/test_virtualisation_layer.py @@ -31,7 +31,9 @@ def test_calculate_inverse_matrix(): ) # M = [[2, 0], [0, 0.5]] => M_inv = [[0.5, 0], [0, 2.0]] expected_inverse = np.array([[0.5, 0.0], [0.0, 2.0]]) - np.testing.assert_array_almost_equal(vl.calculate_inverse_matrix(), expected_inverse) + np.testing.assert_array_almost_equal( + vl.calculate_inverse_matrix(), expected_inverse + ) def test_resolve_voltages_simple_1_to_1(): diff --git a/tests/architecture/quantum_dots/voltage_sequence/test_compensation_pulse.py b/tests/architecture/quantum_dots/voltage_sequence/test_compensation_pulse.py new file mode 100644 index 00000000..a6b851ca --- /dev/null +++ b/tests/architecture/quantum_dots/voltage_sequence/test_compensation_pulse.py @@ -0,0 +1,419 @@ +import numpy as np +import pytest +from typing import List, Dict +from qm import SimulationConfig + +from quam.components import ( + StickyChannelAddon, + pulses, +) +from quam.components.ports import ( + LFFEMAnalogOutputPort, + LFFEMAnalogInputPort, +) + +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + VirtualGateSet, +) +from quam_builder.architecture.quantum_dots.components.voltage_gate import VoltageGate +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.architecture.quantum_dots.components.readout_resonator import ( + ReadoutResonatorSingle, +) +from qm.qua import * + +# Constants +SIM_LENGTH = 40000 +HALF_MAX_HEIGHT = 0.25 +CLOCK_CYCLE = 4 +LFFEM = 6 +MWFEM = 1 +QOP = "172.16.33.115" +CLUSTER = "CS_4" + +# Absolute tolerance (V·ns) for the discrepancy between the expected +# compensation area and the measured compensation area from the waveform report. +# Python path: OPX float16 rounding + duration quantisation → typically < 1 V·ns +# QUA path: additional fixed-point (4.28) rounding in the tracker → larger residual +PYTHON_ATOL = 2.0 +QUA_ATOL = 10.0 + +example_steps = [ + {"voltages": {"virtual_dot_1": 0.1, "virtual_dot_2": -0.3}, "duration": 500}, + { + "voltages": {"virtual_dot_1": 0.01, "virtual_dot_3": 0.3}, + "duration": 1000, + "ramp_duration": 1000, + }, + {"voltages": {"virtual_dot_2": 0.1}, "duration": 1000}, + {"voltages": {"virtual_dot_1": -0.2, "virtual_dot_2": 0.2}, "duration": 1000}, + {"voltages": {}, "duration": 1000}, + {"voltages": {"virtual_dot_1": 0.1}, "duration": 1000, "ramp_duration": 1000}, + {"voltages": {"virtual_dot_1": 0.2, "virtual_dot_2": 0.3}, "duration": 1000}, + {"voltages": {"virtual_dot_2": -0.1, "virtual_dot_3": -0.2}, "duration": 1000}, + { + "voltages": {"virtual_dot_1": 0.02, "virtual_dot_2": 0.05}, + "duration": 1000, + "ramp_duration": 1000, + }, + { + "voltages": {"virtual_dot_1": -0.2, "virtual_dot_2": 0.1}, + "duration": 1000, + "ramp_duration": 1000, + }, + {"voltages": {"virtual_dot_2": -0.1, "virtual_dot_3": 0.1}, "duration": 1000}, +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def find_total_area(waveform_list): + compensation_pulses = waveform_list[-3:] # go_to_zero, comp, return_to_zero + signal_pulses = waveform_list[:-3] + + amp_index = 1 if waveform_list[0]["current_amp_elements"][0] == 0.0 else 0 + + # go_to_zero brings level to 0, so: level_before + delta = 0 + go_to_zero = compensation_pulses[0] + go_to_zero_delta = go_to_zero["current_amp_elements"][amp_index] * HALF_MAX_HEIGHT + final_signal_level = -go_to_zero_delta + + n = len(signal_pulses) + level_before = [None] * n + level_after = [None] * n + + # Forward pass: the waveform report normalises the total level change + # by the base waveform amplitude for both step and ramp pulses, so + # delta = amplitude_element * HALF_MAX_HEIGHT in both cases. + current = 0.0 + for i, wf in enumerate(signal_pulses): + level_before[i] = current + delta = wf["current_amp_elements"][amp_index] * HALF_MAX_HEIGHT + current = current + delta + level_after[i] = current + + # Compute areas + total_area = 0.0 + schedule = signal_pulses[0]["timestamp"] + + for i, wf in enumerate(signal_pulses): + start = wf["timestamp"] + + # Sticky hold during gap (ramp hold durations appear here) + if start > schedule: + gap = start - schedule + hold_level = level_after[i - 1] if i > 0 else 0.0 + total_area += hold_level * gap + + duration = wf["length"] + if wf["pulse_name"] == "ramp": + # Trapezoid + total_area += 0.5 * (level_before[i] + level_after[i]) * duration + else: + # Step: tracker uses target * duration (no sticky-edge correction) + total_area += level_after[i] * duration + + schedule = start + duration + + # Sticky hold between last signal pulse and go_to_zero + comp_start = go_to_zero["timestamp"] + if comp_start > schedule: + total_area += level_after[-1] * (comp_start - schedule) + + # Compensation pulse (after go_to_zero, level is 0, so delta IS the level) + comp = compensation_pulses[1] + comp_level = comp["current_amp_elements"][amp_index] * HALF_MAX_HEIGHT + comp_area = comp_level * comp["length"] + + return total_area, comp_area + + +def random_matrix(rows: int, cols: int, seed: int | None = None) -> np.ndarray: + rng = np.random.default_rng(seed) + m = rng.random((rows, cols)) * 0.01 # values in [0, 1) + np.fill_diagonal(m, 1.0) # sets the main diagonal to 1 + return m + + +def create_voltage_gate( + name: str, + opx_output_port: int, + lf_fem: int = LFFEM, + controller: str = "con1", +) -> VoltageGate: + channel = VoltageGate( + id=name, + opx_output=LFFEMAnalogOutputPort( + controller, lf_fem, port_id=opx_output_port, upsampling_mode="pulse" + ), + sticky=StickyChannelAddon(duration=16, digital=False), + ) + return channel + + +def create_resonator_channel( + name: str, + frequency: int, + opx_output_port: int, + opx_input_port: int, + lf_fem: int = LFFEM, + controller: str = "con1", + readout_pulse_len=200, + readout_pulse_amp=0.01, +) -> ReadoutResonatorSingle: + + readout_pulse = pulses.SquareReadoutPulse( + length=readout_pulse_len, id="readout", amplitude=readout_pulse_amp + ) + resonator = ReadoutResonatorSingle( + id=name, + frequency_bare=0, + intermediate_frequency=frequency, + operations={"readout": readout_pulse}, + opx_output=LFFEMAnalogOutputPort( + controller, lf_fem, port_id=opx_output_port, upsampling_mode="mw" + ), + opx_input=LFFEMAnalogInputPort(controller, lf_fem, port_id=opx_input_port), + ) + return resonator + + +def create_example_quam( + num_plungers: int = 4, + num_barriers: int = 3, + num_sensors: int = 1, + matrix: np.ndarray = None, +) -> BaseQuamQD: + + machine = BaseQuamQD() + + machine.network = {"host": QOP, "cluster_name": CLUSTER} + plungers = { + f"virtual_dot_{i+1}": create_voltage_gate(f"plunger_{i+1}", i + 1) + for i in range(num_plungers) + } + barriers = { + f"virtual_barrier_{i+1}": create_voltage_gate( + f"barrier_{i+1}", i + 1 + num_plungers + ) + for i in range(num_barriers) + } + sensors = { + f"virtual_sensor_{i+1}": create_voltage_gate( + f"sensor_{i+1}", i + 1 + num_plungers + num_barriers + ) + for i in range(num_sensors) + } + resonator = create_resonator_channel( + "readout_resonator", frequency=int(500e6), opx_output_port=6, opx_input_port=1 + ) + channels = {**plungers, **barriers, **sensors} + + machine.create_virtual_gate_set( + virtual_channel_mapping=channels, + gate_set_id="main_qpu", + compensation_matrix=matrix, + ) + + machine.register_channel_elements( + plunger_channels=list(plungers.values()), + barrier_channels=list(barriers.values()), + sensor_resonator_mappings={list(sensors.values())[0]: resonator}, + ) + return machine + + +def expected_pulse_sequence( + virtual_gate_set: VirtualGateSet, + steps: List[Dict[str, List]], + max_voltage_compensation=0.01, + zero_padding_start: int = 318, + num_samples_total: int = 10000, +): + """ + Feed steps into the function in the form: + [ + { + "voltages" : {"virtual_dot_1": 0.1, "virtual_dot_2": 0.05}, + "duration" : 1000, + }, + { + "voltages" : {"virtual_dot_1": -0.01, "virtual_dot_2": 0.02}, + "duration" : 1000, + "ramp_duration" : 1000, + }, + ] + + This function will output the expected waveform. + """ + channels = list(virtual_gate_set.channels.keys()) + valid_names = list(virtual_gate_set.valid_channel_names) + samples = {ch: np.zeros(zero_padding_start).tolist() for ch in channels} + areas = {ch: 0 for ch in channels} + level_tracker = {ch: 0 for ch in channels} + + voltage_state = {name: 0.0 for name in valid_names} + for gate in steps: + voltage_state.update(gate["voltages"]) + physical_voltages = virtual_gate_set.resolve_voltages(voltage_state) + is_ramp = "ramp_duration" in gate + + for ch in channels: + target = physical_voltages[ch] + if is_ramp: + num_ramp_samples = gate["ramp_duration"] // 4 + sample = np.linspace( + level_tracker[ch], target, num_ramp_samples, endpoint=False + ) + samples[ch].extend(sample) + area = sum(sample) + areas[ch] = areas[ch] + area + + num_step_samples = gate["duration"] // 4 + sample = np.ones(num_step_samples) * target + area = sum(sample) + areas[ch] = areas[ch] + area + + samples[ch].extend(sample) + level_tracker[ch] = target + + schedule_len = len(list(samples.values())[0]) + if schedule_len > num_samples_total: + for g, s in samples.items(): + samples[g] = s[:num_samples_total] + else: + for g, s in samples.items(): + diff = num_samples_total - schedule_len + s.extend(np.zeros(diff).tolist()) + + areas = {n: -s * CLOCK_CYCLE for n, s in areas.items()} + return samples, areas + + +def add_pulse_sequence( + sequence, + steps: List[Dict[str, List]], +): + for s in steps: + if "ramp_duration" in s: + sequence.ramp_to_voltages(**s) + else: + sequence.step_to_voltages(**s) + + +def add_pulse_sequence_qua( + sequence, + steps: List[Dict[str, List]], + qua_vars: Dict[str, any], +): + """Replay the same step list but through QUA variables, so the sequence + takes the QUA code paths for delta computation, integrated-voltage + tracking, and compensation calculation.""" + for s in steps: + voltages_qua = {} + for name, val in s["voltages"].items(): + assign(qua_vars[name], val) + voltages_qua[name] = qua_vars[name] + + if "ramp_duration" in s: + sequence.ramp_to_voltages( + voltages=voltages_qua, + duration=s["duration"], + ramp_duration=s["ramp_duration"], + ) + else: + sequence.step_to_voltages( + voltages=voltages_qua, + duration=s["duration"], + ) + + +def _create_program_python(steps): + machine = create_example_quam(matrix=random_matrix(8, 8, seed=42)) + with program() as prog: + seq = machine.voltage_sequences["main_qpu"] + add_pulse_sequence(seq, steps) + seq.apply_compensation_pulse(max_voltage=0.5) + return prog, machine + + +def _create_program_qua(steps): + machine = create_example_quam(matrix=random_matrix(8, 8, seed=42)) + with program() as prog: + seq = machine.voltage_sequences["main_qpu"] + qua_vars = { + name: declare(fixed, value=0.0) for name in seq.gate_set.valid_channel_names + } + add_pulse_sequence_qua(seq, steps, qua_vars) + seq.apply_compensation_pulse(max_voltage=0.5) + return prog, machine + + +def _simulate(machine, prog, simulation_duration: int): + qmm = machine.connect() + simulation_config = SimulationConfig(duration=simulation_duration // 4) + job = qmm.simulate(machine.generate_config(), prog, simulation_config) + job.wait_until("Done") + waveform_report = job.get_simulated_waveform_report() + return waveform_report.to_dict() + + +def _measure_compensation_areas(machine, waveform_dict): + """Return {channel_name: measured_comp_area} from the waveform report.""" + element_names = list(machine.virtual_gate_sets["main_qpu"].channels.keys()) + element_waveforms = { + name: [k for k in waveform_dict["analog_waveforms"] if k["element"] == name] + for name in element_names + } + return {name: find_total_area(wf)[1] for name, wf in element_waveforms.items()} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_compensation_pulse_python_voltages(): + """Compensation area from Python-valued voltages matches expected within PYTHON_ATOL.""" + prog, machine = _create_program_python(example_steps) + _, expected_areas = expected_pulse_sequence( + machine.virtual_gate_sets["main_qpu"], + example_steps, + num_samples_total=SIM_LENGTH, + ) + waveform_dict = _simulate(machine, prog, SIM_LENGTH) + measured_areas = _measure_compensation_areas(machine, waveform_dict) + + for ch_name, measured in measured_areas.items(): + expected = expected_areas[ch_name] + diff = abs(measured - expected) + assert diff < PYTHON_ATOL, ( + f"{ch_name}: compensation area discrepancy {diff:.4f} V·ns " + f"exceeds tolerance {PYTHON_ATOL} V·ns " + f"(expected={expected:.4f}, measured={measured:.4f})" + ) + + +def test_compensation_pulse_qua_voltages(): + """Compensation area from QUA-variable voltages matches expected within QUA_ATOL.""" + prog, machine = _create_program_qua(example_steps) + _, expected_areas = expected_pulse_sequence( + machine.virtual_gate_sets["main_qpu"], + example_steps, + num_samples_total=SIM_LENGTH, + ) + waveform_dict = _simulate(machine, prog, SIM_LENGTH) + measured_areas = _measure_compensation_areas(machine, waveform_dict) + + for ch_name, measured in measured_areas.items(): + expected = expected_areas[ch_name] + diff = abs(measured - expected) + assert diff < QUA_ATOL, ( + f"{ch_name}: compensation area discrepancy {diff:.4f} V·ns " + f"exceeds tolerance {QUA_ATOL} V·ns " + f"(expected={expected:.4f}, measured={measured:.4f})" + ) diff --git a/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py b/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py index 767ed209..daabc084 100644 --- a/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py +++ b/tests/architecture/quantum_dots/voltage_sequence/test_voltage_sequence.py @@ -29,7 +29,7 @@ def test_invalid_timing_multiple_4(machine): machine.gate_set.add_point("p1", voltages={"ch1": 0.1, "ch2": 0.2}, duration=41) with qua.program() as _prog: # noqa: F841 - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) with pytest.raises(TypeError): seq.step_to_point("p1") @@ -37,7 +37,7 @@ def test_invalid_timing_multiple_4(machine): def test_invalid_timing_min_duration(machine): machine.gate_set.add_point("p1", voltages={"ch1": 0.1, "ch2": 0.2}, duration=12) with qua.program() as _prog: # noqa: F841 - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) with pytest.raises(TypeError): seq.step_to_point("p1") @@ -46,7 +46,7 @@ def test_go_to_single_point(machine): """Tests stepping to a single predefined point.""" machine.gate_set.add_point("p1", voltages={"ch1": 0.1, "ch2": 0.2}, duration=100) with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_point("p1") ast = ProgramTreeBuilder().build(prog) @@ -63,7 +63,7 @@ def test_go_to_multiple_points(machine): machine.gate_set.add_point("p1", voltages={"ch1": 0.1, "ch2": 0.2}, duration=100) machine.gate_set.add_point("p2", voltages={"ch1": 0.3, "ch2": 0.4}, duration=200) with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_point("p1") seq.step_to_point("p2") ast = ProgramTreeBuilder().build(prog) @@ -80,9 +80,11 @@ def test_go_to_multiple_points(machine): def test_step_to_point_with_custom_duration(machine): """Tests overriding the point's default duration in step_to_point.""" - machine.gate_set.add_point("p1", voltages={"ch1": 0.1}, duration=100) # Default duration + machine.gate_set.add_point( + "p1", voltages={"ch1": 0.1}, duration=100 + ) # Default duration with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_point("p1", duration=60) seq.step_to_point("p1") ast = ProgramTreeBuilder().build(prog) @@ -100,7 +102,7 @@ def test_step_to_point_with_custom_duration(machine): def test_step_to_voltages_single_channel(machine): """Tests stepping a single channel to a specified voltage level.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.25}, duration=120) ast = ProgramTreeBuilder().build(prog) @@ -114,7 +116,7 @@ def test_step_to_voltages_single_channel(machine): def test_step_to_voltages_multiple_channels(machine): """Tests stepping multiple channels to specified voltage levels simultaneously.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.15, "ch2": -0.1}, duration=160) ast = ProgramTreeBuilder().build(prog) @@ -127,9 +129,11 @@ def test_step_to_voltages_multiple_channels(machine): def test_step_to_voltages_then_step_to_point(machine): """Tests a step_to_voltages operation followed by a step_to_point.""" - machine.gate_set.add_point("p_after_step", voltages={"ch1": 0.2, "ch2": 0.2}, duration=80) + machine.gate_set.add_point( + "p_after_step", voltages={"ch1": 0.2, "ch2": 0.2}, duration=80 + ) with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.1}, duration=100) seq.step_to_point("p_after_step") @@ -147,7 +151,9 @@ def test_step_to_voltages_then_step_to_point(machine): def test_sequence_with_qua_variable_duration_step_to_voltages(machine): """Tests using a QUA variable for duration in step_to_voltages.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) qua_duration = qua.declare(int) qua.assign(qua_duration, 200) # ns seq.step_to_voltages(voltages={"ch1": 0.2}, duration=qua_duration) @@ -174,7 +180,9 @@ def test_sequence_with_qua_variable_duration_step_to_voltages(machine): def test_sequence_with_qua_variable_voltage_step_to_voltages(machine): """Tests using a QUA variable for voltage in step_to_voltages.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence(track_integrated_voltage=False) + seq = machine.gate_set.new_sequence( + track_integrated_voltage=False, enforce_qua_calcs=False + ) qua_voltage = qua.declare(qua.fixed) qua.assign(qua_voltage, 0.15) seq.step_to_voltages(voltages={"ch1": qua_voltage, "ch2": 0.1}, duration=100) @@ -196,7 +204,7 @@ def test_sequence_with_qua_variable_voltage_step_to_voltages(machine): def test_ramp_to_zero_immediate(machine): """Tests ramp_to_zero with no duration (immediate QUA ramp_to_zero).""" with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.2, "ch2": -0.15}, duration=100) seq.ramp_to_zero() # Uses QUA's ramp_to_zero(element_name) ast = ProgramTreeBuilder().build(prog) @@ -214,7 +222,7 @@ def test_ramp_to_zero_immediate(machine): def test_ramp_to_zero_with_duration(machine): """Tests ramp_to_zero with a specified ramp duration.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.25}, duration=100) seq.ramp_to_zero(ramp_duration=200) ast = ProgramTreeBuilder().build(prog) @@ -230,7 +238,7 @@ def test_ramp_to_zero_with_duration(machine): def test_ramp_to_zero_with_duration_multiple_channels(machine): """Tests ramp_to_zero with a specified ramp duration.""" with qua.program() as prog: - seq = machine.gate_set.new_sequence() + seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) seq.step_to_voltages(voltages={"ch1": 0.25}, duration=100) seq.step_to_voltages(voltages={"ch1": 0.25, "ch2": 0.25}, duration=100) seq.ramp_to_zero(ramp_duration=200) @@ -250,7 +258,7 @@ def test_ramp_to_zero_with_duration_multiple_channels(machine): # def test_apply_compensation_pulse_from_zero_integrated_voltage(machine): # """Tests apply_compensation_pulse when integrated voltage is zero.""" # with qua.program() as prog: -# seq = machine.gate_set.new_sequence() +# seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) # # No prior operations, so integrated voltage is zero for all channels. # seq.apply_compensation_pulse(max_voltage=0.4) # # ast = ProgramTreeBuilder().build(prog) @@ -263,7 +271,7 @@ def test_ramp_to_zero_with_duration_multiple_channels(machine): # For example: # def test_apply_compensation_pulse_with_positive_integrated_voltage(machine): # with qua.program() as prog: -# seq = machine.gate_set.new_sequence() +# seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) # seq.step_to_voltages(voltages={"ch1": 0.1}, duration=1000) # Positive integrated_v # seq.apply_compensation_pulse(max_voltage=0.4) # # ast = ProgramTreeBuilder().build(prog) @@ -271,7 +279,7 @@ def test_ramp_to_zero_with_duration_multiple_channels(machine): # def test_apply_compensation_pulse_with_qua_vars(machine): # with qua.program() as prog: -# seq = machine.gate_set.new_sequence() +# seq = machine.gate_set.new_sequence(enforce_qua_calcs=False) # qua_level = qua.declare(qua.fixed, value=0.05) # qua_dur = qua.declare(int, value=2000) #ns # seq.step_to_voltages(voltages={"ch1": qua_level}, duration=qua_dur) diff --git a/tests/builder/quantum_dots/test_build_qpu_refactor.py b/tests/builder/quantum_dots/test_build_qpu_refactor.py index e8bdf4d0..e3221cd4 100644 --- a/tests/builder/quantum_dots/test_build_qpu_refactor.py +++ b/tests/builder/quantum_dots/test_build_qpu_refactor.py @@ -74,7 +74,11 @@ def test_missing_drive_ports_raise(self): def test_missing_resonator_for_sensor_raises(self): machine = LossDiVincenzoQuam() machine.wiring = { - "readout": {"s1": {WiringLineType.SENSOR_GATE.value: {"opx_output": "#/ports/con1/8"}}}, + "readout": { + "s1": { + WiringLineType.SENSOR_GATE.value: {"opx_output": "#/ports/con1/8"} + } + }, "qubits": { "q1": { WiringLineType.PLUNGER_GATE.value: _plunger_ports("q1"), @@ -316,5 +320,7 @@ def test_qubit_pair_sensor_mapping_non_dict_raises(self): } } - with pytest.raises(ValueError, match="must be a dict mapping pair ids to sensor lists"): + with pytest.raises( + ValueError, match="must be a dict mapping pair ids to sensor lists" + ): _QpuBuilder(machine, qubit_pair_sensor_map="bad").build() diff --git a/tests/builder/quantum_dots/test_build_quam.py b/tests/builder/quantum_dots/test_build_quam.py index abea887f..86991e35 100644 --- a/tests/builder/quantum_dots/test_build_quam.py +++ b/tests/builder/quantum_dots/test_build_quam.py @@ -93,7 +93,11 @@ def get_unreferenced_value(self, key): machine.wiring = { "qubits": { - "q1": {WiringLineType.DRIVE.value: DummyRef({"opx_output": "#/ports/con1/1"})} + "q1": { + WiringLineType.DRIVE.value: DummyRef( + {"opx_output": "#/ports/con1/1"} + ) + } } } @@ -119,13 +123,17 @@ def machine_with_wiring(self): WiringLineType.PLUNGER_GATE.value: { "opx_output": "#/wiring/qubits/q1/p/opx_output" }, - WiringLineType.DRIVE.value: {"opx_output": "#/ports/mw_outputs/con1/1/1"}, + WiringLineType.DRIVE.value: { + "opx_output": "#/ports/mw_outputs/con1/1/1" + }, }, "q2": { WiringLineType.PLUNGER_GATE.value: { "opx_output": "#/wiring/qubits/q2/p/opx_output" }, - WiringLineType.DRIVE.value: {"opx_output": "#/ports/mw_outputs/con1/1/2"}, + WiringLineType.DRIVE.value: { + "opx_output": "#/ports/mw_outputs/con1/1/2" + }, }, } } @@ -169,11 +177,10 @@ def built_machine(self): instruments.add_lf_fem(controller=1, slots=[2, 3]) connectivity = Connectivity() - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) allocate_wiring(connectivity, instruments) @@ -187,7 +194,9 @@ def built_machine(self): quam_instance=machine, path=tmp, ) - machine = build_base_quam(machine, calibration_db_path=tmp, connect_qdac=False, save=False) + machine = build_base_quam( + machine, calibration_db_path=tmp, connect_qdac=False, save=False + ) machine = build_loss_divincenzo_quam( machine, implicit_mapping=True, @@ -201,15 +210,17 @@ def test_wire_machine_macros_populates_xy_operations(self, built_machine): for qubit in built_machine.qubits.values(): if qubit.xy is not None: assert len(qubit.xy.operations) > 0 - assert "gaussian" in qubit.xy.operations + assert "gaussian_x90" in qubit.xy.operations def test_wire_machine_macros_handles_empty_machine(self): - from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros + from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + ) machine = LossDiVincenzoQuam() machine.qubits = {} machine.qubit_pairs = {} - wire_machine_macros(machine, strict=False) + wire_machine_macros(machine) class TestBuildQuam: @@ -240,7 +251,9 @@ def test_build_quam_full_workflow(self, temp_dir): machine.network = {"host": "127.0.0.1", "cluster_name": "test"} with ( - patch("quam_builder.builder.quantum_dots.build_quam.build_base_quam") as mock_base, + patch( + "quam_builder.builder.quantum_dots.build_quam.build_base_quam" + ) as mock_base, patch( "quam_builder.builder.quantum_dots.build_quam.build_loss_divincenzo_quam" ) as mock_ld, @@ -257,7 +270,9 @@ def test_build_quam_calls_all_functions(self, temp_dir): machine.network = {"host": "127.0.0.1", "cluster_name": "test"} with ( - patch("quam_builder.builder.quantum_dots.build_quam.build_base_quam") as mock_base, + patch( + "quam_builder.builder.quantum_dots.build_quam.build_base_quam" + ) as mock_base, patch( "quam_builder.builder.quantum_dots.build_quam.build_loss_divincenzo_quam" ) as mock_ld, @@ -274,7 +289,9 @@ def test_build_quam_saves_machine(self, temp_dir): machine.network = {"host": "127.0.0.1", "cluster_name": "test"} with ( - patch("quam_builder.builder.quantum_dots.build_quam.build_base_quam") as mock_base, + patch( + "quam_builder.builder.quantum_dots.build_quam.build_base_quam" + ) as mock_base, patch( "quam_builder.builder.quantum_dots.build_quam.build_loss_divincenzo_quam" ) as mock_ld, @@ -290,7 +307,9 @@ def test_build_quam_can_skip_save(self, temp_dir): machine.network = {"host": "127.0.0.1", "cluster_name": "test"} with ( - patch("quam_builder.builder.quantum_dots.build_quam.build_base_quam") as mock_base, + patch( + "quam_builder.builder.quantum_dots.build_quam.build_base_quam" + ) as mock_base, patch( "quam_builder.builder.quantum_dots.build_quam.build_loss_divincenzo_quam" ) as mock_ld, @@ -311,7 +330,7 @@ class TestCalibrationPathResolver: def test_resolves_none_to_state_parent(self, tmp_path): machine = LossDiVincenzoQuam() serializer = MagicMock() - serializer._get_state_path.return_value = tmp_path / "state.json" + serializer._get_state_path.return_value = tmp_path / "state_old.json" machine.get_serialiser = lambda: serializer resolved = _resolve_calibration_db_path(machine, None) assert resolved == tmp_path @@ -319,7 +338,7 @@ def test_resolves_none_to_state_parent(self, tmp_path): def test_resolves_string_to_path(self): machine = LossDiVincenzoQuam() serializer = MagicMock() - serializer._get_state_path.return_value = Path("/tmp/state.json") + serializer._get_state_path.return_value = Path("/tmp/state_old.json") machine.get_serialiser = lambda: serializer resolved = _resolve_calibration_db_path(machine, "/tmp/calibration") assert resolved == Path("/tmp/calibration") diff --git a/tests/builder/quantum_dots/test_builder_pulses.py b/tests/builder/quantum_dots/test_builder_pulses.py index e108f549..6e42c677 100644 --- a/tests/builder/quantum_dots/test_builder_pulses.py +++ b/tests/builder/quantum_dots/test_builder_pulses.py @@ -12,10 +12,16 @@ import pytest from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring -from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveIQ, XYDriveMW +from quam_builder.architecture.quantum_dots.components.xy_drive import ( + XYDriveIQ, + XYDriveMW, +) from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD from quam_builder.builder.qop_connectivity import build_quam_wiring -from quam_builder.builder.quantum_dots import build_base_quam, build_loss_divincenzo_quam +from quam_builder.builder.quantum_dots import ( + build_base_quam, + build_loss_divincenzo_quam, +) from quam_builder.builder.quantum_dots import build_quam as build_quam_qd @@ -32,10 +38,9 @@ def machine_with_iq_pulses(): instruments.add_lf_fem(controller=1, slots=[2, 3]) connectivity = Connectivity() - connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=False, # No drive lines allocated — injected manually below + quantum_dots=[1, 2], # No drive lines allocated — injected manually below ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) allocate_wiring(connectivity, instruments) @@ -49,7 +54,9 @@ def machine_with_iq_pulses(): quam_instance=machine, path=tmp, ) - machine = build_base_quam(machine, calibration_db_path=tmp, connect_qdac=False, save=False) + machine = build_base_quam( + machine, calibration_db_path=tmp, connect_qdac=False, save=False + ) # Inject IQ drive wiring explicitly — standard QD pipeline never produces IQ wiring xy_drive_wiring = { @@ -82,12 +89,10 @@ def machine_with_mw_pulses(): instruments.add_lf_fem(controller=1, slots=[2, 3]) connectivity = Connectivity() - connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=[1, 2], shared_line=True, use_mw_fem=True ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) allocate_wiring(connectivity, instruments) @@ -127,14 +132,16 @@ def test_gaussian_pulse_present_on_qubits(self, machine_with_iq_pulses): if qubit.xy is None: continue assert ( - "gaussian" in qubit.xy.operations - ), f"Pulse 'gaussian' missing from qubit {qubit_name}" + "gaussian_x90" in qubit.xy.operations + ), f"Pulse 'gaussian_x90' missing from qubit {qubit_name}" def test_gaussian_pulse_properties(self, machine_with_iq_pulses): - qubit = next(q for q in machine_with_iq_pulses.qubits.values() if q.xy is not None) + qubit = next( + q for q in machine_with_iq_pulses.qubits.values() if q.xy is not None + ) - gaussian = qubit.xy.operations["gaussian"] - assert gaussian.length == 1000 + gaussian = qubit.xy.operations["gaussian_x90"] + assert gaussian.length > 0 assert gaussian.amplitude == 1.0 assert gaussian.axis_angle == pytest.approx(0.0) @@ -143,10 +150,11 @@ def test_readout_pulse_present_on_resonators(self, machine_with_iq_pulses): if hasattr(qubit, "resonator") and qubit.resonator is not None: assert "readout" in qubit.resonator.operations - def test_one_xy_operation_per_qubit(self, machine_with_iq_pulses): + def test_all_families_present_on_qubits(self, machine_with_iq_pulses): for qubit in machine_with_iq_pulses.qubits.values(): if qubit.xy is not None: - assert len(qubit.xy.operations) == 1 + for family in ("gaussian", "square", "kaiser"): + assert f"{family}_x90" in qubit.xy.operations class TestAddDefaultLDVQubitPulsesMW: @@ -164,14 +172,16 @@ def test_gaussian_pulse_present_on_qubits(self, machine_with_mw_pulses): if qubit.xy is None: continue assert ( - "gaussian" in qubit.xy.operations - ), f"Pulse 'gaussian' missing from qubit {qubit_name}" + "gaussian_x90" in qubit.xy.operations + ), f"Pulse 'gaussian_x90' missing from qubit {qubit_name}" def test_gaussian_pulse_properties(self, machine_with_mw_pulses): - qubit = next(q for q in machine_with_mw_pulses.qubits.values() if q.xy is not None) + qubit = next( + q for q in machine_with_mw_pulses.qubits.values() if q.xy is not None + ) - gaussian = qubit.xy.operations["gaussian"] - assert gaussian.length == 1000 + gaussian = qubit.xy.operations["gaussian_x90"] + assert gaussian.length > 0 assert gaussian.amplitude == 1.0 assert gaussian.axis_angle == pytest.approx(0.0) diff --git a/tests/builder/quantum_dots/test_e2e_quantum_dots.py b/tests/builder/quantum_dots/test_e2e_quantum_dots.py index c4230a75..220f8794 100644 --- a/tests/builder/quantum_dots/test_e2e_quantum_dots.py +++ b/tests/builder/quantum_dots/test_e2e_quantum_dots.py @@ -10,11 +10,13 @@ # pylint: disable=no-member +import inspect import shutil import tempfile import pytest from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring +from qualang_tools.wirer.connectivity.wiring_spec import WiringFrequency from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD from quam_builder.architecture.quantum_dots.qpu.loss_divincenzo_quam import ( @@ -28,6 +30,22 @@ ) +def _quantum_dot_drive_line_kw_dc(): + """LF-FEM drives: ``wiring_frequency=DC`` (new API) or ``use_mw_fem=False`` (current).""" + params = inspect.signature(Connectivity.add_quantum_dot_drive_lines).parameters + if "wiring_frequency" in params: + return {"wiring_frequency": WiringFrequency.DC} + return {"use_mw_fem": False} + + +def _quantum_dot_drive_line_kw_rf(): + """MW-FEM drives: ``wiring_frequency=RF`` (new API) or ``use_mw_fem=True`` (current).""" + params = inspect.signature(Connectivity.add_quantum_dot_drive_lines).parameters + if "wiring_frequency" in params: + return {"wiring_frequency": WiringFrequency.RF} + return {"use_mw_fem": True} + + @pytest.fixture def temp_dir(): """Create and clean up a temporary directory for test artifacts.""" @@ -61,13 +79,11 @@ def test_five_dot_combined_build(self, instruments, temp_dir): connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") connectivity.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + sensor_dots=sensor_dots, shared_resonator_line=False ) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + connectivity.add_quantum_dots(quantum_dots=quantum_dots) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=quantum_dots, shared_line=True ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) allocate_wiring(connectivity, instruments) @@ -94,13 +110,9 @@ def test_five_dot_combined_build(self, instruments, temp_dir): def test_two_dot_minimal(self, instruments, temp_dir): """Minimal 2-dot system without global gates.""" connectivity = Connectivity() - connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) - connectivity.add_quantum_dots( - quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, - ) + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines(quantum_dots=[1, 2], shared_line=True) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) allocate_wiring(connectivity, instruments) @@ -133,9 +145,9 @@ def _make_stage1_connectivity(global_gates, sensor_dots, quantum_dots, dot_pairs connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") connectivity.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + sensor_dots=sensor_dots, shared_resonator_line=False ) - connectivity.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + connectivity.add_quantum_dots(quantum_dots=quantum_dots) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) return connectivity @@ -144,14 +156,12 @@ def _make_stage2_connectivity(global_gates, sensor_dots, quantum_dots, dot_pairs connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") connectivity.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + sensor_dots=sensor_dots, shared_resonator_line=False ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + connectivity.add_quantum_dots(quantum_dots=quantum_dots) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=quantum_dots, shared_line=True ) return connectivity @@ -168,7 +178,10 @@ def test_two_stage_dot_then_qubit(self, instruments, temp_dir): # Stage 1: dot layer only connectivity_s1 = self._make_stage1_connectivity( - self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + self.GLOBAL_GATES, + self.SENSOR_DOTS, + self.QUANTUM_DOTS, + self.QUANTUM_DOT_PAIRS, ) allocate_wiring(connectivity_s1, instruments) @@ -182,7 +195,9 @@ def test_two_stage_dot_then_qubit(self, instruments, temp_dir): assert len(machine.quantum_dots) == 3 assert len(machine.sensor_dots) == 2 - assert not hasattr(machine, "qubits") or len(getattr(machine, "qubits", {})) == 0 + assert ( + not hasattr(machine, "qubits") or len(getattr(machine, "qubits", {})) == 0 + ) # Stage 2: add qubits with drive lines instruments_s2 = Instruments() @@ -190,7 +205,10 @@ def test_two_stage_dot_then_qubit(self, instruments, temp_dir): instruments_s2.add_lf_fem(controller=1, slots=[2, 3]) connectivity_s2 = self._make_stage2_connectivity( - self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + self.GLOBAL_GATES, + self.SENSOR_DOTS, + self.QUANTUM_DOTS, + self.QUANTUM_DOT_PAIRS, ) allocate_wiring(connectivity_s2, instruments_s2) @@ -222,7 +240,10 @@ def test_incremental_drive_lines(self, temp_dir): # Stage 1 connectivity_s1 = self._make_stage1_connectivity( - self.GLOBAL_GATES, self.SENSOR_DOTS, self.QUANTUM_DOTS, self.QUANTUM_DOT_PAIRS + self.GLOBAL_GATES, + self.SENSOR_DOTS, + self.QUANTUM_DOTS, + self.QUANTUM_DOT_PAIRS, ) allocate_wiring(connectivity_s1, instruments) @@ -237,7 +258,7 @@ def test_incremental_drive_lines(self, temp_dir): # Stage 2: only drive lines connectivity_drive = Connectivity() connectivity_drive.add_quantum_dot_drive_lines( - quantum_dots=self.QUANTUM_DOTS, use_mw_fem=True, shared_line=True + quantum_dots=self.QUANTUM_DOTS, shared_line=True ) allocate_wiring(connectivity_drive, instruments) @@ -266,8 +287,8 @@ def test_dots_without_drive_lines(self, temp_dir): connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=[1], name="rb") - connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False, use_mw_fem=False) - connectivity.add_quantum_dots(quantum_dots=[1, 2, 3], add_drive_lines=False) + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3]) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3)]) allocate_wiring(connectivity, instruments) @@ -301,13 +322,11 @@ def test_eight_dots_four_sensors(self, temp_dir): connectivity = Connectivity() connectivity.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False + sensor_dots=sensor_dots, shared_resonator_line=False ) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + connectivity.add_quantum_dots(quantum_dots=quantum_dots) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=quantum_dots, shared_line=True ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) allocate_wiring(connectivity, instruments) @@ -330,10 +349,10 @@ def test_eight_dots_four_sensors(self, temp_dir): class TestLfFemSingleDriveWorkflow: - """E2E tests for LF-FEM-only systems using use_mw_fem=False. + """E2E tests for LF-FEM-only systems using LF-FEM drive wiring. - When no MW-FEM is present and use_mw_fem=False is specified, the wirer - allocates single LF-FEM outputs for drive lines. quam-builder should + When no MW-FEM is present and drive lines use ``WiringFrequency.DC``, the + wirer allocates single LF-FEM outputs for drive lines. quam-builder should create XYDriveSingle (not XYDriveMW) for these. """ @@ -344,7 +363,7 @@ def instruments(self): return instruments def _run_lf_only_two_stage(self, instruments, temp_dir): - """Two-stage build with LF-FEM only and use_mw_fem=False.""" + """Two-stage build with LF-FEM only and DC (LF-FEM) drive lines.""" sensor_dots = [1, 2] quantum_dots = [1, 2, 3, 4] dot_pairs = [(1, 2), (3, 4)] @@ -352,37 +371,37 @@ def _run_lf_only_two_stage(self, instruments, temp_dir): # Stage 1 conn_s1 = Connectivity() - conn_s1.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False - ) - conn_s1.add_quantum_dots(quantum_dots=quantum_dots, add_drive_lines=False) + conn_s1.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False) + conn_s1.add_quantum_dots(quantum_dots=quantum_dots) conn_s1.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) allocate_wiring(conn_s1, instruments) machine = BaseQuamQD() - machine = build_quam_wiring(conn_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = build_quam_wiring( + conn_s1, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = build_base_quam( machine, calibration_db_path=temp_dir, connect_qdac=False, save=False ) - # Stage 2 with LF-FEM-only instruments and use_mw_fem=False + # Stage 2 with LF-FEM-only instruments (LF-FEM drive lines via WiringFrequency.DC) instruments_s2 = Instruments() instruments_s2.add_lf_fem(controller=1, slots=[3, 5]) conn_s2 = Connectivity() - conn_s2.add_sensor_dots( - sensor_dots=sensor_dots, shared_resonator_line=False, use_mw_fem=False - ) - conn_s2.add_quantum_dots( + conn_s2.add_sensor_dots(sensor_dots=sensor_dots, shared_resonator_line=False) + conn_s2.add_quantum_dots(quantum_dots=quantum_dots) + conn_s2.add_quantum_dot_drive_lines( quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=False, - shared_drive_line=True, + shared_line=True, + **_quantum_dot_drive_line_kw_dc(), ) conn_s2.add_quantum_dot_pairs(quantum_dot_pairs=dot_pairs) allocate_wiring(conn_s2, instruments_s2) - machine = build_quam_wiring(conn_s2, "127.0.0.1", "test_cluster", machine, path=temp_dir) + machine = build_quam_wiring( + conn_s2, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = build_loss_divincenzo_quam( machine, qubit_pair_sensor_map=qubit_pair_sensor_map, @@ -392,7 +411,7 @@ def _run_lf_only_two_stage(self, instruments, temp_dir): return machine def test_lf_fem_drives_are_xy_drive_single(self, instruments, temp_dir): - """With use_mw_fem=False, all XY drives should be XYDriveSingle.""" + """With LF-FEM (DC) drive wiring, all XY drives should be XYDriveSingle.""" from quam_builder.architecture.quantum_dots.components.xy_drive import ( XYDriveSingle, ) @@ -418,7 +437,8 @@ def test_lf_fem_drive_port_refs_are_analog(self, instruments, temp_dir): raw_ref = wiring_xy.get_raw_value("opx_output") ref = str(raw_ref) assert "analog_outputs" in ref, ( - f"Qubit {qubit_id} drive wiring should reference analog_outputs, " f"got: {ref}" + f"Qubit {qubit_id} drive wiring should reference analog_outputs, " + f"got: {ref}" ) assert "mw_outputs" not in ref @@ -443,11 +463,13 @@ def _run_two_stage(self, instruments, temp_dir): """Run the full two-stage workflow and return the final machine.""" # Stage 1: dot layer only (no drive lines) connectivity_s1 = Connectivity() - connectivity_s1.add_voltage_gate_lines(voltage_gates=self.GLOBAL_GATES, name="rb") + connectivity_s1.add_voltage_gate_lines( + voltage_gates=self.GLOBAL_GATES, name="rb" + ) connectivity_s1.add_sensor_dots( - sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False, use_mw_fem=False + sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False ) - connectivity_s1.add_quantum_dots(quantum_dots=self.QUANTUM_DOTS, add_drive_lines=False) + connectivity_s1.add_quantum_dots(quantum_dots=self.QUANTUM_DOTS) connectivity_s1.add_quantum_dot_pairs(quantum_dot_pairs=self.QUANTUM_DOT_PAIRS) allocate_wiring(connectivity_s1, instruments) @@ -465,16 +487,18 @@ def _run_two_stage(self, instruments, temp_dir): instruments_s2.add_lf_fem(controller=1, slots=[2, 3]) connectivity_s2 = Connectivity() - connectivity_s2.add_voltage_gate_lines(voltage_gates=self.GLOBAL_GATES, name="rb") + connectivity_s2.add_voltage_gate_lines( + voltage_gates=self.GLOBAL_GATES, name="rb" + ) connectivity_s2.add_sensor_dots( - sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False, use_mw_fem=False + sensor_dots=self.SENSOR_DOTS, shared_resonator_line=False ) connectivity_s2.add_quantum_dot_pairs(quantum_dot_pairs=self.QUANTUM_DOT_PAIRS) - connectivity_s2.add_quantum_dots( + connectivity_s2.add_quantum_dots(quantum_dots=self.QUANTUM_DOTS) + connectivity_s2.add_quantum_dot_drive_lines( quantum_dots=self.QUANTUM_DOTS, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, + **_quantum_dot_drive_line_kw_rf(), ) allocate_wiring(connectivity_s2, instruments_s2) @@ -521,11 +545,15 @@ def test_two_stage_sensor_dots_wired_to_pairs(self, instruments, temp_dir): pair_q1_q2 = machine.qubit_pairs["q1_q2"] assert len(pair_q1_q2.quantum_dot_pair.sensor_dots) > 0 - assert "#/sensor_dots/virtual_sensor_1" in pair_q1_q2.quantum_dot_pair.sensor_dots + assert ( + "#/sensor_dots/virtual_sensor_1" in pair_q1_q2.quantum_dot_pair.sensor_dots + ) pair_q2_q3 = machine.qubit_pairs["q2_q3"] assert len(pair_q2_q3.quantum_dot_pair.sensor_dots) > 0 - assert "#/sensor_dots/virtual_sensor_2" in pair_q2_q3.quantum_dot_pair.sensor_dots + assert ( + "#/sensor_dots/virtual_sensor_2" in pair_q2_q3.quantum_dot_pair.sensor_dots + ) def test_two_stage_preferred_readout_dot(self, instruments, temp_dir): """Each qubit should have preferred_readout_quantum_dot set. @@ -547,4 +575,6 @@ def test_two_stage_resonator_not_sticky(self, instruments, temp_dir): for sensor in machine.sensor_dots.values(): rr = sensor.readout_resonator - assert rr.sticky is None, f"Readout resonator {rr.id} should not have sticky" + assert ( + rr.sticky is None + ), f"Readout resonator {rr.id} should not have sticky" diff --git a/tests/builder/quantum_dots/test_macro_names.py b/tests/builder/quantum_dots/test_macro_names.py index 0abadc07..5e1593d5 100644 --- a/tests/builder/quantum_dots/test_macro_names.py +++ b/tests/builder/quantum_dots/test_macro_names.py @@ -66,7 +66,9 @@ def test_single_qubit_aliases_map_to_supported_canonical_names(): canonical_single_qubit_names = {name.value for name in SingleQubitMacroName} assert set(SINGLE_QUBIT_MACRO_ALIAS_MAP.keys()) == set(SINGLE_QUBIT_MACRO_ALIASES) - assert set(SINGLE_QUBIT_MACRO_ALIAS_MAP.values()).issubset(canonical_single_qubit_names) + assert set(SINGLE_QUBIT_MACRO_ALIAS_MAP.values()).issubset( + canonical_single_qubit_names + ) def test_two_qubit_enum_values_are_default_keys(): diff --git a/tests/builder/quantum_dots/test_macro_wiring.py b/tests/builder/quantum_dots/test_macro_wiring.py index 1afcba6d..05e811c0 100644 --- a/tests/builder/quantum_dots/test_macro_wiring.py +++ b/tests/builder/quantum_dots/test_macro_wiring.py @@ -1,17 +1,15 @@ """Tests for runtime macro wiring and override behavior.""" +from functools import partial +from unittest.mock import patch + import numpy as np import pytest -from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType -from unittest.mock import patch + from qm import qua +from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam.components import pulses - -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - macro, - overrides, -) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros from quam_builder.architecture.quantum_dots.operations.default_macros.single_qubit_macros import ( X180Macro, XYDriveMacro, @@ -19,11 +17,18 @@ from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import ( InitializeStateMacro, ) +from quam_builder.architecture.quantum_dots.operations.default_macros.two_qubit_macros import ( + CROTMacro, +) +from quam_builder.architecture.quantum_dots.operations.macro_catalog import ( + TypeOverrideCatalog, +) from quam_builder.architecture.quantum_dots.operations.names import ( SingleQubitMacroName, + TwoQubitMacroName, ) -from quam_builder.architecture.quantum_dots.qubit import LDQubit from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD +from quam_builder.architecture.quantum_dots.qubit import LDQubit from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder @@ -66,8 +71,11 @@ def _seed_reference_pulses(machine): for qubit in machine.qubits.values(): if qubit.xy is None: continue - qubit.xy.operations.setdefault( - "gaussian", pulses.GaussianPulse(length=64, amplitude=0.01, sigma=16) + qubit.xy.operations["gaussian_x90"] = pulses.GaussianPulse( + length=64, amplitude=0.01, sigma=16 + ) + qubit.xy.operations["gaussian_x180"] = pulses.GaussianPulse( + length=64, amplitude=0.02, sigma=16 ) @@ -80,7 +88,6 @@ class TunedX180Macro(X180Macro): def test_instance_override_path_supports_quam_mappings(): """Instance overrides should work for collections stored as Quam mappings. - Uses the typed API: instance_overrides with macro() helper. Only q1 gets the override; q2 keeps the default X180Macro. """ machine = _build_machine() @@ -88,13 +95,10 @@ def test_instance_override_path_supports_quam_mappings(): wire_machine_macros( machine, instance_overrides={ - "qubits.q1": overrides( - macros={ - SingleQubitMacroName.X_180: macro(TunedX180Macro), - } - ), + "qubits.q1": { + SingleQubitMacroName.X_180: TunedX180Macro, + }, }, - strict=True, ) assert isinstance(machine.qubits["q1"].macros["x180"], TunedX180Macro) @@ -104,24 +108,24 @@ def test_instance_override_path_supports_quam_mappings(): def test_component_type_override_applies_to_all_instances(): """Component-type overrides should apply to each matching instance. - Uses the typed API: component_overrides keyed by the LDQubit class. - The macro() helper validates the factory and passes params through. + Uses TypeOverrideCatalog keyed by the LDQubit class. """ machine = _build_machine() wire_machine_macros( machine, - component_overrides={ - LDQubit: overrides( - macros={ - SingleQubitMacroName.INITIALIZE: macro( - InitializeStateMacro, - ramp_duration=48, - ), + catalogs=[ + TypeOverrideCatalog( + { + LDQubit: { + SingleQubitMacroName.INITIALIZE: partial( + InitializeStateMacro, + ramp_duration=48, + ), + }, } ), - }, - strict=True, + ], ) for qubit in machine.qubits.values(): @@ -132,24 +136,26 @@ def test_component_type_override_applies_to_all_instances(): def test_component_type_override_sets_xy_drive_runtime_params(): """Override params should populate canonical xy_drive attributes after init. - Uses the typed API with component_overrides to set reference_angle - on all LDQubits at once. + Uses TypeOverrideCatalog to set reference_angle on all LDQubits. """ machine = _build_machine() + def _make_xy_drive(): + m = XYDriveMacro() + m.reference_angle = 1.5 + return m + wire_machine_macros( machine, - component_overrides={ - LDQubit: overrides( - macros={ - SingleQubitMacroName.XY_DRIVE: macro( - XYDriveMacro, - reference_angle=1.5, - ), + catalogs=[ + TypeOverrideCatalog( + { + LDQubit: { + SingleQubitMacroName.XY_DRIVE: _make_xy_drive, + }, } ), - }, - strict=True, + ], ) for qubit in machine.qubits.values(): @@ -157,6 +163,16 @@ def test_component_type_override_sets_xy_drive_runtime_params(): assert qubit.macros["xy_drive"].reference_angle == pytest.approx(1.5) +def test_default_two_qubit_crot_macro_is_wired(): + """LDQubitPair should receive the default CROT macro via wire_machine_macros.""" + machine = _build_machine() + + wire_machine_macros(machine) + + pair = machine.qubit_pairs["q1_q2"] + assert isinstance(pair.macros[TwoQubitMacroName.CROT], CROTMacro) + + def test_canonical_x_and_y_delegate_to_xy_drive(): """Canonical axis macros should delegate into `xy_drive` with proper phase.""" machine = _build_machine() @@ -205,7 +221,7 @@ def test_fixed_angle_macros_delegate_to_canonical_axes(): def test_x180_macro_produces_valid_qua_program(): """X180Macro.apply() inside qua.program() produces a valid non-None QUA program.""" machine = _build_machine() - wire_machine_macros(machine, strict=True) + wire_machine_macros(machine) _seed_reference_pulses(machine) q1 = machine.qubits["q1"] @@ -218,7 +234,7 @@ def test_x180_macro_produces_valid_qua_program(): def test_x180_macro_triggers_play(): """X180Macro.apply() triggers xy.play via delegation chain.""" machine = _build_machine() - wire_machine_macros(machine, strict=True) + wire_machine_macros(machine) _seed_reference_pulses(machine) q1 = machine.qubits["q1"] @@ -227,13 +243,13 @@ def test_x180_macro_triggers_play(): q1.macros["x180"].apply() assert mock_play.call_count >= 1 - assert mock_play.call_args.kwargs["pulse_name"] == "gaussian" + assert mock_play.call_args.kwargs["pulse_name"] == "gaussian_x90" def test_runtime_amplitude_scale_multiplies_angle_scale(): """Runtime amplitude scaling should multiply the angle-derived pulse scaling.""" machine = _build_machine() - wire_machine_macros(machine, strict=True) + wire_machine_macros(machine) _seed_reference_pulses(machine) q1 = machine.qubits["q1"] @@ -249,10 +265,10 @@ def test_runtime_amplitude_scale_multiplies_angle_scale(): def test_reference_pulse_amplitude_is_shared_source_of_truth_for_x_family(): """Updating the reference pulse amplitude should affect both x180 and x90.""" machine = _build_machine() - wire_machine_macros(machine, strict=True) + wire_machine_macros(machine) _seed_reference_pulses(machine) q1 = machine.qubits["q1"] - q1.xy.operations["gaussian"].amplitude = 0.15 + q1.xy.operations["gaussian_x90"].amplitude = 0.15 with ( patch.object(q1.xy, "play", return_value=None) as mock_play, @@ -270,23 +286,54 @@ def test_reference_pulse_amplitude_is_shared_source_of_truth_for_x_family(): q1.x90() x90_scale = mock_play.call_args.kwargs["amplitude_scale"] - assert q1.xy.operations["gaussian"].amplitude * x180_scale == pytest.approx(0.15) - assert q1.xy.operations["gaussian"].amplitude * x90_scale == pytest.approx(0.075) + assert q1.xy.operations["gaussian_x90"].amplitude * x180_scale == pytest.approx(0.15) + assert q1.xy.operations["gaussian_x90"].amplitude * x90_scale == pytest.approx(0.075) def test_fixed_angle_inferred_duration_uses_reference_pulse_length(): """Inferred duration should always equal the reference pulse length (no stretching).""" machine = _build_machine() - wire_machine_macros(machine, strict=True) + wire_machine_macros(machine) _seed_reference_pulses(machine) q1 = machine.qubits["q1"] - ref_duration = q1.xy.operations["gaussian"].length * 1e-9 + ref_duration = q1.xy.operations["gaussian_x90"].length * 1e-9 assert q1.macros["x"].inferred_duration == pytest.approx(ref_duration) assert q1.macros["x90"].inferred_duration == pytest.approx(ref_duration) assert q1.macros["y90"].inferred_duration == pytest.approx(ref_duration) +def test_xy_drive_native_pulse_length_is_converted_to_voltage_tracking_duration(): + """Voltage tracking should advance by the native pulse length.""" + machine = _build_machine() + wire_machine_macros(machine) + _seed_reference_pulses(machine) + q1 = machine.qubits["q1"] + pulse = q1.xy.operations["gaussian_x90"] + + with ( + patch.object(q1.xy, "play", return_value=None), + patch.object(q1.voltage_sequence, "track_sticky_duration") as mock_track, + ): + q1.x90() + + mock_track.assert_called_once_with(pulse.length) + + +def test_xy_drive_update_duration_persists_pulse_length_in_ns(): + """Persisted macro duration is specified and stored in ns (samples at 1 GS/s).""" + machine = _build_machine() + wire_machine_macros(machine) + q1 = machine.qubits["q1"] + xy_macro = q1.macros["xy_drive"] + pulse = q1.xy.operations["gaussian_x90"] + + xy_macro.update(duration=400) + + assert pulse.length == 400 + assert pulse.sigma == pytest.approx(pulse.length * pulse.sigma_ratio) + + def test_negative_x_rotation_is_phase_shifted_positive_angle_drive(): """Negative X should map to +pi phase shift with positive amplitude scale.""" machine = _build_machine() diff --git a/tests/builder/quantum_dots/test_pulse_wiring.py b/tests/builder/quantum_dots/test_pulse_wiring.py index bbc8591c..3cac59b5 100644 --- a/tests/builder/quantum_dots/test_pulse_wiring.py +++ b/tests/builder/quantum_dots/test_pulse_wiring.py @@ -1,7 +1,4 @@ -"""Tests for pulse wiring via wire_machine_macros and TOML pulse overrides.""" - -import pytest -from unittest.mock import MagicMock +"""Tests for pulse wiring via PulseWirer / wire_machine_macros and TOML-style pulse setup.""" from quam.components import pulses as quam_pulses, StickyChannelAddon from quam.components.ports import LFFEMAnalogOutputPort, LFFEMAnalogInputPort @@ -12,16 +9,9 @@ ) from quam_builder.architecture.quantum_dots.components.xy_drive import XYDriveSingle from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam -from quam_builder.architecture.quantum_dots.macro_engine import ( - wire_machine_macros, - pulse, - disabled, - overrides as component_overrides_helper, -) +from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros +from quam_builder.architecture.quantum_dots.macro_engine.wiring import PulseWirer from quam_builder.architecture.quantum_dots.qubit import LDQubit -from quam_builder.architecture.quantum_dots.macro_engine.wiring import ( - _ensure_default_pulses, -) def _make_voltage_gate(lf_fem: int, port: int, gate_id: str) -> VoltageGate: @@ -80,25 +70,68 @@ def _make_wired_machine() -> LossDiVincenzoQuam: class TestDefaultPulseWiring: - """Test that _ensure_default_pulses adds pulses to the right places.""" + """Test that PulseWirer adds pulses to the right places.""" def test_xy_drive_gets_default_pulse(self): machine = _make_wired_machine() - _ensure_default_pulses(machine) + PulseWirer().wire(machine) + + xy = machine.qubits["Q1"].xy + assert "gaussian_x90" in xy.operations + + def test_all_families_wired(self): + machine = _make_wired_machine() + PulseWirer().wire(machine) + + xy = machine.qubits["Q1"].xy + for family in ("gaussian", "square", "kaiser", "hermite", "drag"): + assert f"{family}_x90" in xy.operations + assert f"{family}_x180" in xy.operations + + def test_all_families_axis_variants_wired(self): + """All axis variants (x_neg90, y90, y180, y_neg90) should be wired for every family.""" + machine = _make_wired_machine() + PulseWirer().wire(machine) + + xy = machine.qubits["Q1"].xy + for family in ("gaussian", "square", "kaiser", "hermite", "drag"): + for gate in ("x_neg90", "y90", "y180", "y_neg90"): + assert f"{family}_{gate}" in xy.operations, ( + f"Expected '{family}_{gate}' in xy.operations" + ) + + def test_hermite_pulse_wired_with_correct_type(self): + """Hermite family should wire ScalableHermitePulse instances.""" + from quam_builder.architecture.quantum_dots.components.pulses import ScalableHermitePulse + + machine = _make_wired_machine() + PulseWirer().wire(machine) xy = machine.qubits["Q1"].xy - assert "gaussian" in xy.operations + assert isinstance(xy.operations["hermite_x90"], ScalableHermitePulse) + assert isinstance(xy.operations["hermite_x180"], ScalableHermitePulse) + + def test_drag_pulse_wired_with_correct_type(self): + """DRAG family should wire ScalableDragPulse instances.""" + from quam_builder.architecture.quantum_dots.components.pulses import ScalableDragPulse + + machine = _make_wired_machine() + PulseWirer().wire(machine) + + xy = machine.qubits["Q1"].xy + assert isinstance(xy.operations["drag_x90"], ScalableDragPulse) + assert isinstance(xy.operations["drag_x180"], ScalableDragPulse) def test_single_channel_no_axis_angle(self): machine = _make_wired_machine() - _ensure_default_pulses(machine) + PulseWirer().wire(machine) xy = machine.qubits["Q1"].xy - assert xy.operations["gaussian"].axis_angle is None + assert xy.operations["gaussian_x90"].axis_angle is None def test_readout_resonator_gets_default_pulse(self): machine = _make_wired_machine() - _ensure_default_pulses(machine) + PulseWirer().wire(machine) rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator assert "readout" in rr.operations @@ -107,12 +140,11 @@ def test_readout_resonator_gets_default_pulse(self): def test_existing_pulses_not_overwritten(self): machine = _make_wired_machine() custom_pulse = quam_pulses.GaussianPulse(length=500, amplitude=0.3, sigma=83) - machine.qubits["Q1"].xy.operations["gaussian"] = custom_pulse + machine.qubits["Q1"].xy.operations["gaussian_x90"] = custom_pulse - _ensure_default_pulses(machine) + PulseWirer().wire(machine) - # Custom pulse should be preserved - assert machine.qubits["Q1"].xy.operations["gaussian"] is custom_pulse + assert machine.qubits["Q1"].xy.operations["gaussian_x90"] is custom_pulse def test_existing_readout_not_overwritten(self): machine = _make_wired_machine() @@ -121,7 +153,7 @@ def test_existing_readout_not_overwritten(self): "readout" ] = custom_readout - _ensure_default_pulses(machine) + PulseWirer().wire(machine) rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator assert rr.operations["readout"] is custom_readout @@ -130,74 +162,126 @@ def test_existing_readout_not_overwritten(self): class TestWireMacrosPulseIntegration: """Test that wire_machine_macros wires both macros and pulses.""" - def test_full_wiring_adds_pulses(self, reset_catalog): + def test_full_wiring_adds_pulses(self): machine = _make_wired_machine() wire_machine_macros(machine) xy = machine.qubits["Q1"].xy - assert "gaussian" in xy.operations + assert "gaussian_x90" in xy.operations rr = machine.sensor_dots["virtual_sensor_1"].readout_resonator assert "readout" in rr.operations - def test_pulse_overrides_via_component_overrides(self, reset_catalog): - """Override pulse on all LDQubits using typed component_overrides API. - - Uses pulse() helper to define a shorter gaussian with different amplitude. - """ + def test_pulse_override_all_ldqubits_before_wire(self): + """Override pulse on all LDQubits by pre-seeding xy.operations (PulseWirer is additive).""" machine = _make_wired_machine() + for qubit in machine.qubits.values(): + if not isinstance(qubit, LDQubit) or qubit.xy is None: + continue + qubit.xy.operations["gaussian_x90"] = quam_pulses.GaussianPulse( + length=500, amplitude=0.3, sigma=83 + ) - wire_machine_macros( - machine, - component_overrides={ - LDQubit: component_overrides_helper( - pulses={ - "gaussian": pulse("GaussianPulse", length=500, amplitude=0.3, sigma=83), - } - ), - }, - ) + wire_machine_macros(machine) - gaussian = machine.qubits["Q1"].xy.operations["gaussian"] + gaussian = machine.qubits["Q1"].xy.operations["gaussian_x90"] assert gaussian.length == 500 assert gaussian.amplitude == 0.3 - def test_pulse_disable_via_component_overrides(self, reset_catalog): - """Remove a pulse from all LDQubits using disabled() helper.""" + def test_pulse_removed_after_wire(self): + """Removing the default XY pulse after wiring (no type-level pulse-disable hook).""" machine = _make_wired_machine() + wire_machine_macros(machine) - wire_machine_macros( - machine, - component_overrides={ - LDQubit: component_overrides_helper( - pulses={ - "gaussian": disabled(), - } - ), - }, - ) + for qubit in machine.qubits.values(): + if not isinstance(qubit, LDQubit) or qubit.xy is None: + continue + qubit.xy.operations.pop("gaussian_x90", None) xy = machine.qubits["Q1"].xy - assert "gaussian" not in xy.operations + assert "gaussian_x90" not in xy.operations - def test_instance_pulse_override(self, reset_catalog): - """Override pulse on one specific qubit using instance_overrides API. - - Only qubits.Q1 gets the custom gaussian; others keep the default. - """ + def test_instance_pulse_override_before_wire(self): + """Override pulse on one qubit by pre-seeding operations before wire_machine_macros.""" machine = _make_wired_machine() - - wire_machine_macros( - machine, - instance_overrides={ - "qubits.Q1": component_overrides_helper( - pulses={ - "gaussian": pulse("GaussianPulse", length=800, amplitude=0.15, sigma=133), - } - ), - }, + machine.qubits["Q1"].xy.operations["gaussian_x90"] = quam_pulses.GaussianPulse( + length=800, amplitude=0.15, sigma=133 ) - gaussian = machine.qubits["Q1"].xy.operations["gaussian"] + wire_machine_macros(machine) + + gaussian = machine.qubits["Q1"].xy.operations["gaussian_x90"] assert gaussian.length == 800 assert gaussian.amplitude == 0.15 + + +class TestPulseFamilySwitchingIntegration: + """Test set_pulse_family() propagation to macros after wiring.""" + + def test_set_pulse_family_propagates_to_macros(self): + machine = _make_wired_machine() + wire_machine_macros(machine) + + machine.set_pulse_family("kaiser") + + q1 = machine.qubits["Q1"] + xy_macro = q1.macros["xy_drive"] + assert xy_macro.pulse_family == "kaiser" + assert xy_macro.pulse_name == "kaiser_x90" + + def test_set_pulse_family_invalid_raises(self): + import pytest + + machine = _make_wired_machine() + wire_machine_macros(machine) + + with pytest.raises(ValueError, match="Unknown pulse family"): + machine.set_pulse_family("nonexistent") + + def test_set_pulse_family_switches_all_macro_types(self): + machine = _make_wired_machine() + wire_machine_macros(machine) + + machine.set_pulse_family("square") + + q1 = machine.qubits["Q1"] + assert q1.macros["x180"].pulse_name == "square_x180" + assert q1.macros["x90"].pulse_name == "square_x90" + assert q1.macros["y90"].pulse_name == "square_y90" + + def test_default_pulse_family_is_gaussian(self): + machine = _make_wired_machine() + assert machine.pulse_family == "gaussian" + + wire_machine_macros(machine) + + q1 = machine.qubits["Q1"] + assert q1.macros["xy_drive"].pulse_name == "gaussian_x90" + + def test_set_pulse_family_hermite_propagates(self): + """set_pulse_family('hermite') should switch all XY macros to the hermite family.""" + machine = _make_wired_machine() + wire_machine_macros(machine) + + machine.set_pulse_family("hermite") + + q1 = machine.qubits["Q1"] + assert q1.macros["xy_drive"].pulse_family == "hermite" + assert q1.macros["xy_drive"].pulse_name == "hermite_x90" + assert q1.macros["x180"].pulse_name == "hermite_x180" + assert q1.macros["y90"].pulse_name == "hermite_y90" + assert q1.macros["x_neg90"].pulse_name == "hermite_x_neg90" + + def test_set_pulse_family_drag_propagates(self): + """set_pulse_family('drag') should switch all XY macros to the drag family.""" + machine = _make_wired_machine() + wire_machine_macros(machine) + + machine.set_pulse_family("drag") + + q1 = machine.qubits["Q1"] + assert q1.macros["xy_drive"].pulse_family == "drag" + assert q1.macros["xy_drive"].pulse_name == "drag_x90" + assert q1.macros["x180"].pulse_name == "drag_x180" + assert q1.macros["y90"].pulse_name == "drag_y90" + assert q1.macros["x_neg90"].pulse_name == "drag_x_neg90" diff --git a/tests/builder/quantum_dots/test_stage_workflow_persistence.py b/tests/builder/quantum_dots/test_stage_workflow_persistence.py index b4f03edd..a60b8016 100644 --- a/tests/builder/quantum_dots/test_stage_workflow_persistence.py +++ b/tests/builder/quantum_dots/test_stage_workflow_persistence.py @@ -7,7 +7,10 @@ from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD from quam_builder.builder.qop_connectivity import build_quam_wiring -from quam_builder.builder.quantum_dots import build_base_quam, build_loss_divincenzo_quam +from quam_builder.builder.quantum_dots import ( + build_base_quam, + build_loss_divincenzo_quam, +) EXAMPLE_GLOBAL_GATES = [1] @@ -19,15 +22,15 @@ def _make_stage1_connectivity(): connectivity = Connectivity() if EXAMPLE_GLOBAL_GATES: - connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") + connectivity.add_voltage_gate_lines( + voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb" + ) connectivity.add_sensor_dots( sensor_dots=EXAMPLE_SENSOR_DOTS, shared_resonator_line=False, - use_mw_fem=False, ) connectivity.add_quantum_dots( quantum_dots=EXAMPLE_QUANTUM_DOTS, - add_drive_lines=False, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) return connectivity @@ -36,18 +39,19 @@ def _make_stage1_connectivity(): def _make_stage2_connectivity(): connectivity = Connectivity() if EXAMPLE_GLOBAL_GATES: - connectivity.add_voltage_gate_lines(voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb") + connectivity.add_voltage_gate_lines( + voltage_gates=EXAMPLE_GLOBAL_GATES, name="rb" + ) connectivity.add_sensor_dots( sensor_dots=EXAMPLE_SENSOR_DOTS, shared_resonator_line=False, - use_mw_fem=False, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=EXAMPLE_QUANTUM_DOT_PAIRS) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=EXAMPLE_QUANTUM_DOTS) + connectivity.add_quantum_dot_drive_lines( quantum_dots=EXAMPLE_QUANTUM_DOTS, - add_drive_lines=True, + shared_line=True, use_mw_fem=True, - shared_drive_line=True, ) return connectivity @@ -62,7 +66,9 @@ def _make_instruments(): def _add_calibration_data(machine: BaseQuamQD) -> None: gate_set = machine.virtual_gate_sets["main_qpu"] virtual_targets = [ - name for name in gate_set.layers[0].source_gates if name.startswith("virtual_dot_") + name + for name in gate_set.layers[0].source_gates + if name.startswith("virtual_dot_") ][:2] gate_set.add_to_layer( layer_id="calibration_layer", @@ -122,7 +128,9 @@ def test_calibration_data_persists_across_two_stage_build(tmp_path): ) gate_set = machine_stage2.virtual_gate_sets["main_qpu"] - calibration_layer = next(layer for layer in gate_set.layers if layer.id == "calibration_layer") + calibration_layer = next( + layer for layer in gate_set.layers if layer.id == "calibration_layer" + ) assert calibration_layer.source_gates == ["virtual_calibration_axis"] quantum_dot = machine_stage2.quantum_dots["virtual_dot_1"] diff --git a/tests/builder/quantum_dots/test_two_stage_build.py b/tests/builder/quantum_dots/test_two_stage_build.py index c4dc9317..c49d4bd5 100644 --- a/tests/builder/quantum_dots/test_two_stage_build.py +++ b/tests/builder/quantum_dots/test_two_stage_build.py @@ -9,6 +9,7 @@ import pytest from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType +from quam_builder.architecture.quantum_dots.defaults import DEFAULTS from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam from quam_builder.builder.quantum_dots.build_qpu_stage1 import _BaseQpuBuilder from quam_builder.builder.quantum_dots.build_qpu_stage2 import _LDQubitBuilder @@ -342,8 +343,8 @@ def test_creates_qubit_pairs(self): # Verify qubit pair is created assert "q1_q2" in result.qubit_pairs pair = result.qubit_pairs["q1_q2"] - assert pair.qubit_control.id == "virtual_dot_1" - assert pair.qubit_target.id == "virtual_dot_2" + assert pair.qubit_control.id == "q1" + assert pair.qubit_target.id == "q2" def test_compensation_matrix_preserved(self): """Stage 2 should preserve compensation matrix from Stage 1.""" @@ -359,7 +360,9 @@ def test_compensation_matrix_preserved(self): machine = builder1.build() # Get original matrix - original_matrix = [row[:] for row in machine.virtual_gate_sets["main_qpu"].layers[0].matrix] + original_matrix = [ + row[:] for row in machine.virtual_gate_sets["main_qpu"].layers[0].matrix + ] # Run Stage 2 builder2 = _LDQubitBuilder(machine) @@ -532,8 +535,10 @@ def test_readout_resonator_not_sticky(self): sensor = machine.sensor_dots["virtual_sensor_1"] assert sensor.readout_resonator.sticky is None - def test_readout_resonator_intermediate_frequency_zero(self): - """Readout resonator intermediate_frequency should default to 0.""" + def test_readout_resonator_intermediate_frequency_from_defaults(self, monkeypatch): + """Readout resonator intermediate_frequency should use QD defaults.""" + default_readout_frequency = 123e6 + monkeypatch.setattr(DEFAULTS.readout, "frequency", default_readout_frequency) machine = BaseQuamQD() machine.wiring = { "readout": { @@ -550,7 +555,11 @@ def test_readout_resonator_intermediate_frequency_zero(self): machine = builder.build() sensor = machine.sensor_dots["virtual_sensor_1"] - assert sensor.readout_resonator.intermediate_frequency == 0 + assert ( + sensor.readout_resonator.intermediate_frequency + == DEFAULTS.readout.frequency + ) + assert sensor.readout_resonator.frequency_bare == DEFAULTS.readout.frequency class TestDriveTypeDetection: @@ -715,3 +724,73 @@ def test_build_quam_convenience_wrapper(self): assert isinstance(result, LossDiVincenzoQuam) assert "q1" in result.qubits assert "virtual_dot_1" in result.quantum_dots + + +class TestMWFEMUpconverterResolution: + """Regression tests for upconverter_frequency resolution (quam 0.5). + + When XYDriveMW.LO_frequency is the reference '#./upconverter_frequency', + the builder must resolve it via the port object in machine.ports rather + than relying on the QuAM reference chain (which may not be available + during construction). + """ + + def test_larmor_frequency_set_from_mw_port(self): + """build_loss_divincenzo_quam should resolve LO from the MW port + and set larmor_frequency = LO + IF without warnings.""" + import shutil + import tempfile + import warnings + + from qualang_tools.wirer import Connectivity, Instruments, allocate_wiring + + from quam_builder.builder.qop_connectivity import build_quam_wiring + + instruments = Instruments() + instruments.add_mw_fem(controller=1, slots=[1]) + instruments.add_lf_fem(controller=1, slots=[2, 3]) + + connectivity = Connectivity() + connectivity.add_sensor_dots(sensor_dots=[1], shared_resonator_line=False) + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( + quantum_dots=[1, 2], shared_line=True, use_mw_fem=True + ) + connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) + allocate_wiring(connectivity, instruments) + + tmp = tempfile.mkdtemp() + try: + machine = BaseQuamQD() + machine = build_quam_wiring( + connectivity, "127.0.0.1", "test", machine, path=tmp + ) + machine = build_base_quam( + machine, calibration_db_path=tmp, connect_qdac=False, save=False + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = build_loss_divincenzo_quam( + machine, + qubit_pair_sensor_map={"q1_q2": ["sensor_1"]}, + save=False, + ) + + upconv_warnings = [ + w for w in caught if "upconverter_frequency" in str(w.message) + ] + assert ( + upconv_warnings == [] + ), f"Unexpected upconverter_frequency warnings: {upconv_warnings}" + + for qubit in result.qubits.values(): + if qubit.xy is not None: + lo = qubit.xy.LO_frequency + assert isinstance(lo, (int, float)) + assert isinstance(qubit.xy.intermediate_frequency, (int, float)) + assert qubit.larmor_frequency == pytest.approx( + lo + qubit.xy.intermediate_frequency + ) + finally: + shutil.rmtree(tmp) diff --git a/tests/builder/quantum_dots/test_wirer_builder_integration.py b/tests/builder/quantum_dots/test_wirer_builder_integration.py index a4f73027..80f7cc95 100644 --- a/tests/builder/quantum_dots/test_wirer_builder_integration.py +++ b/tests/builder/quantum_dots/test_wirer_builder_integration.py @@ -2,20 +2,13 @@ # pylint: disable=too-few-public-methods -import json import shutil import tempfile from pathlib import Path import pytest -from qualang_tools.wirer import ( - Instruments, - Connectivity, - allocate_wiring, - lf_fem_spec, - qdac2_spec, -) +from qualang_tools.wirer import Instruments, Connectivity, allocate_wiring from qualang_tools.wirer.connectivity.wiring_spec import WiringLineType from quam_builder.builder.qop_connectivity import build_quam_wiring from quam_builder.builder.quantum_dots import ( @@ -38,36 +31,34 @@ class TestWirerBuilderIntegration: EXAMPLE_QUANTUM_DOT_PAIRS = [(1, 2), (2, 3)] @staticmethod - def _make_stage1_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): + def _make_stage1_connectivity( + global_gates, sensor_dots, quantum_dots, quantum_dot_pairs + ): connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") connectivity.add_sensor_dots( sensor_dots=sensor_dots, shared_resonator_line=False, - use_mw_fem=False, - ) - connectivity.add_quantum_dots( - quantum_dots=quantum_dots, - add_drive_lines=False, ) + connectivity.add_quantum_dots(quantum_dots=quantum_dots) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) return connectivity @staticmethod - def _make_stage2_connectivity(global_gates, sensor_dots, quantum_dots, quantum_dot_pairs): + def _make_stage2_connectivity( + global_gates, sensor_dots, quantum_dots, quantum_dot_pairs + ): connectivity = Connectivity() connectivity.add_voltage_gate_lines(voltage_gates=global_gates, name="rb") connectivity.add_sensor_dots( sensor_dots=sensor_dots, shared_resonator_line=False, - use_mw_fem=False, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=quantum_dot_pairs) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=quantum_dots) + connectivity.add_quantum_dot_drive_lines( quantum_dots=quantum_dots, - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) return connectivity @@ -94,13 +85,11 @@ def test_complete_workflow_basic_setup(self, instruments, temp_dir): connectivity.add_sensor_dots( sensor_dots=[1], shared_resonator_line=False, - use_mw_fem=False, ) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) @@ -130,174 +119,6 @@ def test_complete_workflow_basic_setup(self, instruments, temp_dir): assert len(machine.sensor_dots) > 0 assert len(machine.quantum_dots) > 0 - def test_build_quam_wiring_lf_fem_and_qdac2_dual_voltage_gate(self, temp_dir): - """Wiring dict includes LF analog ref and QDAC2 channel index from wirer allocation.""" - instruments = Instruments() - instruments.add_lf_fem(controller=1, slots=[1]) - instruments.add_qdac2(indices=1) - connectivity = Connectivity() - connectivity.add_voltage_gate_lines( - [1], - triggered=False, - name="vg", - constraints=lf_fem_spec(con=1, out_slot=1) & qdac2_spec(index=1), - ) - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - - gate_wiring = machine.wiring["globals"]["vg1"][WiringLineType.GLOBAL_GATE.value] - assert "opx_output" in gate_wiring - qo = gate_wiring["qdac_output"] - assert qo["unit_index"] == 1 and qo["port"] == 1 - assert qo["ref"] == "qdac/analog_outputs/qdac1/1" - - def test_dac_config_round_trip_in_wiring_json(self, temp_dir): - """DAC driver map is stored under ``dac_config`` in ``wiring.json``.""" - dac_cfg = { - "qdac1": { - "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", - "driver_class": "QDac2", - "connection": {"visalib": "@py", "address": "TCPIP::127.0.0.1::5025::SOCKET"}, - "channel_method": "channel", - "accessor": "dc_constant_V", - "is_qdac": True, - }, - "qdac2": { - "driver_module": "qcodes_contrib_drivers.drivers.QDevil.QDAC2", - "driver_class": "QDac2", - "connection": {"visalib": "@py", "address": "TCPIP::127.0.0.2::5025::SOCKET"}, - "channel_method": "channel", - "accessor": "dc_constant_V", - "is_qdac": True, - }, - } - instruments = Instruments() - instruments.add_lf_fem(controller=1, slots=[1]) - connectivity = Connectivity() - connectivity.add_quantum_dots(quantum_dots=[1], add_drive_lines=False) - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - dac_config=dac_cfg, - ) - - wiring_files = list(Path(temp_dir).rglob("wiring.json")) - assert wiring_files, "expected wiring.json under save path" - on_disk = json.loads(wiring_files[0].read_text(encoding="utf-8")) - assert on_disk.get("dac_config") == dac_cfg - assert on_disk.get("network", {}).get("host") == "127.0.0.1" - - def test_qdac_spec_from_wiring(self, temp_dir): - """Stage 1 attaches QdacSpec from ``machine.wiring``.""" - instruments = Instruments() - instruments.add_lf_fem(controller=1, slots=[1]) - instruments.add_qdac2(indices=1) - connectivity = Connectivity() - connectivity.add_voltage_gate_lines( - [1], - triggered=False, - name="vg", - constraints=lf_fem_spec(con=1, out_slot=1) & qdac2_spec(index=1), - ) - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - build_base_quam( - machine, - calibration_db_path=temp_dir, - connect_qdac=False, - save=False, - ) - - vgs = machine.virtual_gate_sets["main_qpu"] - gate = vgs.channels["vg1"] - assert gate.dac_spec is not None - assert gate.dac_spec.qdac_output_port == 1 - assert gate.dac_spec.dac_name == "qdac1" - - def test_make_voltage_gate_qdac_only_skips_opx_output_ref(self): - """QDAC-only wiring must not set a dangling ``#/wiring/.../opx_output`` reference.""" - from quam_builder.builder.quantum_dots.build_utils import _make_voltage_gate_with_qdac - - ports = { - "qdac_output": {"unit_index": 1, "port": 10, "ref": "qdac/analog_outputs/qdac1/10"}, - } - gate = _make_voltage_gate_with_qdac( - "source", "#/wiring/globals/source/g", ports, qdac_channel=10 - ) - assert gate.opx_output is None - assert gate.qdac_channel == 10 - assert gate.sticky is None - - def test_make_voltage_gate_lf_plus_qdac_keeps_opx_output_ref(self): - from quam_builder.builder.quantum_dots.build_utils import _make_voltage_gate_with_qdac - - ports = { - "opx_output": "#/ports/analog_outputs/con1/1/1/1", - "qdac_output": {"unit_index": 1, "port": 3, "ref": "qdac/analog_outputs/qdac1/3"}, - } - gate = _make_voltage_gate_with_qdac( - "vg1", "#/wiring/globals/vg1/g", ports, qdac_channel=3 - ) - assert gate.opx_output == "#/wiring/globals/vg1/g/opx_output" - - def test_global_voltage_gate_qdac_only_stage1(self, temp_dir): - """Global gate with only QDAC2 channels: Stage 1 builds a gate without OPX analog ref.""" - instruments = Instruments() - instruments.add_qdac2(indices=1) - connectivity = Connectivity() - connectivity.add_voltage_gate_lines( - "source", - name="", - triggered=False, - constraints=qdac2_spec(index=1, out_port=10), - ) - allocate_wiring(connectivity, instruments) - - machine = BaseQuamQD() - machine = build_quam_wiring( - connectivity, - host_ip="127.0.0.1", - cluster_name="test_cluster", - quam_instance=machine, - path=temp_dir, - ) - build_base_quam( - machine, - calibration_db_path=temp_dir, - connect_qdac=False, - save=False, - ) - - vgs = machine.virtual_gate_sets["main_qpu"] - gate = vgs.channels["source"] - assert gate.opx_output is None - assert gate.qdac_channel == 10 - assert gate.sticky is None - qua_cfg = machine.generate_config() - assert "source" not in qua_cfg.get("elements", {}) - def test_example_two_stage_workflow(self, temp_dir): """Exercise the two-stage flow used in wiring_example.""" qubit_pair_sensor_map = {"q1_q2": ["sensor_1"], "q2_q3": ["sensor_2"]} @@ -358,7 +179,9 @@ def test_example_two_stage_workflow(self, temp_dir): assert len(machine_stage2.quantum_dots) == len(self.EXAMPLE_QUANTUM_DOTS) assert len(machine_stage2.sensor_dots) == len(self.EXAMPLE_SENSOR_DOTS) assert len(machine_stage2.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) - assert len(machine_stage2.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) + assert len(machine_stage2.quantum_dot_pairs) == len( + self.EXAMPLE_QUANTUM_DOT_PAIRS + ) def test_example_combined_workflow(self, temp_dir): """Exercise the combined flow used in wiring_example.""" @@ -393,7 +216,9 @@ def test_example_combined_workflow(self, temp_dir): ) assert len(machine_combined.qubits) == len(self.EXAMPLE_QUANTUM_DOTS) - assert len(machine_combined.quantum_dot_pairs) == len(self.EXAMPLE_QUANTUM_DOT_PAIRS) + assert len(machine_combined.quantum_dot_pairs) == len( + self.EXAMPLE_QUANTUM_DOT_PAIRS + ) def test_example_incremental_drive_lines(self, temp_dir): """Exercise incremental drive-line flow from wiring_example.""" @@ -429,7 +254,6 @@ def test_example_incremental_drive_lines(self, temp_dir): connectivity_drive_lines = Connectivity() connectivity_drive_lines.add_quantum_dot_drive_lines( quantum_dots=self.EXAMPLE_QUANTUM_DOTS, - use_mw_fem=True, shared_line=True, ) allocate_wiring(connectivity_drive_lines, instruments_stage1) @@ -459,13 +283,11 @@ def test_workflow_with_multiple_qubits(self, instruments, temp_dir): connectivity.add_sensor_dots( sensor_dots=[1, 2], shared_resonator_line=False, - use_mw_fem=False, ) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3, 4]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2, 3, 4], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3), (3, 4)]) @@ -493,10 +315,7 @@ def test_workflow_with_multiple_qubits(self, instruments, temp_dir): def test_virtual_gate_set_creation(self, instruments, temp_dir): """Test that virtual gate set is correctly created.""" connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1, 2, 3], - add_drive_lines=False, - ) + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3]) allocate_wiring(connectivity, instruments) @@ -522,11 +341,10 @@ def test_virtual_gate_set_creation(self, instruments, temp_dir): def test_qubit_registration_with_xy_drives(self, instruments, temp_dir): """Test that qubits are registered with their XY drives in Stage 2.""" connectivity = Connectivity() - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) allocate_wiring(connectivity, instruments) @@ -554,7 +372,6 @@ def test_sensor_dots_with_resonators(self, instruments, temp_dir): connectivity.add_sensor_dots( sensor_dots=[1, 2], shared_resonator_line=False, - use_mw_fem=False, ) allocate_wiring(connectivity, instruments) @@ -578,11 +395,10 @@ def test_sensor_dots_with_resonators(self, instruments, temp_dir): def test_pulses_are_added(self, instruments, temp_dir): """Test that default pulses are added to qubits in Stage 2.""" connectivity = Connectivity() - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) allocate_wiring(connectivity, instruments) @@ -608,10 +424,7 @@ def test_pulses_are_added(self, instruments, temp_dir): def test_network_configuration_is_set(self, instruments, temp_dir): """Test that network configuration is properly set.""" connectivity = Connectivity() - connectivity.add_quantum_dots( - quantum_dots=[1], - add_drive_lines=False, - ) + connectivity.add_quantum_dots(quantum_dots=[1]) allocate_wiring(connectivity, instruments) @@ -636,13 +449,11 @@ def test_active_element_names_are_set(self, instruments, temp_dir): connectivity.add_sensor_dots( sensor_dots=[1], shared_resonator_line=False, - use_mw_fem=False, ) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2)]) @@ -677,13 +488,11 @@ def test_connectivity_quantum_dot_interface(self): connectivity.add_sensor_dots( sensor_dots=[1, 2], shared_resonator_line=True, - use_mw_fem=False, ) - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2, 3]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2, 3], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) connectivity.add_quantum_dot_pairs(quantum_dot_pairs=[(1, 2), (2, 3)]) @@ -718,11 +527,10 @@ def test_allocate_wiring_creates_channels(self): instruments.add_lf_fem(controller=1, slots=[2, 3]) connectivity = Connectivity() - connectivity.add_quantum_dots( + connectivity.add_quantum_dots(quantum_dots=[1, 2]) + connectivity.add_quantum_dot_drive_lines( quantum_dots=[1, 2], - add_drive_lines=True, - use_mw_fem=True, - shared_drive_line=True, + shared_line=True, ) # Allocate wiring @@ -732,4 +540,4 @@ def test_allocate_wiring_creates_channels(self): for element in connectivity.elements.values(): assert len(element.channels) > 0 for channel_list in element.channels.values(): - assert len(channel_list) > 0 \ No newline at end of file + assert len(channel_list) > 0 diff --git a/tests/builder/superconducting/test_e2e_superconducting.py b/tests/builder/superconducting/test_e2e_superconducting.py index 9f4df6e9..58495edf 100644 --- a/tests/builder/superconducting/test_e2e_superconducting.py +++ b/tests/builder/superconducting/test_e2e_superconducting.py @@ -61,7 +61,9 @@ def test_four_qubit_full_workflow(self, instruments, temp_dir): connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -85,7 +87,9 @@ def test_two_qubit_minimal(self, instruments, temp_dir): connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -111,7 +115,9 @@ def test_drive_only_no_flux_no_pairs(self, temp_dir): allocate_wiring(connectivity, instruments) machine = FixedFrequencyQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FixedFrequencyQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -145,7 +151,9 @@ def test_config_generation_mwfem(self, temp_dir): allocate_wiring(connectivity, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -162,7 +170,9 @@ def test_wiring_structure_opxp_octave(self, instruments, temp_dir): connectivity = self._make_connectivity(qubits, qubit_pairs, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) assert "qubits" in machine.wiring assert "qubit_pairs" in machine.wiring @@ -192,7 +202,9 @@ def test_eight_qubit_two_feedlines(self, instruments, temp_dir): connectivity = Connectivity() connectivity.add_resonator_line(qubits=qubits[:4], constraints=q1to4_res_ch) connectivity.add_resonator_line(qubits=qubits[4:], constraints=q5to8_res_ch) - connectivity.add_qubit_drive_lines(qubits=qubits[:4], constraints=q1to4_drive_ch) + connectivity.add_qubit_drive_lines( + qubits=qubits[:4], constraints=q1to4_drive_ch + ) for qubit in qubits[4:]: connectivity.add_qubit_drive_lines(qubits=qubit, constraints=q5to8_drive_ch) allocate_wiring(connectivity, instruments, block_used_channels=False) @@ -201,7 +213,9 @@ def test_eight_qubit_two_feedlines(self, instruments, temp_dir): allocate_wiring(connectivity, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -230,7 +244,9 @@ def test_four_qubit_mwfem(self, instruments, temp_dir): allocate_wiring(connectivity, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) @@ -265,7 +281,9 @@ def test_three_qubit_lffem_octave(self, temp_dir): allocate_wiring(connectivity, instruments) machine = FluxTunableQuam() - build_quam_wiring(connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir) + build_quam_wiring( + connectivity, "127.0.0.1", "test_cluster", machine, path=temp_dir + ) machine = FluxTunableQuam.load(temp_dir) build_quam_sc(machine, calibration_db_path=temp_dir, save=False) diff --git a/tests/conftest.py b/tests/conftest.py index 2891b5c9..17c0a9f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,6 @@ import pytest -from quam_builder.architecture.quantum_dots.operations import component_macro_catalog -from quam_builder.architecture.quantum_dots.operations import component_pulse_catalog -from quam_builder.architecture.quantum_dots.operations import macro_registry -from quam_builder.architecture.quantum_dots.operations import pulse_registry - # Make test utilities (test_utils.py) importable from any sub-directory sys.path.insert(0, str(Path(__file__).parent)) @@ -27,21 +22,3 @@ def bypass_quam_config_version_check(): """ with patch("quam.config.resolvers.quam_version_validator"): yield - - -@pytest.fixture -def reset_catalog(): - """Reset catalog and registry before each test that uses it. - - Use this fixture in any test that directly verifies registration behavior - (e.g., tests that call wire_machine_macros() and then assert macro presence). - - Do NOT use autouse=True — only tests that care about registration state - should pull this in explicitly. Using autouse=True would break tests that - rely on registration completing during component construction. - """ - component_macro_catalog._reset_registration() - component_pulse_catalog._reset_registration() - macro_registry._reset_registry() - pulse_registry._reset_registry() - yield diff --git a/tests/macros/conftest.py b/tests/macros/conftest.py index fad099ad..03470d16 100644 --- a/tests/macros/conftest.py +++ b/tests/macros/conftest.py @@ -128,19 +128,8 @@ def machine(): barrier_gate_id="virtual_barrier_3", ) - # Define detuning axes for both QuantumDotPairs - machine.quantum_dot_pairs["dot1_dot2_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot1_dot2_epsilon", - set_dc_virtual_axis=False, - ) - machine.quantum_dot_pairs["dot3_dot4_pair"].define_detuning_axis( - matrix=[[1, -1]], - detuning_axis_name="dot3_dot4_epsilon", - set_dc_virtual_axis=False, - ) + # Detuning axes are registered in register_quantum_dot_pair (see BaseQuamQD). - # Register Qubit Pairs machine.register_qubit_pair( qubit_control_name="Q1", qubit_target_name="Q2", diff --git a/tests/pylint_plugin/__init__.py b/tests/pylint_plugin/__init__.py index 356edc8b..7a33550e 100644 --- a/tests/pylint_plugin/__init__.py +++ b/tests/pylint_plugin/__init__.py @@ -1 +1 @@ -"""Tests for the pylint QUA plugin.""" \ No newline at end of file +"""Tests for the pylint QUA plugin.""" diff --git a/tests/pylint_plugin/test_pylint_qua_plugin.py b/tests/pylint_plugin/test_pylint_qua_plugin.py index 4161bbe3..4db7944b 100644 --- a/tests/pylint_plugin/test_pylint_qua_plugin.py +++ b/tests/pylint_plugin/test_pylint_qua_plugin.py @@ -62,7 +62,10 @@ def run_pylint(file_path: Path, use_plugin: bool = True) -> tuple[int, str, str] ] result = subprocess.run( - cmd, capture_output=True, text=True, env={**dict(__import__("os").environ), **env} + cmd, + capture_output=True, + text=True, + env={**dict(__import__("os").environ), **env}, ) return result.returncode, result.stdout, result.stderr @@ -173,7 +176,9 @@ def test_c1805_not_suppressed_in_regular_python(self): # At minimum, there should be SOME C1805 warnings for regular Python code # The exact lines may vary, but we should see warnings outside QUA contexts - assert len(lines_with_c1805) > 0, "C1805 should still appear for regular Python code" + assert ( + len(lines_with_c1805) > 0 + ), "C1805 should still appear for regular Python code" def test_c0121_not_suppressed_in_regular_python(self): """ @@ -184,7 +189,9 @@ def test_c0121_not_suppressed_in_regular_python(self): lines_with_c0121 = get_lines_with_message(stdout, "C0121") # regular_python_function and mixed_function should have C0121 warnings - assert len(lines_with_c0121) > 0, "C0121 should still appear for regular Python code" + assert ( + len(lines_with_c0121) > 0 + ), "C0121 should still appear for regular Python code" class TestComparisonWithoutPlugin: @@ -219,7 +226,8 @@ class TestRealWorldExample: @pytest.mark.skipif( not ( - REPO_ROOT / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" + REPO_ROOT + / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" ).exists(), reason="rabi_chevron.py not found", ) @@ -228,7 +236,10 @@ def test_rabi_chevron_no_qua_false_positives(self): Run pylint on the actual rabi_chevron.py and verify QUA-related false positives are suppressed. """ - rabi_file = REPO_ROOT / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" + rabi_file = ( + REPO_ROOT + / "quam_builder/architecture/quantum_dots/examples/rabi_chevron.py" + ) return_code, stdout, stderr = run_pylint(rabi_file, use_plugin=True) # The file uses QUA constructs - verify no false positives diff --git a/tests/test_utils.py b/tests/test_utils.py index 5025f553..aedbef1d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -49,7 +49,9 @@ def _log_mismatch(path_list: list[str], reason: str, n1_context: Any, n2_context print(f"Node 2: {_format_value_as_code(n2_context, 0)}") -def compare_ast_nodes(node1: Any, node2: Any, current_path: list[str] | None = None) -> bool: +def compare_ast_nodes( + node1: Any, node2: Any, current_path: list[str] | None = None +) -> bool: """Recursively compares two QUA AST elements for structural equality. Compares nodes, their attributes (which can be other nodes, expressions, @@ -178,12 +180,14 @@ def compare_ast_nodes(node1: Any, node2: Any, current_path: list[str] | None = N d_item1 = ( val_item1.__dict__ - if not isinstance(val_item1, dict) and hasattr(val_item1, "__dict__") + if not isinstance(val_item1, dict) + and hasattr(val_item1, "__dict__") else val_item1 ) d_item2 = ( val_item2.__dict__ - if not isinstance(val_item2, dict) and hasattr(val_item2, "__dict__") + if not isinstance(val_item2, dict) + and hasattr(val_item2, "__dict__") else val_item2 ) @@ -268,7 +272,10 @@ def _format_value_as_code(value: Any, indent_level: int) -> str: return "[]" # Format list elements, each on a new line if the list is not empty elements_str = ",\\n".join( - [f"{next_indent_str}{_format_value_as_code(elem, indent_level + 1)}" for elem in value] + [ + f"{next_indent_str}{_format_value_as_code(elem, indent_level + 1)}" + for elem in value + ] ) return f"[\\n{elements_str}\\n{indent_str}]" diff --git a/tests_against_server/voltage_gate_sequence/validation_utils.py b/tests_against_server/voltage_gate_sequence/validation_utils.py index 9d74daac..83e73754 100644 --- a/tests_against_server/voltage_gate_sequence/validation_utils.py +++ b/tests_against_server/voltage_gate_sequence/validation_utils.py @@ -5,6 +5,7 @@ import pytest from qm import SimulationConfig, QuantumMachinesManager, generate_qua_script + pytest.importorskip("qm_saas") from qm_saas import QOPVersion, QmSaas import matplotlib.pyplot as plt @@ -13,7 +14,9 @@ def simulate_program(qmm, machine, prog, simulation_duration=10000): # Simulates the QUA program for the specified duration - simulation_config = SimulationConfig(duration=simulation_duration // 4) # In clock cycles = 4ns + simulation_config = SimulationConfig( + duration=simulation_duration // 4 + ) # In clock cycles = 4ns # Simulate blocks python until the simulation is done config = machine.generate_config() print(generate_qua_script(prog, config)) @@ -54,10 +57,16 @@ def validate_program(samples, requested_wf_p, requested_wf_m): print( f"Relative sum after compensation (-): {np.sum(wf_m[:t1+1]) / np.sum(wf_p[:len(requested_wf_m)]) * 100:.2f} %" ) - print(f"Max gradient during compensation (+): {max(np.diff(wf_p[:t1+1])) * 1000:.2f} mV") - print(f"Max gradient during compensation (-): {max(np.diff(wf_m[:t1+1])) * 1000:.2f} mV") + print( + f"Max gradient during compensation (+): {max(np.diff(wf_p[:t1+1])) * 1000:.2f} mV" + ) + print( + f"Max gradient during compensation (-): {max(np.diff(wf_m[:t1+1])) * 1000:.2f} mV" + ) # Success criteria - assert (np.mean((wf_p[: len(requested_wf_p)] - requested_wf_p) / requested_wf_p) < 0.1) & ( + assert ( + np.mean((wf_p[: len(requested_wf_p)] - requested_wf_p) / requested_wf_p) < 0.1 + ) & ( np.mean((wf_m[: len(requested_wf_m)] - requested_wf_m) / requested_wf_m) < 0.1 ), "Simulated wf doesn't match requested wf." # assert (np.sum(wf_p[: t1 + 1]) / np.sum(wf_p[: len(requested_wf_p)]) * 100 < 1) & ( @@ -85,7 +94,8 @@ def get_linear_ramp(start_value, end_value, duration, sampling_rate=1): if num_points <= 1: return [start_value] * num_points ramp = [ - start_value + (end_value - start_value) * (i + 1) / num_points for i in range(num_points) + start_value + (end_value - start_value) * (i + 1) / num_points + for i in range(num_points) ] return [point for point in ramp for _ in range(sampling_rate)] diff --git a/tutorials/README.md b/tutorials/README.md new file mode 100644 index 00000000..dd678f85 --- /dev/null +++ b/tutorials/README.md @@ -0,0 +1,5 @@ +# Tutorials + +Tutorials for building and customizing QUAM configurations. + +- **[Macro Customization](macro_customization.ipynb)** — Covers use defaults, type-level override, instance-level override, and external macro package. Runs without QM hardware. diff --git a/tutorials/calibration_workflow.ipynb b/tutorials/calibration_workflow.ipynb new file mode 100644 index 00000000..1ca6bc92 --- /dev/null +++ b/tutorials/calibration_workflow.ipynb @@ -0,0 +1,565 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3b4b3d78e68548c3", + "metadata": {}, + "source": [ + "# Post-Calibration Configuration Guide\n", + "\n", + "After building a machine with default macros, this notebook shows how to\n", + "persist calibrated values onto the QUAM state objects. Every update flows\n", + "through the same path that `machine.save()` serialises, so the calibration\n", + "survives a save/load round-trip.\n", + "\n", + "**Sections:**\n", + "\n", + "1. **Setup** — Build the tutorial machine and inspect defaults.\n", + "2. **XY Drive** — Set calibrated amplitude, pulse duration, and drive frequency.\n", + "3. **Voltage Points** — Overwrite gate voltages with calibrated values.\n", + "4. **Initialize & Empty Timing** — Tune ramp and hold durations.\n", + "5. **PSB Measurement Chain** — Configure the full `Measure1Q` → `MeasurePSBPair` → `SensorDotMeasure` pipeline.\n", + "6. **Save & Load** — Verify calibrated values persist through serialisation.\n", + "\n", + "**Runs without QM hardware** — no `qm.open()` or `machine.connect()` required." + ] + }, + { + "cell_type": "markdown", + "id": "600fd2378c3c35cb", + "metadata": {}, + "source": [ + "## 1. Setup\n", + "\n", + "Build the tutorial machine using the combined wiring workflow. The machine\n", + "arrives with default macros and pulses already wired by `build_quam`." + ] + }, + { + "cell_type": "code", + "id": "16c77e68", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:33:46.426078Z", + "start_time": "2026-04-30T22:33:43.271728Z" + } + }, + "source": [ + "from quam_builder.architecture.quantum_dots.examples.tutorial_machine import build_tutorial_machine\n", + "from quam_builder.architecture.quantum_dots.operations.names import VoltagePointName\n", + "\n", + "machine = build_tutorial_machine()\n", + "\n", + "q1 = machine.qubits[\"q1\"]\n", + "q2 = machine.qubits[\"q2\"]\n", + "pair = machine.quantum_dot_pairs[\"virtual_dot_1_virtual_dot_2_pair\"]\n", + "sd = machine.sensor_dots[\"virtual_sensor_1\"]\n", + "\n", + "print(\"Qubit macros:\", sorted(str(k) for k in q1.macros.keys()))\n", + "print(\"Pair macros:\", sorted(str(k) for k in pair.macros.keys()))\n", + "print(\"Sensor macros:\", sorted(str(k) for k in sd.macros.keys()))\n", + "print()\n", + "print(\"q1.preferred_readout_quantum_dot:\", q1.preferred_readout_quantum_dot)\n", + "print(\"q2.preferred_readout_quantum_dot:\", q2.preferred_readout_quantum_dot)\n", + "print(\"Pair sensor dots:\", pair.sensor_dots)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2026-04-30 18:33:43,661 - qm - INFO - Starting session: b8d446a6-b924-4616-ac12-d9d3468535dc\n", + "Qubit macros: ['-x90', '-y90', 'I', 'align', 'empty', 'initialize', 'measure', 'wait', 'x', 'x180', 'x90', 'x_neg90', 'xy_drive', 'y', 'y180', 'y90', 'y_neg90', 'z', 'z180', 'z90', 'z_neg90']\n", + "Pair macros: ['align', 'empty', 'initialize', 'measure', 'wait']\n", + "Sensor macros: ['align', 'measure', 'wait']\n", + "\n", + "q1.preferred_readout_quantum_dot: virtual_dot_2\n", + "q2.preferred_readout_quantum_dot: virtual_dot_1\n", + "Pair sensor dots: ['#/sensor_dots/virtual_sensor_1']\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: ReadoutResonatorSingle\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: VirtualGateSet\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: SensorDot\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: QuantumDotPair\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: LDQubit\n", + " warnings.warn(\n" + ] + } + ], + "execution_count": 1 + }, + { + "cell_type": "markdown", + "id": "047a84cd", + "metadata": {}, + "source": [ + "## 2. XY Drive — Single Source of Truth\n", + "\n", + "All single-qubit gate macros (`x`, `y`, `x/2`, `y/2`, etc.) delegate to a\n", + "single `XYDriveMacro` instance at `qubit.macros[\"xy_drive\"]`. This macro\n", + "owns the **reference amplitude** and points at a **reference pulse** on the\n", + "qubit's XY drive channel.\n", + "\n", + "The `update()` method is the primary API for persisting calibrated values:\n", + "\n", + "| Parameter | Effect |\n", + "|-----------|--------|\n", + "| `amplitude` | Set `reference_amplitude` (absolute π-rotation voltage) |\n", + "| `amplitude_scale` | Multiply current `reference_amplitude` by a factor |\n", + "| `duration` | Set the reference pulse `length` in ns (sigma auto-scales via `sigma_ratio`) |\n", + "| `frequency` | Set `qubit.larmor_frequency` (absolute Hz); IF auto-derives as RF − LO |\n", + "| `frequency_offset` | Add an offset to the current `qubit.larmor_frequency` |\n", + "\n", + "Sub-π rotations (X/2, Y/2, etc.) are derived automatically by scaling\n", + "amplitude proportionally to `angle / reference_angle`." + ] + }, + { + "cell_type": "code", + "id": "4408e5ae", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:33:46.556471Z", + "start_time": "2026-04-30T22:33:46.428760Z" + } + }, + "source": [ + "xy = q1.macros[\"xy_drive\"]\n", + "pulse = xy.reference_pulse\n", + "\n", + "print(\"Before calibration:\")\n", + "print(f\" reference_amplitude: {xy.reference_amplitude}\")\n", + "print(f\" reference_angle: {xy.reference_angle:.4f} rad (π)\")\n", + "print(f\" pulse name: {xy.reference_pulse_name}\")\n", + "print(f\" pulse length: {pulse.length} ns\")\n", + "print(f\" pulse sigma_ratio: {pulse.sigma_ratio}\")\n", + "print(f\" pulse sigma: {pulse.sigma:.1f} ns\")\n", + "print(f\" larmor_frequency: {q1.larmor_frequency} Hz\")\n", + "print(f\" IF frequency: {q1.drive_IF} Hz\")\n", + "\n", + "xy.update(\n", + " amplitude=0.35,\n", + " duration=40,\n", + " frequency=5.1e9,\n", + ")\n", + "\n", + "print(\"\\nAfter calibration:\")\n", + "print(f\" reference_amplitude: {xy.reference_amplitude}\")\n", + "print(f\" pulse length: {pulse.length} ns\")\n", + "print(f\" pulse sigma: {pulse.sigma:.1f} ns (auto-scaled via sigma_ratio)\")\n", + "print(f\" larmor_frequency: {q1.larmor_frequency} Hz\")\n", + "print(f\" IF frequency: {q1.drive_IF} Hz\")" + ], + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'XYDriveMacro' object has no attribute 'reference_pulse'", + "output_type": "error", + "traceback": [ + "\u001B[31m---------------------------------------------------------------------------\u001B[39m", + "\u001B[31mAttributeError\u001B[39m Traceback (most recent call last)", + "\u001B[36mCell\u001B[39m\u001B[36m \u001B[39m\u001B[32mIn[2]\u001B[39m\u001B[32m, line 2\u001B[39m\n\u001B[32m 1\u001B[39m xy = q1.macros[\u001B[33m\"xy_drive\"\u001B[39m]\n\u001B[32m----> \u001B[39m\u001B[32m2\u001B[39m pulse = xy.reference_pulse\n\u001B[32m 3\u001B[39m \n\u001B[32m 4\u001B[39m print(\u001B[33m\"Before calibration:\"\u001B[39m)\n\u001B[32m 5\u001B[39m print(f\" reference_amplitude: {xy.reference_amplitude}\")\n", + "\u001B[36mFile \u001B[39m\u001B[32m~/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/utils/reference_class.py:48\u001B[39m, in \u001B[36mReferenceClass.__getattribute__\u001B[39m\u001B[34m(self, attr)\u001B[39m\n\u001B[32m 47\u001B[39m \u001B[38;5;28;01mdef\u001B[39;00m\u001B[38;5;250m \u001B[39m\u001B[34m__getattribute__\u001B[39m(\u001B[38;5;28mself\u001B[39m, attr: \u001B[38;5;28mstr\u001B[39m) -> Any:\n\u001B[32m---> \u001B[39m\u001B[32m48\u001B[39m attr_val = \u001B[38;5;28;43msuper\u001B[39;49m\u001B[43m(\u001B[49m\u001B[43m)\u001B[49m\u001B[43m.\u001B[49m\u001B[34;43m__getattribute__\u001B[39;49m\u001B[43m(\u001B[49m\u001B[43mattr\u001B[49m\u001B[43m)\u001B[49m\n\u001B[32m 50\u001B[39m \u001B[38;5;28;01mif\u001B[39;00m attr \u001B[38;5;129;01min\u001B[39;00m [\u001B[33m\"\u001B[39m\u001B[33m_is_reference\u001B[39m\u001B[33m\"\u001B[39m, \u001B[33m\"\u001B[39m\u001B[33m_get_referenced_value\u001B[39m\u001B[33m\"\u001B[39m, \u001B[33m\"\u001B[39m\u001B[33m__post_init__\u001B[39m\u001B[33m\"\u001B[39m]:\n\u001B[32m 51\u001B[39m \u001B[38;5;28;01mreturn\u001B[39;00m attr_val\n", + "\u001B[31mAttributeError\u001B[39m: 'XYDriveMacro' object has no attribute 'reference_pulse'" + ] + } + ], + "execution_count": 2 + }, + { + "cell_type": "markdown", + "id": "b7687dea", + "metadata": {}, + "source": [ + "## 3. Voltage Points — Calibrated Gate Voltages\n", + "\n", + "Voltage points define named positions in gate-voltage space (e.g.\n", + "`initialize`, `measure`, `empty`). The tutorial machine ships with\n", + "placeholder voltages. After tuning, overwrite them with calibrated values\n", + "using `add_point()`.\n", + "\n", + "The default `replace_existing_point=True` overwrites any existing point\n", + "with the same name." + ] + }, + { + "cell_type": "code", + "id": "039fc0ba", + "metadata": {}, + "source": [ + "dot_id = q1.quantum_dot.id\n", + "\n", + "q1.add_point(\n", + " VoltagePointName.INITIALIZE,\n", + " voltages={dot_id: 0.12},\n", + " duration=300,\n", + " replace_existing_point=True,\n", + ")\n", + "q1.add_point(\n", + " VoltagePointName.MEASURE,\n", + " voltages={dot_id: 0.18},\n", + " duration=500,\n", + " replace_existing_point=True,\n", + ")\n", + "\n", + "pair_dot_ids = [qd.id for qd in pair.quantum_dots]\n", + "pair.add_point(\n", + " VoltagePointName.INITIALIZE,\n", + " voltages={pair_dot_ids[0]: 0.12, pair_dot_ids[1]: 0.08},\n", + " duration=300,\n", + " replace_existing_point=True,\n", + ")\n", + "pair.add_point(\n", + " VoltagePointName.MEASURE,\n", + " voltages={pair_dot_ids[0]: 0.18, pair_dot_ids[1]: 0.05},\n", + " duration=500,\n", + " replace_existing_point=True,\n", + ")\n", + "\n", + "print(f\"q1 initialize: {dot_id} → 0.12 V, 300 ns\")\n", + "print(f\"q1 measure: {dot_id} → 0.18 V, 500 ns\")\n", + "print(f\"pair initialize: {pair_dot_ids} → [0.12, 0.08] V, 300 ns\")\n", + "print(f\"pair measure: {pair_dot_ids} → [0.18, 0.05] V, 500 ns\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "5864411a", + "metadata": {}, + "source": [ + "## 4. Initialize & Empty — Timing Parameters\n", + "\n", + "`InitializeStateMacro` ramps to the initialize voltage point over\n", + "`ramp_duration` nanoseconds, then optionally holds for `hold_duration`.\n", + "`EmptyStateMacro` steps to the empty voltage point with an optional hold.\n", + "\n", + "Set these directly on the macro instances." + ] + }, + { + "cell_type": "code", + "id": "5356c06c", + "metadata": {}, + "source": [ + "init_macro = q1.macros[\"initialize\"]\n", + "empty_macro = q1.macros[\"empty\"]\n", + "\n", + "print(\"Before:\")\n", + "print(f\" initialize.ramp_duration: {init_macro.ramp_duration} ns\")\n", + "print(f\" initialize.hold_duration: {init_macro.hold_duration}\")\n", + "print(f\" empty.hold_duration: {empty_macro.hold_duration}\")\n", + "\n", + "init_macro.ramp_duration = 48\n", + "init_macro.hold_duration = 200\n", + "empty_macro.hold_duration = 100\n", + "\n", + "print(\"\\nAfter:\")\n", + "print(f\" initialize.ramp_duration: {init_macro.ramp_duration} ns\")\n", + "print(f\" initialize.hold_duration: {init_macro.hold_duration} ns\")\n", + "print(f\" empty.hold_duration: {empty_macro.hold_duration} ns\")\n", + "\n", + "pair_init = pair.macros[\"initialize\"]\n", + "pair_init.ramp_duration = 48\n", + "pair_init.hold_duration = 200\n", + "print(f\"\\n pair.initialize.ramp_duration: {pair_init.ramp_duration} ns\")\n", + "print(f\" pair.initialize.hold_duration: {pair_init.hold_duration} ns\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "4a09bf36", + "metadata": {}, + "source": [ + "## 5. PSB Measurement — Full Chain\n", + "\n", + "Single-qubit measurement follows a three-layer delegation:\n", + "\n", + " qubit.measure()\n", + " → Measure1QMacro\n", + " → finds QuantumDotPair via preferred_readout_quantum_dot\n", + " → MeasurePSBPairMacro\n", + " → steps to \"measure\" voltage point on the pair\n", + " → SensorDotMeasureMacro\n", + " → readout_resonator.measure(\"readout\")\n", + " → projects: x = I·wI + Q·wQ + offset\n", + " → returns: x > threshold\n", + "\n", + "Each layer has calibration parameters:\n", + "\n", + "| Layer | What to configure |\n", + "|-------|-------------------|\n", + "| **Qubit** | `preferred_readout_quantum_dot` — which neighbor forms the PSB pair |\n", + "| **Pair macro** | `buffer_duration` — settling time at the measure voltage point before readout |\n", + "| **Pair voltages** | Measure voltage point via `add_point` |\n", + "| **Sensor** | `readout_thresholds[pair_id]` and `readout_projectors[pair_id]` |" + ] + }, + { + "cell_type": "markdown", + "id": "67f7b7c9", + "metadata": {}, + "source": [ + "### 5a. Verify Topology\n", + "\n", + "The builder sets `preferred_readout_quantum_dot` automatically from qubit-pair\n", + "topology. Verify the mapping is correct for your device." + ] + }, + { + "cell_type": "code", + "id": "4bd3f01f", + "metadata": {}, + "source": [ + "for name, qubit in machine.qubits.items():\n", + " dot = qubit.quantum_dot.id\n", + " readout_dot = qubit.preferred_readout_quantum_dot\n", + " pair_name = machine.find_quantum_dot_pair(dot, readout_dot)\n", + " print(f\"{name}: dot={dot}, readout_dot={readout_dot}, pair={pair_name}\")\n", + "\n", + "print(f\"\\nPair '{pair.id}' sensor dots: {pair.sensor_dots}\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "95dfc788", + "metadata": {}, + "source": [ + "### 5b. Pair Measure Macro\n", + "\n", + "`MeasurePSBPairMacro` steps to the measure voltage point (already updated in\n", + "Section 3) and then delegates to the sensor dot. Configure `buffer_duration` to\n", + "control settling time at the PSB readout point before the readout pulse fires.\n", + "The readout pulse length dictates the actual measurement window." + ] + }, + { + "cell_type": "code", + "id": "b6f5b861", + "metadata": {}, + "source": [ + "pair_measure = pair.macros[\"measure\"]\n", + "print(f\"point: {pair_measure.point}\")\n", + "print(f\"buffer_duration: {pair_measure.buffer_duration}\")\n", + "\n", + "pair_measure.buffer_duration = 500\n", + "\n", + "print(f\"\\nAfter calibration:\")\n", + "print(f\" buffer_duration: {pair_measure.buffer_duration} ns\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "4262f5d9", + "metadata": {}, + "source": [ + "### 5c. Sensor Dot — Readout Discrimination\n", + "\n", + "`SensorDotMeasureMacro` measures the readout resonator, then applies a\n", + "linear projector in the IQ plane for state discrimination:\n", + "\n", + " x = I · wI + Q · wQ + offset\n", + " state = x > threshold\n", + "\n", + "Each quantum dot pair has its own threshold and projector stored on the\n", + "sensor dot, keyed by pair ID. Set these after running a readout\n", + "calibration experiment (e.g. single-shot IQ histograms)." + ] + }, + { + "cell_type": "code", + "id": "cd527876", + "metadata": {}, + "source": [ + "pair_id = pair.id\n", + "\n", + "sd.readout_thresholds[pair_id] = 0.003\n", + "sd.readout_projectors[pair_id] = {\n", + " \"wI\": 0.95,\n", + " \"wQ\": -0.12,\n", + " \"offset\": 0.001,\n", + "}\n", + "\n", + "print(f\"Sensor: {sd.id}\")\n", + "print(f\" threshold[{pair_id}]: {sd.readout_thresholds[pair_id]}\")\n", + "print(f\" projector[{pair_id}]: {sd.readout_projectors[pair_id]}\")\n", + "\n", + "sensor_measure = sd.macros[\"measure\"]\n", + "print(f\" pulse_name: {sensor_measure.pulse_name}\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "5eef78b2", + "metadata": {}, + "source": [ + "### 5d. End-to-End — Build a QUA Program\n", + "\n", + "With all calibration values in place, the full chain compiles into a\n", + "valid QUA program. `q1.measure()` triggers the complete\n", + "`Measure1QMacro` → `MeasurePSBPairMacro` → `SensorDotMeasureMacro`\n", + "pipeline." + ] + }, + { + "cell_type": "code", + "id": "c39aa5fd", + "metadata": {}, + "source": [ + "from qm import qua\n", + "\n", + "with qua.program() as prog:\n", + " q1.initialize()\n", + " q1.x()\n", + " q1.measure()\n", + "\n", + "print(\"QUA program compiled successfully with calibrated values.\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "744d1eaf", + "metadata": {}, + "source": [ + "## 6. Save & Load Round-Trip\n", + "\n", + "All calibrated values live on QuAM dataclass fields and persist\n", + "through `machine.save()` / `machine.load()`." + ] + }, + { + "cell_type": "code", + "id": "a8a3c06e", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:10.207931Z", + "start_time": "2026-04-30T22:34:10.123657Z" + } + }, + "source": [ + "import tempfile\n", + "from pathlib import Path\n", + "\n", + "with tempfile.TemporaryDirectory() as tmp:\n", + " path = Path(tmp) / \"calibrated_machine.json\"\n", + " machine.save(path)\n", + " loaded = type(machine).load(path)\n", + "\n", + "loaded_q1 = loaded.qubits[\"q1\"]\n", + "loaded_xy = loaded_q1.macros[\"xy_drive\"]\n", + "loaded_pair = loaded.quantum_dot_pairs[\"virtual_dot_1_virtual_dot_2_pair\"]\n", + "loaded_sd = loaded.sensor_dots[\"virtual_sensor_1\"]\n", + "loaded_pair_id = loaded_pair.id\n", + "\n", + "assert loaded_xy.reference_amplitude == 0.35\n", + "assert loaded_xy.reference_pulse.length == 40\n", + "assert loaded_q1.larmor_frequency == 5.1e9\n", + "expected_if = loaded_q1.larmor_frequency - loaded_q1.xy.LO_frequency\n", + "assert loaded_q1.xy.intermediate_frequency == expected_if\n", + "assert loaded_q1.macros[\"initialize\"].ramp_duration == 48\n", + "assert loaded_pair.macros[\"measure\"].buffer_duration == 500\n", + "assert loaded_sd.readout_thresholds[loaded_pair_id] == 0.003\n", + "assert loaded_sd.readout_projectors[loaded_pair_id][\"wI\"] == 0.95\n", + "\n", + "print(\"All calibrated values survived save/load:\")\n", + "print(f\" xy.reference_amplitude: {loaded_xy.reference_amplitude}\")\n", + "print(f\" xy pulse length: {loaded_xy.reference_pulse.length} ns\")\n", + "print(f\" larmor_frequency: {loaded_q1.larmor_frequency} Hz\")\n", + "print(f\" IF frequency (derived): {loaded_q1.xy.intermediate_frequency} Hz\")\n", + "print(f\" initialize.ramp_duration: {loaded_q1.macros['initialize'].ramp_duration} ns\")\n", + "print(f\" pair measure buffer: {loaded_pair.macros['measure'].buffer_duration} ns\")\n", + "print(f\" sensor threshold: {loaded_sd.readout_thresholds[loaded_pair_id]}\")\n", + "print(f\" sensor projector: {loaded_sd.readout_projectors[loaded_pair_id]}\")" + ], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: ReadoutResonatorSingle\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: VirtualGateSet\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: QuantumDotPair\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: LDQubit\n", + " warnings.warn(\n" + ] + }, + { + "ename": "AttributeError", + "evalue": "'XYDriveMacro' object has no attribute 'reference_amplitude'", + "output_type": "error", + "traceback": [ + "\u001B[31m---------------------------------------------------------------------------\u001B[39m", + "\u001B[31mAttributeError\u001B[39m Traceback (most recent call last)", + "\u001B[36mCell\u001B[39m\u001B[36m \u001B[39m\u001B[32mIn[3]\u001B[39m\u001B[32m, line 15\u001B[39m\n\u001B[32m 11\u001B[39m loaded_pair = loaded.quantum_dot_pairs[\u001B[33m\"virtual_dot_1_virtual_dot_2_pair\"\u001B[39m]\n\u001B[32m 12\u001B[39m loaded_sd = loaded.sensor_dots[\u001B[33m\"virtual_sensor_1\"\u001B[39m]\n\u001B[32m 13\u001B[39m loaded_pair_id = loaded_pair.id\n\u001B[32m 14\u001B[39m \n\u001B[32m---> \u001B[39m\u001B[32m15\u001B[39m \u001B[38;5;28;01massert\u001B[39;00m loaded_xy.reference_amplitude == \u001B[32m0.35\u001B[39m\n\u001B[32m 16\u001B[39m \u001B[38;5;28;01massert\u001B[39;00m loaded_xy.reference_pulse.length == \u001B[32m40\u001B[39m\n\u001B[32m 17\u001B[39m \u001B[38;5;28;01massert\u001B[39;00m loaded_q1.larmor_frequency == \u001B[32m5.1e9\u001B[39m\n\u001B[32m 18\u001B[39m expected_if = loaded_q1.larmor_frequency - loaded_q1.xy.LO_frequency\n", + "\u001B[36mFile \u001B[39m\u001B[32m~/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/utils/reference_class.py:48\u001B[39m, in \u001B[36mReferenceClass.__getattribute__\u001B[39m\u001B[34m(self, attr)\u001B[39m\n\u001B[32m 47\u001B[39m \u001B[38;5;28;01mdef\u001B[39;00m\u001B[38;5;250m \u001B[39m\u001B[34m__getattribute__\u001B[39m(\u001B[38;5;28mself\u001B[39m, attr: \u001B[38;5;28mstr\u001B[39m) -> Any:\n\u001B[32m---> \u001B[39m\u001B[32m48\u001B[39m attr_val = \u001B[38;5;28;43msuper\u001B[39;49m\u001B[43m(\u001B[49m\u001B[43m)\u001B[49m\u001B[43m.\u001B[49m\u001B[34;43m__getattribute__\u001B[39;49m\u001B[43m(\u001B[49m\u001B[43mattr\u001B[49m\u001B[43m)\u001B[49m\n\u001B[32m 50\u001B[39m \u001B[38;5;28;01mif\u001B[39;00m attr \u001B[38;5;129;01min\u001B[39;00m [\u001B[33m\"\u001B[39m\u001B[33m_is_reference\u001B[39m\u001B[33m\"\u001B[39m, \u001B[33m\"\u001B[39m\u001B[33m_get_referenced_value\u001B[39m\u001B[33m\"\u001B[39m, \u001B[33m\"\u001B[39m\u001B[33m__post_init__\u001B[39m\u001B[33m\"\u001B[39m]:\n\u001B[32m 51\u001B[39m \u001B[38;5;28;01mreturn\u001B[39;00m attr_val\n", + "\u001B[31mAttributeError\u001B[39m: 'XYDriveMacro' object has no attribute 'reference_amplitude'" + ] + } + ], + "execution_count": 3 + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": [ + "for k, q in machine.qubit_pairs.items():\n", + " print(q.id)" + ], + "id": "2d1f13b5d45b1db9" + }, + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "", + "id": "b9f5a6e0b979fb37" + } + ], + "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.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tutorials/macro_customization.ipynb b/tutorials/macro_customization.ipynb new file mode 100644 index 00000000..23b0e39b --- /dev/null +++ b/tutorials/macro_customization.ipynb @@ -0,0 +1,382 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Macro Customization Tutorial\n", + "\n", + "This notebook covers **four workflows** for customizing macros on QUAM quantum-dot machines:\n", + "\n", + "1. **Use defaults** — Wire built-in macros with no overrides.\n", + "2. **Type-level override** — Replace a macro for all instances of a component type (e.g. all `QuantumDot`).\n", + "3. **Instance-level override** — Replace a macro for one specific component (e.g. `quantum_dots.virtual_dot_1`).\n", + "4. **External macro package** — Import overrides from a separate Python package.\n", + "\n", + "**Runs without QM hardware** — no `qm.open()`, `qm.run()`, or `machine.connect()` required. All code builds QUA programs in memory only." + ], + "id": "a51d342735b50cd8" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Workflow 1 — Use Defaults\n", + "\n", + "Wire the machine with default macros only. No profile, no overrides." + ], + "id": "4450a87ea2b406ac" + }, + { + "cell_type": "code", + "id": "448a4ae2", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:18.861694Z", + "start_time": "2026-04-30T22:34:15.669622Z" + } + }, + "source": [ + "from quam_builder.architecture.quantum_dots.examples.tutorial_machine import build_tutorial_machine\n", + "from quam_builder.architecture.quantum_dots.macro_engine import wire_machine_macros\n", + "from qm import qua\n", + "\n", + "machine = build_tutorial_machine()\n", + "wire_machine_macros(machine)\n", + "\n", + "# Verify quantum_dots, quantum_dot_pairs, sensor_dots have expected macros\n", + "qd = next(iter(machine.quantum_dots.values()))\n", + "pair = next(iter(machine.quantum_dot_pairs.values()))\n", + "sd = next(iter(machine.sensor_dots.values()))\n", + "assert \"initialize\" in qd.macros and \"empty\" in qd.macros\n", + "assert \"initialize\" in pair.macros and \"measure\" in pair.macros and \"empty\" in pair.macros\n", + "assert \"measure\" in sd.macros\n", + "print(\"QuantumDot:\", sorted(qd.macros.keys()))\n", + "print(\"QuantumDotPair:\", sorted(pair.macros.keys()))\n", + "print(\"SensorDot:\", sorted(sd.macros.keys()))\n", + "\n", + "# Build a small QUA program (no run)\n", + "q1, q2 = machine.qubits[\"q1\"], machine.qubits[\"q2\"]\n", + "with qua.program() as prog:\n", + " q1.initialize()\n", + " q2.initialize()\n", + " pair.initialize()\n", + " sd.measure('readout')\n", + " q1.measure()\n", + " q2.measure()\n", + "print(\"Built QUA program successfully (Workflow 1).\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2026-04-30 18:34:15,983 - qm - INFO - Starting session: a19726fe-7b35-4eef-84ed-91136d8e1957\n", + "QuantumDot: ['align', 'empty', 'initialize', 'wait']\n", + "QuantumDotPair: ['align', 'empty', 'initialize', , 'wait']\n", + "SensorDot: ['align', , 'wait']\n", + "Built QUA program successfully (Workflow 1).\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: ReadoutResonatorSingle\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: VirtualGateSet\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: SensorDot\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: QuantumDotPair\n", + " warnings.warn(\n", + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/core/quam_classes.py:291: UserWarning: This component is not part of any QuamRoot, using last instantiated QuamRoot. This is not recommended as it may lead to unexpected behaviour. Component: LDQubit\n", + " warnings.warn(\n" + ] + } + ], + "execution_count": 1 + }, + { + "cell_type": "markdown", + "id": "554d3b85", + "metadata": {}, + "source": [ + "## Workflow 2 — Type-Level Override\n", + "\n", + "Override the `initialize` macro for **all** `QuantumDot` components with a custom class." + ] + }, + { + "cell_type": "code", + "id": "9bdba603", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:18.899477Z", + "start_time": "2026-04-30T22:34:18.863840Z" + } + }, + "source": [ + "from functools import partial\n", + "\n", + "from quam.core import quam_dataclass\n", + "from quam.core.macro import QuamMacro\n", + "from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import InitializeStateMacro\n", + "from quam_builder.architecture.quantum_dots.operations.names import VoltagePointName\n", + "from quam_builder.architecture.quantum_dots.operations.macro_catalog import TypeOverrideCatalog\n", + "from quam_builder.architecture.quantum_dots.components import QuantumDot\n", + "\n", + "\n", + "@quam_dataclass\n", + "class CustomInitMacro(InitializeStateMacro):\n", + " \"\"\"Custom initialize macro with different ramp_duration.\"\"\"\n", + " ramp_duration: int = 64\n", + "\n", + "\n", + "machine = build_tutorial_machine()\n", + "wire_machine_macros(\n", + " machine,\n", + " catalogs=[\n", + " TypeOverrideCatalog({\n", + " QuantumDot: {\n", + " VoltagePointName.INITIALIZE: partial(CustomInitMacro, ramp_duration=64),\n", + " },\n", + " }),\n", + " ],\n", + ")\n", + "\n", + "# Verify override applied\n", + "qd = next(iter(machine.quantum_dots.values()))\n", + "init_macro = qd.macros[\"initialize\"]\n", + "assert isinstance(init_macro, CustomInitMacro)\n", + "assert init_macro.ramp_duration == 64\n", + "print(\"QuantumDot initialize is CustomInitMacro with ramp_duration=\", init_macro.ramp_duration)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "QuantumDot initialize is CustomInitMacro with ramp_duration= 64\n" + ] + } + ], + "execution_count": 2 + }, + { + "cell_type": "markdown", + "id": "9e17ed99", + "metadata": {}, + "source": [ + "## Workflow 3 — Instance-Level Override\n", + "\n", + "Override a macro for **one** specific component using its path (`quantum_dots.`, etc.)." + ] + }, + { + "cell_type": "code", + "id": "2c71e230", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:18.934774Z", + "start_time": "2026-04-30T22:34:18.900660Z" + } + }, + "source": [ + "@quam_dataclass\n", + "class MyInitMacro(InitializeStateMacro):\n", + " \"\"\"Instance-specific custom initialize macro.\"\"\"\n", + " ramp_duration: int = 96\n", + "\n", + "\n", + "machine = build_tutorial_machine()\n", + "wire_machine_macros(\n", + " machine,\n", + " instance_overrides={\n", + " \"quantum_dots.virtual_dot_1\": {\n", + " VoltagePointName.INITIALIZE: MyInitMacro,\n", + " },\n", + " },\n", + ")\n", + "\n", + "# Verify only virtual_dot_1 has override\n", + "qd1 = machine.quantum_dots[\"virtual_dot_1\"]\n", + "qd2 = machine.quantum_dots[\"virtual_dot_2\"]\n", + "assert isinstance(qd1.macros[\"initialize\"], MyInitMacro)\n", + "assert not isinstance(qd2.macros[\"initialize\"], MyInitMacro)\n", + "print(\"virtual_dot_1.initialize:\", type(qd1.macros[\"initialize\"]).__name__)\n", + "print(\"virtual_dot_2.initialize:\", type(qd2.macros[\"initialize\"]).__name__)" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "virtual_dot_1.initialize: MyInitMacro\n", + "virtual_dot_2.initialize: InitializeStateMacro\n" + ] + } + ], + "execution_count": 3 + }, + { + "cell_type": "markdown", + "id": "1bc4a4bb", + "metadata": {}, + "source": [ + "## Workflow 4 — External Macro Package\n", + "\n", + "In practice, your lab keeps custom macros in a separate package. Here we simulate that with an inline `build_macro_overrides()` function." + ] + }, + { + "cell_type": "code", + "id": "f78a5fa7", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:18.972091Z", + "start_time": "2026-04-30T22:34:18.937947Z" + } + }, + "source": [ + "class LabMacroCatalog:\n", + " \"\"\"Simulates an external package catalog implementing MacroCatalog protocol.\n", + "\n", + " In practice this lives in my_lab_qd_macros.catalog.\n", + " \"\"\"\n", + " priority = 200\n", + "\n", + " def get_factories(self, component_type):\n", + " if issubclass(component_type, QuantumDot):\n", + " return {\n", + " VoltagePointName.INITIALIZE: partial(CustomInitMacro, ramp_duration=64),\n", + " }\n", + " return {}\n", + "\n", + "\n", + "machine = build_tutorial_machine()\n", + "wire_machine_macros(machine, catalogs=[LabMacroCatalog()])\n", + "\n", + "qd = next(iter(machine.quantum_dots.values()))\n", + "assert isinstance(qd.macros[\"initialize\"], CustomInitMacro)\n", + "print(\"External catalog applied: QuantumDot.initialize = CustomInitMacro\")" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "External catalog applied: QuantumDot.initialize = CustomInitMacro\n" + ] + } + ], + "execution_count": 4 + }, + { + "cell_type": "markdown", + "id": "b8d3dcd6", + "metadata": {}, + "source": [ + "## @quam_dataclass Anti-Pattern\n", + "\n", + "Custom macro classes **must** use `@quam_dataclass` and be defined in a proper Python module for save/load round-trips. Notebook-defined classes (even with the decorator) have no module path and fail on load. The demo shows BadMacro (notebook-defined) failing vs InitializeStateMacro (from the library) surviving." + ] + }, + { + "cell_type": "code", + "id": "12847bce", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-30T22:34:19.075691Z", + "start_time": "2026-04-30T22:34:18.973584Z" + } + }, + "source": [ + "import tempfile\n", + "from pathlib import Path\n", + "from quam_builder.architecture.quantum_dots.operations.default_macros.state_macros import (\n", + " InitializeStateMacro,\n", + ")\n", + "\n", + "\n", + "# Bad: no @quam_dataclass, defined in notebook — no module path for deserialization\n", + "class BadMacro(QuamMacro):\n", + " def __init__(self, ramp_duration=16):\n", + " self.ramp_duration = ramp_duration\n", + "\n", + " def apply(self, **kwargs):\n", + " pass\n", + "\n", + "\n", + "machine = build_tutorial_machine()\n", + "wire_machine_macros(machine)\n", + "\n", + "# Assign BadMacro, save, load — load fails (notebook-defined class has no module path)\n", + "qd = next(iter(machine.quantum_dots.values()))\n", + "qd.macros[\"initialize\"] = BadMacro(ramp_duration=32)\n", + "with tempfile.TemporaryDirectory() as tmp:\n", + " path = Path(tmp) / \"machine.json\"\n", + " machine.save(path)\n", + " try:\n", + " loaded = type(machine).load(path)\n", + " assert False, \"Load should have failed\"\n", + " except ValueError as e:\n", + " print(\"BadMacro (no decorator, notebook-defined): load failed as expected\")\n", + "\n", + "# Good: @quam_dataclass from a proper module — survives save/load\n", + "machine2 = build_tutorial_machine()\n", + "wire_machine_macros(machine2)\n", + "qd2 = next(iter(machine2.quantum_dots.values()))\n", + "qd2.macros[\"initialize\"] = InitializeStateMacro(ramp_duration=48)\n", + "with tempfile.TemporaryDirectory() as tmp:\n", + " path = Path(tmp) / \"machine.json\"\n", + " machine2.save(path)\n", + " loaded2 = type(machine2).load(path)\n", + "ld_qd2 = next(iter(loaded2.quantum_dots.values()))\n", + "assert isinstance(ld_qd2.macros[\"initialize\"], InitializeStateMacro)\n", + "assert ld_qd2.macros[\"initialize\"].ramp_duration == 48\n", + "print(\"InitializeStateMacro (@quam_dataclass from library) survived save/load with ramp_duration=48\")\n" + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "BadMacro (no decorator, notebook-defined): load failed as expected\n", + "InitializeStateMacro (@quam_dataclass from library) survived save/load with ramp_duration=48\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/sebastian/Documents/GitHub/superconducting_qualibrate/CS_installations/.venv/lib/python3.12/site-packages/quam/utils/general.py:39: UserWarning: Could not determine the module of BadMacro, this may cause issues when trying to load QUAM from a file. Please ensure that all QUAM classes are defined in a Python module\n", + " warnings.warn(\n" + ] + } + ], + "execution_count": 5 + } + ], + "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.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/uv.lock b/uv.lock index 63463140..7b9fcae4 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/28/ebd4764fa162455cd9211ac8e1d3733baf87fad5e0e7fc60e8474d172b8f/autobahn-25.12.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0ad4c10c897ad67d31be2ef8547ed2922875d90ddb95553787cc46c271f822de", size = 2157704, upload-time = "2025-12-15T11:13:17.422Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "betterproto" version = "2.0.0b7" @@ -243,6 +256,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/9f08ac9f818e092b6d11a707a29fd9aa66862b422f8ce80ff49e37df0e3d/black-26.1a1-py3-none-any.whl", hash = "sha256:29e6ef7319e76767d369b58e8cf4a8b9b88a5e841db144f8bdf6ea9e97007cb3", size = 203742, upload-time = "2025-12-08T01:46:39.989Z" }, ] +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "webencodings", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083, upload-time = "2024-10-29T18:30:40.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406, upload-time = "2024-10-29T18:30:38.186Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "webencodings", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2", marker = "python_full_version >= '3.10'" }, +] + [[package]] name = "broadbean" version = "0.14.0" @@ -1058,6 +1113,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.8.0rc2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/3b/b8849dcc3f96913924137dc4ea041d74aa513a3c5dda83d8366491290c74/defusedxml-0.8.0rc2.tar.gz", hash = "sha256:138c7d540a78775182206c7c97fe65b246a2f40b29471e1a2f1b0da76e7a3942", size = 52575, upload-time = "2023-09-29T08:01:27.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/c7/6b4ad89ca6f7732ff97ce5e9caa6fe739600d26c5d53c20d0bf9abb79ec5/defusedxml-0.8.0rc2-py2.py3-none-any.whl", hash = "sha256:1c812964311154c3bf4aaf3bc1443b31ee13530b7f255eaaa062c0553c76103d", size = 25756, upload-time = "2023-09-29T08:01:25.515Z" }, +] + [[package]] name = "dependency-injector" version = "4.48.2" @@ -1154,6 +1218,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + [[package]] name = "filelock" version = "3.19.1" @@ -1956,6 +2029,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, ] +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + [[package]] name = "jupyterlab-widgets" version = "3.0.16" @@ -2369,6 +2451,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "mistune" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -2544,6 +2638,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "jupyter-client", marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "nbformat", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424, upload-time = "2024-12-19T10:32:27.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434, upload-time = "2024-12-19T10:32:24.139Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "jupyter-client", marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nbformat", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version < '3.10'" }, + { name = "bleach", version = "6.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["css"], marker = "python_full_version >= '3.10'" }, + { name = "defusedxml" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient", version = "0.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "nbclient", version = "0.10.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema", version = "4.25.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jsonschema", version = "4.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jupyter-core", version = "5.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jupyter-core", version = "5.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -2678,6 +2856,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/af/7be05277859a7bc399da8ba68b88c96b27b48740b6cf49688899c6eb4176/pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa", size = 11359119, upload-time = "2025-09-29T23:34:46.339Z" }, ] +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + [[package]] name = "parso" version = "0.8.5" @@ -3794,9 +3981,10 @@ dependencies = [ [[package]] name = "qualang-tools" -version = "0.21.0" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "attrs" }, { name = "grpclib" }, { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "matplotlib", version = "3.10.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3809,9 +3997,9 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "scipy", version = "1.16.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/6d/ff037886ebad70b929b5595549229ff53e6936406262a65b143b8ea26237/qualang_tools-0.21.0.tar.gz", hash = "sha256:e3ae09631c5bea9ca87c9246fba2d13a5a118da790a8bf4c6590007ea842bba0", size = 4412281, upload-time = "2025-10-29T15:15:36.829Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/c86dc18120f968c1219ec56ba4e1620508aac5122963780c35b90eb96ecb/qualang_tools-0.22.0.tar.gz", hash = "sha256:585afbf56437c471ab420e001c5f12293dcd9e6f934c8096bd323023ea6887c2", size = 4438241, upload-time = "2026-04-01T13:30:51.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/c5/f96de5854d81ead29932e5c3ce3e1ac75f88351b4fb8b5976aeefe328585/qualang_tools-0.21.0-py3-none-any.whl", hash = "sha256:a2d3f3c90524728f41f4e69d6d866271b6a65ca7492163800c696967f250b5e9", size = 4463666, upload-time = "2025-10-29T15:15:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/8a/19/e3ff5da7fa4ddc94044b4acb6f50ae3a0446a0cae2918e2e9dadefd03fc4/qualang_tools-0.22.0-py3-none-any.whl", hash = "sha256:b7fabf9e974cf1e408cd12f1cb0bc06262342b156af87a18d640719a4b780e46", size = 4492444, upload-time = "2026-04-01T13:30:50.403Z" }, ] [[package]] @@ -3889,10 +4077,7 @@ dependencies = [ { name = "xarray", version = "2025.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -[package.dev-dependencies] -ast = [ - { name = "qua-qsim" }, -] +[package.optional-dependencies] dev = [ { name = "astroid", version = "3.3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "astroid", version = "4.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3900,9 +4085,27 @@ dev = [ { name = "black", version = "26.1a1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "commitizen", version = "4.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "commitizen", version = "4.11.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pylint", version = "3.3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pylint", version = "4.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +ast = [ + { name = "qua-qsim" }, +] +dev = [ + { name = "astroid", version = "3.3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "astroid", version = "4.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ipykernel", version = "7.0.0a2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "ipykernel", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mypy" }, + { name = "nbconvert" }, { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pre-commit", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pylint", version = "3.3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -3911,33 +4114,39 @@ dev = [ { name = "pytest", version = "9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, { name = "pytest-mock" }, - { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "astroid", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "black", marker = "extra == 'dev'" }, + { name = "commitizen", marker = "extra == 'dev'" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "qcodes-contrib-drivers", specifier = ">=0.22.0" }, { name = "qm-qua", specifier = ">=1.2.2" }, { name = "qm-saas", specifier = ">=1.1.7" }, - { name = "qualang-tools", specifier = ">=0.19.0" }, + { name = "qualang-tools", specifier = ">=0.22.0" }, { name = "quam", specifier = ">=0.4.0" }, + { name = "ruff", marker = "extra == 'dev'" }, { name = "xarray", specifier = ">=2024.7.0" }, ] +provides-extras = ["dev"] [package.metadata.requires-dev] ast = [{ name = "qua-qsim", git = "https://github.com/qua-platform/qua-qsim.git?rev=bare-ast-visitor" }] dev = [ { name = "astroid", specifier = ">=3.0" }, - { name = "black" }, - { name = "commitizen" }, { name = "ipykernel", specifier = ">=7.0.0a1" }, - { name = "mypy" }, + { name = "nbconvert" }, { name = "pre-commit" }, { name = "pylint", specifier = ">=3.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov" }, { name = "pytest-mock", specifier = ">=3.14.0" }, - { name = "ruff" }, ] [[package]] @@ -4454,6 +4663,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -4521,6 +4739,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + [[package]] name = "tinydb" version = "4.8.2" @@ -4828,6 +5058,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "websockets" version = "15.0.1"