Skip to content

Pulse Generation Tutorial

This tutorial covers the complete pulse generation workflow in QubitOS, from defining your physical system to executing optimized pulses on quantum backends.

Prerequisites


Pulse Generation from Hamiltonians

In QubitOS, every pulse is generated by optimizing control amplitudes against a physical Hamiltonian. The system evolves under:

\[ H(t) = H_0 + \sum_k u_k(t) H_k \]

The optimizer (GRAPE) finds amplitudes \(u_k(t)\) that make the resulting time evolution match your target unitary.

Defining Your System

from qubitos.pulsegen import GrapeOptimizer, GrapeConfig
from qubitos.pulsegen.hamiltonians import build_hamiltonian, get_target_unitary

# Define a transmon qubit: drift = qubit frequency, controls = X/Y drives
H0, Hc = build_hamiltonian(
    drift="5.0 * Z0",        # 5 GHz qubit frequency
    controls=["X0", "Y0"],   # Microwave drive channels
    num_qubits=1,
)

# Target: X gate (bit-flip)
target = get_target_unitary("X", num_qubits=1)

Optimizing the Pulse

config = GrapeConfig(
    num_time_steps=100,    # Pulse discretization
    duration_ns=50,        # Total pulse length
    max_iterations=200,    # Optimization budget
    target_fidelity=0.999, # Fidelity threshold
)

optimizer = GrapeOptimizer(config)
result = optimizer.optimize(target, num_qubits=1)

# Step 4: Inspect the result
print(f"Converged: {result.converged}")
print(f"Fidelity: {result.fidelity:.6f}")
print(f"I envelope: {result.i_envelope[:5]}...")  # First 5 samples
print(f"Q envelope: {result.q_envelope[:5]}...")

Understanding the Result

The GrapeResult object contains:

Attribute Type Description
i_envelope np.ndarray In-phase pulse samples
q_envelope np.ndarray Quadrature pulse samples
fidelity float Achieved gate fidelity
converged bool Whether target fidelity was reached
iterations int Number of optimization steps
fidelity_history list[float] Fidelity at each iteration
final_unitary np.ndarray \| None Unitary implemented by optimized pulse

Executing Pulses on Backends

Once you have a pulse, execute it on a quantum backend:

Synchronous Execution

from qubitos.client import HALClientSync

# Connect and execute
with HALClientSync("localhost:50051") as client:
    result = client.execute_pulse(
        i_envelope=pulse.i_envelope.tolist(),
        q_envelope=pulse.q_envelope.tolist(),
        duration_ns=50,
        target_qubits=[0],
        num_shots=1024,
    )

print(f"Measurements: {result.bitstring_counts}")

Asynchronous Execution

For higher throughput:

import asyncio
from qubitos.client import HALClient

async def run_experiment():
    async with HALClient("localhost:50051") as client:
        result = await client.execute_pulse(
            i_envelope=pulse.i_envelope.tolist(),
            q_envelope=pulse.q_envelope.tolist(),
            duration_ns=50,
            target_qubits=[0],
            num_shots=1024,
        )
    return result

result = asyncio.run(run_experiment())

Pulse Configuration Options

Time Discretization

The number of time steps affects pulse resolution and optimization speed:

# Coarse (fast optimization, less accurate)
config = GrapeConfig(num_time_steps=50)

# Fine (slower, more accurate)
config = GrapeConfig(num_time_steps=200)

Rule of thumb

Use num_time_steps = duration_ns * 2 for good balance.

Duration

Pulse duration trades off speed vs. fidelity:

# Fast gate (may have lower fidelity)
config = GrapeConfig(duration_ns=20)

# Slow gate (easier to achieve high fidelity)
config = GrapeConfig(duration_ns=100)

Amplitude Constraints

Limit pulse amplitudes to hardware-safe values:

config = GrapeConfig(
    max_amplitude=1.0,  # Maximum |Ω| value (MHz)
)

Gate Presets

QubitOS provides preset target unitaries for common gates. These are shortcuts — under the hood, each is a specific target unitary that the optimizer evolves toward:

Gate Matrix Description
X \(\begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix}\) Pauli-X (NOT)
Y \(\begin{pmatrix} 0 & -i \\ i & 0 \end{pmatrix}\) Pauli-Y
Z \(\begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix}\) Pauli-Z
H \(\frac{1}{\sqrt{2}}\begin{pmatrix} 1 & 1 \\ 1 & -1 \end{pmatrix}\) Hadamard
S \(\begin{pmatrix} 1 & 0 \\ 0 & i \end{pmatrix}\) Phase gate
T \(\begin{pmatrix} 1 & 0 \\ 0 & e^{i\pi/4} \end{pmatrix}\) T gate

Generating Different Gates

from qubitos.pulsegen.hamiltonians import get_target_unitary

# Generate various gates
for gate_name in ["X", "Y", "Z", "H"]:
    target = get_target_unitary(gate_name, num_qubits=1)
    result = optimizer.optimize(target, num_qubits=1)
    print(f"{gate_name} gate: fidelity = {result.fidelity:.4f}")

Visualizing Pulses

Basic Plot

import matplotlib.pyplot as plt
import numpy as np

def plot_pulse(result, title="Pulse Envelope"):
    t = np.linspace(0, config.duration_ns, len(result.i_envelope))

    fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=True)

    axes[0].plot(t, result.i_envelope, 'b-', linewidth=1.5)
    axes[0].set_ylabel('I Amplitude')
    axes[0].set_title(title)
    axes[0].grid(True, alpha=0.3)

    axes[1].plot(t, result.q_envelope, 'r-', linewidth=1.5)
    axes[1].set_ylabel('Q Amplitude')
    axes[1].set_xlabel('Time (ns)')
    axes[1].grid(True, alpha=0.3)

    plt.tight_layout()
    return fig

# Usage
fig = plot_pulse(result, title="X-Gate Pulse")
plt.show()

Comparing Multiple Gates

from qubitos.pulsegen.hamiltonians import get_target_unitary

fig, axes = plt.subplots(2, 4, figsize=(15, 6))

gates = ["X", "Y", "Z", "H"]
for i, gate_name in enumerate(gates):
    target = get_target_unitary(gate_name, num_qubits=1)
    result = optimizer.optimize(target, num_qubits=1)
    t = np.linspace(0, 50, len(result.i_envelope))

    axes[0, i].plot(t, result.i_envelope, 'b-')
    axes[0, i].set_title(f"{gate_name} gate (I)")
    axes[1, i].plot(t, result.q_envelope, 'r-')
    axes[1, i].set_title(f"{gate_name} gate (Q)")

plt.tight_layout()
plt.show()

Saving and Loading Pulses

Save to JSON

import json

def save_pulse(result, filename):
    data = {
        "i_envelope": result.i_envelope.tolist(),
        "q_envelope": result.q_envelope.tolist(),
        "fidelity": result.fidelity,
        "converged": result.converged,
        "iterations": result.iterations,
    }
    with open(filename, 'w') as f:
        json.dump(data, f, indent=2)

save_pulse(result, "x_gate_pulse.json")

Load from JSON

def load_pulse(filename):
    with open(filename, 'r') as f:
        return json.load(f)

pulse_data = load_pulse("x_gate_pulse.json")

Using CLI

# Generate and save
qubit-os pulse generate --gate X --duration 50 -o x_gate.json

# Execute from file
qubit-os pulse execute x_gate.json --shots 1024

Advanced Topics

Batch Pulse Generation

Generate multiple pulses efficiently:

from qubitos.pulsegen.hamiltonians import get_target_unitary

gates = ["X", "Y", "Z", "H", "S", "T"]
pulses = {}

for gate_name in gates:
    target = get_target_unitary(gate_name, num_qubits=1)
    result = optimizer.optimize(target, num_qubits=1)
    if result.converged:
        pulses[gate_name] = result
        print(f"[PASS] {gate_name}: fidelity = {result.fidelity:.4f}")
    else:
        print(f"[FAIL] {gate_name}: did not converge")

Random Initial Guess

Different starting points can help find better solutions:

import numpy as np

# Try multiple random initializations
from qubitos.pulsegen.hamiltonians import get_target_unitary

target = get_target_unitary("H", num_qubits=1)
best_result = None
for seed in range(5):
    config = GrapeConfig(num_time_steps=100, duration_ns=50, random_seed=seed)
    optimizer = GrapeOptimizer(config)
    result = optimizer.optimize(target, num_qubits=1)
    if best_result is None or result.fidelity > best_result.fidelity:
        best_result = result

print(f"Best fidelity: {best_result.fidelity:.6f}")

Next Steps