Skip to content

API Reference

A high-level Python API is provided for the programmatic execution of quantum control optimizations. The principal interface is the CtrlFreeQAPI class, defined in the ctrl_freeq.api module, which encapsulates the complete workflow from configuration loading through optimization to result retrieval.


Quick Start

from ctrl_freeq.api import CtrlFreeQAPI, load_single_qubit_config

# Load a built-in example configuration
api = load_single_qubit_config()

# View configuration summary
print(api.get_config_summary())

# Run optimization (returns optimized pulse parameters as a torch.Tensor)
solution = api.run_optimization()

# Access results stored on the parameters object
print(f"Final fidelity: {api.parameters.final_fidelity}")
print(f"Iterations: {api.parameters.iterations}")

CtrlFreeQAPI Class

The CtrlFreeQAPI class serves as the primary interface for interacting with the ctrl-freeq optimization framework.

Constructor

CtrlFreeQAPI(config: Union[str, Path, Dict[str, Any]], hamiltonian_model=None)

An API instance may be created from a path to a JSON configuration file (provided as a string or Path object) or from a dictionary containing the configuration parameters directly. An optional hamiltonian_model argument allows a pre-built HamiltonianModel instance to be injected directly, bypassing the registry lookup.

Parameters:

Parameter Type Description
config str, Path, or dict Configuration source
hamiltonian_model HamiltonianModel or None Optional pre-built model instance (overrides hamiltonian_type in config)

Examples:

from ctrl_freeq.api import CtrlFreeQAPI

api = CtrlFreeQAPI("path/to/config.json")
from ctrl_freeq.api import CtrlFreeQAPI

config = {
    "qubits": ["q1"],
    "hamiltonian_type": "spin_chain",  # or "superconducting"
    "compute_resource": "cpu",
    "parameters": {
        "Delta": [0.0],
        "Omega_R_max": [10000.0],
        "pulse_duration": [0.001],
        "point_in_pulse": [100],
        # ... other parameters
    },
    "initial_states": [["Z"]],
    "target_states": {"Axis": [["-Z"]]},
    "optimization": {
        "space": "hilbert",
        "algorithm": "bfgs",
        "max_iter": 100,
        "targ_fid": 0.99
    }
}

api = CtrlFreeQAPI(config)
from ctrl_freeq.api import load_single_qubit_config

api = load_single_qubit_config()

Methods

run_optimization()

Executes the quantum control optimization with the loaded configuration.

solution = api.run_optimization()

Returns: torch.Tensor — The optimized pulse parameters.

Upon completion of the optimization, the following attributes are stored on api.parameters:

Attribute Type Description
final_fidelity float Physical fidelity of the returned solution
final_penalty float Amplitude penalty of the returned solution
final_score float Penalized score, final_fidelity - final_penalty
iterations int Number of iterations performed
fidelity_history list[float] Physical fidelity at each iteration
penalty_history list[float] Amplitude penalty at each iteration
score_history list[float] Penalized score at each iteration
objective_mode str "gate" or "state_transfer" — see Objectives and Fidelity
amplitude_report list[dict] Per-qubit peak amplitude against Omega_R_max

Final metrics are evaluated on the solution that is returned, not on the optimizer's last trial point.

Example:

from ctrl_freeq.api import load_single_qubit_config

api = load_single_qubit_config()
solution = api.run_optimization()

# Access results from the parameters object
print(f"Final fidelity: {api.parameters.final_fidelity}")
print(f"Penalty: {api.parameters.final_penalty}")
print(f"Penalized score: {api.parameters.final_score}")
print(f"Iterations: {api.parameters.iterations}")
print(f"Fidelity history: {api.parameters.fidelity_history}")

fidelity_history semantics changed in 0.4.0

It previously stored fidelity - penalty. It now stores the physical fidelity; the penalized series is score_history.


get_config_summary()

Returns a human-readable summary of the current configuration.

summary = api.get_config_summary()

Returns: str — A formatted string containing the number of qubits, optimization space (Hilbert/Liouville), algorithm name, maximum iterations, target fidelity, and the initial and target states.

Example:

from ctrl_freeq.api import load_two_qubit_config

api = load_two_qubit_config()
print(api.get_config_summary())

Output:

Number of qubits: 2
Optimization space: hilbert
Algorithm: newton-cg
Max iterations: 1000
Target fidelity: 0.999
Initial states: [['Z', '-Z']]
Target Gate: ['CNOT']


update_parameter(parameter_path, value)

Updates a specific parameter in the configuration and reinitializes the internal state accordingly.

api.update_parameter(parameter_path: str, value: Any)

Parameters:

Parameter Type Description
parameter_path str Dot-separated path to the parameter
value Any New value for the parameter

Raises: KeyError if the parameter path is not found.

Example:

from ctrl_freeq.api import load_single_qubit_config

api = load_single_qubit_config()

# Update optimization settings
api.update_parameter("optimization.max_iter", 200)
api.update_parameter("optimization.algorithm", "qiskit-cobyla")
api.update_parameter("optimization.targ_fid", 0.999)

# Update pulse parameters
api.update_parameter("parameters.pulse_duration", [0.002])

# Run with updated configuration
solution = api.run_optimization()

Common parameter paths:

Path Description
hamiltonian_type spin_chain or superconducting
optimization.algorithm Optimization algorithm
optimization.max_iter Maximum iterations
optimization.targ_fid Target penalized score (fidelity − penalty) at which to stop
optimization.space hilbert or liouville
optimization.dissipation_mode non-dissipative or dissipative
parameters.Delta Detuning / qubit frequency (Hz)
parameters.Omega_R_max Maximum Rabi frequency / drive amplitude (Hz)
parameters.pulse_duration Pulse duration (seconds)
parameters.point_in_pulse Discretization points
parameters.T1 Amplitude damping time (seconds)
parameters.T2 Pure dephasing time (seconds)
compute_resource cpu or gpu

Properties

config

The raw configuration dictionary.

raw_config = api.config

config_path

The path to the configuration file, if the instance was created from a file; otherwise None.

path = api.config_path

parameters

The initialized parameter object used internally by the optimization engine.

params = api.parameters

Helper Functions

run_from_config(config, hamiltonian_model=None)

A convenience function that creates a CtrlFreeQAPI instance and executes the optimization in a single call.

from ctrl_freeq.api import run_from_config

solution = run_from_config("path/to/config.json")

Parameters:

Parameter Type Description
config str, Path, or dict Configuration source
hamiltonian_model HamiltonianModel or None Optional pre-built model instance

Returns: torch.Tensor — The optimized pulse parameters.

Example with dictionary:

from ctrl_freeq.api import run_from_config

solution = run_from_config({
    "qubits": ["q1"],
    "compute_resource": "cpu",
    "parameters": { ... },
    "initial_states": [["Z"]],
    "target_states": {"Axis": [["-Z"]]},
    "optimization": {
        "space": "hilbert",
        "algorithm": "bfgs",
        "max_iter": 100,
        "targ_fid": 0.99
    }
})

load_config(config_path)

Loads a JSON configuration file and returns a CtrlFreeQAPI instance.

from ctrl_freeq.api import load_config

api = load_config("path/to/config.json")

Parameters:

Parameter Type Description
config_path str or Path Path to JSON configuration file

Returns: CtrlFreeQAPI instance.


Built-in Example Configurations

Several pre-configured examples are bundled with the package for common use cases. These correspond to JSON files located under src/ctrl_freeq/data/json_input/.

Single-Qubit Configurations

load_single_qubit_config()

A basic single-qubit optimization configuration.

from ctrl_freeq.api import load_single_qubit_config

api = load_single_qubit_config()
solution = api.run_optimization()

load_single_qubit_multiple_config()

A single-qubit configuration with multiple initial/target state pairs for universal rotation design.

from ctrl_freeq.api import load_single_qubit_multiple_config

api = load_single_qubit_multiple_config()

load_single_qubit_polar_phase_config()

A single-qubit configuration using the polar/phase waveform mode.

from ctrl_freeq.api import load_single_qubit_polar_phase_config

api = load_single_qubit_polar_phase_config()

load_single_qubit_dissipative_config()

A single-qubit configuration with dissipative (Lindblad) dynamics enabled. The evolution includes amplitude damping (T1 = 1 ms) and pure dephasing (T2 = 500 μs), and the optimization is performed in Liouville (density matrix) space.

from ctrl_freeq.api import load_single_qubit_dissipative_config

api = load_single_qubit_dissipative_config()

Two-Qubit Configurations

load_two_qubit_config()

A basic two-qubit optimization configuration with inter-qubit coupling.

from ctrl_freeq.api import load_two_qubit_config

api = load_two_qubit_config()

load_two_qubit_multiple_config()

A two-qubit configuration with multiple initial/target state pairs.

from ctrl_freeq.api import load_two_qubit_multiple_config

api = load_two_qubit_multiple_config()

load_two_qubit_polar_phase_config()

A two-qubit configuration using the polar/phase waveform mode.

from ctrl_freeq.api import load_two_qubit_polar_phase_config

api = load_two_qubit_polar_phase_config()

Multi-Qubit Configurations

load_four_qubit_polar_phase_config()

A four-qubit optimization configuration using the polar/phase waveform mode.

from ctrl_freeq.api import load_four_qubit_polar_phase_config

api = load_four_qubit_polar_phase_config()

Complete Workflow Example

The following example illustrates a complete optimization workflow, from configuration loading through parameter customization, optimization, and result visualization:

from ctrl_freeq.api import CtrlFreeQAPI, load_single_qubit_config
from ctrl_freeq.visualisation.plotter import process_and_plot
from ctrl_freeq.visualisation.dashboard import create_dashboard
from datetime import datetime

# 1. Load configuration
api = load_single_qubit_config()

# 2. Customize parameters
api.update_parameter("optimization.algorithm", "qiskit-cobyla")
api.update_parameter("optimization.max_iter", 150)
api.update_parameter("optimization.targ_fid", 0.995)

# 3. Review configuration
print(api.get_config_summary())

# 4. Run optimization
solution = api.run_optimization()

# 5. Check results
print(f"Final fidelity: {api.parameters.final_fidelity}")
print(f"Iterations: {api.parameters.iterations}")

# 6. Generate plots
# process_and_plot takes the solution tensor and the parameters object
waveforms, figures = process_and_plot(solution, api.parameters, save_plots=True)

# 7. Create dashboard (pass parameters for a richer sidebar)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dashboard_path = create_dashboard(figures, timestamp, parameters=api.parameters)
print(f"Dashboard saved to: {dashboard_path}")

Dissipative (Lindblad) Workflow Example

The following example demonstrates a complete optimization workflow for an open quantum system subject to amplitude damping and pure dephasing. The Lindblad master equation is employed to model the dissipative channels, and the optimization is performed in Liouville (density matrix) space.

from ctrl_freeq.api import load_single_qubit_dissipative_config
from ctrl_freeq.visualisation.plotter import process_and_plot

# Load the pre-configured dissipative example
api = load_single_qubit_dissipative_config()

# Review configuration (includes T1/T2 information)
print(api.get_config_summary())

# Run optimization
solution = api.run_optimization()

# Check results
print(f"Final fidelity: {api.parameters.final_fidelity}")
print(f"Iterations: {api.parameters.iterations}")

# Generate plots
waveforms, figures = process_and_plot(solution, api.parameters, show_plots=True)
from ctrl_freeq.api import CtrlFreeQAPI
from ctrl_freeq.visualisation.plotter import process_and_plot

config = {
    "qubits": ["q1"],
    "compute_resource": "cpu",
    "parameters": {
        "Delta": [10000000.0],
        "Omega_R_max": [40000000.0],
        "pulse_duration": [2e-07],
        "point_in_pulse": [100],
        "wf_type": ["cheb"],
        "wf_mode": ["cart"],
        "amplitude_envelope": ["gn"],
        "amplitude_order": [1],
        "coverage": ["broadband"],
        "sw": [5000000.0],
        "pulse_offset": [0.0],
        "pulse_bandwidth": [500000.0],
        "ratio_factor": [0.5],
        "sigma_Delta": [0.0],
        "sigma_Omega_R_max": [0.0],
        "profile_order": [2],
        "n_para": [16],
        "J": [[0.0]],
        "T1": [0.001],           # 1 ms amplitude damping
        "T2": [0.0005]           # 500 μs pure dephasing
    },
    "initial_states": [["Z"]],
    "target_states": {"Axis": [["-Z"]]},
    "optimization": {
        "space": "liouville",
        "dissipation_mode": "dissipative",
        "H0_snapshots": 100,
        "Omega_R_snapshots": 1,
        "algorithm": "qiskit-cobyla",
        "max_iter": 1000,
        "targ_fid": 0.99
    }
}

api = CtrlFreeQAPI(config)
solution = api.run_optimization()
print(f"Final fidelity: {api.parameters.final_fidelity}")

Dissipation Requirements

When dissipation_mode is set to "dissipative", the following conditions must be satisfied:

  • The optimization space must be "liouville" (density matrix mode)
  • Per-qubit T1 and T2 values must be provided in the parameters section
  • The physical constraint \(T_2 \leq 2\,T_1\) must hold for each qubit

For a detailed description of the dissipation parameters, see Optimization → Parameters → Dissipation Parameters.


Hamiltonian Models

Ctrl-freeq provides a platform-agnostic Hamiltonian model abstraction for defining quantum control problems. All models implement the HamiltonianModel abstract base class, which follows the standard bilinear control formulation:

\[ H(t) = H_\text{drift} + \sum_k u_k(t) \, H_{\text{ctrl},k} \]

The framework uses a plugin architecture based on a model registry. New Hamiltonian types can be added by subclassing HamiltonianModel and decorating with @register_hamiltonian — no framework files need to be modified.

HamiltonianModel (Abstract Base Class)

from ctrl_freeq.setup.hamiltonian_generation import HamiltonianModel

All Hamiltonian models implement the following interface:

Method / Property Description
build_drift(frequency_instances, coupling_instances=None, **kwargs) Construct drift Hamiltonian \(H_0\) snapshots (list of \((D, D)\) numpy arrays)
build_control_ops() Return fixed control operators \(H_{\text{ctrl},k}\) (list of \((D, D)\) numpy arrays)
control_amplitudes(cx, cy, rabi_freq, n_h0) Map waveform outputs to control amplitudes \(u_k(t)\)
from_config(n_qubits, params) (classmethod) Construct a model instance from a configuration dictionary
default_config(n_qubits) (classmethod) Return a complete runnable configuration dictionary
dim (property) Hilbert space dimension \(D\) (e.g. \(2^n\) for qubit models, \(3^n\) for 3-level models)
n_controls (property) Number of independent control channels
control_ops_tensor() Control operators as a stacked torch tensor \((n_\text{controls}, D, D)\)
embed_computational_state(state) Embed a \(2^n\) state into the model's Hilbert space (identity for qubit models)
embed_computational_gate(gate) Embed a \(2^n \times 2^n\) gate into the model's Hilbert space (identity for qubit models)

The build_drift method uses a standardized signature across all models: frequency_instances contains per-qubit frequency arrays (detunings, qubit frequencies, etc.), and coupling_instances contains coupling matrices. Model-specific parameters are passed via **kwargs.

Model Registry

The registry provides automatic discovery and lookup of Hamiltonian models:

from ctrl_freeq.setup.hamiltonian_generation import (
    register_hamiltonian,
    get_hamiltonian_class,
    list_hamiltonians,
)

# List all registered models
print(list_hamiltonians())  # ['duffing_transmon', 'spin_chain', 'superconducting']

# Look up a model class by name
cls = get_hamiltonian_class("spin_chain")
model = cls.from_config(n_qubits=2, params={"coupling_type": "XY"})
Function Description
register_hamiltonian(name) Decorator that registers a HamiltonianModel subclass under the given name
get_hamiltonian_class(name) Look up and return the model class for a given name
list_hamiltonians() Return a sorted list of all registered model names

SpinChainModel

Models spin-chain qubits with detuning-based drift and configurable inter-qubit coupling (Ising, XY, Heisenberg).

from ctrl_freeq.setup.hamiltonian_generation import SpinChainModel

model = SpinChainModel(n_qubits=2, coupling_type="XY")

# Build drift Hamiltonian
import numpy as np
Delta = np.array([2 * np.pi * 1e7, 2 * np.pi * 2e7])
J = np.array([[0.0, 2 * np.pi * 1e6], [0.0, 0.0]])
H0_list = model.build_drift(frequency_instances=[Delta], coupling_instances=[J])

# Get control operators
ctrl_ops = model.build_control_ops()  # [X_0, Y_0, X_1, Y_1]
Parameter Description
n_qubits Number of spin-1/2 qubits
coupling_type "Z" (Ising), "XY" (exchange), or "XYZ" (Heisenberg)

SuperconductingQubitModel

Models fixed-frequency transmon qubits in the per-qubit rotating frame (RWA applied) with capacitive coupling, supporting exchange (XY), static ZZ, and combined (XY+ZZ) coupling. The frequency_instances values are rotating-frame detunings \(\delta_i\), not bare transition frequencies.

from ctrl_freeq.setup.hamiltonian_generation import SuperconductingQubitModel

model = SuperconductingQubitModel(
    n_qubits=2,
    coupling_type="XY+ZZ",
    anharmonicities=np.array([2 * np.pi * -330e6, 2 * np.pi * -330e6]),
)

# Build drift Hamiltonian
omega = np.array([2 * np.pi * 5e9, 2 * np.pi * 5.2e9])
g = np.array([[0.0, 2 * np.pi * 5e6], [0.0, 0.0]])
H0_list = model.build_drift(frequency_instances=[omega], coupling_instances=[g])
Parameter Description
n_qubits Number of transmon qubits
coupling_type "XY" (exchange), "ZZ" (static ZZ), or "XY+ZZ" (both)
anharmonicities Per-qubit anharmonicities \(\alpha_i\) (rad/s), used for perturbative ZZ computation
zz_crosstalk Calibrated static ZZ coupling matrix (rad/s). Overrides the perturbative formula when provided
stark_shift_coeffs Per-qubit AC Stark shift coefficients \(s_i\). Adds a Z control channel per qubit with amplitude \(-s_i (I^2+Q^2) \Omega_d^2\) — \(s_i\) shifts the qubit frequency, so it enters the drift with the same minus sign as the detuning

ZZ coupling priority chain

When coupling_type includes "ZZ", the ZZ interaction is determined by the first available source:

  1. Runtime zz_instances kwarg to build_drift (per-snapshot, highest priority)
  2. Constructor zz_crosstalk (calibrated, fixed across snapshots)
  3. Perturbative formula \(\zeta_{ij} = 2 g_{ij}^2 (\alpha_i + \alpha_j) / \bigl[(\Delta + \alpha_i)(\Delta - \alpha_j)\bigr]\) with \(\Delta = \delta_i - \delta_j\), evaluated per snapshot. Refused when the \(|11\rangle\)-\(|20\rangle\)/\(|02\rangle\) mixing \(\sqrt{2}|g| / \min(|\Delta+\alpha_i|, |\Delta-\alpha_j|)\) exceeds 0.1
  4. Zero matrix

DuffingTransmonModel

A 3-level (Duffing oscillator) transmon model that captures leakage to the \(|2\rangle\) state. Each transmon is modelled as a 3-level system, giving a Hilbert space of dimension \(3^{n_\text{qubits}}\). Anharmonicities are required.

from ctrl_freeq.setup.hamiltonian_generation import DuffingTransmonModel

model = DuffingTransmonModel(
    n_qubits=2,
    coupling_type="XY",
    anharmonicities=np.array([2 * np.pi * -330e6, 2 * np.pi * -330e6]),
)

# Drift: ω n̂ + (α/2) n̂(n̂−I) + g (a†a + aa†)
omega = np.array([2 * np.pi * 5e9, 2 * np.pi * 5.2e9])
g = np.array([[0.0, 2 * np.pi * 5e6], [0.0, 0.0]])
H0_list = model.build_drift(frequency_instances=[omega], coupling_instances=[g])

# Embed 2-level states/gates into the 3-level space
import numpy as np
state_2 = np.array([1, 0, 0, 0], dtype=complex)  # |00>
state_3 = model.embed_computational_state(state_2)  # (9,) vector

X_gate = np.array([[0,1],[1,0]], dtype=complex)
X_embed = model.embed_computational_gate(X_gate)  # (3,3) — identity on |2>

# Compute leakage
leakage = model.leakage(state_3)  # 0.0 for computational states
Parameter Description
n_qubits Number of transmon qubits
coupling_type Currently "XY" (exchange coupling via \(a^\dagger_i a_j + a_i a^\dagger_j\))
anharmonicities Per-qubit anharmonicities \(\alpha_i\) (rad/s). Required.
Method Description
embed_computational_state(state) Map a \(2^n\) state vector into the \(3^n\) Hilbert space
embed_computational_gate(gate) Embed a \(2^n \times 2^n\) gate (identity on leakage subspace)
leakage(state) Compute population outside the computational subspace ({

Using Models in Configuration

To use a Hamiltonian model, set the hamiltonian_type field in the configuration:

config = {
    "qubits": ["q1", "q2"],
    "hamiltonian_type": "superconducting",  # or "spin_chain"
    "compute_resource": "cpu",
    "parameters": { ... },
    "initial_states": [["Z", "Z"]],
    "target_states": {"Gate": ["iSWAP"]},
    "optimization": { ... }
}

Default Configurations

Each model provides a default_config classmethod that returns a complete, ready-to-run configuration dictionary with sensible physical defaults. This is useful for quick experiments and will also serve as the basis for GUI form population.

from ctrl_freeq.setup.hamiltonian_generation import (
    SpinChainModel, SuperconductingQubitModel, DuffingTransmonModel
)
from ctrl_freeq.api import CtrlFreeQAPI

# Get a complete spin-chain config for 2 qubits (CNOT gate, XY coupling)
config = SpinChainModel.default_config(n_qubits=2)
api = CtrlFreeQAPI(config)

# Get a complete superconducting config for 2 qubits (iSWAP gate, XY coupling)
config = SuperconductingQubitModel.default_config(n_qubits=2)
api = CtrlFreeQAPI(config)

# Get a complete 3-level Duffing config for 2 qubits (leakage-aware)
config = DuffingTransmonModel.default_config(n_qubits=2)
api = CtrlFreeQAPI(config)

Adding Custom Hamiltonian Models

A user who wants to add a new Hamiltonian needs to do just one thing: write a Python class that subclasses HamiltonianModel and decorate it with @register_hamiltonian("name"). No framework files need to be touched.

Step 1 — Define the model class

Implement six required members (build_drift, build_control_ops, control_amplitudes, from_config, dim, n_controls) and optionally default_config:

from ctrl_freeq.setup.hamiltonian_generation import HamiltonianModel, register_hamiltonian
import numpy as np

@register_hamiltonian("trapped_ion")
class TrappedIonModel(HamiltonianModel):
    def __init__(self, n_qubits, laser_wavelength=729e-9):
        self.n_qubits = n_qubits
        self.laser_wavelength = laser_wavelength
        # ... pre-compute operators, Lamb-Dicke params, etc.

    @property
    def dim(self):
        return 2 ** self.n_qubits

    @property
    def n_controls(self):
        return 2 * self.n_qubits  # I, Q per qubit

    def build_drift(self, frequency_instances, coupling_instances=None, **kwargs):
        # frequency_instances: list of arrays with per-qubit trap frequencies
        # coupling_instances: list of coupling matrices (Mølmer-Sørensen, etc.)
        # Return: list of (D, D) numpy arrays — one per snapshot
        ...

    def build_control_ops(self):
        # Return list of (D, D) numpy arrays: [X_0, Y_0, X_1, Y_1, ...]
        ...

    def control_amplitudes(self, cx, cy, rabi_freq, n_h0):
        # Map waveform (cx, cy) and Rabi frequency to control amplitudes u_k(t)
        ...

    @classmethod
    def from_config(cls, n_qubits, params):
        # Extract model-specific constructor args from the config dict
        return cls(n_qubits, laser_wavelength=params.get("laser_wavelength", 729e-9))

    @classmethod
    def default_config(cls, n_qubits):
        # Optional: return a complete, ready-to-run config with sensible defaults
        return {
            "hamiltonian_type": "trapped_ion",
            "qubits": [f"q{i+1}" for i in range(n_qubits)],
            "parameters": { ... },
            "initial_states": [["Z", "Z"]],
            "target_states": {"Gate": ["XX"]},
            "optimization": {"algorithm": "l-bfgs", "max_iter": 300, "targ_fid": 0.999, ...},
        }

Step 2 — Make sure the class is imported

The @register_hamiltonian decorator fires at import time, so the module just needs to be imported before the config is loaded. Define it in the same script that runs the optimization, or import it from your own package.

Step 3 — Use it

Two workflows are available:

Reference the model by name in any configuration dictionary or JSON file:

from ctrl_freeq.api import CtrlFreeQAPI

# Use default_config for zero-effort setup
config = TrappedIonModel.default_config(n_qubits=2)
# or: config = {"hamiltonian_type": "trapped_ion", ...}

api = CtrlFreeQAPI(config)
solution = api.run_optimization()

For one-off experiments, inject a model instance directly without registration:

model = TrappedIonModel(n_qubits=2, laser_wavelength=729e-9)
api = CtrlFreeQAPI(config_dict, hamiltonian_model=model)
solution = api.run_optimization()

The injected model overrides any hamiltonian_type specified in the configuration.

What you get for free

Everything else in the pipeline works automatically — waveform parameterisation, gradient computation (autograd), fidelity evaluation, plotting, and dashboards — because it all goes through the generic pulse_hamiltonian_generic path using build_control_ops and control_amplitudes. The user only defines the physics; the optimisation machinery is inherited.


Analysis and Plotting

process_and_plot(solution, parameters) replays an optimised pulse and draws the waveforms, trajectories, observables, leakage, excitation profiles and, for a single qubit, the Bloch-sphere video. The replay uses the optimizer's own control mapping, propagator and dissipative channel, so plotted final states match the objective's.

Time grids

Waveform samples sit at the midpoints of the propagation intervals, \((k + \tfrac{1}{2})\,T/N\), while propagated states exist at the \(N+1\) interval boundaries \(k\,T/N\), starting with the initial state. Plot trajectories against parameters.state_boundary_times(), not parameters.t:

p = api.parameters
p.t                        # N waveform sample times  (midpoints)
p.state_boundary_times()   # N+1 state times, ending exactly at T
p.dt                       # T / N

Leakage

For models with a local dimension above 2 (e.g. duffing_transmon), compute_and_store_evolution also returns the total leakage \(L(t) = 1 - \mathrm{Tr}(\Pi_{\text{comp}}\,\rho(t))\) at every state boundary, as a nominal trace plus the snapshot minimum/maximum envelope. This is a single total for the whole register — per-qubit leakages are not summed.

cxs, cys, history, history_mean, leakage = compute_and_store_evolution(
    solution, p, p.init[0]
)
leakage["nominal"]     # (N+1,)
leakage["snapshots"]   # (N+1, n_snapshots)

Bloch-sphere video

For a single qubit, with the video extra installed (pip install "ctrl-freeq[video]"), process_and_plot adds the Bloch-sphere dynamics to figures as a nutate.Trajectory, and create_dashboard renders it into the dashboard. With save_plots=True the video is rendered straight away to results/plots/ and its MP4 path takes the trajectory's place; with show_plots=True nutate's interactive viewer opens in its own window. The two steps are also available on their own:

from ctrl_freeq.visualisation.plotter import bloch_trajectory, render_bloch_video

p = api.parameters
traj = bloch_trajectory(solution, p, p.init[0])  # 31 offsets across Delta ± sw/2
video = render_bloch_video(traj, "bloch.mp4")    # Path, or None if rendering failed

bloch_trajectory replays the pulse with the optimizer's physics (including dissipation) at the nominal Rabi amplitude, and takes num_points= and waveform_spec= like the other analysis functions. Times are in seconds and offsets in Hz. render_bloch_video writes a 720p MP4 in a separate process, so a crash in VTK (for example without a display) cannot take your session down. The trajectory is plain nutate data, so nutate's own functions accept it too, e.g. nutate.snapshot(traj, "bloch.png") for a still; those run in your process.

Waveform representation

A solution vector is meaningless without the representation it was optimised in: a basis-mode solution holds basis coefficients, a piecewise solution holds one value per pulse segment against an identity basis. Each optimizer records its representation on the parameters object, and analysis uses it automatically.

When two optimizers share one parameters object, the recorded representation tracks the most recent run only. Pass the owning run's representation explicitly:

basis_spec = api.waveform_spec()              # stays valid across later runs
piecewise = PiecewiseAPI(api)
pw_solution = piecewise.run_optimization()
pw_spec = piecewise.waveform_spec()

process_and_plot(basis_solution, api.parameters, waveform_spec=basis_spec)
process_and_plot(pw_solution, piecewise.parameters, waveform_spec=pw_spec)

waveform_spec= is accepted by process_and_plot, compute_and_store_evolution, get_final_rho_for_excitation_profile and plot_excitation_profiles. Omitting it keeps the implicit behaviour.

Equal parameter counts are indistinguishable

Analysing a solution whose parameter count disagrees with the recorded representation raises a named error. Representations of the same size cannot be told apart — for example a two-point basis solution and a two-point piecewise cart solution, both 4 parameters — and will replay with the latest run's representation. Optimisation metrics such as final_fidelity live on the same shared object and are overwritten the same way.


Configuration Schema

For a detailed treatment of all configuration parameters, the reader is referred to:


Error Handling

from ctrl_freeq.api import CtrlFreeQAPI

try:
    api = CtrlFreeQAPI("nonexistent.json")
except FileNotFoundError as e:
    print(f"Configuration file not found: {e}")

try:
    api.update_parameter("invalid.path", 100)
except KeyError as e:
    print(f"Invalid parameter path: {e}")

Next Steps