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 +25 -0
- qenvs/_version.py +24 -0
- qenvs/cli.py +167 -0
- qenvs/envs/__init__.py +7 -0
- qenvs/envs/base.py +71 -0
- qenvs/envs/battery.py +127 -0
- qenvs/envs/state_transfer.py +110 -0
- qenvs/models/__init__.py +6 -0
- qenvs/models/base.py +123 -0
- qenvs/models/two_level.py +75 -0
- qenvs/observables.py +33 -0
- qenvs/registry.py +62 -0
- qenvs/tasks/__init__.py +5 -0
- qenvs/tasks/qubit.py +44 -0
- qenvs/toolkit.py +146 -0
- qenvs/validation/__init__.py +28 -0
- qenvs/validation/core.py +49 -0
- qenvs/validation/qubit.py +157 -0
- qenvs/wrappers/__init__.py +5 -0
- qenvs/wrappers/gymnasium.py +67 -0
- qenvs-0.0.1.dist-info/METADATA +160 -0
- qenvs-0.0.1.dist-info/RECORD +26 -0
- qenvs-0.0.1.dist-info/WHEEL +4 -0
- qenvs-0.0.1.dist-info/entry_points.txt +2 -0
- qenvs-0.0.1.dist-info/licenses/LICENSE +202 -0
- qenvs-0.0.1.dist-info/licenses/NOTICE +25 -0
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)
|
qenvs/models/__init__.py
ADDED
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
|