Skip to content

Pulse Generation API

The qubitos.pulsegen module provides the GRAPE (Gradient Ascent Pulse Engineering) optimizer for synthesizing high-fidelity quantum gate pulses.

Overview

Component Description
GrapeOptimizer Main optimization class
GrapeConfig Configuration dataclass
GrapeResult Optimization result
generate_pulse Convenience function
hamiltonians Hamiltonian construction utilities

Quick Start

from qubitos.pulsegen import generate_pulse

# Generate an X-gate pulse
result = generate_pulse("X", duration_ns=20, target_fidelity=0.999)

print(f"Converged: {result.converged}")
print(f"Fidelity: {result.fidelity:.6f}")
print(f"I envelope: {result.i_envelope[:5]}...")
print(f"Q envelope: {result.q_envelope[:5]}...")

Using GrapeOptimizer

For more control, use the GrapeOptimizer class directly:

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

# Configure optimization
config = GrapeConfig(
    num_time_steps=100,
    duration_ns=50,
    max_iterations=200,
    target_fidelity=0.999,
    learning_rate=1.0,
)

# Create optimizer
optimizer = GrapeOptimizer(config)

# Get target unitary
target = get_target_unitary("H")  # Hadamard gate

# Run optimization
result = optimizer.optimize(
    target_unitary=target,
    num_qubits=1,
)

With Custom Hamiltonians

from qubitos.pulsegen.hamiltonians import parse_pauli_string

# Define system Hamiltonian
H0 = parse_pauli_string("5.0 * Z0", num_qubits=1)
Hc = [
    parse_pauli_string("X0", num_qubits=1),
    parse_pauli_string("Y0", num_qubits=1),
]

result = optimizer.optimize(
    target_unitary=target,
    num_qubits=1,
    drift_hamiltonian=H0,
    control_hamiltonians=Hc,
)

Configuration Options

Essential Parameters

config = GrapeConfig(
    num_time_steps=100,      # Pulse discretization (more = finer control)
    duration_ns=50.0,        # Total pulse duration
    target_fidelity=0.999,   # Stop when reached
    max_iterations=1000,     # Maximum optimization steps
    learning_rate=1.0,       # Gradient ascent step size
)

Advanced Parameters

config = GrapeConfig(
    # ... essential parameters ...
    convergence_threshold=1e-8,  # Stop if progress < this
    max_amplitude=100.0,         # Maximum pulse amplitude (MHz)
    use_second_order=False,      # Enable GRAPE-II (experimental)
    regularization=0.0,          # L2 penalty on pulses
    random_seed=42,              # For reproducibility
)

Supported Gates

Single-Qubit Gates

Gate Description
X Pauli-X (NOT)
Y Pauli-Y
Z Pauli-Z
H Hadamard
SX √X gate
RX Rotation around X (requires angle)
RY Rotation around Y (requires angle)
RZ Rotation around Z (requires angle)

Two-Qubit Gates

Gate Description
CZ Controlled-Z
CNOT Controlled-NOT (CX)
ISWAP iSWAP gate

Custom Gates

import numpy as np

# Define custom unitary
custom_gate = np.array([
    [1, 0],
    [0, np.exp(1j * np.pi / 8)],
], dtype=np.complex128)

result = optimizer.optimize(
    target_unitary=custom_gate,
    num_qubits=1,
)

Optimization Callbacks

Monitor optimization progress:

def callback(iteration: int, fidelity: float) -> bool:
    print(f"Iter {iteration}: F = {fidelity:.6f}")
    # Return True to stop early
    return fidelity > 0.9999

result = optimizer.optimize(
    target_unitary=target,
    num_qubits=1,
    callback=callback,
)

Warm Starting

Use a previous result as starting point:

# First optimization
result1 = optimizer.optimize(target_unitary=X_gate, num_qubits=1)

# Warm start for similar gate
result2 = optimizer.optimize(
    target_unitary=Y_gate,
    num_qubits=1,
    initial_pulses=(result1.i_envelope, result1.q_envelope),
)

Hamiltonians Module

The hamiltonians submodule provides utilities for constructing quantum Hamiltonians.

Pauli String Parsing

from qubitos.pulsegen.hamiltonians import parse_pauli_string

# Single term
H = parse_pauli_string("X0", num_qubits=1)

# Multiple terms with coefficients
H = parse_pauli_string("5.0 * Z0 + 0.1 * X0", num_qubits=1)

# Multi-qubit
H = parse_pauli_string("0.01 * Z0 Z1", num_qubits=2)

Standard Unitaries

from qubitos.pulsegen.hamiltonians import get_target_unitary
import numpy as np

# Standard gates
X = get_target_unitary("X")
H = get_target_unitary("H")

# Rotation gates
RX_90 = get_target_unitary("RX", angle=np.pi/2)

# Two-qubit gates
CZ = get_target_unitary("CZ", num_qubits=2)

# Embedded in larger space
X_on_q1 = get_target_unitary("X", num_qubits=3, qubit_indices=[1])

Building Hamiltonians

from qubitos.pulsegen.hamiltonians import build_hamiltonian

H0, Hc = build_hamiltonian(
    drift="5.0 * Z0",
    controls=["X0", "Y0"],
    num_qubits=1,
)

API Reference

Main Classes

qubitos.pulsegen.grape.GrapeOptimizer

GrapeOptimizer(config: GrapeConfig | None = None)

GRAPE pulse optimizer.

Implements gradient ascent pulse engineering for quantum gate synthesis.

Initialize the optimizer.

Parameters:

Name Type Description Default
config GrapeConfig | None

Optimization configuration. Uses defaults if None.

None
Source code in src/qubitos/pulsegen/grape.py
def __init__(self, config: GrapeConfig | None = None):
    """Initialize the optimizer.

    Args:
        config: Optimization configuration. Uses defaults if None.
    """
    self.config = config or GrapeConfig()
    self._rng = np.random.default_rng(self.config.random_seed)

optimize

optimize(target_unitary: NDArray[complex128], num_qubits: int, drift_hamiltonian: NDArray[complex128] | None = None, control_hamiltonians: list[NDArray[complex128]] | None = None, initial_pulses: tuple[NDArray[float64], NDArray[float64]] | None = None, callback: Callable[[int, float], bool] | None = None) -> GrapeResult

Optimize pulses to implement a target unitary.

Parameters:

Name Type Description Default
target_unitary NDArray[complex128]

Target unitary matrix to implement

required
num_qubits int

Number of qubits

required
drift_hamiltonian NDArray[complex128] | None

Time-independent drift Hamiltonian (optional)

None
control_hamiltonians list[NDArray[complex128]] | None

List of control Hamiltonians for I and Q

None
initial_pulses tuple[NDArray[float64], NDArray[float64]] | None

Initial (I, Q) pulse envelopes (random if None)

None
callback Callable[[int, float], bool] | None

Called each iteration with (iteration, fidelity). Return True to stop optimization early.

None

Returns:

Type Description
GrapeResult

GrapeResult with optimized pulses and metrics

Raises:

Type Description
ValueError

If parameters are invalid

Source code in src/qubitos/pulsegen/grape.py
def optimize(
    self,
    target_unitary: NDArray[np.complex128],
    num_qubits: int,
    drift_hamiltonian: NDArray[np.complex128] | None = None,
    control_hamiltonians: list[NDArray[np.complex128]] | None = None,
    initial_pulses: tuple[NDArray[np.float64], NDArray[np.float64]] | None = None,
    callback: Callable[[int, float], bool] | None = None,
) -> GrapeResult:
    """Optimize pulses to implement a target unitary.

    Args:
        target_unitary: Target unitary matrix to implement
        num_qubits: Number of qubits
        drift_hamiltonian: Time-independent drift Hamiltonian (optional)
        control_hamiltonians: List of control Hamiltonians for I and Q
        initial_pulses: Initial (I, Q) pulse envelopes (random if None)
        callback: Called each iteration with (iteration, fidelity).
                 Return True to stop optimization early.

    Returns:
        GrapeResult with optimized pulses and metrics

    Raises:
        ValueError: If parameters are invalid
    """
    dim = 2**num_qubits
    n_steps = self.config.num_time_steps

    # Memory estimation and warning for large systems
    mem_est = _estimate_memory_bytes(dim, n_steps)
    if dim >= _LARGE_SYSTEM_DIM:
        logger.warning(
            "Large system: dim=%d (%d qubits), ~%.1f MB estimated peak memory. "
            "Consider reducing num_time_steps or using Rust GRAPE for >5 qubits.",
            dim,
            num_qubits,
            mem_est / 1e6,
        )

    # When a TimePoint with AWG config is provided, derive n_steps from
    # num_samples so each GRAPE step corresponds to one AWG sample.
    if self.config.duration is not None and self.config.duration.num_samples > 0:
        n_steps = self.config.duration.num_samples

    # Compute dt using the effective (AWG-quantized) duration
    dt = self.config.effective_duration_ns * 1e-9 / n_steps  # seconds

    # Additional safety check (should never trigger due to config validation)
    if dt < MIN_DT_SECONDS:
        logger.warning(
            f"Computed dt={dt:.2e}s is very small, clamping to {MIN_DT_SECONDS:.2e}s"
        )
        dt = MIN_DT_SECONDS

    # Validate target unitary
    if target_unitary.shape != (dim, dim):
        raise ValueError(
            f"Target unitary shape {target_unitary.shape} doesn't match "
            f"expected ({dim}, {dim}) for {num_qubits} qubits"
        )

    # Set up Hamiltonians
    if drift_hamiltonian is None:
        drift_hamiltonian = np.zeros((dim, dim), dtype=np.complex128)

    if control_hamiltonians is None:
        control_hamiltonians = self._default_control_hamiltonians(num_qubits)

    # Initialize pulses
    n_channels = len(control_hamiltonians) // 2  # Number of qubit channels
    if initial_pulses is not None:
        i_pulse, q_pulse = initial_pulses
    else:
        # Initialize with significant amplitude to avoid saddle point at U=I
        # For trace-zero targets like X, Y, CZ, the gradient vanishes when U~I
        # Starting with ~25% of max amplitude helps escape this saddle point
        init_amp = 0.25 * self.config.max_amplitude
        if n_channels > 1:
            # Multi-qubit: independent envelope per qubit (n_channels, n_steps)
            i_pulse = self._rng.uniform(-init_amp, init_amp, (n_channels, n_steps))
            q_pulse = self._rng.uniform(-init_amp, init_amp, (n_channels, n_steps))
        else:
            # Single-qubit: 1D array (backward compatible)
            i_pulse = self._rng.uniform(-init_amp, init_amp, n_steps)
            q_pulse = self._rng.uniform(-init_amp, init_amp, n_steps)

    # Optimization loop
    fidelity_history = []
    best_fidelity = 0.0
    best_i_pulse = i_pulse.copy()
    best_q_pulse = q_pulse.copy()

    for iteration in range(self.config.max_iterations):
        # Compute forward propagators
        propagators = self._compute_propagators(
            i_pulse, q_pulse, drift_hamiltonian, control_hamiltonians, dt
        )

        # Compute total unitary
        total_unitary = self._chain_propagators(propagators)

        # Compute fidelity (clamped to [0, 1])
        fidelity = self._gate_fidelity(total_unitary, target_unitary)
        fidelity_history.append(fidelity)

        # Update best
        if fidelity > best_fidelity:
            best_fidelity = fidelity
            best_i_pulse = i_pulse.copy()
            best_q_pulse = q_pulse.copy()

        # Check convergence
        if fidelity >= self.config.target_fidelity:
            logger.info(
                f"GRAPE converged at iteration {iteration} with fidelity {fidelity:.6f}"
            )
            return GrapeResult(
                i_envelope=best_i_pulse,
                q_envelope=best_q_pulse,
                fidelity=best_fidelity,
                iterations=iteration + 1,
                converged=True,
                fidelity_history=fidelity_history,
                final_unitary=total_unitary,
                duration=self.config.duration,
                awg_config=self.config.awg_config,
            )

        # Check for stagnation
        if len(fidelity_history) > 10:
            recent_improvement = fidelity_history[-1] - fidelity_history[-10]
            if abs(recent_improvement) < self.config.convergence_threshold:
                logger.info(
                    f"GRAPE stagnated at iteration {iteration} with fidelity {fidelity:.6f}"
                )
                break

        # Callback
        if callback is not None and callback(iteration, fidelity):
            logger.info(f"GRAPE stopped by callback at iteration {iteration}")
            break

        # Compute gradients
        grad_i, grad_q = self._compute_gradients(
            propagators, target_unitary, control_hamiltonians, dt, total_unitary
        )

        # Apply regularization
        if self.config.regularization > 0:
            grad_i -= self.config.regularization * i_pulse
            grad_q -= self.config.regularization * q_pulse

        # Update pulses (gradient ascent) with adaptive learning rate
        lr = self._adaptive_learning_rate(iteration, fidelity_history, dim)
        i_pulse += lr * grad_i
        q_pulse += lr * grad_q

        # Clip to amplitude bounds
        i_pulse = np.clip(i_pulse, -self.config.max_amplitude, self.config.max_amplitude)
        q_pulse = np.clip(q_pulse, -self.config.max_amplitude, self.config.max_amplitude)

        # Log progress
        if iteration % 100 == 0:
            logger.debug(f"Iteration {iteration}: fidelity = {fidelity:.6f}")

    # Return best result
    final_propagators = self._compute_propagators(
        best_i_pulse, best_q_pulse, drift_hamiltonian, control_hamiltonians, dt
    )
    final_unitary = self._chain_propagators(final_propagators)

    return GrapeResult(
        i_envelope=best_i_pulse,
        q_envelope=best_q_pulse,
        fidelity=best_fidelity,
        iterations=len(fidelity_history),
        converged=best_fidelity >= self.config.target_fidelity,
        fidelity_history=fidelity_history,
        final_unitary=final_unitary,
        duration=self.config.duration,
        awg_config=self.config.awg_config,
    )

qubitos.pulsegen.grape.GrapeConfig dataclass

GrapeConfig(num_time_steps: int = 100, duration_ns: float = 20.0, target_fidelity: float = 0.999, max_iterations: int = 1000, learning_rate: float = 1.0, convergence_threshold: float = 1e-08, max_amplitude: float = 100.0, use_second_order: bool = False, regularization: float = 0.0, random_seed: int | None = None, duration: TimePoint | None = None, awg_config: AWGClockConfig | None = None)

Configuration for GRAPE optimization.

Attributes:

Name Type Description
num_time_steps int

Number of time discretization steps (must be >= 1). When duration has an AWG config, num_samples is preferred.

duration_ns float

Total pulse duration in nanoseconds (must be > 0). DEPRECATED in favor of duration TimePoint.

target_fidelity float

Target gate fidelity (0 to 1)

max_iterations int

Maximum optimization iterations

learning_rate float

Initial learning rate for gradient ascent

convergence_threshold float

Stop when fidelity improvement < threshold

max_amplitude float

Maximum pulse amplitude (in MHz)

use_second_order bool

Use second-order (GRAPE-II) optimization

regularization float

L2 regularization strength for pulse smoothness

random_seed int | None

Random seed for reproducibility

duration TimePoint | None

TimePoint carrying nominal + quantized duration with precision/jitter metadata. When set, effective_duration_ns returns the AWG-quantized value and num_time_steps may be derived from duration.num_samples.

awg_config AWGClockConfig | None

AWG clock configuration. Stored on the result for provenance tracking.

effective_dt_seconds property

effective_dt_seconds: float

Time step in SI seconds for the GRAPE propagator.

effective_duration_ns property

effective_duration_ns: float

Return the AWG-quantized duration, or fall back to duration_ns.

__post_init__

__post_init__() -> None

Validate configuration parameters.

Source code in src/qubitos/pulsegen/grape.py
def __post_init__(self) -> None:
    """Validate configuration parameters."""
    if self.num_time_steps < MIN_TIME_STEPS:
        raise ValueError(
            f"num_time_steps must be >= {MIN_TIME_STEPS}, got {self.num_time_steps}"
        )
    if self.duration_ns <= 0:
        raise ValueError(f"duration_ns must be > 0, got {self.duration_ns}")
    if not 0 <= self.target_fidelity <= 1:
        raise ValueError(f"target_fidelity must be in [0, 1], got {self.target_fidelity}")
    if self.max_iterations < 1:
        raise ValueError(f"max_iterations must be >= 1, got {self.max_iterations}")
    if self.max_amplitude <= 0:
        raise ValueError(f"max_amplitude must be > 0, got {self.max_amplitude}")

qubitos.pulsegen.grape.GrapeResult dataclass

GrapeResult(i_envelope: NDArray[float64], q_envelope: NDArray[float64], fidelity: float, iterations: int, converged: bool, fidelity_history: list[float] = list(), final_unitary: NDArray[complex128] | None = None, duration: TimePoint | None = None, awg_config: AWGClockConfig | None = None)

Result of GRAPE optimization.

Attributes:

Name Type Description
i_envelope NDArray[float64]

Optimized I (in-phase) pulse envelope

q_envelope NDArray[float64]

Optimized Q (quadrature) pulse envelope

fidelity float

Achieved gate fidelity (clamped to [0, 1])

iterations int

Number of iterations performed

converged bool

Whether optimization converged

fidelity_history list[float]

Fidelity at each iteration

final_unitary NDArray[complex128] | None

The unitary implemented by the optimized pulse

duration TimePoint | None

Quantized duration used for this optimization (provenance).

awg_config AWGClockConfig | None

AWG clock config used for this optimization (provenance).

qubitos.target_unitary.TargetUnitary

Bases: Enum

Preset target unitaries for pulse optimization.

These correspond to common quantum gates. Each value matches the proto enum name (without the TARGET_UNITARY_ prefix) and can be used as a key into the TARGET_UNITARIES dict in hamiltonians.py to get the matrix.

Groups

Single-qubit fixed: I, X, Y, Z, H, SX, S, T Single-qubit parametric: RX, RY, RZ (require angle parameter) Two-qubit: CZ, CNOT, CX, ISWAP, SQISWAP, SWAP Three-qubit: TOFFOLI, CCX, FREDKIN, CSWAP Custom: CUSTOM (user-provided unitary matrix)

is_parametric property

is_parametric: bool

Whether this target unitary requires a rotation angle.

num_qubits property

num_qubits: int

Number of qubits this unitary acts on.

Returns 0 for UNSPECIFIED and CUSTOM (unknown without matrix).

Convenience Function

qubitos.pulsegen.grape.generate_pulse

generate_pulse(gate: str | TargetUnitary, num_qubits: int = 1, duration_ns: float = 20.0, target_fidelity: float = 0.999, qubit_indices: list[int] | None = None, config: GrapeConfig | None = None) -> GrapeResult

Generate an optimized pulse for a target unitary.

This is the main entry point for pulse generation using preset targets. For full control over the system Hamiltonian, use GrapeOptimizer.optimize() directly with explicit drift and control Hamiltonians.

Parameters:

Name Type Description Default
gate str | TargetUnitary

Target unitary name (e.g., "X", "CZ") or TargetUnitary enum.

required
num_qubits int

Number of qubits in the system

1
duration_ns float

Pulse duration in nanoseconds (must be > 0)

20.0
target_fidelity float

Target gate fidelity

0.999
qubit_indices list[int] | None

Indices of target qubits (default: [0] or [0,1])

None
config GrapeConfig | None

Advanced configuration options

None

Returns:

Type Description
GrapeResult

GrapeResult with optimized pulse envelopes

Raises:

Type Description
ValueError

If parameters are invalid

Example

result = generate_pulse("X", duration_ns=20, target_fidelity=0.999) result = generate_pulse(TargetUnitary.CZ, num_qubits=2)

Source code in src/qubitos/pulsegen/grape.py
def generate_pulse(
    gate: str | TargetUnitary,
    num_qubits: int = 1,
    duration_ns: float = 20.0,
    target_fidelity: float = 0.999,
    qubit_indices: list[int] | None = None,
    config: GrapeConfig | None = None,
) -> GrapeResult:
    """Generate an optimized pulse for a target unitary.

    This is the main entry point for pulse generation using preset targets.
    For full control over the system Hamiltonian, use GrapeOptimizer.optimize()
    directly with explicit drift and control Hamiltonians.

    Args:
        gate: Target unitary name (e.g., "X", "CZ") or TargetUnitary enum.
        num_qubits: Number of qubits in the system
        duration_ns: Pulse duration in nanoseconds (must be > 0)
        target_fidelity: Target gate fidelity
        qubit_indices: Indices of target qubits (default: [0] or [0,1])
        config: Advanced configuration options

    Returns:
        GrapeResult with optimized pulse envelopes

    Raises:
        ValueError: If parameters are invalid

    Example:
        >>> result = generate_pulse("X", duration_ns=20, target_fidelity=0.999)
        >>> result = generate_pulse(TargetUnitary.CZ, num_qubits=2)
    """
    from .hamiltonians import build_drift_hamiltonian, get_target_unitary

    # Convert string to enum
    if isinstance(gate, str):
        gate = TargetUnitary(gate.upper())

    # Set up configuration
    if config is None:
        config = GrapeConfig(
            duration_ns=duration_ns,
            target_fidelity=target_fidelity,
        )
    else:
        config.duration_ns = duration_ns
        config.target_fidelity = target_fidelity

    # Get target unitary
    target = get_target_unitary(gate, num_qubits, qubit_indices)

    # Build drift Hamiltonian for multi-qubit systems.
    # Use default qubit frequencies offset by 100 MHz to break degeneracy,
    # and a small ZZ coupling for entangling gates.
    # Users needing specific parameters should use GrapeOptimizer.optimize()
    # directly with explicit Hamiltonians.
    drift = None
    if num_qubits > 1:
        # Default frequencies: 5.0, 5.1, 5.2, ... GHz (100 MHz detuning)
        freqs = [5.0 + 0.1 * q for q in range(num_qubits)]

        # Default ZZ couplings: 5 MHz between nearest neighbors
        couplings: dict[tuple[int, int], float] = {}
        for q in range(num_qubits - 1):
            couplings[(q, q + 1)] = 5.0  # MHz

        drift = build_drift_hamiltonian(freqs, couplings)

    # Run optimization
    optimizer = GrapeOptimizer(config)
    result = optimizer.optimize(target, num_qubits, drift_hamiltonian=drift)

    return result

Hamiltonians

qubitos.pulsegen.hamiltonians.parse_pauli_string

parse_pauli_string(expression: str, num_qubits: int) -> NDArray[np.complex128]

Parse a Pauli string expression into a Hamiltonian matrix.

Format: "coeff1 * P1 P2 + coeff2 * P3 - coeff3 * P4 P5"

Parameters:

Name Type Description Default
expression str

Pauli string expression

required
num_qubits int

Number of qubits

required

Returns:

Type Description
NDArray[complex128]

Hamiltonian matrix

Example

H = parse_pauli_string("0.5 * X0 + 0.3 * Z0 Z1", num_qubits=2)

Source code in src/qubitos/pulsegen/hamiltonians.py
def parse_pauli_string(
    expression: str,
    num_qubits: int,
) -> NDArray[np.complex128]:
    """Parse a Pauli string expression into a Hamiltonian matrix.

    Format: "coeff1 * P1 P2 + coeff2 * P3 - coeff3 * P4 P5"

    Args:
        expression: Pauli string expression
        num_qubits: Number of qubits

    Returns:
        Hamiltonian matrix

    Example:
        >>> H = parse_pauli_string("0.5 * X0 + 0.3 * Z0 Z1", num_qubits=2)
    """
    dim = 2**num_qubits
    H = np.zeros((dim, dim), dtype=np.complex128)

    # Normalize expression
    expression = expression.replace("-", "+-")
    terms = [t.strip() for t in expression.split("+") if t.strip()]

    for term in terms:
        # Parse coefficient and operators
        if "*" in term:
            coeff_str, ops_str = term.split("*", 1)
            coeff = float(coeff_str.strip())
        else:
            # No coefficient means 1.0
            coeff = 1.0
            ops_str = term

        # Parse Pauli operators
        ops_str = ops_str.strip()
        if ops_str:
            matrix = pauli_string_to_matrix(ops_str, num_qubits)
            H += coeff * matrix

    return H

qubitos.pulsegen.hamiltonians.get_target_unitary

get_target_unitary(gate: str | TargetUnitary, num_qubits: int = 1, qubit_indices: list[int] | None = None, angle: float | None = None) -> NDArray[np.complex128]

Get the target unitary matrix for a quantum gate or preset name.

Parameters:

Name Type Description Default
gate str | TargetUnitary

Gate name string (e.g., "X", "CZ") or TargetUnitary enum.

required
num_qubits int

Total number of qubits in the system

1
qubit_indices list[int] | None

Which qubits the gate acts on (default: first qubit(s))

None
angle float | None

Rotation angle for parameterized gates (RX, RY, RZ)

None

Returns:

Type Description
NDArray[complex128]

Unitary matrix for the gate

Example

from qubitos.target_unitary import TargetUnitary X = get_target_unitary(TargetUnitary.X, num_qubits=1) X = get_target_unitary("X", num_qubits=1) # Also works RX = get_target_unitary("RX", num_qubits=1, angle=np.pi/2)

Source code in src/qubitos/pulsegen/hamiltonians.py
def get_target_unitary(
    gate: str | TargetUnitary,
    num_qubits: int = 1,
    qubit_indices: list[int] | None = None,
    angle: float | None = None,
) -> NDArray[np.complex128]:
    """Get the target unitary matrix for a quantum gate or preset name.

    Args:
        gate: Gate name string (e.g., "X", "CZ") or TargetUnitary enum.
        num_qubits: Total number of qubits in the system
        qubit_indices: Which qubits the gate acts on (default: first qubit(s))
        angle: Rotation angle for parameterized gates (RX, RY, RZ)

    Returns:
        Unitary matrix for the gate

    Example:
        >>> from qubitos.target_unitary import TargetUnitary
        >>> X = get_target_unitary(TargetUnitary.X, num_qubits=1)
        >>> X = get_target_unitary("X", num_qubits=1)  # Also works
        >>> RX = get_target_unitary("RX", num_qubits=1, angle=np.pi/2)
    """
    # Handle GateType enum
    gate_str: str = gate.value if hasattr(gate, "value") else gate
    gate_str = gate_str.upper()

    # Handle rotation gates
    if gate_str in ("RX", "RY", "RZ"):
        if angle is None:
            raise ValueError(f"{gate_str} requires an angle parameter")
        axis = gate_str[1]  # X, Y, or Z
        base_gate = rotation_gate(axis, angle)
    elif gate_str in STANDARD_GATES:
        base_gate = STANDARD_GATES[gate_str]
    else:
        raise ValueError(f"Unknown gate: {gate_str}")

    # Determine gate size
    gate_qubits = int(np.log2(base_gate.shape[0]))

    # Set default qubit indices
    if qubit_indices is None:
        qubit_indices = list(range(gate_qubits))

    if len(qubit_indices) != gate_qubits:
        raise ValueError(
            f"Gate {gate} acts on {gate_qubits} qubits, but {len(qubit_indices)} indices provided"
        )

    # If system size matches gate size, return directly
    if num_qubits == gate_qubits:
        return base_gate

    # Otherwise, embed gate in larger Hilbert space
    return embed_gate(base_gate, num_qubits, qubit_indices)

qubitos.pulsegen.hamiltonians.build_hamiltonian

build_hamiltonian(drift: str | NDArray[complex128] | None = None, controls: list[str] | list[NDArray[complex128]] | None = None, num_qubits: int = 1) -> tuple[NDArray[np.complex128], list[NDArray[np.complex128]]]

Build drift and control Hamiltonians.

Parameters:

Name Type Description Default
drift str | NDArray[complex128] | None

Drift Hamiltonian (Pauli string or matrix)

None
controls list[str] | list[NDArray[complex128]] | None

List of control Hamiltonians

None
num_qubits int

Number of qubits

1

Returns:

Type Description
tuple[NDArray[complex128], list[NDArray[complex128]]]

Tuple of (drift_hamiltonian, control_hamiltonians)

Source code in src/qubitos/pulsegen/hamiltonians.py
def build_hamiltonian(
    drift: str | NDArray[np.complex128] | None = None,
    controls: list[str] | list[NDArray[np.complex128]] | None = None,
    num_qubits: int = 1,
) -> tuple[NDArray[np.complex128], list[NDArray[np.complex128]]]:
    """Build drift and control Hamiltonians.

    Args:
        drift: Drift Hamiltonian (Pauli string or matrix)
        controls: List of control Hamiltonians
        num_qubits: Number of qubits

    Returns:
        Tuple of (drift_hamiltonian, control_hamiltonians)
    """
    dim = 2**num_qubits

    # Process drift
    if drift is None:
        H0 = np.zeros((dim, dim), dtype=np.complex128)
    elif isinstance(drift, str):
        H0 = parse_pauli_string(drift, num_qubits)
    else:
        H0 = drift

    # Process controls
    if controls is None:
        # Default: X and Y on each qubit
        Hc = []
        for q in range(num_qubits):
            Hc.append(pauli_string_to_matrix(f"X{q}", num_qubits))
            Hc.append(pauli_string_to_matrix(f"Y{q}", num_qubits))
    else:
        Hc = []
        for ctrl in controls:
            if isinstance(ctrl, str):
                Hc.append(parse_pauli_string(ctrl, num_qubits))
            else:
                Hc.append(ctrl)

    return H0, Hc

qubitos.pulsegen.hamiltonians.rotation_gate

rotation_gate(axis: str, angle: float) -> NDArray[np.complex128]

Generate a rotation gate around a Pauli axis.

R_P(theta) = exp(-i * theta/2 * P) = cos(theta/2) * I - i * sin(theta/2) * P

Parameters:

Name Type Description Default
axis str

Rotation axis ("X", "Y", or "Z")

required
angle float

Rotation angle in radians

required

Returns:

Type Description
NDArray[complex128]

2x2 rotation matrix

Source code in src/qubitos/pulsegen/hamiltonians.py
def rotation_gate(
    axis: str,
    angle: float,
) -> NDArray[np.complex128]:
    """Generate a rotation gate around a Pauli axis.

    R_P(theta) = exp(-i * theta/2 * P)
               = cos(theta/2) * I - i * sin(theta/2) * P

    Args:
        axis: Rotation axis ("X", "Y", or "Z")
        angle: Rotation angle in radians

    Returns:
        2x2 rotation matrix
    """
    c = np.cos(angle / 2)
    s = np.sin(angle / 2)

    if axis.upper() == "X":
        return np.array(
            [
                [c, -1j * s],
                [-1j * s, c],
            ],
            dtype=np.complex128,
        )
    elif axis.upper() == "Y":
        return np.array(
            [
                [c, -s],
                [s, c],
            ],
            dtype=np.complex128,
        )
    elif axis.upper() == "Z":
        return np.array(
            [
                [c - 1j * s, 0],
                [0, c + 1j * s],
            ],
            dtype=np.complex128,
        )
    else:
        raise ValueError(f"Unknown rotation axis: {axis}")

qubitos.pulsegen.hamiltonians.tensor_product

tensor_product(operators: list[NDArray[complex128]]) -> NDArray[np.complex128]

Compute tensor product of a list of operators.

Parameters:

Name Type Description Default
operators list[NDArray[complex128]]

List of square matrices (typically 2x2).

required

Returns:

Type Description
NDArray[complex128]

Tensor product matrix.

Raises:

Type Description
ValueError

If operators list is empty.

Source code in src/qubitos/pulsegen/hamiltonians.py
def tensor_product(operators: list[NDArray[np.complex128]]) -> NDArray[np.complex128]:
    """Compute tensor product of a list of operators.

    Args:
        operators: List of square matrices (typically 2x2).

    Returns:
        Tensor product matrix.

    Raises:
        ValueError: If operators list is empty.
    """
    if not operators:
        raise ValueError("tensor_product requires at least one operator (got empty list)")
    result = operators[0]
    for op in operators[1:]:
        result = np.kron(result, op)  # type: ignore[assignment]
    return result

qubitos.pulsegen.hamiltonians.embed_gate

embed_gate(gate: NDArray[complex128], num_qubits: int, qubit_indices: list[int]) -> NDArray[np.complex128]

Embed a gate in a larger Hilbert space.

Parameters:

Name Type Description Default
gate NDArray[complex128]

Gate unitary matrix

required
num_qubits int

Total number of qubits

required
qubit_indices list[int]

Which qubits the gate acts on

required

Returns:

Type Description
NDArray[complex128]

Embedded gate matrix

Source code in src/qubitos/pulsegen/hamiltonians.py
def embed_gate(
    gate: NDArray[np.complex128],
    num_qubits: int,
    qubit_indices: list[int],
) -> NDArray[np.complex128]:
    """Embed a gate in a larger Hilbert space.

    Args:
        gate: Gate unitary matrix
        num_qubits: Total number of qubits
        qubit_indices: Which qubits the gate acts on

    Returns:
        Embedded gate matrix
    """
    dim = 2**num_qubits

    # Build the full unitary
    result = np.zeros((dim, dim), dtype=np.complex128)

    for i in range(dim):
        for j in range(dim):
            # Extract the bits for the gate qubits
            i_gate = 0
            j_gate = 0
            for k, q in enumerate(qubit_indices):
                i_gate |= ((i >> q) & 1) << k
                j_gate |= ((j >> q) & 1) << k

            # Check if non-gate qubits match
            i_other = i
            j_other = j
            for q in qubit_indices:
                i_other &= ~(1 << q)
                j_other &= ~(1 << q)

            if i_other == j_other:
                result[i, j] = gate[i_gate, j_gate]

    return result

Constants

from qubitos.pulsegen.hamiltonians import (
    PAULI_I,    # 2x2 identity
    PAULI_X,    # Pauli X matrix
    PAULI_Y,    # Pauli Y matrix
    PAULI_Z,    # Pauli Z matrix
    STANDARD_GATES,  # Dict of standard gate matrices
)