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 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