qenvs 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qenvs/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """QEnvS: physically validated quantum control environments for RL and optimal control."""
2
+
3
+ from qenvs import envs, models, observables, tasks, validation
4
+ from qenvs.envs.base import Environment, EnvState, TimeStep
5
+ from qenvs.registry import make, register, registry
6
+
7
+ try:
8
+ from qenvs._version import __version__
9
+ except ImportError: # pragma: no cover - editable install without build hook
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = [
13
+ "EnvState",
14
+ "Environment",
15
+ "TimeStep",
16
+ "__version__",
17
+ "envs",
18
+ "make",
19
+ "models",
20
+ "observables",
21
+ "register",
22
+ "registry",
23
+ "tasks",
24
+ "validation",
25
+ ]
qenvs/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
qenvs/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """``qenvs`` command line interface (no extra dependencies; ``--json`` on every command).
2
+
3
+ Designed to be read by humans and by coding agents alike::
4
+
5
+ qenvs list --json
6
+ qenvs info qubit/StateTransfer-v0 --json
7
+ qenvs run qubit/StateTransfer-v0 --policy pi-pulse --json
8
+ qenvs validate --json
9
+ qenvs doctor --json
10
+
11
+ Errors are reported as ``{"error": {"code": ..., "message": ...}}`` with exit code 1
12
+ when ``--json`` is given.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from collections.abc import Sequence
21
+
22
+ from qenvs import toolkit
23
+
24
+
25
+ def _emit(obj: object, as_json: bool, text: str) -> None:
26
+ if as_json:
27
+ json.dump(obj, sys.stdout, indent=2, default=str)
28
+ sys.stdout.write("\n")
29
+ else:
30
+ sys.stdout.write(text + "\n")
31
+
32
+
33
+ def _parse_kv(items: list[str] | None) -> dict[str, object]:
34
+ out: dict[str, object] = {}
35
+ for item in items or []:
36
+ if "=" not in item:
37
+ msg = f"expected key=value, got {item!r}"
38
+ raise argparse.ArgumentTypeError(msg)
39
+ k, v = item.split("=", 1)
40
+ try:
41
+ out[k] = json.loads(v)
42
+ except json.JSONDecodeError:
43
+ out[k] = v
44
+ return out
45
+
46
+
47
+ def cmd_list(args: argparse.Namespace) -> int:
48
+ """List registered tasks."""
49
+ tasks = toolkit.list_tasks()
50
+ text = "\n".join(f"{t['id']:32s} {t['model']:24s} {t['description']}" for t in tasks)
51
+ _emit(tasks, args.json, text)
52
+ return 0
53
+
54
+
55
+ def cmd_info(args: argparse.Namespace) -> int:
56
+ """Describe one task."""
57
+ info = toolkit.task_info(args.task)
58
+ lines = [f"{info['id']} ({info['model']})", "", info["description"], "", "kwargs:"]
59
+ lines += [f" {k}: {v}" for k, v in info["kwargs"].items()]
60
+ lines += ["", "defaults: " + json.dumps(info["defaults"]), "", "references:"]
61
+ lines += [f" - {r}" for r in info["references"]]
62
+ _emit(info, args.json, "\n".join(lines))
63
+ return 0
64
+
65
+
66
+ def cmd_run(args: argparse.Namespace) -> int:
67
+ """Open-loop rollout with a reference policy."""
68
+ out = toolkit.run_rollout(args.task, policy=args.policy, seed=args.seed, **_parse_kv(args.set))
69
+ text = (
70
+ f"{out['task']} policy={out['policy']} horizon={out['horizon']} dt={out['dt']}\n"
71
+ f"return={out['return']:.6f} final={json.dumps(out['final'])}"
72
+ )
73
+ _emit(out, args.json, text)
74
+ return 0
75
+
76
+
77
+ def cmd_validate(args: argparse.Namespace) -> int:
78
+ """Run physics checks and print errors vs tolerances."""
79
+ out = toolkit.validate()
80
+ rows = [f"{'check':30s} {'category':16s} {'error':>10s} {'tol':>8s} status"]
81
+ for c in out["checks"]:
82
+ status = "PASS" if c["passed"] else "FAIL"
83
+ rows.append(
84
+ f"{c['name']:30s} {c['category']:16s} "
85
+ f"{c['error']:10.2e} {c['tolerance']:8.0e} {status}"
86
+ )
87
+ rows.append("")
88
+ rows += [f"{k}: {v}" for k, v in out["environment"].items()]
89
+ _emit(out, args.json, "\n".join(rows))
90
+ return 0 if out["all_passed"] else 1
91
+
92
+
93
+ def cmd_doctor(args: argparse.Namespace) -> int:
94
+ """Report installation health."""
95
+ out = toolkit.doctor()
96
+ lines = [f"{k}: {v}" for k, v in out["environment"].items()]
97
+ lines += ["", "optional:"] + [f" {k}: {v}" for k, v in out["optional"].items()]
98
+ lines += ["", f"smoke: {out['smoke']}"]
99
+ _emit(out, args.json, "\n".join(lines))
100
+ return 0 if out["ok"] else 1
101
+
102
+
103
+ def build_parser() -> argparse.ArgumentParser:
104
+ """Construct the argument parser."""
105
+ from qenvs import __version__
106
+
107
+ p = argparse.ArgumentParser(prog="qenvs", description=__doc__.split("\n\n")[0])
108
+ p.add_argument("--version", action="version", version=f"qenvs {__version__}")
109
+ sub = p.add_subparsers(dest="command", required=True)
110
+
111
+ s = sub.add_parser("list", help="list registered tasks")
112
+ s.add_argument("--json", action="store_true")
113
+ s.set_defaults(func=cmd_list)
114
+
115
+ s = sub.add_parser("info", help="describe a task (physics, kwargs, references)")
116
+ s.add_argument("task")
117
+ s.add_argument("--json", action="store_true")
118
+ s.set_defaults(func=cmd_info)
119
+
120
+ s = sub.add_parser("run", help="open-loop rollout with a reference policy")
121
+ s.add_argument("task")
122
+ s.add_argument("--policy", choices=toolkit.POLICIES, default="pi-pulse")
123
+ s.add_argument("--seed", type=int, default=0)
124
+ s.add_argument(
125
+ "--set", action="append", metavar="KEY=VALUE", help="task kwargs, e.g. --set gamma1=0.1"
126
+ )
127
+ s.add_argument("--json", action="store_true")
128
+ s.set_defaults(func=cmd_run)
129
+
130
+ s = sub.add_parser("validate", help="run physics validation checks and report errors")
131
+ s.add_argument("--json", action="store_true")
132
+ s.set_defaults(func=cmd_validate)
133
+
134
+ s = sub.add_parser("doctor", help="check installation, backend and optional extras")
135
+ s.add_argument("--json", action="store_true")
136
+ s.set_defaults(func=cmd_doctor)
137
+ return p
138
+
139
+
140
+ ERROR_CODES: dict[type[Exception], str] = {
141
+ KeyError: "unknown_task",
142
+ ValueError: "invalid_argument",
143
+ argparse.ArgumentTypeError: "invalid_argument",
144
+ }
145
+
146
+
147
+ def main(argv: Sequence[str] | None = None) -> int:
148
+ """Entry point (enables JAX x64 so reported errors reflect double precision)."""
149
+ import jax
150
+
151
+ jax.config.update("jax_enable_x64", True)
152
+ args = build_parser().parse_args(argv)
153
+ try:
154
+ return int(args.func(args))
155
+ except (KeyError, ValueError, argparse.ArgumentTypeError) as e:
156
+ code = next(c for t, c in ERROR_CODES.items() if isinstance(e, t))
157
+ msg = e.args[0] if e.args else str(e)
158
+ if getattr(args, "json", False):
159
+ json.dump({"error": {"code": code, "message": msg}}, sys.stdout)
160
+ sys.stdout.write("\n")
161
+ else:
162
+ sys.stderr.write(f"error [{code}]: {msg}\n")
163
+ return 1
164
+
165
+
166
+ if __name__ == "__main__": # pragma: no cover
167
+ sys.exit(main())
qenvs/envs/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Functional (JAX-native) control environments."""
2
+
3
+ from qenvs.envs.base import Environment, EnvState, TimeStep
4
+ from qenvs.envs.battery import BatteryCharging
5
+ from qenvs.envs.state_transfer import StateTransfer
6
+
7
+ __all__ = ["BatteryCharging", "EnvState", "Environment", "StateTransfer", "TimeStep"]
qenvs/envs/base.py ADDED
@@ -0,0 +1,71 @@
1
+ """Functional environment API (``reset``/``step`` return ``(state, timestep)``).
2
+
3
+ The design follows Jumanji (Apache-2.0): the environment object is stateless,
4
+ all mutable data lives in :class:`EnvState`, so ``step`` can be ``jit``-ed and
5
+ ``vmap``-ed over batches of states and actions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+
12
+ import equinox as eqx
13
+ from dynamiqs import QArray
14
+ from jax import Array
15
+
16
+
17
+ class EnvState(eqx.Module):
18
+ """Everything needed to advance the environment."""
19
+
20
+ qstate: QArray
21
+ t: Array
22
+ step: Array
23
+ key: Array
24
+
25
+
26
+ class TimeStep(eqx.Module):
27
+ """Output of ``reset``/``step``.
28
+
29
+ Attributes:
30
+ observation: real-valued observation vector.
31
+ reward: scalar reward.
32
+ done: whether the episode terminated at this step.
33
+ info: dictionary of named diagnostics (observable expectation values, ...).
34
+ """
35
+
36
+ observation: Array
37
+ reward: Array
38
+ done: Array
39
+ info: dict[str, Array]
40
+
41
+
42
+ class Environment(eqx.Module):
43
+ """Abstract functional environment."""
44
+
45
+ @property
46
+ @abc.abstractmethod
47
+ def action_dim(self) -> int:
48
+ """Dimension of the continuous action vector."""
49
+
50
+ @property
51
+ @abc.abstractmethod
52
+ def observation_dim(self) -> int:
53
+ """Dimension of the observation vector."""
54
+
55
+ @property
56
+ @abc.abstractmethod
57
+ def action_bound(self) -> float:
58
+ """Actions are clipped to ``[-action_bound, action_bound]``."""
59
+
60
+ @property
61
+ @abc.abstractmethod
62
+ def horizon(self) -> int:
63
+ """Number of control steps per episode."""
64
+
65
+ @abc.abstractmethod
66
+ def reset(self, key: Array) -> tuple[EnvState, TimeStep]:
67
+ """Start a new episode."""
68
+
69
+ @abc.abstractmethod
70
+ def step(self, state: EnvState, action: Array) -> tuple[EnvState, TimeStep]:
71
+ """Advance one control interval."""
qenvs/envs/battery.py ADDED
@@ -0,0 +1,127 @@
1
+ """Battery-charging task: maximise stored energy or ergotropy of ``model.battery_hamiltonian()``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dynamiqs as dq
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from dynamiqs import QArray
9
+ from dynamiqs.method import Method, Tsit5
10
+ from jax import Array
11
+
12
+ from qenvs.envs.base import Environment, EnvState, TimeStep
13
+ from qenvs.models.base import Model
14
+ from qenvs.observables import energy, ergotropy
15
+
16
+ REWARDS = ("energy", "ergotropy")
17
+
18
+
19
+ class BatteryCharging(Environment):
20
+ """Piecewise-constant charging control of a quantum battery.
21
+
22
+ Works for any :class:`~qenvs.models.Model` that implements
23
+ ``battery_hamiltonian()``. The reward is the per-step *gain* of the chosen
24
+ quantity, so the undiscounted return equals ``Q(final) - Q(initial)`` with
25
+ ``Q`` the stored energy or the ergotropy. ``info`` reports energy,
26
+ ergotropy, average charging power ``E(t) / t`` and purity every step.
27
+
28
+ Attributes:
29
+ model: physical model with a battery Hamiltonian.
30
+ horizon: number of control steps.
31
+ dt: duration of each step (control period; the integrator adapts inside it).
32
+ action_bound: maximum control amplitude.
33
+ reward: ``"energy"`` or ``"ergotropy"``.
34
+ method: dynamiqs ODE method.
35
+ """
36
+
37
+ model: Model
38
+ horizon: int = 20
39
+ dt: float = 0.1
40
+ action_bound: float = 2.0
41
+ reward: str = "energy"
42
+ method: Method = Tsit5()
43
+
44
+ def __check_init__(self) -> None:
45
+ if self.reward not in REWARDS:
46
+ msg = f"reward must be one of {REWARDS}, got {self.reward!r}"
47
+ raise ValueError(msg)
48
+
49
+ @property
50
+ def action_dim(self) -> int:
51
+ return self.model.n_controls
52
+
53
+ @property
54
+ def observation_dim(self) -> int:
55
+ n = self.model.dim
56
+ return 2 * (n * n if self.model.is_open else n) + 1
57
+
58
+ @property
59
+ def duration(self) -> float:
60
+ """Total episode duration ``horizon * dt``."""
61
+ return self.horizon * self.dt
62
+
63
+ def _quantity(self, qstate: QArray) -> Array:
64
+ hb = self.model.battery_hamiltonian()
65
+ return energy(qstate, hb) if self.reward == "energy" else ergotropy(qstate, hb)
66
+
67
+ def _observe(self, qstate: QArray, t: Array) -> Array:
68
+ x = qstate.to_jax().reshape(-1)
69
+ return jnp.concatenate([x.real, x.imag, jnp.atleast_1d(t / self.duration)])
70
+
71
+ def _info(self, qstate: QArray, t: Array) -> dict[str, Array]:
72
+ hb = self.model.battery_hamiltonian()
73
+ e = energy(qstate, hb)
74
+ rho = qstate.todm().to_jax()
75
+ info = {name: dq.expect(op, qstate).real for name, op in self.model.observables().items()}
76
+ info.update(
77
+ {
78
+ "energy": e,
79
+ "ergotropy": ergotropy(qstate, hb),
80
+ "power": jnp.where(t > 0, e / jnp.maximum(t, 1e-12), 0.0),
81
+ "purity": jnp.real(jnp.trace(rho @ rho)),
82
+ }
83
+ )
84
+ return info
85
+
86
+ def reset(self, key: Array) -> tuple[EnvState, TimeStep]:
87
+ qstate = self.model.initial_state()
88
+ t = jnp.asarray(0.0)
89
+ state = EnvState(qstate=qstate, t=t, step=jnp.asarray(0), key=key)
90
+ ts = TimeStep(
91
+ observation=self._observe(qstate, t),
92
+ reward=jnp.asarray(0.0),
93
+ done=jnp.asarray(False),
94
+ info=self._info(qstate, t),
95
+ )
96
+ return state, ts
97
+
98
+ def step(self, state: EnvState, action: Array) -> tuple[EnvState, TimeStep]:
99
+ action = jnp.clip(
100
+ jnp.asarray(action, dtype=jnp.float32), -self.action_bound, self.action_bound
101
+ )
102
+ h = self.model.hamiltonian(action)
103
+ qstate = self.model.evolve(state.qstate, h, self.dt, method=self.method)
104
+ q_prev = self._quantity(state.qstate)
105
+ q_new = self._quantity(qstate)
106
+ t = state.t + self.dt
107
+ step = state.step + 1
108
+ new_state = EnvState(qstate=qstate, t=t, step=step, key=state.key)
109
+ ts = TimeStep(
110
+ observation=self._observe(qstate, t),
111
+ reward=q_new - q_prev,
112
+ done=step >= self.horizon,
113
+ info=self._info(qstate, t),
114
+ )
115
+ return new_state, ts
116
+
117
+ def rollout(self, actions: Array, key: Array | None = None) -> tuple[EnvState, Array]:
118
+ """Open-loop rollout of ``actions`` of shape ``(horizon, action_dim)`` via ``lax.scan``."""
119
+ if key is None:
120
+ key = jax.random.PRNGKey(0)
121
+ state0, _ = self.reset(key)
122
+
123
+ def body(state: EnvState, a: Array) -> tuple[EnvState, Array]:
124
+ state, ts = self.step(state, a)
125
+ return state, ts.reward
126
+
127
+ return jax.lax.scan(body, state0, actions)
@@ -0,0 +1,110 @@
1
+ """State-transfer task: drive ``model`` from its initial state to ``target``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dynamiqs as dq
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from dynamiqs import QArray
9
+ from dynamiqs.method import Method, Tsit5
10
+ from jax import Array
11
+
12
+ from qenvs.envs.base import Environment, EnvState, TimeStep
13
+ from qenvs.models.base import Model
14
+ from qenvs.observables import fidelity
15
+
16
+
17
+ class StateTransfer(Environment):
18
+ """Piecewise-constant control towards a target state.
19
+
20
+ Each step applies the action as constant control amplitudes for ``dt``. The
21
+ observation is the flattened real/imaginary parts of the quantum state plus
22
+ the normalised time. The reward is the fidelity *gain* of the step, so the
23
+ undiscounted return equals ``F(final) - F(initial)``.
24
+
25
+ Attributes:
26
+ model: physical model.
27
+ target: target state (ket or density matrix).
28
+ horizon: number of control steps.
29
+ dt: duration of each step.
30
+ action_bound: maximum control amplitude.
31
+ method: dynamiqs ODE method used inside each control interval. The
32
+ integrator step is independent of ``dt`` (adaptive by default).
33
+ """
34
+
35
+ model: Model
36
+ target: QArray
37
+ horizon: int = 20
38
+ dt: float = 0.1
39
+ action_bound: float = 2.0
40
+ method: Method = Tsit5()
41
+
42
+ @property
43
+ def action_dim(self) -> int:
44
+ return self.model.n_controls
45
+
46
+ @property
47
+ def observation_dim(self) -> int:
48
+ n = self.model.dim
49
+ return 2 * (n * n if self.model.is_open else n) + 1
50
+
51
+ @property
52
+ def duration(self) -> float:
53
+ """Total episode duration ``horizon * dt``."""
54
+ return self.horizon * self.dt
55
+
56
+ def _observe(self, qstate: QArray, t: Array) -> Array:
57
+ x = qstate.to_jax().reshape(-1)
58
+ return jnp.concatenate([x.real, x.imag, jnp.atleast_1d(t / self.duration)])
59
+
60
+ def _info(self, qstate: QArray) -> dict[str, Array]:
61
+ info = {name: dq.expect(op, qstate).real for name, op in self.model.observables().items()}
62
+ info["fidelity"] = fidelity(qstate, self.target)
63
+ return info
64
+
65
+ def reset(self, key: Array) -> tuple[EnvState, TimeStep]:
66
+ qstate = self.model.initial_state()
67
+ t = jnp.asarray(0.0)
68
+ state = EnvState(qstate=qstate, t=t, step=jnp.asarray(0), key=key)
69
+ ts = TimeStep(
70
+ observation=self._observe(qstate, t),
71
+ reward=jnp.asarray(0.0),
72
+ done=jnp.asarray(False),
73
+ info=self._info(qstate),
74
+ )
75
+ return state, ts
76
+
77
+ def step(self, state: EnvState, action: Array) -> tuple[EnvState, TimeStep]:
78
+ action = jnp.clip(
79
+ jnp.asarray(action, dtype=jnp.float32), -self.action_bound, self.action_bound
80
+ )
81
+ h = self.model.hamiltonian(action)
82
+ qstate = self.model.evolve(state.qstate, h, self.dt, method=self.method)
83
+ f_prev = fidelity(state.qstate, self.target)
84
+ f_new = fidelity(qstate, self.target)
85
+ t = state.t + self.dt
86
+ step = state.step + 1
87
+ new_state = EnvState(qstate=qstate, t=t, step=step, key=state.key)
88
+ ts = TimeStep(
89
+ observation=self._observe(qstate, t),
90
+ reward=f_new - f_prev,
91
+ done=step >= self.horizon,
92
+ info=self._info(qstate),
93
+ )
94
+ return new_state, ts
95
+
96
+ def rollout(self, actions: Array, key: Array | None = None) -> tuple[EnvState, Array]:
97
+ """Open-loop rollout of ``actions`` of shape ``(horizon, action_dim)``.
98
+
99
+ Returns the final state and the per-step rewards. Implemented with
100
+ ``lax.scan`` so it is differentiable with respect to ``actions``.
101
+ """
102
+ if key is None:
103
+ key = jax.random.PRNGKey(0)
104
+ state0, _ = self.reset(key)
105
+
106
+ def body(state: EnvState, a: Array) -> tuple[EnvState, Array]:
107
+ state, ts = self.step(state, a)
108
+ return state, ts.reward
109
+
110
+ return jax.lax.scan(body, state0, actions)
@@ -0,0 +1,6 @@
1
+ """Physical models: drift + control Hamiltonians, dissipators, initial states, observables."""
2
+
3
+ from qenvs.models.base import Model
4
+ from qenvs.models.two_level import TwoLevel
5
+
6
+ __all__ = ["Model", "TwoLevel"]
qenvs/models/base.py ADDED
@@ -0,0 +1,123 @@
1
+ """Base class for physical models.
2
+
3
+ A :class:`Model` is a pure description of the physics: the drift Hamiltonian,
4
+ the control operators the agent can drive, optional Lindblad jump operators,
5
+ the initial state and a dictionary of named observables. It knows nothing about
6
+ rewards, horizons or action spaces; those live in :mod:`qenvs.envs`.
7
+
8
+ Every model ships with analytic reference results that the validation test-suite
9
+ checks (see ``tests/``). That is the contract behind "physically validated".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import abc
15
+
16
+ import dynamiqs as dq
17
+ import equinox as eqx
18
+ import jax.numpy as jnp
19
+ from dynamiqs import QArray, TimeQArray
20
+ from dynamiqs.method import Method, Tsit5
21
+ from jax import Array
22
+
23
+ DEFAULT_METHOD: Method = Tsit5()
24
+
25
+
26
+ class Model(eqx.Module):
27
+ """Abstract physical model.
28
+
29
+ Subclasses are immutable equinox modules (JAX pytrees), so they can be
30
+ jitted, vmapped and differentiated through.
31
+ """
32
+
33
+ @property
34
+ @abc.abstractmethod
35
+ def dim(self) -> int:
36
+ """Hilbert-space dimension."""
37
+
38
+ @abc.abstractmethod
39
+ def drift(self) -> QArray:
40
+ """Drift (uncontrolled) Hamiltonian ``H_0``."""
41
+
42
+ @abc.abstractmethod
43
+ def controls(self) -> list[QArray]:
44
+ """Control operators ``H_k``; the total Hamiltonian is ``H_0 + sum_k u_k(t) H_k``."""
45
+
46
+ def jump_ops(self) -> list[QArray]:
47
+ """Lindblad jump operators. Empty list means closed (unitary) dynamics."""
48
+ return []
49
+
50
+ @abc.abstractmethod
51
+ def initial_state(self) -> QArray:
52
+ """Initial state (ket for closed systems, density matrix otherwise)."""
53
+
54
+ def observables(self) -> dict[str, QArray]:
55
+ """Named Hermitian observables reported by environments (may be empty)."""
56
+ return {}
57
+
58
+ def battery_hamiltonian(self) -> QArray:
59
+ """Energy-storage Hamiltonian ``H_B`` (ground energy 0); battery models override."""
60
+ msg = f"{type(self).__name__} does not define a battery Hamiltonian"
61
+ raise NotImplementedError(msg)
62
+
63
+ # ------------------------------------------------------------------ helpers
64
+ @property
65
+ def n_controls(self) -> int:
66
+ """Number of control channels."""
67
+ return len(self.controls())
68
+
69
+ @property
70
+ def is_open(self) -> bool:
71
+ """Whether the model has dissipation."""
72
+ return len(self.jump_ops()) > 0
73
+
74
+ def hamiltonian(self, amplitudes: Array) -> QArray:
75
+ """Constant total Hamiltonian for fixed control amplitudes of shape ``(n_controls,)``."""
76
+ h = self.drift()
77
+ for k, hk in enumerate(self.controls()):
78
+ h = h + amplitudes[k] * hk
79
+ return h
80
+
81
+ def pwc_hamiltonian(self, times: Array, amplitudes: Array) -> TimeQArray:
82
+ """Piecewise-constant Hamiltonian.
83
+
84
+ Args:
85
+ times: interval boundaries, shape ``(N + 1,)``.
86
+ amplitudes: control amplitudes, shape ``(n_controls, N)``.
87
+ """
88
+ h: TimeQArray | QArray = self.drift()
89
+ for k, hk in enumerate(self.controls()):
90
+ h = h + dq.pwc(times, amplitudes[k], hk)
91
+ return h
92
+
93
+ def evolve(
94
+ self,
95
+ state: QArray,
96
+ hamiltonian: QArray | TimeQArray,
97
+ duration: float | Array,
98
+ *,
99
+ method: Method | None = None,
100
+ ) -> QArray:
101
+ """Evolve ``state`` under ``hamiltonian`` for ``duration`` and return the final state.
102
+
103
+ Uses the Schrodinger equation for closed models and the Lindblad master
104
+ equation otherwise. Density matrices are always accepted.
105
+
106
+ The ODE ``method`` (adaptive Tsit5 by default) takes as many internal
107
+ integration steps as needed inside ``duration``: the agent's control
108
+ period and the integrator's time step are independent. For piecewise
109
+ constant Hamiltonians ``dynamiqs.method.Expm()`` is exact.
110
+ """
111
+ tsave = jnp.asarray([0.0, duration])
112
+ method = DEFAULT_METHOD if method is None else method
113
+ if self.is_open:
114
+ # dynamiqs 0.3.6: mesolve(..., method=Expm(), save_states=False).final_state
115
+ # returns the *vectorised* density matrix (n^2, 1); states[-1] is always (n, n).
116
+ res = dq.mesolve(
117
+ hamiltonian, self.jump_ops(), state, tsave, method=method, progress_meter=False
118
+ )
119
+ return res.states[-1]
120
+ res = dq.sesolve(
121
+ hamiltonian, state, tsave, method=method, save_states=False, progress_meter=False
122
+ )
123
+ return res.final_state