epgx 0.1.dev0__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.
- epgx/__init__.py +19 -0
- epgx/axes.py +285 -0
- epgx/common.py +63 -0
- epgx/compiler.py +347 -0
- epgx/diff.py +242 -0
- epgx/examples.py +146 -0
- epgx/functions.py +602 -0
- epgx/kernels/__init__.py +9 -0
- epgx/kernels/affine.py +187 -0
- epgx/kernels/evolution.py +40 -0
- epgx/kernels/operator.py +32 -0
- epgx/kernels/probe.py +107 -0
- epgx/kernels/shift.py +106 -0
- epgx/kernels/state.py +447 -0
- epgx/kernels/transition.py +48 -0
- epgx/library/__init__.py +3 -0
- epgx/library/sequences.py +210 -0
- epgx/lowering.py +95 -0
- epgx/merging.py +137 -0
- epgx/methods.py +329 -0
- epgx/ops/__init__.py +30 -0
- epgx/ops/dephasing.py +71 -0
- epgx/ops/evolution.py +135 -0
- epgx/ops/functions.py +158 -0
- epgx/ops/operator.py +224 -0
- epgx/ops/probe.py +116 -0
- epgx/ops/shift.py +118 -0
- epgx/ops/transition.py +58 -0
- epgx/plotting.py +226 -0
- epgx/progress.py +81 -0
- epgx/sample.py +142 -0
- epgx/sequences.py +850 -0
- epgx/stats.py +117 -0
- epgx-0.1.dev0.dist-info/METADATA +64 -0
- epgx-0.1.dev0.dist-info/RECORD +38 -0
- epgx-0.1.dev0.dist-info/WHEEL +5 -0
- epgx-0.1.dev0.dist-info/licenses/LICENSE +28 -0
- epgx-0.1.dev0.dist-info/top_level.txt +1 -0
epgx/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Extended Phase Graph (EPG) simulations in JAX
|
|
2
|
+
|
|
3
|
+
Note: numerical precision follows JAX's configuration (float32 by default).
|
|
4
|
+
For float64 precision, enable x64 *before* any computation:
|
|
5
|
+
|
|
6
|
+
import jax
|
|
7
|
+
jax.config.update("jax_enable_x64", True)
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.dev0"
|
|
12
|
+
|
|
13
|
+
from .axes import Axis, resolve
|
|
14
|
+
from .functions import Trace, get_adc_times, getnshift, simulate, trace
|
|
15
|
+
from .lowering import compile_sequence
|
|
16
|
+
from .methods import Isochromats, Lattice
|
|
17
|
+
from .ops import DephasingAxis, kAxis, tAxis
|
|
18
|
+
from .sample import Sample, Scanner
|
|
19
|
+
from .sequences import Sequence, sequence
|
epgx/axes.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Named axes and resolution to a positional layout
|
|
2
|
+
|
|
3
|
+
Batch dimensions are either:
|
|
4
|
+
- named: `Axis(name, values)` (`name` may be a tuple for nd values)
|
|
5
|
+
- unnamed (positional): non-scalar arrays, aligned with the implicit axes
|
|
6
|
+
dim_0, dim_1... (left-aligned by default, cf. `align`)
|
|
7
|
+
|
|
8
|
+
`resolve` collects all axes of a set of pytrees (sequence, sample, scanner...),
|
|
9
|
+
orders them (positional axes first, then named axes by first appearance, or
|
|
10
|
+
explicit `dims`), and converts every leaf into an array broadcastable to the
|
|
11
|
+
common batch shape. This is done once, on static shapes, before simulation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
|
|
17
|
+
from .common import pytree, static
|
|
18
|
+
|
|
19
|
+
# alignment of unnamed arrays: "left" (default) or "right" (NumPy)
|
|
20
|
+
ALIGNMENT = "left"
|
|
21
|
+
|
|
22
|
+
POSITIONAL = "dim_{}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytree
|
|
26
|
+
class Axis:
|
|
27
|
+
"""Named array (named axis when 1d)
|
|
28
|
+
|
|
29
|
+
The names cover the leading dimensions of the values; the remaining
|
|
30
|
+
dimensions are core dimensions, declared by `@sequence` variables (e.g.
|
|
31
|
+
`Axis("candidate", alphas)` of shape (10, 400) for `alpha[n]`). At layer 2
|
|
32
|
+
nothing is declared, so every dimension needs a name (checked when the
|
|
33
|
+
axes are collected, cf. `get_layout`).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: axis name (str), or tuple of names (leading dimensions of values)
|
|
37
|
+
values: array with at least one dimension per name
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
values: jax.Array = None
|
|
41
|
+
name: object = static()
|
|
42
|
+
|
|
43
|
+
def __init__(self, name, values):
|
|
44
|
+
values = jnp.asarray(values)
|
|
45
|
+
names = (name,) if isinstance(name, str) else tuple(name)
|
|
46
|
+
if not all(isinstance(n, str) for n in names):
|
|
47
|
+
raise TypeError(f"Axis names must be strings: {name}")
|
|
48
|
+
elif len(set(names)) != len(names):
|
|
49
|
+
raise ValueError(f"Duplicate axis names: {names}")
|
|
50
|
+
elif values.ndim < len(names):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"More Axis names than dimensions: {names}, {values.shape}"
|
|
53
|
+
)
|
|
54
|
+
object.__setattr__(self, "values", values)
|
|
55
|
+
object.__setattr__(self, "name", names[0] if len(names) == 1 else names)
|
|
56
|
+
|
|
57
|
+
def __repr__(self):
|
|
58
|
+
return f"Axis({self.name!r}, <{tuple(self.shape)}>)"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def names(self):
|
|
62
|
+
return (self.name,) if isinstance(self.name, str) else tuple(self.name)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def shape(self):
|
|
66
|
+
return jnp.shape(self.values)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def sizes(self):
|
|
70
|
+
"""dict of axis sizes"""
|
|
71
|
+
return dict(zip(self.names, self.shape))
|
|
72
|
+
|
|
73
|
+
def expand(self, names, sizes=None):
|
|
74
|
+
"""return values with axes ordered as `names` (missing axes: size 1 or sizes)"""
|
|
75
|
+
values = place(self.values, self.names, names)
|
|
76
|
+
if sizes is not None:
|
|
77
|
+
values = jnp.broadcast_to(values, tuple(sizes[n] for n in names))
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_axis(obj):
|
|
82
|
+
return isinstance(obj, Axis)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def align(value, ndim, alignment=None):
|
|
86
|
+
"""expand an unnamed array to ndim positional axes
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
value: array with at most ndim dimensions
|
|
90
|
+
ndim: number of positional axes
|
|
91
|
+
alignment: "left" (new axes appended) or "right" (prepended,
|
|
92
|
+
NumPy), default: ALIGNMENT
|
|
93
|
+
"""
|
|
94
|
+
value = jnp.asarray(value)
|
|
95
|
+
return jnp.reshape(value, align_shape(value.shape, ndim, alignment))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def align_shape(shape, ndim, alignment=None):
|
|
99
|
+
"""aligned shape (cf. `align`)"""
|
|
100
|
+
alignment = alignment or ALIGNMENT
|
|
101
|
+
shape = tuple(shape)
|
|
102
|
+
diff = ndim - len(shape)
|
|
103
|
+
if diff < 0:
|
|
104
|
+
raise ValueError(f"Cannot align shape {shape} to {ndim} dimensions")
|
|
105
|
+
elif alignment == "left":
|
|
106
|
+
return shape + (1,) * diff
|
|
107
|
+
elif alignment == "right":
|
|
108
|
+
return (1,) * diff + shape
|
|
109
|
+
raise ValueError(f"Unknown alignment: {alignment}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def place(values, names, dims):
|
|
113
|
+
"""transpose values (with given axis names) to `dims`, insert missing axes"""
|
|
114
|
+
names = tuple(names)
|
|
115
|
+
if not set(names) <= set(dims):
|
|
116
|
+
raise ValueError(f"Unknown axes {set(names) - set(dims)} in {dims}")
|
|
117
|
+
perm = sorted(range(len(names)), key=lambda i: dims.index(names[i]))
|
|
118
|
+
values = jnp.transpose(values, perm)
|
|
119
|
+
ordered = [names[i] for i in perm]
|
|
120
|
+
shape = [values.shape[ordered.index(d)] if d in names else 1 for d in dims]
|
|
121
|
+
return jnp.reshape(values, shape)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytree
|
|
125
|
+
class Layout:
|
|
126
|
+
"""Batch layout: ordered axis names and sizes
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
dims: (static) ordered axis names
|
|
130
|
+
shape: (static) axis sizes
|
|
131
|
+
npos: (static) number of positional axes
|
|
132
|
+
alignment: (static) alignment of unnamed arrays
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
dims: tuple = static(())
|
|
136
|
+
shape: tuple = static(())
|
|
137
|
+
npos: int = static(0)
|
|
138
|
+
alignment: str = static("left")
|
|
139
|
+
|
|
140
|
+
def __init__(self, dims=(), shape=(), npos=0, alignment=None):
|
|
141
|
+
object.__setattr__(self, "dims", tuple(dims))
|
|
142
|
+
object.__setattr__(self, "shape", tuple(shape))
|
|
143
|
+
object.__setattr__(self, "npos", npos)
|
|
144
|
+
object.__setattr__(self, "alignment", alignment or ALIGNMENT)
|
|
145
|
+
|
|
146
|
+
def __repr__(self):
|
|
147
|
+
axes = ", ".join(f"{d}={s}" for d, s in zip(self.dims, self.shape))
|
|
148
|
+
return f"Layout({axes})"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def positional(self):
|
|
152
|
+
"""names of the positional axes"""
|
|
153
|
+
return tuple(POSITIONAL.format(i) for i in range(self.npos))
|
|
154
|
+
|
|
155
|
+
def names_of(self, value):
|
|
156
|
+
"""axis names of a leaf (Axis or unnamed array)"""
|
|
157
|
+
if is_axis(value):
|
|
158
|
+
return value.names
|
|
159
|
+
ndim = jnp.ndim(value)
|
|
160
|
+
if ndim == 0:
|
|
161
|
+
return ()
|
|
162
|
+
elif self.alignment == "left":
|
|
163
|
+
return self.positional[:ndim]
|
|
164
|
+
return self.positional[self.npos - ndim :]
|
|
165
|
+
|
|
166
|
+
def index(self, axis):
|
|
167
|
+
"""position of an axis given by name, or by positional index (int)"""
|
|
168
|
+
if isinstance(axis, str):
|
|
169
|
+
if axis not in self.dims:
|
|
170
|
+
raise ValueError(f"Unknown axis: {axis}, available: {self.dims}")
|
|
171
|
+
return self.dims.index(axis)
|
|
172
|
+
positional = self.positional
|
|
173
|
+
if not -len(positional) <= axis < len(positional):
|
|
174
|
+
raise ValueError(f"Invalid positional axis {axis} for {self.npos} axes")
|
|
175
|
+
return self.dims.index(positional[axis])
|
|
176
|
+
|
|
177
|
+
def reduced_dims(self, reduce):
|
|
178
|
+
"""axis names remaining after reduction
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
reduce: None/False (no reduction), True (all axes),
|
|
182
|
+
or tuple of positions (e.g. resolved `Adc.reduce`)
|
|
183
|
+
"""
|
|
184
|
+
if reduce is None or reduce is False:
|
|
185
|
+
return self.dims
|
|
186
|
+
elif reduce is True:
|
|
187
|
+
return ()
|
|
188
|
+
return tuple(d for i, d in enumerate(self.dims) if i not in reduce)
|
|
189
|
+
|
|
190
|
+
def convert(self, value):
|
|
191
|
+
"""convert a leaf to an array broadcastable to the batch shape"""
|
|
192
|
+
if is_axis(value):
|
|
193
|
+
return place(value.values, value.names, self.dims)
|
|
194
|
+
elif jnp.ndim(value) == 0:
|
|
195
|
+
return value
|
|
196
|
+
aligned = align(value, self.npos, self.alignment)
|
|
197
|
+
return place(aligned, self.positional, self.dims)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_layout(*trees, dims=None, alignment=None):
|
|
201
|
+
"""collect the batch axes of pytrees (Axis or unnamed array leaves)
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
trees: pytrees (sequence, sample, scanner...)
|
|
205
|
+
dims: explicit order of all axes (positional axes are named dim_i)
|
|
206
|
+
alignment: alignment of unnamed arrays (default: ALIGNMENT)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Layout
|
|
210
|
+
"""
|
|
211
|
+
alignment = alignment or ALIGNMENT
|
|
212
|
+
leaves = jax.tree.leaves(trees, is_leaf=is_axis)
|
|
213
|
+
|
|
214
|
+
# named axes, by first appearance
|
|
215
|
+
named = {}
|
|
216
|
+
for leaf in filter(is_axis, leaves):
|
|
217
|
+
if len(leaf.names) != len(leaf.shape):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Axis {leaf.name!r} names {len(leaf.names)} of {len(leaf.shape)} "
|
|
220
|
+
f"dimensions {tuple(leaf.shape)}: at layer 2 every dimension needs "
|
|
221
|
+
"a name (in @sequence, the other dimensions must be the declared "
|
|
222
|
+
"core of the variable)"
|
|
223
|
+
)
|
|
224
|
+
for name, size in leaf.sizes.items():
|
|
225
|
+
if named.setdefault(name, size) != size:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Inconsistent sizes for axis {name}: {named[name]}, {size}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# positional axes
|
|
231
|
+
shapes = [jnp.shape(leaf) for leaf in leaves if not is_axis(leaf)]
|
|
232
|
+
npos = max((len(shape) for shape in shapes), default=0)
|
|
233
|
+
aligned = [align_shape(shape, npos, alignment) for shape in shapes]
|
|
234
|
+
try:
|
|
235
|
+
posshape = jnp.broadcast_shapes(*aligned) if aligned else ()
|
|
236
|
+
except ValueError:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Incompatible unnamed array shapes ({alignment}-aligned): {shapes}"
|
|
239
|
+
)
|
|
240
|
+
positional = {POSITIONAL.format(i): size for i, size in enumerate(posshape)}
|
|
241
|
+
if set(positional) & set(named):
|
|
242
|
+
raise ValueError(f"Reserved axis names: {set(positional) & set(named)}")
|
|
243
|
+
|
|
244
|
+
sizes = {**positional, **named}
|
|
245
|
+
if dims is None:
|
|
246
|
+
dims = tuple(sizes)
|
|
247
|
+
elif set(dims) != set(sizes) or len(dims) != len(sizes):
|
|
248
|
+
raise ValueError(f"`dims` must contain all axes {tuple(sizes)}, got: {dims}")
|
|
249
|
+
return Layout(dims, tuple(sizes[d] for d in dims), npos, alignment)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_coords(*trees):
|
|
253
|
+
"""coordinates of 1d named axes (values of their first occurrence)"""
|
|
254
|
+
coords = {}
|
|
255
|
+
for leaf in filter(is_axis, jax.tree.leaves(trees, is_leaf=is_axis)):
|
|
256
|
+
if len(leaf.names) == 1:
|
|
257
|
+
coords.setdefault(leaf.name, leaf.values)
|
|
258
|
+
return coords
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolve(*trees, dims=None, alignment=None):
|
|
262
|
+
"""convert named and unnamed leaves to a common positional layout
|
|
263
|
+
|
|
264
|
+
Objects defining a `resolve_axes(layout)` method (e.g. ADC with named
|
|
265
|
+
reduction axes) are given the layout before conversion.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
trees: pytrees (sequence, sample, scanner...)
|
|
269
|
+
dims, alignment: cf. `get_layout`
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
layout, resolved trees (same structure, Axis leaves replaced by arrays)
|
|
273
|
+
"""
|
|
274
|
+
layout = get_layout(*trees, dims=dims, alignment=alignment)
|
|
275
|
+
|
|
276
|
+
def has_resolve(obj):
|
|
277
|
+
return hasattr(obj, "resolve_axes") and not isinstance(obj, type)
|
|
278
|
+
|
|
279
|
+
trees = jax.tree.map(
|
|
280
|
+
lambda obj: obj.resolve_axes(layout) if has_resolve(obj) else obj,
|
|
281
|
+
trees,
|
|
282
|
+
is_leaf=lambda obj: has_resolve(obj) or is_axis(obj),
|
|
283
|
+
)
|
|
284
|
+
trees = jax.tree.map(layout.convert, trees, is_leaf=is_axis)
|
|
285
|
+
return layout, trees
|
epgx/common.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Common utilities"""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import dataclasses
|
|
5
|
+
|
|
6
|
+
import jax
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def static(default=None):
|
|
11
|
+
"""dataclass field stored as static metadata (not a pytree leaf)"""
|
|
12
|
+
return dataclasses.field(default=default, metadata={"static": True})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pytree(cls):
|
|
16
|
+
"""class decorator: frozen dataclass registered as a pytree
|
|
17
|
+
|
|
18
|
+
Fields declared with `static()` are static metadata, others are leaves.
|
|
19
|
+
Unflattening does not call `__init__`, so constructors may convert and
|
|
20
|
+
validate their arguments. A `replace(**changes)` method is added (copy
|
|
21
|
+
without calling `__init__`).
|
|
22
|
+
"""
|
|
23
|
+
cls = dataclasses.dataclass(frozen=True, eq=False, init=False, repr=False)(cls)
|
|
24
|
+
fields = dataclasses.fields(cls)
|
|
25
|
+
data = tuple(f.name for f in fields if not f.metadata.get("static"))
|
|
26
|
+
meta = tuple(f.name for f in fields if f.metadata.get("static"))
|
|
27
|
+
|
|
28
|
+
def flatten(obj):
|
|
29
|
+
children = [(jax.tree_util.GetAttrKey(n), getattr(obj, n)) for n in data]
|
|
30
|
+
return children, tuple(getattr(obj, n) for n in meta)
|
|
31
|
+
|
|
32
|
+
def unflatten(aux, children):
|
|
33
|
+
obj = object.__new__(cls)
|
|
34
|
+
for name, value in zip(meta + data, tuple(aux) + tuple(children)):
|
|
35
|
+
object.__setattr__(obj, name, value)
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
jax.tree_util.register_pytree_with_keys(cls, flatten, unflatten)
|
|
39
|
+
if "replace" not in cls.__dict__:
|
|
40
|
+
cls.replace = _replace
|
|
41
|
+
return cls
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def repr_value(value):
|
|
45
|
+
"""short representation of a parameter value (scalar, array, Axis, traced)"""
|
|
46
|
+
if value is None:
|
|
47
|
+
return "None"
|
|
48
|
+
elif isinstance(value, jax.core.Tracer):
|
|
49
|
+
return "<traced>"
|
|
50
|
+
elif hasattr(value, "names"): # Axis
|
|
51
|
+
return repr(value)
|
|
52
|
+
value = np.asarray(value)
|
|
53
|
+
if value.size == 1:
|
|
54
|
+
return f"{value.item():.4g}"
|
|
55
|
+
return f"<{value.shape}>"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _replace(self, **changes):
|
|
59
|
+
"""return a copy with given fields replaced (`__init__` is not called)"""
|
|
60
|
+
new = copy.copy(self)
|
|
61
|
+
for name, value in changes.items():
|
|
62
|
+
object.__setattr__(new, name, value)
|
|
63
|
+
return new
|