diff --git a/quam_builder/architecture/quantum_dots/README.md b/quam_builder/architecture/quantum_dots/README.md index e23ff8bc..630dfa42 100644 --- a/quam_builder/architecture/quantum_dots/README.md +++ b/quam_builder/architecture/quantum_dots/README.md @@ -1,668 +1,193 @@ -# Quantum Dot Components: Orchestrating DC Voltage Control in QUA & Abstracting Gate Control with Virtualization Layers +# Quantum-dot QuAM architecture +This package (`quam_builder.architecture.quantum_dots`) provides QuAM components, voltage-control tooling, default operations and macros, and wiring helpers for **quantum-dot and spin-qubit** processors. -## 1. Introduction +It is aimed at: -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. +- Labs **building a machine from connectivity specs** (recommended: [`quam_builder.builder.quantum_dots`](../../builder/quantum_dots/)). +- Experimentalists **moving from raw QUA** to a structured QuAM machine (components, named voltage points, macros, and `wire_machine_macros`). -The components `GateSet` and `VoltageSequence` enable precise physical voltage control, essential for quantum dot operations and forming a basis for `VirtualGateSet`. +You can use the **builder** to generate a full machine, or assemble **components piecemeal** when you need a custom topology. Child READMEs cover specific areas in depth; this document is the onboarding hub. -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. +## Prerequisites -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. +You should be familiar with: -Virtual gates simplify complex tuning procedures in experiments, especially for spin qubits, by allowing control over abstract parameters that map to multiple physical gate voltages. +- **[QUA](https://docs.quantum-machines.co/latest/)** — programs, sticky elements, and pulse play. +- **[QUAM](https://qua-platform.github.io/quam/)** — machine components, serialization, and references. +- **[qualang_tools](https://github.com/qua-platform/py-qua-tools)** (optional but common) — connectivity / wiring helpers used by several examples and by `build_quam_wiring`. -**`VirtualGateSet` builds upon `GateSet` and inherits all of its features**, including physical channel management, `VoltageTuningPoint` definitions, and all voltage control methods. This means you can use virtual gates while still having access to all the voltage control capabilities of the underlying `GateSet`. +This documentation stays **hardware-agnostic** (no specific OPX port lists here). Examples may assume LF-FEM or cluster settings; adapt ports and hosts to your setup. -### 1.1 Relevance to Spin Qubits +## Package map -Virtual gates are extremely powerful for operating spin qubits: +| Area | Location | Role | +|------|----------|------| +| Components | [`components/`](components/) | Primary QuAM dataclasses: dots, gates, readout, `QPU`, etc. | +| Voltage sequencing and Virtual gates | [`voltage_sequence/`](voltage_sequence/), [`virtual_gates/`](virtual_gates/)| Shared control of channels and full layered virtualization of gates see [voltage_sequence/README.md](voltage_sequence/README.md) for the full DC/virtual-gate guide | +| Operations & defaults | [`operations/`](operations/) | Macro and pulse registries, canonical names, default macro classes — [operations/README.md](operations/README.md) | +| QPU models | [`qpu/`](qpu/) | `BaseQuamQD` (dot-centric root); **Loss DiVincenzo spin stack** — [`qpu/README.md`](qpu/README.md) (`LossDiVincenzoQuam`, `LDQubit`, `LDQubitPair`, XY drives) | +| Examples | [`examples/`](examples/) | Runnable scripts and tutorial helpers | -- **Orthogonal Control:** Provide more orthogonal control over quantum dot properties (e.g., independently tuning inter-dot tunnel coupling and dot chemical potential). -- **Hierarchical Tuning:** Multiple layers allow for a hierarchy of control, from coarse adjustments to fine-tuning. -- **Simplified Tuning:** Complex multi-dimensional tuning tasks become simpler in virtual gate space. -- **Automated Calibration:** Virtual gate matrices can be calibrated automatically, adapting to device changes. -- **Standardization:** Defines device operation in terms of abstract parameters rather than specific physical gate voltages, improving experiment portability and comparability. +## Start here -The `VirtualGateSet` framework provides the necessary tools to implement these advanced control schemes within QUA. +First build a machine using the necessary components for your experimental setup. -## 2. Overview and Workflow +### 1. Build the machine (recommended first step) -### 2.1. Components Overview +Use [`quam_builder.builder.quantum_dots`](../../builder/quantum_dots/) to materialize connectivity into a QuAM tree: -#### 2.1.1 VoltageGate +- **`build_quam`** — combined workflow when you have wiring + qubit/dot specs. +- **`build_base_quam`** — dot-centric layout (`BaseQuamQD`): virtual gate sets, dots, pairs, sensors, barriers. +- **`build_loss_divincenzo_quam`** — extends the dot layout with spin qubits (`LossDiVincenzoQuam`, `LDQubit`, pairs, XY drives). -`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. +Pair this with [`quam_builder.builder.qop_connectivity`](../../builder/qop_connectivity/) (`build_quam_wiring`) so logical elements map to controller ports. Builder entry points call **`wire_machine_macros`** so default macros and pulses exist on the loaded machine. -- `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: +**Coming from QUA:** you replace ad-hoc channel dicts and copy-pasted pulse blocks with a single machine object, named voltage points, and `qubit.x180()`-style macros after wiring. - ```python - channel_p1 = VoltageGate(...) +### 2. Understand the machine layout - channel_p1.offset_parameter = QDAC.ch17.dc_constant_V # QDAC is a QCoDeS driver +After building (or loading a saved machine), the usual roots are: - channel_p1.offset_parameter(0.1) # Sets the DC offset to 0.1 - channel_p1.offset_parameter() # Returns 0.1 - ``` +| Root type | Use when | +|-----------|----------| +| **`BaseQuamQD`** | Calibrating **dots and gates** (plungers, barriers, sensors) without a full spin abstraction. | +| **`LossDiVincenzoQuam`** | Running **ESR/EDSR and two-qubit** experiments on top of the same dot connectivity. | -#### 2.1.2 GateSet +Key groupings: `virtual_gate_sets`, `voltage_sequences`, `quantum_dots`, `qubit_pairs`, `qubits` (spin stack only). Dots and qubits delegate voltage moves through **`VoltageSequence`** on the relevant gate set. -`GateSet` is a `QuantumComponent` grouping physical `VoltageGate` (and thus `SingleChannel`) objects. It manages named voltage presets (`VoltageTuningPoint` macros) and creates `VoltageSequence` instances. +Details: [`qpu/README.md`](qpu/README.md). -#### 2.1.3 VoltageSequence +### 3. Gates and voltage sequences -`VoltageSequence` uses the GateSet to apply QUA voltage operations (steps, ramps) within a QUA Program. It tracks channel states, optionally including integrated voltage for DC compensation, which is useful for AC-coupled lines. **One of its primary features is that it keeps track of the current voltage on each channel, allowing you to ramp to absolute voltages even with sticky mode enabled.** +For **sticky** control in QUA: -#### 2.1.4 VirtualGateSet +1. Define **`VoltageGate`** channels (with `half_max_square` operations). +2. Group them in a **`GateSet`** or **`VirtualGateSet`** (virtualization layers for tuning axes). +3. Inside a QUA program: `seq = gate_set.new_sequence()` then `step_to_voltages` / `ramp_to_point` / etc. -A subclass of `GateSet`. It manages a list of `VirtualizationLayer` objects, which define the transformations from virtual gate voltages to underlying (either physical or lower-level virtual) gate voltages. +Default behaviour uses **`keep_levels=True`**: gate names you omit keep their last value (physical and virtual). Pass `keep_levels=False` when every call should treat omitted gates as 0 V. See [voltage_sequence/README.md](voltage_sequence/README.md). -#### 2.1.5 VirtualizationLayer +**Coming from QUA:** you keep writing `program()` blocks, but voltage targets are absolute levels tracked per channel instead of manual delta pulses on each gate. -Represents a single linear transformation (matrix) from a set of source (virtual) gates to a set of target gates. +### 4. Macros, pulses, and overrides -### 2.2 Workflow +Default **state** macros (`initialize`, `measure`, `empty`, `exchange`) and **gate** macros (`x180`, `cz`, …) are registered per component type under [`operations/`](operations/). At runtime, **`wire_machine_macros`** fills missing defaults and applies Python or TOML overrides. -**This document will start with an end-to-end example before diving into the specifics. This example workflow takes place in 6 broad steps:** +Typical flow: build machine → `wire_machine_macros(machine)` (often already done in `load()` / builder) → call `qubit.initialize()` or `qubit.x180()` in QUA. -#### 1. Define QUAM `VoltageGate` objects for physical gates +Override patterns (single macro, type-level, TOML profile): [operations/README.md](operations/README.md). -- Below is an example of how a `VoltageGate` is instantiated. As appropriate, add `offset_parameter` and `attenuation` arguments. +## Component inventory -- 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. +**Machine root** - ```python - from quam_builder.architecture.quantum_dots.components import VoltageGate - from quam.components import StickyChannelAddon, pulses +- **`QPU`** — Top-level quantum processing unit component. +**Voltage and gate control** - channel_p1 = VoltageGate( - opx_output = ("con1", 1), #Specify the OPX output - sticky=StickyChannelAddon(duration=1_000, digital=False), # For DC offsets - operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, # Ensure that the instantiated channel is STICKY - ) +- **`VoltageGate`** — Baseband channel with `offset_parameter` and `attenuation` (extends `SingleChannel`) for OPX control plus optional external DC drivers. +- **`GateSet`**, **`VirtualGateSet`**, **`VirtualizationLayer`** — Group channels, named tuning points, and layered virtual-to-physical maps. Math and workflows: [voltage_sequence/README.md](voltage_sequence/README.md). +- **`GlobalGate`** — `VoltageGate` not tied to a `GateSet` (e.g. global back gate). +- **`VirtualDCSet`** — Python-side virtualization of external DC instruments (`offset_parameter`); shares layer concepts with `VirtualGateSet` but is not used in `VoltageSequence`. +- **`VoltageSequence`** — QUA sequence helper for a `GateSet` / `VirtualGateSet` (level tracking, optional integrated-voltage compensation). See [voltage_sequence/README.md](voltage_sequence/README.md). +**Readout and transport** - channel_p2 = VoltageGate( - opx_output = ("con1", 2), #Specify the OPX output - sticky=StickyChannelAddon(duration=1_000, digital=False), # For DC offsets - operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, - ) - ``` +- **`ReadoutResonator`**, **`ReadoutTransport`**, **`Reservoir`** — Resonator and transport/reservoir constructs for readout. -- Each channel should have a base QUA operation named `"half_max_square"`, as shown above. Note that `GateSet.new_sequence()` automatically updates the channel operations to include `"half_max_square"`; ensure that the config is generated, and the QM is opened only afterwards. +**Dot topology and coupling** +- **`QuantumDot`** — Single dot, tied to a `VoltageGate` or `VirtualGate` and the machine’s `VirtualGateSet`. +- **`QuantumDotPair`** — Two dots plus shared barrier control. +- **`SensorDot`** — Sensor dot for SET-style readout. -#### 2. Group channels into a channel dictionary +**Hardware helpers** - ```python - channels = { - "channel_p1": channel_p1, - "channel_p2": channel_p2, - } - ``` +- **`DacSpec`**, **`QdacSpec`** — DAC channel metadata on `VoltageGate` ([`dac_spec.py`](components/dac_spec.py)). -- When creating this mapping, it is important to ensure that the string names used here match the string names in your QuAM machine. +**Pulses** -- 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: +- **`pulses.py`** — Pulse helpers; default pulse factories are registered with macros in `operations/`. - ```python - channels = { - "channel_p1": channel_p1.get_reference(), - "channel_p2": channel_p2.get_reference() - } - ``` +**Spin qubits (Loss DiVincenzo)** +- **`LossDiVincenzoQuam`**, **`LDQubit`**, **`LDQubitPair`**, **XY drives** — See [`qpu/README.md`](qpu/README.md). -#### 3. Instantiate your GateSet with your channel mapping +## Voltage sequencing and virtualization -- Below shows an example of instantiating your `GateSet`, for basic group control of `VoltageGate` channels. +`GateSet` and `VoltageSequence` coordinate sticky channels in QUA, track absolute levels (and optional integrated voltage for compensation), and apply named tuning-point macros. `VirtualGateSet` adds `VirtualizationLayer` matrices so experiments can work in virtual gate space. - ```python - from quam_builder.architecture.quantum_dots.components import GateSet +Requirements and behaviour: - my_gate_set = GateSet(id="dot_plungers", channels=channels) - ``` +- Channels must be **sticky** for correct holding and `ramp_to_zero`. +- By default, **`keep_levels=True`** on `new_sequence()`: omitted physical and virtual gate names **keep their last value**; use `keep_levels=False` or explicit `0.0` to clear contributions. +- Full workflows, API detail, and mathematics: **[voltage_sequence/README.md](voltage_sequence/README.md)**. -- If virtual gates are necessary in your setup, use the `VirtualGateSet` instead. The instantiation of `VirtualGateSet` is identical to the `GateSet`. +## Operations and macros - ```python - from quam_builder.architecture.quantum_dots.components import VirtualGateSet +Canonical names (`initialize`, `measure`, `x180`, `cz`, …) live in [`operations/names.py`](operations/names.py). Default macro classes and per-component maps are under [`operations/default_macros/`](operations/default_macros/). - my_virtual_gate_set = VirtualGateSet(id="dot_plungers", channels=channels) +| Topic | Document | +|-------|----------| +| Macro tables, invocation (`q.x180()` vs registry), overrides | [operations/README.md](operations/README.md) | +| Spin-qubit component layout | [qpu/README.md](qpu/README.md) | - ``` +## Macro engine and machine wiring -##### 3.1 (Optional) Add Virtualization Layers +**`wire_machine_macros`** ([`macro_engine/wiring.py`](macro_engine/wiring.py)) materializes missing default macros and pulses and merges **`ComponentOverrides`** from Python or TOML (`load_macro_profile`). It runs during component setup, **builder** entry points, and `BaseQuamQD` / `LossDiVincenzoQuam` **`load()`** so serialized machines stay consistent. -- 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) - my_virtual_gate_set.add_layer( - source_gates=["v_Coarse1", "v_Coarse2"], - target_gates=["channel_p1", "channel_p2"], #Your existing physical gates should be the target_gates of your first layer - matrix=[[1.0, 0.5], [0.5, 1.0]] # Coupled control - ) +## Building and loading QuAM machines - # Add fine tuning layer (virtual gates for precise adjustments) - my_virtual_gate_set.add_layer( - source_gates=["v_FineTune1", "v_FineTune2"], - target_gates=["v_Coarse1", "v_Coarse2"], - matrix=[[0.1, 0.0], [0.0, 0.1]] # Small adjustments - ) - ``` +**Preferred path:** [`quam_builder.builder.quantum_dots`](../../builder/quantum_dots/) -#### 4. Add `VoltageTuningPoint` macros to the `GateSet` or `VirtualGateSet` +| Function | Produces | +|----------|----------| +| `build_quam` | Full machine from connectivity + specs (common entry point). | +| `build_base_quam` | `BaseQuamQD` — dots, gate sets, sensors, pairs. | +| `build_loss_divincenzo_quam` | `LossDiVincenzoQuam` — adds qubits, pairs, MW/XY wiring. | -- 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 +Staged builders (`build_qpu_stage1`, `build_qpu_stage2`) and utilities live in the same package. Combine with **`build_quam_wiring`** from [`quam_builder.builder.qop_connectivity`](../../builder/qop_connectivity/) for port mapping. - ```python - my_gate_set.add_point(name="idle", voltages={"channel_P1": 0.1, "channel_P2": -0.05}, duration=1000) - ``` +After build: `machine.save()` / `machine.load()`; spin roots may upgrade from `BaseQuamQD` on load and re-run wiring. -- Internally this adds a **`VoltageTuningPoint` to GateSet.macros** +For **manual** assembly (custom topology without the builder), follow the workflow described in [`quam_qd_example.py`](examples/quam_qd_example.py) and [`quam_ld_example.py`](examples/quam_ld_example.py) docstrings — register channels, `create_virtual_gate_set`, dots, then qubits. -- 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. +## Examples - ```python - my_virtual_gate_set.add_point(name="idle", voltages={"v_FineTune1": 0.1, "v_Coarse2": -0.05}, duration=1000) - ``` +Scripts live under [`examples/`](examples/). **Start with the shared machine builder**, then run scripts that import it: -- NOTE: Any virtual gate not **explicitly** provided in a call is assumed to be 0 V for that operation. This effectively removes any prior contribution from that virtual gate in the resolved physical voltages. For more specific information on this, see sections [4](#important-behavior-zeroing-semantics) and [6.1](#61-important-behavior-unspecified-virtual-gates-are-zeroed-per-operation). +| Script | What it demonstrates | +|--------|----------------------| +| [`tutorial_machine.py`](examples/tutorial_machine.py) | **`build_tutorial_machine()`** — minimal `LossDiVincenzoQuam` (dots, pair, qubits, virtual gate set, voltage points for state macros). Intended as the shared machine for tutorials; does not call `wire_machine_macros` (callers wire macros themselves). | +| [`default_macro_defaults_example.py`](examples/default_macro_defaults_example.py) | Wire defaults only, parameterize built-in macros/pulses, run QUA using `initialize` / `x180`. | +| [`full_workflow_example.py`](examples/full_workflow_example.py) | Builder + `wire_machine_macros`, pulse/macro overrides, DRAG swap (end-to-end spin workflow). | +| [`quam_qd_generator_example.py`](examples/quam_qd_generator_example.py) | **Builder-first** generator path for a dot + LD qubit machine. | +| [`virtual_gate_set_example.py`](examples/virtual_gate_set_example.py) | `VirtualGateSet` layers and `resolve_voltages` (includes rectangular-matrix check). | +| [`wiring_example.py`](examples/wiring_example.py) | Connectivity / `build_quam_wiring` with quantum-dot builders. | -#### 5. Create a `VoltageSequence` from the `GateSet` or `VirtualGateSet` inside your QUA programme +**More scripts** in [`examples/`](examples/) (overrides-only, Rabi–Chevron, external macro packages, `VirtualDCSet`, pulse overrides, etc.) — read each file’s module docstring for scope and prerequisites. -- `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: - voltage_seq = my_gate_set.new_sequence() - ``` - -- Or, if using the `VirtualGateSet`, - - ```python - with program() as complex_control: - voltage_seq = my_virtual_gate_set.new_sequence() - ``` - -#### 6. Create your QUA program with your `VoltageSequence` - -- Instantiate your new sequence in the QUA programme, and step/ramp to any point. - -- For a basic `GateSet`, - - ```python - with qua.program() as basic_control: - voltage_seq = my_gate_set.new_sequence() - voltage_seq.step_to_point("idle") # Step to pre-defined point "idle". ramp_to_point also valid, with a ramp_duration argument, also shown in the VirtualGateSet example - 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 - ``` - -- Use a `VirtualGateSet` for full control over virtual and physical points: - - ```python - # Create voltage sequence - with qua.program() as complex_control: - voltage_seq = my_virtual_gate_set.new_sequence() - - # Step to a virtual gate configuration - voltage_seq.step_to_voltages( - voltages={"v_FineTune1": 0.05, "v_FineTune2": -0.02}, - duration=500 - ) - - # Ramp to a predefined point - voltage_seq.ramp_to_point("readout", ramp_duration=100, duration=2000) - - # Combine virtual and physical control - voltage_seq.step_to_voltages( - voltages={ - "v_FineTune1": 0.1, # Virtual gate adjustment - "channel_p2": 0.3 # Direct physical gate control - }, - duration=1000 - ) - - # Fine ramp with virtual gates - voltage_seq.ramp_to_voltages( - voltages={"v_FineTune2": 0.0}, - duration=500, - ramp_duration=40 # Ensure multiple of 4 - ) - - # Return to zero - voltage_seq.ramp_to_zero(ramp_duration=200) - - # The system automatically resolves all virtual gate contributions: - # - v_FineTune1 (0.1V) -> v_Coarse1: 0.1V contribution - # - v_FineTune2 (0.0V) -> v_Coarse2: 0.0V contribution - # - v_Coarse1 (0.1V) + readout (0.2V) -> P1: 0.3V total - # - v_Coarse2 (0.0V) + readout (0.1V) -> P2: 0.1V total - # - P3: 0.3V direct control - ``` - -## 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 - -- 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(...) - -- Voltage Sequences: Used in conjunction with VoltageSequence, a Sequence created in the GateSet allows you to quickly apply complex QUA commands to groups of gates, as well as keeping track of the gate voltages such that a compensating pulse can be applied later. - -**Key Features:** - -- Defines named voltage/duration presets using the `add_point()` method, which internally registers a `VoltageTuningPoint` in `GateSet.macros`. - -- `resolve_voltages()`: Ensures all `GateSet` channels have a defined voltage (defaulting to 0.0V if unspecified). This is particularly useful when you want to specify voltages for only a subset of channels while ensuring all other channels have defined values. - - **Example:** - - ```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. - 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 - complete_voltages = gate_set.resolve_voltages(partial_voltages) - # Result: {"P1": 0.3, "P2": 0.0, "B1": -0.1} - ``` - -- `new_sequence()`: Creates `VoltageSequence` instances. - -- While the tuning points can be defined dynamically within a program, it may be useful to predefine fixed tuning points, for example the readout point. This can be dded via `my_gate_set.add_point(name="...", voltages={...}, duration=...)`. - -- Internally this adds a **`VoltageTuningPoint` to GateSet.macros** - -## 4. `VoltageSequence` - -Generates QUA commands for voltage manipulation, associated with a `GateSet`. - -**Key Features:** - -- Translates high-level requests into QUA `play`, `ramp`, `wait`. - -- Tracks current voltage for each channel. - -- Optionally tracks integrated voltage for DC compensation (enable via `track_integrated_voltage=True` in `new_sequence()`). - -- Supports Python numbers and QUA variables for levels/durations. - -### Important Behavior (Zeroing Semantics) - -- Any channels unspecified in the input voltages dict are treated as 0 V on each call (consistent with `GateSet.resolve_voltages`). -- When the sequence is created from a `VirtualGateSet`, any virtual gate not included in a call is assumed 0 V for that operation. This clears any previous contribution from that virtual gate in the resolved physical voltages. - -Implication: virtual gates do not maintain state across calls. To preserve a virtual configuration, always include all relevant virtual gates (and their values) in each `step_to_voltages`/`ramp_to_voltages` call, or operate directly on physical gates. - -- NOTE: When using QUA loops (such as for_, or infinite_loop_), it is good practise to end the inner loop with a `ramp_to_zero` command, to ensure that the voltages are accurately tracked across loops. - -**Creating a `VoltageSequence`:** - -- 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)` - 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)` - 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)` - 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 - voltage_seq.step_to_point("idle") - voltage_seq.step_to_point("readout", duration=2000) # Override default duration - ``` - -- `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)` - 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 - voltage_seq.ramp_to_zero() # Immediate ramp using QUA built-in - voltage_seq.ramp_to_zero(ramp_duration=100) # Controlled ramp over 100ns - ``` - -- `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 - voltage_seq.apply_compensation_pulse() # Use default 0.49V limit - 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`. -A `VirtualGateSet` translates virtual gate operations into physical gate voltage changes, which are then applied using the `VoltageSequence` mechanisms. - - -## 6. VirtualGateSet - -A `VirtualGateSet` allows users to define and operate with virtual gates, abstracting the underlying physical gate operations. - -**Key Features:** - -- **Inherits from `GateSet`:** Retains all functionalities of `GateSet`, including physical channel management and `VoltageTuningPoint` definitions. -- **Manage Multiple Virtualization Layers:** Stores a list of `VirtualizationLayer` objects. Multiple layers can be defined and stacked, allowing for hierarchical virtualization. Layers are applied sequentially (in reverse order during voltage resolution) to translate top-level virtual gate voltages into physical gate voltages. -- **Add Layers:** Use `add_layer(source_gates, target_gates, matrix)` to define and append a new `VirtualizationLayer`: - - `source_gates`: Names of the new virtual gates defined by this layer. - - `target_gates`: Names of the gates (physical or virtual from a previous layer) that this layer maps onto. - - `matrix`: The transformation matrix (list of lists of floats). -- **Rectangular Matrices (Opt-In):** Set `allow_rectangular_matrices=True` on a `VirtualGateSet` to enable virtualization layers whose matrices are not square. These layers are resolved with the Moore–Penrose pseudo-inverse, allowing over- or under-complete virtual controls while keeping square layers unchanged. -- **Additive Voltage Resolution:** Overrides `GateSet.resolve_voltages()`. When voltages are specified for virtual gates (potentially across different layers) and/or physical gates simultaneously, this method applies the inverse of the virtualization matrices for each layer. Contributions from all specified virtual and physical gates are resolved and become additive at the physical gate level. Handles multi-layered virtualization by processing layers from the outermost to the innermost. - -### 6.1 Important Behavior: Unspecified Virtual Gates Are Zeroed Per Operation - -Each `VoltageSequence` call (e.g., `step_to_voltages`, `ramp_to_voltages`, `step_to_point`) is resolved independently. Any virtual gate not explicitly provided in a call is assumed to be 0 V for that operation. This effectively removes any prior contribution from that virtual gate in the resolved physical voltages. - -- This applies at every layer. If a higher-level virtual gate is not specified, its contribution is taken as 0 V when resolving to lower levels. -- Physical channels not specified in a call are also driven to 0 V for that operation (same behavior as `GateSet`). - -Implication: Virtual gates do not “remember” their last values across operations. If you want to maintain a virtual configuration, include all relevant virtual gates and their values in each call, or operate directly on physical gates. - -Example: +## Import cheat sheet ```python -# Suppose v_C1 and v_C2 map to P1, P2 via a layer - -# First call: set both virtual gates -voltage_seq.step_to_voltages({"v_C1": 0.2, "v_C2": 0.1}, duration=1000) - -# Second call: only specify v_C1 -# v_C2 is assumed 0 V for this call, so its previous contribution is removed -voltage_seq.step_to_voltages({"v_C1": 0.2}, duration=1000) -``` - - -## 7. VirtualizationLayer - -A `VirtualizationLayer` defines a single step in the virtual-to-physical gate voltage transformation. - -**Key Attributes:** - -- `source_gates` (`List[str]`): Names of the virtual gates defined in this layer. -- `target_gates` (`List[str]`): Names of the physical or underlying virtual gates this layer maps to. -- `matrix` (`List[List[float]]`): The virtualization matrix defining the linear transformation. The relationship is `V_source = M * V_target`. When resolving, `V_target = M_inverse * V_source` is used for this layer's contribution. -- Handles the calculation of the inverse matrix and the resolution of voltages for its specific layer. - -### Mathematical Relations - -### 7.1 Forward Transformation (Virtual to Physical) - -The core mathematical relationship for each virtualization layer is: - -``` -V_target = M * V_source -``` - -Where: - -- `V_source` is the vector of virtual gate voltages (source gates) -- `V_target` is the vector of target gate voltages (physical or lower-level virtual gates) -- `M` is the transformation matrix - -For example, with a 2x2 matrix: - -```python -matrix = [[1.0, 0.5], [0.5, 1.0]] -source_gates = ["v_Gate1", "v_Gate2"] -target_gates = ["P1", "P2"] -``` - -The relationship becomes: - -``` -[P1] [1.0 0.5] [v_Gate1] -[P2] = [0.5 1.0] [v_Gate2] -``` - -Expanded: - -- `P1 = 1.0 * v_Gate1 + 0.5 * v_Gate2` -- `P2 = 0.5 * v_Gate1 + 1.0 * v_Gate2` - -### 7.2 Inverse Transformation (Voltage Resolution) - -During voltage resolution, the system applies the inverse transformation: - -``` -V_source = M⁻¹ * V_target -``` - -The code implements this using `numpy.linalg.inv()` to calculate the inverse matrix. For each layer's resolution: - -```python -inverse_matrix = np.linalg.inv(matrix) -for target_gate, inv_matrix_row in zip(target_gates, inverse_matrix): - resolved_voltages[target_gate] += inv_matrix_row @ source_voltages -``` - -### 7.3 Multi-Layer Resolution - -For multiple virtualization layers, transformations are applied sequentially in reverse order. Consider two layers: - -**Layer 1:** `v_Coarse1, v_Coarse2 → P1, P2` - -``` -matrix_1 = [[1.0, 0.5], [0.5, 1.0]] -``` - -**Layer 2:** `v_Fine1, v_Fine2 → v_Coarse1, v_Coarse2` - -``` -matrix_2 = [[0.1, 0.0], [0.0, 0.1]] -``` - -The combined transformation is: - -``` -[P1] [1.0 0.5] [0.1 0.0] [v_Fine1] -[P2] = [0.5 1.0] [0.0 0.1] [v_Fine2] -``` - -Which gives the overall relationship: - -``` -[P1] [0.1 0.05] [v_Fine1] -[P2] = [0.05 0.1 ] [v_Fine2] -``` - -### 7.4 Additive Voltage Contributions - -The system supports additive contributions from different layers and direct physical gate control. If you specify: - -- `v_Fine1 = 1.0V` (from Layer 2) -- `v_Coarse1 = 0.2V` (from Layer 1) -- `P1 = 0.1V` (direct) - -The final voltage for P1 becomes: - -``` -P1_final = P1_direct + P1_from_v_Coarse1 + P1_from_v_Fine1 - = 0.1 + (1.0 * 0.2) + (1.0 * 0.1) - = 0.1 + 0.2 + 0.1 = 0.4V -``` - -### 7.5 Matrix Constraints and Rectangular Support - -For a valid virtualization layer: - -- The matrix dimensions must match the number of source and target gates: `matrix.shape == (len(source_gates), len(target_gates))`. -- By default, the matrix must be square and invertible (`det(M) ≠ 0`), and the layer uses `numpy.linalg.inv(matrix)` during resolution. -- If `VirtualGateSet.allow_rectangular_matrices` is set to `True`, non-square matrices are permitted. These layers store `use_pseudoinverse=True` and are resolved with `numpy.linalg.pinv(matrix)`, yielding the least-squares solution for tall matrices and the minimum-norm solution for wide matrices. -- Square matrices continue to use the true inverse even when rectangular layers are enabled, so existing behaviour and calibrations remain unchanged. - -### 7.6 Core Allocation and Performance - -**One core is dedicated to each physical gate**, regardless of the number of virtual gates or virtualization layers. This has important implications: - -- **Scalable Performance**: Adding virtual gates or virtualization layers doesn't increase the computational load on the QUA system -- **Real-Time Operation**: All matrix calculations are performed at compile time, not during execution -- **Predictable Resource Usage**: The number of cores required is determined solely by the number of physical channels - -For example, if you have 3 physical gates (`P1`, `P2`, `P3`) but 10 virtual gates across 3 virtualization layers, you still only need 3 cores - one for each physical gate. - -### 7.7 Matrix Calculation Example - -Consider a simple two-layer system: - -```python -# Layer 1: Virtual gates to physical gates -matrix_1 = [[1.0, 0.5], [0.5, 1.0]] # v_Coarse1, v_Coarse2 -> P1, P2 - -# Layer 2: Higher-level virtual gates to Layer 1 virtual gates -matrix_2 = [[0.1, 0.0], [0.0, 0.1]] # v_FineTune1, v_FineTune2 -> v_Coarse1, v_Coarse2 -``` - -When you set `v_FineTune1 = 0.1V`, the system: - -1. Applies inverse of matrix_2: `v_Coarse1 = 0.1V / 0.1 = 1.0V` -2. Applies inverse of matrix_1: `P1 = 1.0V * 1.0 + 0V * 0.5 = 1.0V`, `P2 = 1.0V * 0.5 + 0V * 1.0 = 0.5V` - -This transformation happens at compile time, so the QUA program only sees the final physical gate voltages. - -### 7.8 Verifying Rectangular Layers (Round Trip & Plotting) - -To validate a rectangular virtualization layer: - -1. Enable pseudo-inverse support: - - ```python - vgs = VirtualGateSet(id="plunger_set", channels=channels) - vgs.allow_rectangular_matrices = True - vgs.add_layer( - source_gates=["V_bias", "V_sym"], - target_gates=["P1", "P2", "P3"], - matrix=[[1.0, 0.0, 0.0], [0.0, 1.0, 1.0]], - ) - ``` - -2. Generate a set of virtual voltage samples, convert them to the “expected” physical voltages using the pseudo-inverse (`physical_expected = M_pinv @ source`), then resolve the same virtual voltages through the `VirtualGateSet`. The resolved physical voltages should match `physical_expected` within numerical tolerance. - -3. Optional visualisation: plot the original vs. resolved physical voltages to confirm they fall on the identity line. The automated regression `tests/architecture/quantum_dots/components/test_rectangular_virtual_gate_set.py::test_rectangular_roundtrip_visualisation` performs exactly this procedure (using Matplotlib’s Agg backend) so you can run `pytest` and inspect the generated scatter data if deeper debugging is needed. - -## 8. Full End to End Example - -### 8.1. Create your channels (VoltageGate or SingleChannel) - -```python - -from quam.components import ( - BasicQuam, - StickyChannelAddon, - pulses -) -from quam_builder.architecture.quantum_dots.components import VoltageGate - -machine = BasicQuam() - -# Define some VoltageGate channels that form your Quam Machine -machine.channels["ch1"] = VoltageGate( - opx_output=("con1", 1), # OPX controller and port - sticky=StickyChannelAddon(duration=1000, digital=False), - operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, -) -machine.channels["ch2"] = VoltageGate( - opx_output=("con1", 2), # OPX controller and port - sticky=StickyChannelAddon(duration=1000, digital=False), # For DC offsets - operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, -) -machine.channels["ch3"] = VoltageGate( - opx_output=("con1", 3), # OPX controller and port - sticky=StickyChannelAddon(duration=100000, digital=False), # For DC offsets - operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, +from quam_builder.architecture.quantum_dots.components import ( + VoltageGate, + GateSet, + VirtualGateSet, + QuantumDot, + QPU, ) - -``` - -### 8.2. Create channel dictionary and create your VirtualGateSet - -```python - -#Ensure that the naming convention is consistent here - "ch1" if it maps to machine.channels["ch1"] -channels = { - "ch1": machine.channels["ch1"].get_reference(), # .get_reference() necessary to avoid reparenting the Quam component - "ch2": machine.channels["ch2"].get_reference(), - "ch3": machine.channels["ch3"].get_reference(), -} - -from quam_builder.architecture.quantum_dots.components import VirtualGateSet # Requires quam-builder -machine.virtual_gate_set = VirtualGateSet(id = "Plungers", channels = channels) - -``` - -### 8.3. Add virtual gate layers - -```python -machine.virtual_gate_set.add_layer( - source_gates = ["V1", "V2"], # Pick the virtual gate names here - target_gates = ["ch1", "ch2"], # Must be a subset of gates in the gate_set - matrix = [[2,1],[0,1]] # Any example matrix +from quam_builder.architecture.quantum_dots.macro_engine import ( + wire_machine_macros, + overrides, + macro, + ComponentOverrides, ) +from quam_builder.builder.quantum_dots import build_quam, build_loss_divincenzo_quam +from quam_builder.architecture.quantum_dots.qpu import BaseQuamQD, LossDiVincenzoQuam ``` -### 8.4. Add any relevant tuning points to your GateSet - -```python -#Some example points -machine.virtual_gate_set.add_point("init", {"ch1": -0.25, "ch3": 0.12}, duration = 10_000) -machine.virtual_gate_set.add_point("op", {"V1": 0.2, "V2": 0.1}, duration = 1000) -machine.virtual_gate_set.add_point("meas", {"ch3": -0.12}, duration = 3_000) - -``` - -### 8.5. Write QUA program - -```python -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") - my_new_seq.step_to_point("meas") -``` - -**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 `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}`. +For `LDQubit`, `LDQubitPair`, and XY drive types, see **[`qpu/README.md`](qpu/README.md)**. For DC sequencing detail, see **[`voltage_sequence/README.md`](voltage_sequence/README.md)**. diff --git a/quam_builder/architecture/quantum_dots/operations/README.md b/quam_builder/architecture/quantum_dots/operations/README.md index 6adcc7a8..4f7e4a05 100644 --- a/quam_builder/architecture/quantum_dots/operations/README.md +++ b/quam_builder/architecture/quantum_dots/operations/README.md @@ -1,43 +1,53 @@ +> This document is the detailed guide for Quantum Dot Operations and Macros. For an overview of all quantum-dot QuAM components, operations, and macros, see [../README.md](../README.md). # 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 +60,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 +227,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/qpu/README.md b/quam_builder/architecture/quantum_dots/qpu/README.md new file mode 100644 index 00000000..e050b7d1 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/qpu/README.md @@ -0,0 +1,114 @@ +> This document is the detailed guide for Loss DiVincenzo and spin-qubit quam. For an overview of all quantum-dot QuAM components, operations, and macros, see [../README.md](../README.md). + +# Loss DiVincenzo and spin-qubit QuAM + +This document covers the **spin-qubit layer** built on top of the quantum-dot architecture: the QuAM root types that register qubits and pairs, microwave **XY** control lines, and how they connect to underlying `QuantumDot` objects and voltage sequencing. + +For DC gates, virtual gates, and `VoltageSequence`, see the parent [quantum-dot architecture README](../README.md) and [voltage_sequence/README.md](../voltage_sequence/README.md). For default macros, pulses, and overrides, see [operations/README.md](../operations/README.md). + +## `BaseQuamQD` vs `LossDiVincenzoQuam` + +- **`BaseQuamQD`** ([`base_quam_qd.py`](base_quam_qd.py)) — QuAM root focused on **quantum-dot device layout**: `quantum_dots`, `sensor_dots`, `barrier_gates`, `quantum_dot_pairs`, `virtual_gate_sets`, `voltage_sequences`, global gates, Octave/mixer metadata, and helpers to create gate sets and register dots. Use it when calibrating and operating the **underlying dots** without a full spin-qubit abstraction. + +- **`LossDiVincenzoQuam`** ([`loss_divincenzo_quam.py`](loss_divincenzo_quam.py)) — Extends `BaseQuamQD` with **Loss DiVincenzo–style spin qubits**: `qubits`, `qubit_pairs`, `b_field`, `active_qubit_names`, and `active_qubit_pair_names`. It is the usual root when you calibrate and run **ESR/EDSR** experiments and two-qubit gates on top of the same dot connectivity. `load()` upgrades a deserialized `BaseQuamQD` instance to this class when appropriate and runs `wire_machine_macros`. + +## `LossDiVincenzoQuam` surface + +Notable attributes and behaviour (see class docstring for the full list): + +- **`qubits`** — `Dict[str, AnySpinQubit]`; **`LDQubit`** instances keyed by name. +- **`qubit_pairs`** — `Dict[str, AnySpinQubitPair]` (e.g. **`LDQubitPair`**) for two-qubit control. +- **`b_field`** — Operating external magnetic field (device metadata). +- **`active_qubit_names`**, **`active_qubit_pair_names`** — Subsets used when broadcasting QPU-level routines. +- **`register_qubit`**, **`register_qubit_pair`** — Construct qubit / pair objects from existing `QuantumDot` (and pair) topology. +- **`get_component`** — Resolves names across qubits, pairs, dots, sensors, barriers, etc. + +## `LDQubit` ([`../qubit/ld_qubit.py`](../qubit/ld_qubit.py)) + +A Loss DiVincenzo qubit ties a **`QuantumDot`** (plunger / voltage sequence) to microwave control and readout-oriented fields: + +- **`quantum_dot`** — The physical dot; voltage macros (`step_to_point`, `add_point`, …) are delegated through the dot’s `VoltageSequence`. +- **`xy`** — Optional **`XYDriveBase`** subclass for EDSR/ESR drive lines; see [XY drive components](#xy-drive-components) below. +- **Coherence and reset** — `T1`, `T2ramsey`, `T2echo`, `thermalization_time_factor`, `reset`, and **`calibrate_octave`** for drive. +- **Macros** — Inherits **`VoltageMacroMixin`** with QuAM `Qubit`; default single-qubit gates and state macros are wired via [`operations/`](../operations/) and **`wire_machine_macros`**. + +## `LDQubitPair` ([`../qubit_pair/ld_qubit_pair.py`](../qubit_pair/ld_qubit_pair.py)) + +Pairs two **`LDQubit`** instances for two-qubit primitives; default two-qubit macros (`cnot`, `cz`, …) are registered the same way as for `LDQubit`, with placeholders until you supply calibration overrides (see [operations/README.md](../operations/README.md)). + +## XY drive components ([`../components/xy_drive.py`](../components/xy_drive.py)) + +All three concrete drive types inherit **`XYDriveBase`** and are composed on **`LDQubit.xy`**. They differ by **QuAM channel type** and **hardware wiring**, not by macro API — single-qubit gates still go through `XYDriveMacro` and default pulses from [`component_pulse_catalog`](../operations/component_pulse_catalog.py). + +| Variant | QuAM base | Typical hardware | Required ports | IF / LO | +|---------|-----------|------------------|----------------|---------| +| **`XYDriveSingle`** | `SingleChannel` | **LF-FEM / OPX+** baseband analog output | single `opx_output` → `analog_outputs` | Required `RF_frequency` (aliases `intermediate_frequency`) | +| **`XYDriveIQ`** | `IQChannel` | **LF-FEM / OPX+ + Octave or external mixer** | `opx_output_I`, `opx_output_Q`, `frequency_converter_up` | `intermediate_frequency` + `LO_frequency` via `upconverter_frequency` | +| **`XYDriveMW`** | `MWChannel` | **MW-FEM** direct microwave output | single `opx_output` → `mw_outputs` | `intermediate_frequency` + upconverter on port (`upconverter_frequency` from `opx_output`) | + +**`XYDriveBase`** provides shared helpers (e.g. `calculate_voltage_scaling_factor` for scaling between dBm levels). + +### `XYDriveSingle` + +- Baseband EDSR/ESR on a **single** LF-FEM analog port — no external upconversion. +- Simplest setup; `RF_frequency` is the drive IF. +- Pulses use **real-valued waveforms** (`axis_angle=None`); rotation axis is handled by **virtual-Z** in `XYDriveMacro`, not hardware IQ mixing. +- No built-in `get_output_power` / `set_output_power` (only shared `XYDriveBase.calculate_voltage_scaling_factor`). + +### `XYDriveIQ` + +- Preferred when wiring exposes **I/Q outputs and a frequency converter** (Octave path). +- **Hardware IQ mixing** — default reference pulse gets `axis_angle=0.0`; macro applies virtual-Z for X/Y axis selection (same macro path as MW). +- Power helpers: `get_output_power` / `set_output_power` via IQ gain/amplitude ([`power_tools.py`](../../../tools/power_tools.py)). +- Builder note: preferred for LF-FEM RF allocation when IQ ports are available (see `_create_xy_drive_from_wiring` in [`build_utils.py`](../../../builder/quantum_dots/build_utils.py)). + +### `XYDriveMW` + +- For **MW-FEM** setups (examples: [`quam_ld_generator_example.py`](../examples/quam_ld_generator_example.py), [`rabi_chevron.py`](../examples/rabi_chevron.py)). +- Single MW port with on-module upconversion; IF + port upconverter frequency define the emitted tone. +- Same pulse/macro model as IQ (`axis_angle=0.0`). +- Power helpers via MW full-scale power ([`power_tools.py`](../../../tools/power_tools.py)). + +### `XYDrive` Parallel execution + +For all types of XYDrive, it is important to note that they are by default running in parallel to `VirtualGateSet / VoltageSequence` operations and other XYDrive components. This has implications on the user managing timings of these pulses. + +### Builder auto-detection + +When you use [`build_loss_divincenzo_quam`](../../../builder/quantum_dots/) (or stage-2 wiring), `_validate_drive_ports` in [`build_utils.py`](../../../builder/quantum_dots/build_utils.py) picks the variant from port keys and reference paths: + +```mermaid +flowchart TD + wiring[Wiring ports] --> validate[_validate_drive_ports] + validate -->|I+Q+converter| IQ[XYDriveIQ] + validate -->|opx_output mw_outputs| MW[XYDriveMW] + validate -->|opx_output analog_outputs| Single[XYDriveSingle] +``` + +Pulse factories for XY are registered in the operations layer (`component_pulse_catalog`, default pulses for `LDQubit`) via **`wire_machine_macros`**. + +## Macros and wiring + +Spin qubits use the same **macro engine** as the rest of the architecture: `LossDiVincenzoQuam.load()` calls **`wire_machine_macros`**. Default macro maps for `LDQubit` and `LDQubitPair` (state macros, `xy_drive`, `x180`, two-qubit names, …) live under [`../operations/default_macros/`](../operations/default_macros/). Invocation patterns are described in [operations/README.md](../operations/README.md). + +## Builder and examples + +- **Builder** — [`quam_builder.builder.quantum_dots`](../../../builder/quantum_dots/) exposes `build_loss_divincenzo_quam` (and staged builders) to materialize a full `LossDiVincenzoQuam` from connectivity specs. +- **Examples** — [`../examples/quam_ld_example.py`](../examples/quam_ld_example.py), [`../examples/quam_ld_generator_example.py`](../examples/quam_ld_generator_example.py), [`../examples/rabi_chevron.py`](../examples/rabi_chevron.py), [`../examples/rabi_chevron_transport.py`](../examples/rabi_chevron_transport.py). + +## Tests + +Spin-qubit and QPU tests live under [`tests/architecture/quantum_dots/components/`](../../../../tests/architecture/quantum_dots/components/) (e.g. `test_ld_qubit.py`, `test_ld_qubit_pair.py`, `test_base_quam_qd.py`). + +## Import cheat sheet + +```python +from quam_builder.architecture.quantum_dots.qpu import LossDiVincenzoQuam, BaseQuamQD +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 import ( + XYDriveSingle, + XYDriveIQ, + XYDriveMW, +) +``` diff --git a/quam_builder/architecture/quantum_dots/virtual_gates/README.md b/quam_builder/architecture/quantum_dots/virtual_gates/README.md new file mode 100644 index 00000000..fbb184ab --- /dev/null +++ b/quam_builder/architecture/quantum_dots/virtual_gates/README.md @@ -0,0 +1 @@ +See [../voltage_sequence/README.md](../voltage_sequence/README.md) for a full description of `VirtualGateSet` and `VoltageSequence`. \ No newline at end of file diff --git a/quam_builder/architecture/quantum_dots/voltage_sequence/README.md b/quam_builder/architecture/quantum_dots/voltage_sequence/README.md new file mode 100644 index 00000000..0c6ca2c9 --- /dev/null +++ b/quam_builder/architecture/quantum_dots/voltage_sequence/README.md @@ -0,0 +1,738 @@ +> This document is the detailed guide for DC voltage control in QUA: `VoltageGate`, `GateSet`, `VoltageSequence`, `VirtualGateSet`, and `VirtualizationLayer`. For an overview of all quantum-dot QuAM components, operations, and macros, see [../README.md](../README.md). + +# Quantum Dot Components: Orchestrating DC Voltage Control in QUA & Abstracting Gate Control with Virtualization Layers + + +## 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. + +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. + +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. + +Virtual gates simplify complex tuning procedures in experiments, especially for spin qubits, by allowing control over abstract parameters that map to multiple physical gate voltages. + +**`VirtualGateSet` builds upon `GateSet` and inherits all of its features**, including physical channel management, `VoltageTuningPoint` definitions, and all voltage control methods. This means you can use virtual gates while still having access to all the voltage control capabilities of the underlying `GateSet`. + +### 1.1 Relevance to Spin Qubits + +Virtual gates are extremely powerful for operating spin qubits: + +- **Orthogonal Control:** Provide more orthogonal control over quantum dot properties (e.g., independently tuning inter-dot tunnel coupling and dot chemical potential). +- **Hierarchical Tuning:** Multiple layers allow for a hierarchy of control, from coarse adjustments to fine-tuning. +- **Simplified Tuning:** Complex multi-dimensional tuning tasks become simpler in virtual gate space. +- **Automated Calibration:** Virtual gate matrices can be calibrated automatically, adapting to device changes. +- **Standardization:** Defines device operation in terms of abstract parameters rather than specific physical gate voltages, improving experiment portability and comparability. + +The `VirtualGateSet` framework provides the necessary tools to implement these advanced control schemes within QUA. + +## 2. Overview and Workflow + +### 2.1. Components Overview + +#### 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. + +- `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(...) + + channel_p1.offset_parameter = QDAC.ch17.dc_constant_V # QDAC is a QCoDeS driver + + channel_p1.offset_parameter(0.1) # Sets the DC offset to 0.1 + channel_p1.offset_parameter() # Returns 0.1 + ``` + +#### 2.1.2 GateSet + +`GateSet` is a `QuantumComponent` grouping physical `VoltageGate` (and thus `SingleChannel`) objects. It manages named voltage presets (`VoltageTuningPoint` macros) and creates `VoltageSequence` instances. + +#### 2.1.3 VoltageSequence + +`VoltageSequence` uses the GateSet to apply QUA voltage operations (steps, ramps) within a QUA Program. It tracks channel states, optionally including integrated voltage for DC compensation, which is useful for AC-coupled lines. **One of its primary features is that it keeps track of the current voltage on each physical channel, allowing you to ramp to absolute voltages even with sticky mode enabled.** + +By default, `GateSet.new_sequence()` creates a sequence with **`keep_levels=True`**: physical and virtual gate names that you omit in a call keep their last set value (see [§4](#important-behavior-level-holding-and-zeroing-semantics)). Pass `keep_levels=False` only when you want omitted gates to be treated as 0 V on every call. + +#### 2.1.4 VirtualGateSet + +A subclass of `GateSet`. It manages a list of `VirtualizationLayer` objects, which define the transformations from virtual gate voltages to underlying (either physical or lower-level virtual) gate voltages. + +#### 2.1.5 VirtualizationLayer + +Represents a single linear transformation (matrix) from a set of source (virtual) gates to a set of target gates. + +### 2.2 Workflow + +**This document will start with an end-to-end example before diving into the specifics. This example workflow takes place in 6 broad steps:** + +#### 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. + +- 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 + from quam_builder.architecture.quantum_dots.components import VoltageGate + from quam.components import StickyChannelAddon, pulses + + + channel_p1 = VoltageGate( + opx_output = ("con1", 1), #Specify the OPX output + sticky=StickyChannelAddon(duration=1_000, digital=False), # For DC offsets + operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, # Ensure that the instantiated channel is STICKY + ) + + + channel_p2 = VoltageGate( + opx_output = ("con1", 2), #Specify the OPX output + sticky=StickyChannelAddon(duration=1_000, digital=False), # For DC offsets + operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, + ) + ``` + +- Each channel must have a base QUA operation named `"half_max_square"` (see `DEFAULT_PULSE_NAME` in the implementation), as shown above. Define this on the channel before generating the QUA config and opening the QM. + + +#### 2. Group channels into a channel dictionary + + ```python + channels = { + "channel_p1": channel_p1, + "channel_p2": channel_p2, + } + ``` + +- 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: + + ```python + channels = { + "channel_p1": channel_p1.get_reference(), + "channel_p2": channel_p2.get_reference() + } + ``` + + +#### 3. Instantiate your GateSet with your channel mapping + +- Below shows an example of instantiating your `GateSet`, for basic group control of `VoltageGate` channels. + + ```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`. + + ```python + from quam_builder.architecture.quantum_dots.components import VirtualGateSet + + my_virtual_gate_set = VirtualGateSet(id="dot_plungers", channels=channels) + + ``` + +##### 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. + + ```python + # Add coarse tuning layer (virtual gates for overall dot positions) + my_virtual_gate_set.add_layer( + source_gates=["v_Coarse1", "v_Coarse2"], + target_gates=["channel_p1", "channel_p2"], #Your existing physical gates should be the target_gates of your first layer + matrix=[[1.0, 0.5], [0.5, 1.0]] # Coupled control + ) + + # Add fine tuning layer (virtual gates for precise adjustments) + my_virtual_gate_set.add_layer( + source_gates=["v_FineTune1", "v_FineTune2"], + target_gates=["v_Coarse1", "v_Coarse2"], + matrix=[[0.1, 0.0], [0.0, 0.1]] # Small adjustments + ) + ``` + +#### 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. + + ```python + my_virtual_gate_set.add_point(name="idle", voltages={"v_FineTune1": 0.1, "v_Coarse2": -0.05}, duration=1000) + ``` + +- With the default `keep_levels=True`, gates you omit in a call keep their last value (physical or virtual). To force 0 V on a gate, pass it explicitly (e.g. `"v_FineTune2": 0.0`) or use `new_sequence(keep_levels=False)`. See [§4](#important-behavior-level-holding-and-zeroing-semantics) and [§6.1](#61-virtual-gates-and-keeplevels). + +#### 5. Create a `VoltageSequence` from the `GateSet` or `VirtualGateSet` inside your QUA programme + +- `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: + voltage_seq = my_gate_set.new_sequence() + ``` + +- Or, if using the `VirtualGateSet`, + + ```python + with program() as complex_control: + voltage_seq = my_virtual_gate_set.new_sequence() + ``` + +#### 6. Create your QUA program with your `VoltageSequence` + +- Instantiate your new sequence in the QUA programme, and step/ramp to any point. + +- For a basic `GateSet`, + + ```python + with qua.program() as basic_control: + voltage_seq = my_gate_set.new_sequence() + voltage_seq.step_to_point("idle") # Step to pre-defined point "idle". ramp_to_point also valid, with a ramp_duration argument, also shown in the VirtualGateSet example + 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 + ``` + +- Use a `VirtualGateSet` for full control over virtual and physical points: + + ```python + # Create voltage sequence + with qua.program() as complex_control: + voltage_seq = my_virtual_gate_set.new_sequence() + + # Step to a virtual gate configuration + voltage_seq.step_to_voltages( + voltages={"v_FineTune1": 0.05, "v_FineTune2": -0.02}, + duration=500 + ) + + # Ramp to a predefined point + voltage_seq.ramp_to_point("readout", ramp_duration=100, duration=2000) + + # Combine virtual and physical control + voltage_seq.step_to_voltages( + voltages={ + "v_FineTune1": 0.1, # Virtual gate adjustment + "channel_p2": 0.3 # Direct physical gate control + }, + duration=1000 + ) + + # Ramp only v_FineTune2 to 0 V; other gates keep their levels (default keep_levels=True) + voltage_seq.ramp_to_voltages( + voltages={"v_FineTune2": 0.0}, + duration=500, + ramp_duration=40 # Must be a multiple of 4 ns + ) + + # Return to zero on all physical channels + voltage_seq.ramp_to_zero(ramp_duration=200) + ``` + + With `keep_levels=True` (default), each call only updates the gate names you list; all other physical and virtual names in the `VirtualGateSet` retain their previous values before resolution. Virtual layers then map the combined virtual + direct physical targets to physical outputs additively. + +## 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 + +- 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(...) + +- Voltage Sequences: Used in conjunction with VoltageSequence, a Sequence created in the GateSet allows you to quickly apply complex QUA commands to groups of gates, as well as keeping track of the gate voltages such that a compensating pulse can be applied later. + +**Key Features:** + +- Defines named voltage/duration presets using the `add_point()` method, which internally registers a `VoltageTuningPoint` in `GateSet.macros`. + +- `resolve_voltages()`: Ensures all channels in the `GateSet` have a defined voltage, defaulting to `0.0` V for any **physical** channel missing from the input dict. This is particularly useful when you specify voltages for only a subset of channels while ensuring every physical gate has a defined target. On a `VirtualGateSet`, this method is overridden: it first applies virtualization layers (virtual and physical names), then fills any remaining physical channels with `0.0` V. + + Every `VoltageSequence` step/ramp/point call invokes `resolve_voltages()` after optional level holding: + + - **`keep_levels=True` (default):** Omitted physical and virtual names are filled from the sequence’s `KeepLevels` tracker (last values) *before* `resolve_voltages()` runs. The method then completes the dict (zero-fill only for physical channels still missing after virtual resolution). + - **`keep_levels=False`:** No pre-fill; omitted names are treated as `0.0` V at resolution time (virtual layers use `0.0` for omitted source gates; `GateSet.resolve_voltages()` zero-fills omitted physical channels). + + You can also call `resolve_voltages()` directly on a `GateSet` or `VirtualGateSet` to inspect the effective target dict without playing pulses. + + **Example (`GateSet` only):** + + ```python + # Assume gate_set has channels: {"P1": channel_P1, "P2": channel_P2, "B1": channel_B1} + + # Only specify a partial subset; resolve_voltages fills un-named physical gates with 0.0 V + partial_voltages = {"P1": 0.3, "B1": -0.1} + complete_voltages = gate_set.resolve_voltages(partial_voltages) + # Result: {"P1": 0.3, "P2": 0.0, "B1": -0.1} + ``` + +- `adjust_for_attenuation` (bool, default `False`): When `True`, pulse amplitudes sent to the OPX are scaled to account for each channel’s `attenuation` (dB). Compensation pulse limits also respect the effective voltage at the sample. + +- `new_sequence(track_integrated_voltage=False, keep_levels=True, enforce_qua_calcs=False)`: Creates `VoltageSequence` instances. See [§4](#creating-a-voltagesequence) for parameter details. + +- While the tuning points can be defined dynamically within a program, it may be useful to predefine fixed tuning points, for example the readout point. This can be dded via `my_gate_set.add_point(name="...", voltages={...}, duration=...)`. + +- Internally this adds a **`VoltageTuningPoint` to GateSet.macros** + +## 4. `VoltageSequence` + +Generates QUA commands for voltage manipulation, associated with a `GateSet`. + +**Key Features:** + +- Translates high-level requests into QUA `play`, `ramp`, `wait`. + +- Tracks current voltage for each channel. + +- Optionally tracks integrated voltage for DC compensation (`track_integrated_voltage=True` in `new_sequence()`; default is `False`). + +- Supports Python numbers and QUA variables for levels/durations. + +- Target levels are rounded to 16-bit precision before play (`round_amplitude`) to match sticky-element accumulation. + +### Important Behavior: Level Holding and Zeroing Semantics + +**Recommended default: `keep_levels=True`** (set on `GateSet.new_sequence()` and `VoltageSequence`). + +Each `step_to_voltages`, `ramp_to_voltages`, `step_to_point`, or `ramp_to_point` call runs through: + +1. **`KeepLevels` (if `keep_levels=True`)** — For every name in `gate_set.valid_channel_names` (physical channels plus all virtual source gates on a `VirtualGateSet`), names you **omit** keep their last value from the previous call. Names you **include** are updated to the new value (use `0.0` to drive a gate to zero). +2. **`GateSet.resolve_voltages()` / `VirtualGateSet.resolve_voltages()`** — Virtual layers map virtual targets to physical contributions; any physical channel still missing after resolution is set to `0.0` V. + +**Example (physical gates, default `keep_levels=True`):** + +```python +voltage_seq = gate_set.new_sequence() # keep_levels=True by default +voltage_seq.step_to_voltages({"P1": 0.3}, duration=1000) # P2 held at 0.0 (initial) +voltage_seq.step_to_voltages({"P2": 0.1}, duration=1000) # P1 stays at 0.3 +voltage_seq.step_to_voltages({"P1": 0.0}, duration=500) # explicit 0 on P1; P2 stays at 0.1 +``` + +**Stateless mode: `keep_levels=False`** + +Omitted physical channels are filled with `0.0` V before play. On a `VirtualGateSet`, each virtualization layer treats omitted **source** virtual gates as `0.0` V for that call, so prior virtual contributions are cleared unless you list those gates again. Use this when every call should define the full effective state, or when you rely on `GateSet.resolve_voltages()` zero-fill semantics directly. + +```python +voltage_seq = gate_set.new_sequence(keep_levels=False) +voltage_seq.step_to_voltages({"P1": 0.3}, duration=1000) +voltage_seq.step_to_voltages({"P2": 0.1}, duration=1000) # P1 driven to 0.0 (omitted) +``` + +**QUA loops:** End inner loops with `ramp_to_zero()` (and `apply_compensation_pulse()` when tracking integrated voltage) so physical trackers and compensation state stay consistent across iterations. + +### Creating a `VoltageSequence` + +- The sequence must be created inside a QUA program (`with program() as ...`). + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `track_integrated_voltage` | `False` | Track ∫V·dt per physical channel for `apply_compensation_pulse()`. | +| `keep_levels` | `True` | Hold last voltage for omitted physical/virtual gate names (recommended). | +| `enforce_qua_calcs` | `False` | If `True`, promote per-channel `current_level` to a QUA `fixed` variable at init so level tracking stays correct when targets are QUA expressions. | + +```python +with program() as prog: + voltage_seq = my_gate_set.new_sequence( + track_integrated_voltage=True, # enable compensation pulses + keep_levels=True, # default; hold omitted gates + enforce_qua_calcs=False, + ) +``` + + +**Core Methods (used in `qua.program()` context):** + +- `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)` + 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)` + 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 + voltage_seq.step_to_point("idle") + voltage_seq.step_to_point("readout", duration=2000) # Override default duration + ``` + +- `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, reset_tracker: bool = False)` + Ramps all **physical** channels in the GateSet to 0 V. Does **not** reset integrated-voltage trackers unless `reset_tracker=True`. If `ramp_duration` is `None`, uses QUA’s built-in `ramp_to_zero` per element (using each channel’s sticky duration). If `ramp_duration` is set, uses `ramp_to_voltages` to 0 V on all physical channels. + + ```python + voltage_seq.ramp_to_zero() # QUA built-in ramp per channel + voltage_seq.ramp_to_zero(ramp_duration=100) + voltage_seq.ramp_to_zero(reset_tracker=True) # also zero integrated-voltage trackers + ``` + +- `apply_compensation_pulse(max_voltage: float = 0.05, go_to_zero: bool = True, return_to_zero: bool = True)` + Applies a compensation pulse on each physical channel to counteract integrated drift on AC-coupled lines. Requires `track_integrated_voltage=True` when the sequence was created. Resets integrated-voltage trackers after the pulse (including partial Python offsets when QUA variables were used). + + - `go_to_zero`: If `True`, step all tracked gates to 0 V before computing compensation (default `True`). + - `return_to_zero`: If `True`, step to zero and call `ramp_to_zero()` after compensation (default `True`). + - With `keep_levels=True`, the pre/post zeroing uses all names in the level tracker (physical and virtual). + + ```python + voltage_seq = gate_set.new_sequence(track_integrated_voltage=True) + voltage_seq.apply_compensation_pulse() # default max_voltage=0.05 V + voltage_seq.apply_compensation_pulse(max_voltage=0.03, go_to_zero=True, return_to_zero=True) + ``` + +- `simultaneous(duration=16, ramp_duration=None)` (context manager) + Batch several `step_to_voltages` / `ramp_to_voltages` calls so they execute on the same timeline. Updates are merged; one combined step or ramp runs on exit. + + ```python + with voltage_seq.simultaneous(duration=1000): + voltage_seq.step_to_voltages({"P1": 0.3}, duration=1000) # batched, not played yet + voltage_seq.step_to_voltages({"P2": 0.1}, duration=1000) + # Both channels step together when the block exits + ``` + +- `track_sticky_duration(duration_ns: int)` + Updates integrated-voltage trackers for the hold time at current levels **without** emitting pulses. Used when other macros run while sticky DC is non-zero (see macro duration contract below). + +### 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`. +A `VirtualGateSet` translates virtual gate operations into physical gate voltage changes, which are then applied using the `VoltageSequence` mechanisms. + + +## 6. VirtualGateSet + +A `VirtualGateSet` allows users to define and operate with virtual gates, abstracting the underlying physical gate operations. + +**Key Features:** + +- **Inherits from `GateSet`:** Retains all functionalities of `GateSet`, including physical channel management and `VoltageTuningPoint` definitions. +- **Manage Multiple Virtualization Layers:** Stores a list of `VirtualizationLayer` objects. Multiple layers can be defined and stacked, allowing for hierarchical virtualization. Layers are applied sequentially (in reverse order during voltage resolution) to translate top-level virtual gate voltages into physical gate voltages. +- **Add Layers:** Use `add_layer(source_gates, target_gates, matrix)` to define and append a new `VirtualizationLayer`: + - `source_gates`: Names of the new virtual gates defined by this layer. + - `target_gates`: Names of the gates (physical or virtual from a previous layer) that this layer maps onto. + - `matrix`: The transformation matrix (list of lists of floats). +- **Rectangular Matrices (Opt-In):** Set `allow_rectangular_matrices=True` on a `VirtualGateSet` to enable virtualization layers whose matrices are not square. These layers are resolved with the Moore–Penrose pseudo-inverse, allowing over- or under-complete virtual controls while keeping square layers unchanged. +- **Additive Voltage Resolution:** Overrides `GateSet.resolve_voltages()`. When voltages are specified for virtual gates (potentially across different layers) and/or physical gates simultaneously, this method applies the inverse of the virtualization matrices for each layer. Contributions from all specified virtual and physical gates are resolved and become additive at the physical gate level. Handles multi-layered virtualization by processing layers from the outermost to the innermost. + +### 6.1 Virtual Gates and `keep_levels` + +With the default **`keep_levels=True`**, virtual gate names are tracked the same way as physical channels: if you omit `v_C2` in a call, its last value is reused when resolving layers. To remove a virtual contribution, set it explicitly to `0.0` or use `new_sequence(keep_levels=False)`. + +**Default (`keep_levels=True`):** + +```python +voltage_seq = vgs.new_sequence() # virtual + physical names tracked + +voltage_seq.step_to_voltages({"v_C1": 0.2, "v_C2": 0.1}, duration=1000) +voltage_seq.step_to_voltages({"v_C1": 0.25}, duration=1000) +# v_C2 remains 0.1; physical targets include both virtual contributions +``` + +**Stateless (`keep_levels=False`):** + +```python +voltage_seq = vgs.new_sequence(keep_levels=False) + +voltage_seq.step_to_voltages({"v_C1": 0.2, "v_C2": 0.1}, duration=1000) +voltage_seq.step_to_voltages({"v_C1": 0.2}, duration=1000) +# v_C2 treated as 0 V for this call — prior virtual contribution cleared +``` + +`VirtualGateSet.resolve_voltages()` still applies each layer’s inverse matrix and **adds** contributions from virtual and direct physical entries in the combined dict (see [§7.4](#74-additive-voltage-contributions)). Level holding happens **before** that resolution. + + +## 7. VirtualizationLayer + +A `VirtualizationLayer` defines a single step in the virtual-to-physical gate voltage transformation. + +**Key Attributes:** + +- `source_gates` (`List[str]`): Names of the virtual gates defined in this layer. +- `target_gates` (`List[str]`): Names of the physical or underlying virtual gates this layer maps to. +- `matrix` (`List[List[float]]`): The virtualization matrix defining the linear transformation. The relationship is `V_source = M * V_target`. When resolving, `V_target = M_inverse * V_source` is used for this layer's contribution. +- Handles the calculation of the inverse matrix and the resolution of voltages for its specific layer. + +### Mathematical Relations + +### 7.1 Forward Transformation (Virtual to Physical) + +The core mathematical relationship for each virtualization layer is: + +``` +V_source = M * V_target +``` + +Where: + +- `V_source` is the vector of virtual gate voltages (source gates) +- `V_target` is the vector of target gate voltages (physical or lower-level virtual gates) +- `M` is the transformation matrix + +For example, with a 2x2 matrix: + +```python +matrix = [[1.0, 0.5], [0.5, 1.0]] +source_gates = ["v_Gate1", "v_Gate2"] +target_gates = ["P1", "P2"] +``` + +The relationship becomes: + +``` +[v_Gate1] [1.0 0.5] [P1] +[v_Gate2] = [0.5 1.0] [P2] +``` + +Expanded: + +- `v_Gate1 = 1.0 * P1 + 0.5 * P2` +- `v_Gate2 = 0.5 * P1 + 1.0 * P2` + +### 7.2 Inverse Transformation (Voltage Resolution) + +During voltage resolution, the system applies the inverse transformation: + +``` +V_target = M⁻¹ * V_source +``` + +The code implements this using `numpy.linalg.inv()` to calculate the inverse matrix. For each layer's resolution: + +```python +inverse_matrix = np.linalg.inv(matrix) +for target_gate, inv_matrix_row in zip(target_gates, inverse_matrix): + resolved_voltages[target_gate] += inv_matrix_row @ source_voltages +``` + +### 7.3 Multi-Layer Resolution + +For multiple virtualization layers, transformations are applied sequentially in reverse order. Consider two layers: + +**Layer 1:** `v_Coarse1, v_Coarse2 → P1, P2` + +``` +matrix_1 = [[1.0, 0.5], [0.5, 1.0]] +``` + +**Layer 2:** `v_Fine1, v_Fine2 → v_Coarse1, v_Coarse2` + +``` +matrix_2 = [[0.1, 0.0], [0.0, 0.1]] +``` + +The combined transformation is: + +``` +[P1] [1.0 0.5] [0.1 0.0] [v_Fine1] +[P2] = [0.5 1.0] [0.0 0.1] [v_Fine2] +``` + +Which gives the overall relationship: + +``` +[P1] [0.1 0.05] [v_Fine1] +[P2] = [0.05 0.1 ] [v_Fine2] +``` + +### 7.4 Additive Voltage Contributions + +The system supports additive contributions from different layers and direct physical gate control. If you specify: + +- `v_Fine1 = 1.0V` (from Layer 2) +- `v_Coarse1 = 0.2V` (from Layer 1) +- `P1 = 0.1V` (direct) + +The final voltage for P1 becomes: + +``` +P1_final = P1_direct + P1_from_v_Coarse1 + P1_from_v_Fine1 + = 0.1 + (1.0 * 0.2) + (1.0 * 0.1) + = 0.1 + 0.2 + 0.1 = 0.4V +``` + +### 7.5 Matrix Constraints and Rectangular Support + +For a valid virtualization layer: + +- The matrix dimensions must match the number of source and target gates: `matrix.shape == (len(source_gates), len(target_gates))`. +- By default, the matrix must be square and invertible (`det(M) ≠ 0`), and the layer uses `numpy.linalg.inv(matrix)` during resolution. +- If `VirtualGateSet.allow_rectangular_matrices` is set to `True`, non-square matrices are permitted. These layers store `use_pseudoinverse=True` and are resolved with `numpy.linalg.pinv(matrix)`, yielding the least-squares solution for tall matrices and the minimum-norm solution for wide matrices. +- Square matrices continue to use the true inverse even when rectangular layers are enabled, so existing behaviour and calibrations remain unchanged. + +### 7.6 Core Allocation and Performance + +**One core is dedicated to each physical gate**, regardless of the number of virtual gates or virtualization layers. This has important implications: + +- **Scalable Performance**: Adding virtual gates or virtualization layers doesn't increase the computational load on the QUA system +- **Real-Time Operation**: All matrix calculations are performed at compile time, not during execution +- **Predictable Resource Usage**: The number of cores required is determined solely by the number of physical channels + +For example, if you have 3 physical gates (`P1`, `P2`, `P3`) but 10 virtual gates across 3 virtualization layers, you still only need 3 cores - one for each physical gate. + +### 7.7 Matrix Calculation Example + +Consider a simple two-layer system: + +```python +# Layer 1: Virtual gates to physical gates +matrix_1 = [[1.0, 0.5], [0.5, 1.0]] # v_Coarse1, v_Coarse2 -> P1, P2 + +# Layer 2: Higher-level virtual gates to Layer 1 virtual gates +matrix_2 = [[0.1, 0.0], [0.0, 0.1]] # v_FineTune1, v_FineTune2 -> v_Coarse1, v_Coarse2 +``` + +When you set `v_FineTune1 = 0.1V`, the system: + +1. Applies inverse of matrix_2: `v_Coarse1 = 0.1V / 0.1 = 1.0V` +2. Applies inverse of matrix_1: `P1 = 1.0V * 1.0 + 0V * 0.5 = 1.0V`, `P2 = 1.0V * 0.5 + 0V * 1.0 = 0.5V` + +This transformation happens at compile time, so the QUA program only sees the final physical gate voltages. + +### 7.8 Verifying Rectangular Layers (Round Trip & Plotting) + +To validate a rectangular virtualization layer: + +1. Enable pseudo-inverse support: + + ```python + vgs = VirtualGateSet(id="plunger_set", channels=channels) + vgs.allow_rectangular_matrices = True + vgs.add_layer( + source_gates=["V_bias", "V_sym"], + target_gates=["P1", "P2", "P3"], + matrix=[[1.0, 0.0, 0.0], [0.0, 1.0, 1.0]], + ) + ``` + +2. Generate a set of virtual voltage samples, convert them to the “expected” physical voltages using the pseudo-inverse (`physical_expected = M_pinv @ source`), then resolve the same virtual voltages through the `VirtualGateSet`. The resolved physical voltages should match `physical_expected` within numerical tolerance. + +3. Optional visualisation: plot the original vs. resolved physical voltages to confirm they fall on the identity line. The automated regression `tests/architecture/quantum_dots/components/test_rectangular_virtual_gate_set.py::test_rectangular_roundtrip_visualisation` performs exactly this procedure (using Matplotlib’s Agg backend) so you can run `pytest` and inspect the generated scatter data if deeper debugging is needed. + +## 8. Full End to End Example + +### 8.1. Create your channels (VoltageGate or SingleChannel) + +```python + +from quam.components import ( + BasicQuam, + StickyChannelAddon, + pulses +) +from quam_builder.architecture.quantum_dots.components import VoltageGate + +machine = BasicQuam() + +# Define some VoltageGate channels that form your Quam Machine +machine.channels["ch1"] = VoltageGate( + opx_output=("con1", 1), # OPX controller and port + sticky=StickyChannelAddon(duration=1000, digital=False), + operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, +) +machine.channels["ch2"] = VoltageGate( + opx_output=("con1", 2), # OPX controller and port + sticky=StickyChannelAddon(duration=1000, digital=False), # For DC offsets + operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, +) +machine.channels["ch3"] = VoltageGate( + opx_output=("con1", 3), # OPX controller and port + sticky=StickyChannelAddon(duration=100000, digital=False), # For DC offsets + operations={"half_max_square": pulses.SquarePulse(amplitude=0.25, length=1000)}, +) + +``` + +### 8.2. Create channel dictionary and create your VirtualGateSet + +```python + +#Ensure that the naming convention is consistent here - "ch1" if it maps to machine.channels["ch1"] +channels = { + "ch1": machine.channels["ch1"].get_reference(), # .get_reference() necessary to avoid reparenting the Quam component + "ch2": machine.channels["ch2"].get_reference(), + "ch3": machine.channels["ch3"].get_reference(), +} + +from quam_builder.architecture.quantum_dots.components import VirtualGateSet # Requires quam-builder +machine.virtual_gate_set = VirtualGateSet(id = "Plungers", channels = channels) + +``` + +### 8.3. Add virtual gate layers + +```python +machine.virtual_gate_set.add_layer( + source_gates = ["V1", "V2"], # Pick the virtual gate names here + target_gates = ["ch1", "ch2"], # Must be a subset of gates in the gate_set + matrix = [[2,1],[0,1]] # Any example matrix +) +``` + +### 8.4. Add any relevant tuning points to your GateSet + +```python +#Some example points +machine.virtual_gate_set.add_point("init", {"ch1": -0.25, "ch3": 0.12}, duration = 10_000) +machine.virtual_gate_set.add_point("op", {"V1": 0.2, "V2": 0.1}, duration = 1000) +machine.virtual_gate_set.add_point("meas", {"ch3": -0.12}, duration = 3_000) + +``` + +### 8.5. Write QUA program + +```python +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") + my_new_seq.step_to_point("meas") +``` + +**What is happening here?** (default `keep_levels=True`; matrix `[[2, 1], [0, 1]]` for `V1`, `V2` → `ch1`, `ch2`) + +- **`init`** — Tuning point sets `ch1` and `ch3` only. Level tracker: `ch1=-0.25`, `ch2=0`, `ch3=0.12`, `V1=0`, `V2=0`. Physical outputs match those targets (no virtual contribution yet). + +- **`op`** — Tuning point sets `V1=0.2`, `V2=0.1` only. Level tracker keeps `ch1`, `ch2`, `ch3` from `init` unless you override them, and updates `V1`, `V2`. Resolution **adds** the virtual inverse contribution on top of the retained physical entries, e.g. `ch1` gets `-0.25` plus `0.05` from `(V1, V2)`, `ch2` gets `0` plus `0.1`, and **`ch3` stays at `0.12`** because it was not changed and `keep_levels` holds it. + +- **`meas`** — Sets `ch3=-0.12` only; `ch1`, `ch2`, `V1`, `V2` keep their previous tracked values, then layers resolve to updated physical targets. To return `ch1`/`ch2` to zero here, include them explicitly or call `ramp_to_zero()`. + +All targets are **absolute** sticky levels, not deltas. Enable `track_integrated_voltage=True` only when you need `apply_compensation_pulse()` on AC-coupled lines.