Skip to content

Commit ffb5be6

Browse files
Fix Dynamiqs backend sesolve and QuTiP parity
Correct dq.sesolve usage (method/Tsit5), document units and Diffrax defaults, add time rescaling and solver options, and add parity tests for Rabi and red sideband. Disables progress meter by default (fixes #26). Fixes #43 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c9d2b54 commit ffb5be6

10 files changed

Lines changed: 550 additions & 39 deletions

File tree

docs/explanation/backends.md

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,51 @@
11
# Backends
22

3-
Backends are used to execute the AtomicCircuit.
3+
Backends execute compiled [`AtomicCircuit`][oqd_core.interface.atomic.AtomicCircuit] programs.
44

5-
## Supported Backends
5+
## Supported backends
66

7-
- [QuTiP](https://qutip.readthedocs.io/en/latest/) <div style="float:right;"> [![](https://img.shields.io/badge/Implementation-7C4DFF)][oqd_trical.backend.qutip.QutipBackend] </div>
8-
- [Dynamiqs](https://qutip.readthedocs.io/en/latest/) <div style="float:right;"> [![](https://img.shields.io/badge/Implementation-7C4DFF)][oqd_trical.backend.dynamiqs.DynamiqsBackend] </div>
7+
- [QuTiP](https://qutip.org/)[`QutipBackend`][oqd_trical.backend.qutip.QutipBackend]
8+
- [Dynamiqs](https://www.dynamiqs.org/) (JAX + Diffrax) — [`DynamiqsBackend`][oqd_trical.backend.dynamiqs.DynamiqsBackend]
99

10-
## Compile
10+
## Compile and run
1111

12-
Compiles the AtomicCircuit into a compatible form for the backend to run on.
12+
Both backends share the same compile path (atomic IR → emulator Hamiltonian → backend experiment) and return a result dict with `states`, `tspan`, and `final_state`.
1313

14-
<!-- prettier-ignore -->
15-
/// admonition | Examples
16-
type: example
14+
```python
15+
from oqd_trical.backend import DynamiqsBackend, QutipBackend
1716

18-
- QuTiP requires the AtomicCircuit be compiled to a [`QutipExperiment`][oqd_trical.backend.qutip.interface.QutipExperiment].
19-
- Dynamiqs requires the AtomicCircuit be compiled to a [`DynamiqsExperiment`][oqd_trical.backend.dynamiqs.interface.DynamiqsExperiment].
17+
backend = DynamiqsBackend(approx_pass=approx_pass)
18+
experiment, hilbert_space = backend.compile(circuit, fock_cutoff=3)
19+
result = backend.run(experiment, hilbert_space, timestep=1e-8)
20+
```
2021

21-
///
22+
### Task API
2223

23-
## Run
24+
For parity with the analog emulator’s [`QutipBackend.run`][oqd_analog_emulator.qutip_backend.QutipBackend], Dynamiqs also supports a task entry point:
2425

25-
Executes the compatible form of the AtomicCircuit with the backend using a tree walking interpreter.
26+
```python
27+
from oqd_core.backend.task import Task
28+
from oqd_trical.backend import DynamiqsBackend, TaskArgsAtomicEmulator
2629

27-
<!-- prettier-ignore -->
28-
/// admonition | Examples
29-
type: example
30+
args = TaskArgsAtomicEmulator(fock_trunc=3, dt=1e-8)
31+
task = Task(program=circuit, args=args)
32+
result = DynamiqsBackend(approx_pass=approx_pass).run_task(task)
33+
```
3034

31-
- QuTiP uses [`QutipVM`][oqd_trical.backend.qutip.vm.QutipVM] as its tree walking interpreter.
32-
- Dynamiqs uses [`DynamiqsVM`][oqd_trical.backend.dynamiqs.vm.DynamiqsVM] as its tree walking interpreter.
35+
## Dynamiqs: units and Diffrax settings
3336

34-
///
37+
Atomic inputs use **angular frequencies in rad/s** (often written with factors of \(2\pi\)) and **pulse durations in seconds**. The lowered Hamiltonian and `dq.sesolve` use the same convention (\(\hbar = 1\), \(d|\psi\rangle/dt = -i H |\psi\rangle\)).
38+
39+
Because \(|H|\) can be \(\sim 2\pi \times 10^6\,\mathrm{rad/s}\) or larger, the VM optionally rescales to dimensionless time \(\tau = \omega t\) with \(H' = H/\omega\) before calling Diffrax. Physical times in `result["tspan"]` are unchanged.
40+
41+
Configure the integrator via [`DynamiqsSolverOptions`][oqd_trical.backend.dynamiqs.solver.DynamiqsSolverOptions] (default: **Tsit5** with PID step control, `rtol=atol=1e-3`, `progress_meter=False` to avoid Jupyter ZMQ issues):
42+
43+
```python
44+
from oqd_trical.backend import DynamiqsBackend, DynamiqsSolverOptions
45+
46+
backend = DynamiqsBackend(
47+
solver_options=DynamiqsSolverOptions(rtol=1e-4, atol=1e-4, rescale_time=True),
48+
)
49+
```
50+
51+
See [`solver.py`][oqd_trical.backend.dynamiqs.solver] for full documentation of defaults.

docs/reference/dynamiqs.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
::: oqd_trical.backend.dynamiqs
1+
# Dynamiqs backend
2+
3+
::: oqd_trical.backend.dynamiqs.DynamiqsBackend
4+
5+
::: oqd_trical.backend.dynamiqs.DynamiqsSolverOptions
6+
7+
::: oqd_trical.backend.dynamiqs.TaskArgsAtomicEmulator
8+
9+
::: oqd_trical.backend.dynamiqs.solver

src/oqd_trical/backend/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
# limitations under the License.
1414

1515
from . import dynamiqs, qutip
16-
from .dynamiqs import DynamiqsBackend
16+
from .dynamiqs import DynamiqsBackend, DynamiqsSolverOptions, TaskArgsAtomicEmulator
1717
from .qutip import QutipBackend
1818

1919
__all__ = [
2020
"qutip",
2121
"dynamiqs",
2222
"DynamiqsBackend",
23+
"DynamiqsSolverOptions",
24+
"TaskArgsAtomicEmulator",
2325
"QutipBackend",
2426
]

src/oqd_trical/backend/dynamiqs/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@
1414

1515
from .base import DynamiqsBackend
1616
from .codegen import DynamiqsCodeGeneration
17+
from .solver import DynamiqsSolverOptions
18+
from .task import TaskArgsAtomicEmulator
1719
from .vm import DynamiqsVM
1820

1921
__all__ = [
2022
"DynamiqsBackend",
2123
"DynamiqsCodeGeneration",
24+
"DynamiqsSolverOptions",
25+
"TaskArgsAtomicEmulator",
2226
"DynamiqsVM",
2327
]

src/oqd_trical/backend/dynamiqs/base.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,18 @@
1414

1515
from oqd_compiler_infrastructure import Chain, Post, Pre
1616
from oqd_core.backend.base import BackendBase
17+
from oqd_core.backend.task import Task, TaskArgsAtomic
1718
from oqd_core.compiler.atomic.canonicalize import canonicalize_atomic_circuit_factory
1819
from oqd_core.interface.atomic import AtomicCircuit
1920

2021
from oqd_trical.backend.dynamiqs.codegen import DynamiqsCodeGeneration
22+
from oqd_trical.backend.dynamiqs.solver import (
23+
normalize_solver_options,
24+
)
25+
from oqd_trical.backend.dynamiqs.task import (
26+
TaskArgsAtomicEmulator,
27+
task_args_from_atomic,
28+
)
2129
from oqd_trical.backend.dynamiqs.vm import DynamiqsVM
2230
from oqd_trical.light_matter.compiler.analysis import GetHilbertSpace, HilbertSpace
2331
from oqd_trical.light_matter.compiler.canonicalize import (
@@ -37,7 +45,9 @@ class DynamiqsBackend(BackendBase):
3745
save_intermediate (bool): Whether compiler saves the intermediate representation of the atomic circuit
3846
approx_pass (PassBase): Pass of approximations to apply to the system.
3947
solver (Literal["SESolver","MESolver"]): Dynamiqs solver to use.
40-
solver_options (Dict[str,Any]): Dynamiqs solver options
48+
solver_options (Union[DynamiqsSolverOptions, Dict[str, Any]]): Diffrax method and
49+
tolerances passed to ``dq.sesolve``; see
50+
[`DynamiqsSolverOptions`][oqd_trical.backend.dynamiqs.solver.DynamiqsSolverOptions].
4151
intermediate (AtomicEmulatorCircuit): Intermediate representation of the atomic circuit during compilation
4252
"""
4353

@@ -46,15 +56,15 @@ def __init__(
4656
save_intermediate=True,
4757
approx_pass=None,
4858
solver="SESolver",
49-
solver_options={},
59+
solver_options=None,
5060
):
5161
super().__init__()
5262

5363
self.save_intermediate = save_intermediate
5464
self.intermediate = None
5565
self.approx_pass = approx_pass
5666
self.solver = solver
57-
self.solver_options = solver_options
67+
self.solver_options = normalize_solver_options(solver_options)
5868

5969
def compile(self, circuit, fock_cutoff, *, relabel=True):
6070
"""
@@ -87,7 +97,6 @@ def compile(self, circuit, fock_cutoff, *, relabel=True):
8797

8898
get_hilbert_space = GetHilbertSpace()
8999
analysis = Post(get_hilbert_space)
90-
analysis(intermediate)
91100

92101
if relabel:
93102
analysis(intermediate)
@@ -143,3 +152,45 @@ def run(self, experiment, hilbert_space, timestep, *, initial_state=None):
143152
vm(experiment)
144153

145154
return vm.children[0].result
155+
156+
def run_task(
157+
self,
158+
task: Task,
159+
*,
160+
relabel: bool = True,
161+
initial_state=None,
162+
):
163+
"""
164+
Compile and run an atomic-layer [`Task`][oqd_core.backend.task.Task].
165+
166+
Expects ``task.program`` to be an [`AtomicCircuit`][oqd_core.interface.atomic.AtomicCircuit]
167+
and ``task.args`` to be [`TaskArgsAtomic`][oqd_core.backend.task.TaskArgsAtomic]
168+
or [`TaskArgsAtomicEmulator`][oqd_trical.backend.dynamiqs.task.TaskArgsAtomicEmulator]
169+
(the latter adds ``dynamiqs_solver_options``).
170+
171+
Returns:
172+
Same dict as [`run`][oqd_trical.backend.dynamiqs.DynamiqsBackend.run]
173+
(``final_state``, ``states``, ``tspan``, ...).
174+
"""
175+
if not isinstance(task.program, AtomicCircuit):
176+
raise TypeError(
177+
"DynamiqsBackend.run_task expects task.program to be AtomicCircuit."
178+
)
179+
if not isinstance(task.args, (TaskArgsAtomic, TaskArgsAtomicEmulator)):
180+
raise TypeError(
181+
"DynamiqsBackend.run_task expects TaskArgsAtomic or TaskArgsAtomicEmulator."
182+
)
183+
184+
fock_trunc, dt, solver_opts = task_args_from_atomic(task.args)
185+
if solver_opts is not None:
186+
self.solver_options = normalize_solver_options(solver_opts)
187+
188+
experiment, hilbert_space = self.compile(
189+
task.program, fock_trunc, relabel=relabel
190+
)
191+
return self.run(
192+
experiment,
193+
hilbert_space,
194+
dt,
195+
initial_state=initial_state,
196+
)

src/oqd_trical/backend/dynamiqs/codegen.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,15 @@ def map_Displacement(self, model, operands):
8787
)
8888

8989
def map_OperatorMul(self, model, operands):
90-
return lambda t: operands["op1"](t) @ operands["op2"](t)
90+
op1, op2 = operands["op1"], operands["op2"]
91+
92+
def combined(t):
93+
m1, m2 = op1(t), op2(t)
94+
if m1.shape != m2.shape:
95+
return dq.tensor(m1, m2)
96+
return m1 @ m2
97+
98+
return combined
9199

92100
def map_OperatorKron(self, model, operands):
93101
return lambda t: dq.tensor(operands["op1"](t), operands["op2"](t))
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright 2024-2025 Open Quantum Design
2+
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Diffrax / Dynamiqs integration settings for the TrICal Dynamiqs backend.
16+
17+
Unit convention (matches QuTiP and the atomic-interface lowering):
18+
- Level energies, phonon energies, Rabi frequencies, and detunings are angular
19+
frequencies in rad/s (inputs often carry explicit factors of 2*pi).
20+
- Pulse durations are in seconds.
21+
- The lowered Hamiltonian H and ``tsave`` passed to ``dq.sesolve`` use the same
22+
rad/s and second units; Dynamiqs integrates d|psi>/dt = -i H |psi> with hbar=1.
23+
24+
Adaptive step-size controllers are sensitive to |H| and the integration interval.
25+
Trapped-ion circuits after rotating-frame and RWA passes typically have |H| ~ 2*pi
26+
* 1e6 rad/s or smaller; default tolerances are relaxed slightly versus Dynamiqs'
27+
library defaults so stiff sideband dynamics do not exhaust ``max_steps``.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from dataclasses import dataclass
33+
from typing import Any, Dict, Optional, Union
34+
35+
import dynamiqs as dq
36+
37+
########################################################################################
38+
39+
# Tsit5 uses a Diffrax PID step-size controller (see dynamiqs.method.Tsit5).
40+
# 1e-6 (library default) can exhaust max_steps on ~MHz-scale trapped-ion dynamics;
41+
# 1e-3 matches QuTiP SESolver on reference circuits to within ~5% on populations.
42+
DEFAULT_RTOL = 1e-3
43+
DEFAULT_ATOL = 1e-3
44+
DEFAULT_MAX_STEPS = 2_000_000
45+
46+
47+
@dataclass
48+
class DynamiqsSolverOptions:
49+
"""Options forwarded to ``dq.sesolve`` / ``dq.mesolve`` (Diffrax under the hood).
50+
51+
Pass an instance via ``DynamiqsBackend(solver_options=...)`` or a plain dict with
52+
the same keys. Mirrors the role of QuTiP's ``solver_options`` on
53+
``QutipBackend``.
54+
"""
55+
56+
rtol: float = DEFAULT_RTOL
57+
atol: float = DEFAULT_ATOL
58+
max_steps: int = DEFAULT_MAX_STEPS
59+
method: Optional[Any] = None
60+
options: Optional[Any] = None
61+
# Rescale t -> omega*t and H -> H/omega so Diffrax sees O(1) frequencies (rad/s in, seconds in).
62+
rescale_time: bool = True
63+
time_scale: Optional[float] = None
64+
65+
def to_solver_dict(self) -> Dict[str, Any]:
66+
method = self.method
67+
if method is None:
68+
method = dq.method.Tsit5(
69+
rtol=self.rtol, atol=self.atol, max_steps=self.max_steps
70+
)
71+
options = self.options
72+
if options is None:
73+
options = dq.Options(progress_meter=False)
74+
return {
75+
"method": method,
76+
"options": options,
77+
"rescale_time": self.rescale_time,
78+
"time_scale": self.time_scale,
79+
}
80+
81+
82+
def normalize_solver_options(
83+
solver_options: Optional[Union[DynamiqsSolverOptions, Dict[str, Any]]] = None,
84+
) -> Dict[str, Any]:
85+
if solver_options is None:
86+
return DynamiqsSolverOptions().to_solver_dict()
87+
if isinstance(solver_options, DynamiqsSolverOptions):
88+
return solver_options.to_solver_dict()
89+
if "method" not in solver_options:
90+
opts = DynamiqsSolverOptions(
91+
rtol=solver_options.get("rtol", DEFAULT_RTOL),
92+
atol=solver_options.get("atol", DEFAULT_ATOL),
93+
max_steps=solver_options.get("max_steps", DEFAULT_MAX_STEPS),
94+
method=solver_options.get("method"),
95+
options=solver_options.get("options"),
96+
rescale_time=solver_options.get("rescale_time", True),
97+
time_scale=solver_options.get("time_scale"),
98+
)
99+
merged = opts.to_solver_dict()
100+
merged.update({k: v for k, v in solver_options.items() if k not in merged})
101+
return merged
102+
if "options" not in solver_options:
103+
solver_options = {
104+
**solver_options,
105+
"options": dq.Options(progress_meter=False),
106+
}
107+
return solver_options

0 commit comments

Comments
 (0)