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.
@@ -0,0 +1,210 @@
1
+ """Reference sequences
2
+
3
+ Commonly used sequences, defined once with `@sequence`, for tests, examples
4
+ and benchmarks where the sequence is not the focus. Conventions:
5
+
6
+ - angles in degrees, times in ms; durations are set on the operators, so
7
+ the ADC times are meaningful (`output="times"`);
8
+ - relaxation and frequency offsets from the `Sample` (T1, T2, df), B1 and
9
+ B0 from the `Scanner`;
10
+ - numeric parameters (angles, timings, trains) are declared variables,
11
+ structural sizes (numbers of echoes, dummy repetitions) are static;
12
+ - the ADC phase follows the RF phase (RF-spoiled and phase-cycled
13
+ sequences are demodulated).
14
+
15
+ Each sequence gives the signal when called (`mse(esp=10.0, sample=...)`),
16
+ derivatives and statistics by name (`mse.jacobian(["T2"], esp=10.0, ...)`),
17
+ and the layer-2 list with `.build(...)`. A multi-point sequence (e.g. pSPGR)
18
+ is a batch axis of the variables: `spgr(alpha=Axis("point", alphas),
19
+ phi=Axis("point", phis), ...)`.
20
+
21
+ Preparation modules (`inversion`, `t2prep`) are plain functions returning
22
+ lists of operators, to compose into sequences.
23
+ """
24
+
25
+ import jax.numpy as jnp
26
+
27
+ from .. import ops
28
+ from ..sequences import sequence
29
+
30
+ #
31
+ # spin echoes
32
+
33
+
34
+ @sequence(variables=["esp", "alpha"])
35
+ def mse(esp, alpha=180.0, necho=10):
36
+ """multi spin echo (CPMG): 90y excitation, `necho` refocusing pulses (x)
37
+
38
+ Args:
39
+ esp: echo spacing (ms)
40
+ alpha: refocusing flip angle (degrees)
41
+ necho: (static) number of echoes
42
+ """
43
+ shift, relax = ops.S(1), ops.E(esp / 2, duration=True)
44
+ block = [shift, relax, ops.T(alpha, 0), shift, relax, ops.ADC]
45
+ return [ops.T(90, 90)] + [block] * necho
46
+
47
+
48
+ @sequence(variables=["esp", "alpha[n]"])
49
+ def tse(esp, alpha):
50
+ """turbo spin echo with a train of refocusing angles (one echo per angle)
51
+
52
+ Args:
53
+ esp: echo spacing (ms)
54
+ alpha: refocusing flip angles (degrees), one per echo
55
+ """
56
+ shift, relax = ops.S(1), ops.E(esp / 2, duration=True)
57
+ return [ops.T(90, 90)] + [
58
+ [shift, relax, ops.T(a, 0), shift, relax, ops.ADC] for a in alpha
59
+ ]
60
+
61
+
62
+ @sequence(variables=["alpha", "esp"])
63
+ def hyperecho(alpha, esp=10.0, n=20):
64
+ """hyperecho: 90y, n pulses (alpha, x), 180x, n pulses (-alpha, x)
65
+
66
+ The hyperecho forms at the last ADC (full refocusing without relaxation).
67
+
68
+ Args:
69
+ alpha: flip angle of the trains (degrees)
70
+ esp: pulse spacing (ms)
71
+ n: (static) number of pulses of each train
72
+ """
73
+ shift, relax = ops.S(1), ops.E(esp, duration=True)
74
+ first = [[ops.T(alpha, 0), shift, relax, ops.ADC]] * n
75
+ second = [[ops.T(-alpha, 0), shift, relax, ops.ADC]] * n
76
+ return (
77
+ [ops.T(90, 90), shift, relax] + first + [ops.T(180, 0), shift, relax] + second
78
+ )
79
+
80
+
81
+ #
82
+ # gradient echoes
83
+
84
+
85
+ @sequence(variables=["alpha", "TR", "TE", "phi"])
86
+ def spgr(alpha, TR, TE, phi=117.0, ndummy=200, nread=1):
87
+ """RF-spoiled gradient echo (quadratic phase), acquired at steady state
88
+
89
+ Phase of pulse n: phi * n * (n + 1) / 2 (increment growing by phi); the
90
+ ADC phase follows the RF phase. Small `phi` gives partially spoiled
91
+ sequences (pSPGR).
92
+
93
+ Args:
94
+ alpha: flip angle (degrees)
95
+ TR, TE: repetition and echo times (ms)
96
+ phi: phase increment step (degrees)
97
+ ndummy: (static) number of dummy repetitions
98
+ nread: (static) number of acquired repetitions (after the dummies)
99
+ """
100
+ n = jnp.arange(ndummy + nread)
101
+ phases = phi * n * (n + 1) / 2
102
+ rlx1, rlx2 = ops.E(TE, duration=True), ops.E(TR - TE, duration=True)
103
+ dummies = [[ops.T(alpha, p), rlx1, rlx2, ops.S(1)] for p in phases[:ndummy]]
104
+ reads = [
105
+ [ops.T(alpha, p), rlx1, ops.Adc(phase=p), rlx2, ops.S(1)]
106
+ for p in phases[ndummy:]
107
+ ]
108
+ return dummies + reads
109
+
110
+
111
+ @sequence(variables=["alpha", "TR"])
112
+ def bssfp(alpha, TR, ndummy=200, nread=1):
113
+ """balanced SSFP: phase alternation (0, 180), echo at TE = TR / 2
114
+
115
+ Off-resonance from the sample (`df`) or the scanner (`B0`); the ADC phase
116
+ follows the RF phase.
117
+
118
+ Args:
119
+ alpha: flip angle (degrees)
120
+ TR: repetition time (ms)
121
+ ndummy: (static) number of dummy repetitions
122
+ nread: (static) number of acquired repetitions (after the dummies)
123
+ """
124
+ half = ops.E(TR / 2, duration=True)
125
+ blocks = []
126
+ for n in range(ndummy + nread):
127
+ phase = 180.0 * (n % 2)
128
+ adc = [ops.Adc(phase=phase)] if n >= ndummy else []
129
+ blocks.append([ops.T(alpha, phase), half, *adc, half])
130
+ return blocks
131
+
132
+
133
+ @sequence(variables=["alpha", "TR", "TE"])
134
+ def dess(alpha, TR, TE, ndummy=200, nread=1):
135
+ """double echo steady state: FID and echo of an unbalanced gradient echo
136
+
137
+ The FID is acquired TE after the pulse (probe "fid"), the echo TE before
138
+ the next pulse (probe "echo"); `group="name"` separates them.
139
+
140
+ Args:
141
+ alpha: flip angle (degrees)
142
+ TR, TE: repetition time and echo time of the FID (ms)
143
+ ndummy: (static) number of dummy repetitions
144
+ nread: (static) number of acquired repetitions (after the dummies)
145
+ """
146
+ rf, shift = ops.T(alpha, 0), ops.S(1)
147
+ rlx1, rlx2 = ops.E(TE, duration=True), ops.E(TR - 2 * TE, duration=True)
148
+ fid, echo = ops.Adc(name="fid"), ops.Adc(name="echo")
149
+ dummies = [[rf, rlx1, shift, rlx2, rlx1]] * ndummy
150
+ reads = [[rf, rlx1, fid, shift, rlx2, echo, rlx1]] * nread
151
+ return dummies + reads
152
+
153
+
154
+ @sequence(variables=["alpha[n]", "TR[n]", "TE", "TI"])
155
+ def mrf_fisp(alpha, TR, TE=3.0, TI=20.0, phi=90.0):
156
+ """MR fingerprinting (FISP): inversion, then a train of (alpha_i, TR_i)
157
+
158
+ Args:
159
+ alpha: flip angles (degrees), one per repetition
160
+ TR: repetition times (ms), one per repetition
161
+ TE: echo time (ms)
162
+ TI: inversion delay (ms)
163
+ phi: (static) RF phase (degrees)
164
+ """
165
+ rlx1 = ops.E(TE, duration=True)
166
+ return [ops.T(180, phi), ops.E(TI, duration=True)] + [
167
+ [ops.T(a, phi), rlx1, ops.ADC, ops.E(tr - TE, duration=True), ops.S(1)]
168
+ for a, tr in zip(alpha, TR)
169
+ ]
170
+
171
+
172
+ @sequence(variables=["TE", "esp", "alpha"])
173
+ def epi(TE, esp=0.5, alpha=90.0, nlines=64):
174
+ """single-shot gradient echo EPI, at the signal level (no encoding)
175
+
176
+ One ADC per line, every `esp`, the center line at TE (requires TE >=
177
+ (nlines // 2) * esp). Intra-voxel dephasing (T2*) can be modelled by a
178
+ frequency distribution of the sample (`df` on an axis), summed with
179
+ `probe=Adc(reduce=...)`.
180
+
181
+ Args:
182
+ TE: echo time of the center line (ms)
183
+ esp: line spacing (ms)
184
+ alpha: flip angle (degrees)
185
+ nlines: (static) number of lines
186
+ """
187
+ first = TE - (nlines // 2) * esp
188
+ lines = [[ops.E(esp, duration=True), ops.ADC]] * (nlines - 1)
189
+ return [ops.T(alpha, 90), ops.E(first, duration=True), ops.ADC] + lines
190
+
191
+
192
+ #
193
+ # preparation modules (lists of operators)
194
+
195
+
196
+ def inversion(TI, phi=0.0, spoil=True):
197
+ """inversion module: 180 pulse, spoiler (optional), delay TI (ms)"""
198
+ spoiler = [ops.SPOILER] if spoil else []
199
+ return [ops.T(180, phi)] + spoiler + [ops.E(TI, duration=True)]
200
+
201
+
202
+ def t2prep(TE, nrefocus=4):
203
+ """T2 preparation: 90x, `nrefocus` 180y pulses over TE (ms), -90x, spoiler
204
+
205
+ Stores exp(-TE / T2) of the longitudinal magnetization (on resonance,
206
+ without T1 recovery).
207
+ """
208
+ half = ops.E(TE / nrefocus / 2, duration=True)
209
+ refocus = [[half, ops.T(180, 90), half]] * nrefocus
210
+ return [ops.T(90, 0)] + refocus + [ops.T(-90, 0), ops.SPOILER]
epgx/lowering.py ADDED
@@ -0,0 +1,95 @@
1
+ """EPG front-end of the compiler: operators -> steps, compile_sequence
2
+
3
+ The compiler (cf. compiler) is generic; this module provides what is specific
4
+ to operators:
5
+ - `lower(op)`: Step applying the operator's kernel with its normalized
6
+ parameters, from the run context (sample, scanner); probes acquire;
7
+ - `compile_sequence`: compiler with this lowering and, with `merge=True`,
8
+ the merging pass over affine maps (cf. merging, kernels.affine).
9
+
10
+ Programs compiled here run with `program.run(state, Context(sample, scanner,
11
+ method))`; a tuple (sample, scanner) or None is also accepted. The method
12
+ (static) gives map builders access to the representation of the states (e.g.
13
+ the coordinates of a phase grid, for maps of shifts).
14
+ """
15
+
16
+ from . import compiler, merging
17
+ from .common import pytree, static
18
+ from .kernels.affine import AffineAlgebra
19
+ from .ops import Operator, Probe
20
+
21
+
22
+ @pytree
23
+ class Context:
24
+ """Run context of a compiled sequence
25
+
26
+ Attributes:
27
+ sample, scanner: Sample and Scanner (or None)
28
+ method: (static) representation of the states (e.g. `Lattice(...)`)
29
+ """
30
+
31
+ sample: object = None
32
+ scanner: object = None
33
+ method: object = static()
34
+
35
+ def __init__(self, sample=None, scanner=None, method=None):
36
+ object.__setattr__(self, "sample", sample)
37
+ object.__setattr__(self, "scanner", scanner)
38
+ object.__setattr__(self, "method", method)
39
+
40
+
41
+ def lower(op):
42
+ """Step of an operator (run context: Context, (sample, scanner) or None)"""
43
+ if not isinstance(op, Operator):
44
+ raise ValueError(f"Invalid operator: {op}")
45
+
46
+ def params(context):
47
+ return op.normalize(*_unpack(context))
48
+
49
+ acquire = None
50
+ if isinstance(op, Probe):
51
+
52
+ def acquire(state, context):
53
+ return op.acquire(state, *_unpack(context))
54
+
55
+ return compiler.Step(op.kernel, params, acquire, repr(op), source=op)
56
+
57
+
58
+ def compile_sequence(
59
+ sequence,
60
+ *,
61
+ max_unroll=compiler.MAX_UNROLL,
62
+ checkpoint=False,
63
+ merge=False,
64
+ passes=(),
65
+ ):
66
+ """compile a (nested) list of operators (cf. compiler.build)
67
+
68
+ Args:
69
+ sequence: (nested) list of operators; repeated blocks are consecutive
70
+ sub-lists with the same structure (e.g. `[exc] + [block] * N`)
71
+ max_unroll, checkpoint: cf. compiler.build
72
+ merge: merge consecutive affine operators (RF pulses, relaxation,
73
+ precession, spoilers) into single maps, including repeated blocks
74
+ passes: other passes (Program -> Program), applied after merging
75
+
76
+ Returns:
77
+ Program, run with `program.run(state, Context(sample, scanner, method))`
78
+ """
79
+ passes = ((merging.merge(AffineAlgebra),) if merge else ()) + tuple(passes)
80
+ return compiler.build(
81
+ sequence,
82
+ lower=lower,
83
+ passes=passes,
84
+ max_unroll=max_unroll,
85
+ checkpoint=checkpoint,
86
+ )
87
+
88
+
89
+ def _unpack(context):
90
+ """(sample, scanner) from the run context"""
91
+ if context is None:
92
+ return None, None
93
+ elif isinstance(context, Context):
94
+ return context.sample, context.scanner
95
+ return context
epgx/merging.py ADDED
@@ -0,0 +1,137 @@
1
+ """Merging pass: consecutive operators composed into single maps
2
+
3
+ A compiler pass (cf. compiler) parameterized by a map algebra, e.g.
4
+ `kernels.affine.AffineAlgebra` (affine maps on (F+, F-, Z)). An algebra provides:
5
+
6
+ mergeable(op): True if the operator has a map in this algebra
7
+ build(op, params, context): map of an operator from its kernel parameters
8
+ and the run context (e.g. the method, for maps depending on the
9
+ representation of the states)
10
+ apply(state, map): kernel applying a map to the states
11
+ compose(*maps): composition, first applied first
12
+ power(map, n): map applied n times (static n)
13
+ fold(stacked): composition of stacked maps (leading axis), in order
14
+
15
+ The pass, applied to each level of the program (top level and scan bodies):
16
+ - reduces repeated blocks made of a single mergeable step (after merging
17
+ of the body) to one step: `power` for identical blocks, maps of all
18
+ blocks then `fold` otherwise;
19
+ - composes runs of consecutive mergeable steps (no acquisition) into one
20
+ step; other steps (shifts, probes) are barriers.
21
+
22
+ A merged step is an ordinary step: kernel `algebra.apply`, parameters the
23
+ composed map (computed from the run context when the step runs).
24
+ """
25
+
26
+ import dataclasses
27
+ import typing
28
+
29
+ import jax
30
+
31
+ from .compiler import Program, Scan, Step, Unrolled, join_unrolled
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True)
35
+ class Merged:
36
+ """source of a merged step: its map as a function of the run context"""
37
+
38
+ map: typing.Callable
39
+
40
+
41
+ def merge(algebra):
42
+ """merging pass (Program -> Program) for a map algebra"""
43
+
44
+ def merge_pass(program):
45
+ segments = [_reduce_scan(segment, algebra) for segment in program.segments]
46
+ segments = [
47
+ Unrolled(_merge_steps(segment.steps, algebra))
48
+ if isinstance(segment, Unrolled)
49
+ else segment
50
+ for segment in join_unrolled(segments)
51
+ ]
52
+ return Program(segments)
53
+
54
+ return merge_pass
55
+
56
+
57
+ def map_of(step, algebra):
58
+ """map of a step as a function of the context, or None (not mergeable)"""
59
+ if isinstance(step.source, Merged):
60
+ return step.source.map
61
+ elif step.acquire is not None or not algebra.mergeable(step.source):
62
+ return None
63
+ return lambda context: algebra.build(step.source, step.params(context), context)
64
+
65
+
66
+ def merged_step(map_fn, algebra, label, count):
67
+ """step applying the map map_fn(context)"""
68
+
69
+ def kernel(state, value):
70
+ return algebra.apply(state, value)
71
+
72
+ def params(context):
73
+ return {"value": map_fn(context)}
74
+
75
+ return Step(kernel, params, None, label, count, Merged(map_fn))
76
+
77
+
78
+ #
79
+ # private
80
+
81
+
82
+ def _merge_steps(steps, algebra):
83
+ """compose runs of consecutive mergeable steps"""
84
+ merged, run = [], []
85
+
86
+ def flush():
87
+ if len(run) > 1:
88
+ maps = [map_of(step, algebra) for step in run]
89
+
90
+ def composed(context, maps=maps):
91
+ return algebra.compose(*(map_fn(context) for map_fn in maps))
92
+
93
+ count = sum(step.count for step in run)
94
+ merged.append(merged_step(composed, algebra, f"merged({len(run)})", count))
95
+ else:
96
+ merged.extend(run)
97
+ run.clear()
98
+
99
+ for step in steps:
100
+ if map_of(step, algebra) is not None:
101
+ run.append(step)
102
+ else:
103
+ flush()
104
+ merged.append(step)
105
+ flush()
106
+ return merged
107
+
108
+
109
+ def _single_step(program):
110
+ """the only step of a program, or None"""
111
+ if len(program.segments) != 1 or not isinstance(program.segments[0], Unrolled):
112
+ return None
113
+ steps = program.segments[0].steps
114
+ return steps[0] if len(steps) == 1 else None
115
+
116
+
117
+ def _reduce_scan(segment, algebra):
118
+ """Scan of blocks reduced to a single mergeable step -> one merged step"""
119
+ if not isinstance(segment, Scan):
120
+ return segment
121
+ step = _single_step(segment.body)
122
+ if step is None or map_of(step, algebra) is None:
123
+ return segment
124
+
125
+ def block_map(xs, context):
126
+ return map_of(_single_step(segment.body_at(xs)), algebra)(context)
127
+
128
+ def scan_map(context):
129
+ if segment.xs is None:
130
+ # same block repeated: power (log2(length) compositions)
131
+ return algebra.power(block_map(None, context), segment.length)
132
+ # maps of all blocks (vectorized), then composition in order
133
+ stacked = jax.vmap(lambda xs: block_map(xs, context))(segment.xs)
134
+ return algebra.fold(stacked)
135
+
136
+ step = merged_step(scan_map, algebra, f"merged({segment!r})", segment.count)
137
+ return Unrolled([step])