nutate 0.1.0__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.
- nutate/__init__.py +36 -0
- nutate/_format.py +20 -0
- nutate/_scene.py +422 -0
- nutate/b1.py +91 -0
- nutate/profiles.py +177 -0
- nutate/pulses/__init__.py +8 -0
- nutate/pulses/composite/__init__.py +5 -0
- nutate/pulses/composite/composite.py +59 -0
- nutate/pulses/swept_frequency/__init__.py +17 -0
- nutate/pulses/swept_frequency/chirp.py +68 -0
- nutate/pulses/swept_frequency/chorus.py +97 -0
- nutate/pulses/swept_frequency/hs.py +31 -0
- nutate/pulses/swept_frequency/phase_correction.py +35 -0
- nutate/render.py +268 -0
- nutate/run.py +127 -0
- nutate/segment.py +155 -0
- nutate/simulate.py +222 -0
- nutate-0.1.0.dist-info/METADATA +211 -0
- nutate-0.1.0.dist-info/RECORD +22 -0
- nutate-0.1.0.dist-info/WHEEL +4 -0
- nutate-0.1.0.dist-info/licenses/LICENSE +202 -0
- nutate-0.1.0.dist-info/licenses/NOTICE +1 -0
nutate/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Bloch simulations of any pulse sequence rendered as 3D Bloch-sphere videos."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from nutate.b1 import B1Map, b1_map, b1_phase_sensitivity
|
|
6
|
+
from nutate.profiles import plot_b1_map, plot_b1_phase, plot_map, plot_profiles
|
|
7
|
+
from nutate.render import Camera, Style, render, show, snapshot
|
|
8
|
+
from nutate.run import RunResult, run
|
|
9
|
+
from nutate.segment import Segment, delay, waveform
|
|
10
|
+
from nutate.simulate import PhaseCycle, Trajectory, simulate, simulate_final
|
|
11
|
+
|
|
12
|
+
__version__ = version("nutate")
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"B1Map",
|
|
16
|
+
"Camera",
|
|
17
|
+
"PhaseCycle",
|
|
18
|
+
"RunResult",
|
|
19
|
+
"Segment",
|
|
20
|
+
"Style",
|
|
21
|
+
"Trajectory",
|
|
22
|
+
"b1_map",
|
|
23
|
+
"b1_phase_sensitivity",
|
|
24
|
+
"delay",
|
|
25
|
+
"plot_b1_map",
|
|
26
|
+
"plot_b1_phase",
|
|
27
|
+
"plot_map",
|
|
28
|
+
"plot_profiles",
|
|
29
|
+
"render",
|
|
30
|
+
"run",
|
|
31
|
+
"show",
|
|
32
|
+
"simulate",
|
|
33
|
+
"simulate_final",
|
|
34
|
+
"snapshot",
|
|
35
|
+
"waveform",
|
|
36
|
+
]
|
nutate/_format.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""SI-prefix scaling for axis labels, overlays and colorbars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
_PREFIXES = {-9: "n", -6: "µ", -3: "m", 0: "", 3: "k", 6: "M", 9: "G"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def si_scale(max_abs: float, unit: str) -> tuple[float, str]:
|
|
11
|
+
"""Factor and prefixed unit so that ``max_abs · factor`` lies in ``[1, 1000)``.
|
|
12
|
+
|
|
13
|
+
``si_scale(15e3, "Hz") == (1e-3, "kHz")``; zero gives ``(1.0, unit)``. Prefixes run
|
|
14
|
+
from n to G.
|
|
15
|
+
"""
|
|
16
|
+
if max_abs == 0 or not math.isfinite(max_abs):
|
|
17
|
+
return 1.0, unit
|
|
18
|
+
exponent = 3 * math.floor(math.log10(abs(max_abs)) / 3 + 1e-9)
|
|
19
|
+
exponent = min(max(exponent, -9), 9)
|
|
20
|
+
return 10.0**-exponent, _PREFIXES[exponent] + unit
|
nutate/_scene.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""The Bloch-sphere scene: built once, then moved to any simulation step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import matplotlib as mpl
|
|
10
|
+
import matplotlib.colors as mcolors
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pyvista as pv
|
|
13
|
+
|
|
14
|
+
from nutate._format import si_scale
|
|
15
|
+
from nutate.simulate import Trajectory
|
|
16
|
+
|
|
17
|
+
# Starting values; final look agreed at the milestone-2 review.
|
|
18
|
+
THEMES: dict[str, dict[str, str]] = {
|
|
19
|
+
"light": {
|
|
20
|
+
"background": "#ffffff",
|
|
21
|
+
"text": "#222222",
|
|
22
|
+
"sphere": "#c8d3e0",
|
|
23
|
+
"grid": "#8a94a3",
|
|
24
|
+
"cmap": "berlin", # dark centre reads on white
|
|
25
|
+
"series": "#2a78d6", # inset amplitude line
|
|
26
|
+
},
|
|
27
|
+
"dark": {
|
|
28
|
+
"background": "#0f1115",
|
|
29
|
+
"text": "#e6e6e6",
|
|
30
|
+
"sphere": "#9fb3c8",
|
|
31
|
+
"grid": "#9aa3b2",
|
|
32
|
+
"cmap": "coolwarm", # light centre reads on dark
|
|
33
|
+
"series": "#5b9cf0",
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_FIT_RADIUS = 1.45 # sphere + axis labels
|
|
38
|
+
_VIEW_ANGLE = 25.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Style:
|
|
43
|
+
"""Fine-tuning of the scene's look. ``cmap=None`` takes the theme's default; ``color``
|
|
44
|
+
paints every arrow one colour (no colorbar)."""
|
|
45
|
+
|
|
46
|
+
cmap: str | None = None
|
|
47
|
+
color: str | None = None
|
|
48
|
+
sphere_opacity: float = 0.12
|
|
49
|
+
font: str = "times"
|
|
50
|
+
arrow_shaft_radius: float = 0.015
|
|
51
|
+
arrow_tip_radius: float = 0.05
|
|
52
|
+
arrow_tip_length: float = 0.15
|
|
53
|
+
line_width: float = 3.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Camera:
|
|
58
|
+
"""Viewpoint in degrees: azimuth about +z measured from −y toward +x, elevation above
|
|
59
|
+
the xy-plane.
|
|
60
|
+
|
|
61
|
+
``orbit`` adds that many degrees of azimuth across a video.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
azimuth: float = 45.0
|
|
65
|
+
elevation: float = 22.5
|
|
66
|
+
orbit: float = 0.0
|
|
67
|
+
orthographic: bool = False
|
|
68
|
+
zoom: float = 1.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _camera_position(
|
|
72
|
+
camera: Camera, distance: float, fraction: float = 0.0
|
|
73
|
+
) -> tuple[float, float, float]:
|
|
74
|
+
az = math.radians(camera.azimuth + camera.orbit * fraction)
|
|
75
|
+
el = math.radians(camera.elevation)
|
|
76
|
+
return (
|
|
77
|
+
distance * math.sin(az) * math.cos(el),
|
|
78
|
+
-distance * math.cos(az) * math.cos(el),
|
|
79
|
+
distance * math.sin(el),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _trail_window(step: int, n_steps: int, trail: float, max_vertices: int = 200) -> np.ndarray:
|
|
84
|
+
"""Steps drawn in the trail ending at ``step``: the last ``trail`` fraction of the
|
|
85
|
+
sequence, evenly subsampled to at most ``max_vertices``. Empty when ``trail`` is 0."""
|
|
86
|
+
if trail <= 0:
|
|
87
|
+
return np.array([], dtype=int)
|
|
88
|
+
start = max(0, step - round(trail * n_steps))
|
|
89
|
+
if step - start + 1 <= max_vertices:
|
|
90
|
+
return np.arange(start, step + 1)
|
|
91
|
+
return np.round(np.linspace(start, step, max_vertices)).astype(int)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _polylines(points: np.ndarray) -> pv.PolyData:
|
|
95
|
+
"""One polyline per row of ``points`` (shape ``(n_lines, n_points, 3)``)."""
|
|
96
|
+
n_lines, n_points, _ = points.shape
|
|
97
|
+
mesh = pv.PolyData(points.reshape(-1, 3))
|
|
98
|
+
ids = np.arange(n_lines * n_points).reshape(n_lines, n_points)
|
|
99
|
+
mesh.lines = np.column_stack([np.full(n_lines, n_points), ids]).ravel()
|
|
100
|
+
return mesh
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _circle(points: np.ndarray) -> pv.PolyData:
|
|
104
|
+
return pv.lines_from_points(points, close=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _latitude(lat_deg: float) -> pv.PolyData:
|
|
108
|
+
s = np.linspace(0, 2 * np.pi, 181)[:-1]
|
|
109
|
+
lat = np.radians(lat_deg)
|
|
110
|
+
return _circle(
|
|
111
|
+
np.column_stack(
|
|
112
|
+
[np.cos(lat) * np.cos(s), np.cos(lat) * np.sin(s), np.full_like(s, np.sin(lat))]
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _meridian(az_deg: float) -> pv.PolyData:
|
|
118
|
+
s = np.linspace(0, 2 * np.pi, 181)[:-1]
|
|
119
|
+
az = np.radians(az_deg)
|
|
120
|
+
return _circle(np.column_stack([np.cos(az) * np.cos(s), np.sin(az) * np.cos(s), np.sin(s)]))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _Scene:
|
|
124
|
+
"""All actors for one trajectory; :meth:`update` moves them to a step."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
plotter: pv.Plotter,
|
|
129
|
+
traj: Trajectory,
|
|
130
|
+
*,
|
|
131
|
+
offsets: np.ndarray,
|
|
132
|
+
style: Style,
|
|
133
|
+
camera: Camera,
|
|
134
|
+
theme: Literal["light", "dark"] = "light",
|
|
135
|
+
mode: Literal["arrows", "ribbon", "both"] = "arrows",
|
|
136
|
+
trail: float = 0.1,
|
|
137
|
+
inset: bool = True,
|
|
138
|
+
inset_phase: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
if theme not in THEMES:
|
|
141
|
+
raise ValueError(f"theme must be one of {sorted(THEMES)}, got {theme!r}")
|
|
142
|
+
if mode not in ("arrows", "ribbon", "both"):
|
|
143
|
+
raise ValueError(f"mode must be 'arrows', 'ribbon' or 'both', got {mode!r}")
|
|
144
|
+
if not 0 <= trail <= 1:
|
|
145
|
+
raise ValueError(f"trail must be a fraction in [0, 1], got {trail}")
|
|
146
|
+
if mode != "arrows" and len(offsets) < 2:
|
|
147
|
+
raise ValueError("a ribbon needs at least 2 displayed offsets")
|
|
148
|
+
self.plotter = plotter
|
|
149
|
+
self.traj = traj
|
|
150
|
+
self.idx = np.asarray(offsets)
|
|
151
|
+
self.style = style
|
|
152
|
+
self.camera = camera
|
|
153
|
+
self.mode = mode
|
|
154
|
+
self.trail = trail
|
|
155
|
+
self.theme = THEMES[theme]
|
|
156
|
+
height = plotter.window_size[1]
|
|
157
|
+
self._font_size = max(8, round(24 * height / 1080))
|
|
158
|
+
|
|
159
|
+
factor, self._offset_unit = si_scale(np.abs(traj.offsets).max(), "Hz")
|
|
160
|
+
self._offset_values = traj.offsets[self.idx] * factor
|
|
161
|
+
limit = np.abs(traj.offsets).max() * factor
|
|
162
|
+
self._clim = (-limit, limit) if limit > 0 else (-1.0, 1.0)
|
|
163
|
+
self._cmap = style.cmap or self.theme["cmap"]
|
|
164
|
+
if style.color is not None:
|
|
165
|
+
rgb = np.tile(mcolors.to_rgb(style.color), (self.idx.size, 1))
|
|
166
|
+
else:
|
|
167
|
+
norm = mcolors.Normalize(*self._clim)
|
|
168
|
+
rgb = mpl.colormaps[self._cmap](norm(self._offset_values))[:, :3]
|
|
169
|
+
self._rgb = 255 * rgb
|
|
170
|
+
self._background = 255 * np.array(mcolors.to_rgb(self.theme["background"]))
|
|
171
|
+
|
|
172
|
+
plotter.set_background(self.theme["background"])
|
|
173
|
+
plotter.enable_depth_peeling()
|
|
174
|
+
plotter.enable_anti_aliasing("ssaa")
|
|
175
|
+
self._add_sphere()
|
|
176
|
+
self._add_axes()
|
|
177
|
+
coloured = []
|
|
178
|
+
if trail > 0:
|
|
179
|
+
self._add_trails()
|
|
180
|
+
if mode != "arrows":
|
|
181
|
+
coloured.append(self._add_ribbon())
|
|
182
|
+
if mode != "ribbon":
|
|
183
|
+
coloured.append(self._add_arrows())
|
|
184
|
+
if style.color is None:
|
|
185
|
+
self._add_scalar_bar(coloured[-1])
|
|
186
|
+
self._cursors: list[tuple[pv.charts.LinePlot2D, float]] = []
|
|
187
|
+
self._t_factor, self._t_unit = si_scale(traj.t[-1], "s")
|
|
188
|
+
# Shift the sphere left to leave the bottom-right free for the inset.
|
|
189
|
+
self._window_center = (0.2, 0.0) if inset else (0.0, 0.0)
|
|
190
|
+
if inset:
|
|
191
|
+
self._add_inset(inset_phase)
|
|
192
|
+
self._text = plotter.add_text(
|
|
193
|
+
"",
|
|
194
|
+
position=(0.03, 0.85),
|
|
195
|
+
viewport=True,
|
|
196
|
+
font_size=self._font_size,
|
|
197
|
+
color=self.theme["text"],
|
|
198
|
+
font=style.font,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
self._distance = _FIT_RADIUS / math.sin(math.radians(_VIEW_ANGLE / 2)) / camera.zoom
|
|
202
|
+
if camera.orthographic:
|
|
203
|
+
plotter.enable_parallel_projection()
|
|
204
|
+
self._place_camera(0.0)
|
|
205
|
+
self.update(0)
|
|
206
|
+
|
|
207
|
+
# -- static parts ---------------------------------------------------------------
|
|
208
|
+
def _add_sphere(self) -> None:
|
|
209
|
+
p, th, st = self.plotter, self.theme, self.style
|
|
210
|
+
p.add_mesh(
|
|
211
|
+
pv.Sphere(
|
|
212
|
+
radius=0.99, theta_resolution=96, phi_resolution=96
|
|
213
|
+
), # r=1 would z-fight trails
|
|
214
|
+
color=th["sphere"],
|
|
215
|
+
opacity=st.sphere_opacity,
|
|
216
|
+
smooth_shading=True,
|
|
217
|
+
specular=0.4,
|
|
218
|
+
specular_power=20,
|
|
219
|
+
)
|
|
220
|
+
faint = pv.merge(
|
|
221
|
+
[_latitude(lat) for lat in (-60, -30, 30, 60)]
|
|
222
|
+
+ [_meridian(az) for az in (30, 60, 90, 120, 150)]
|
|
223
|
+
)
|
|
224
|
+
p.add_mesh(faint, color=th["grid"], line_width=1, opacity=0.25)
|
|
225
|
+
strong = pv.merge([_latitude(0), _meridian(0)])
|
|
226
|
+
p.add_mesh(strong, color=th["grid"], line_width=2, opacity=0.6)
|
|
227
|
+
|
|
228
|
+
def _add_axes(self) -> None:
|
|
229
|
+
p, th = self.plotter, self.theme
|
|
230
|
+
for axis in np.eye(3):
|
|
231
|
+
p.add_mesh(
|
|
232
|
+
pv.Line(-1.2 * axis, 1.2 * axis), color=th["text"], line_width=1.5, opacity=0.6
|
|
233
|
+
)
|
|
234
|
+
points = np.vstack([1.3 * np.eye(3), -1.3 * np.eye(3)])
|
|
235
|
+
labels = ["x", "y", "z", "−x", "−y", "−z"]
|
|
236
|
+
p.add_point_labels(
|
|
237
|
+
points,
|
|
238
|
+
labels,
|
|
239
|
+
italic=True,
|
|
240
|
+
bold=False,
|
|
241
|
+
font_size=self._font_size,
|
|
242
|
+
text_color=th["text"],
|
|
243
|
+
font_family=self.style.font,
|
|
244
|
+
shape=None,
|
|
245
|
+
show_points=False,
|
|
246
|
+
always_visible=True,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# -- arrows -----------------------------------------------------------------------
|
|
250
|
+
def _arrow_glyphs(self, step: int) -> pv.PolyData:
|
|
251
|
+
src = pv.PolyData(np.zeros((self.idx.size, 3)))
|
|
252
|
+
src["vectors"] = self.traj.m[step, self.idx]
|
|
253
|
+
src["offset"] = self._offset_values
|
|
254
|
+
return src.glyph(orient="vectors", scale="vectors", geom=self._arrow_geom)
|
|
255
|
+
|
|
256
|
+
def _add_coloured(self, mesh: pv.PolyData, **kwargs) -> pv.Actor:
|
|
257
|
+
"""Add ``mesh`` coloured by its ``offset`` scalars, or in the single style colour."""
|
|
258
|
+
if self.style.color is not None:
|
|
259
|
+
return self.plotter.add_mesh(mesh, color=self.style.color, **kwargs)
|
|
260
|
+
return self.plotter.add_mesh(
|
|
261
|
+
mesh,
|
|
262
|
+
scalars="offset",
|
|
263
|
+
cmap=self._cmap,
|
|
264
|
+
clim=self._clim,
|
|
265
|
+
show_scalar_bar=False,
|
|
266
|
+
**kwargs,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _add_arrows(self) -> pv.Actor:
|
|
270
|
+
st = self.style
|
|
271
|
+
self._arrow_geom = pv.Arrow(
|
|
272
|
+
tip_length=st.arrow_tip_length,
|
|
273
|
+
tip_radius=st.arrow_tip_radius,
|
|
274
|
+
shaft_radius=st.arrow_shaft_radius,
|
|
275
|
+
)
|
|
276
|
+
self._arrows = self._arrow_glyphs(0)
|
|
277
|
+
return self._add_coloured(self._arrows, smooth_shading=True)
|
|
278
|
+
|
|
279
|
+
# -- trails and ribbon -------------------------------------------------------------
|
|
280
|
+
def _trail_mesh(self, step: int) -> pv.PolyData:
|
|
281
|
+
window = _trail_window(step, self.traj.t.size - 1, self.trail)
|
|
282
|
+
if window.size < 2: # nothing to draw yet: a zero-length line
|
|
283
|
+
window = np.array([step, step])
|
|
284
|
+
points = self.traj.m[window][:, self.idx].transpose(1, 0, 2)
|
|
285
|
+
mesh = _polylines(points)
|
|
286
|
+
# Fade toward the background rather than with opacity: overlapping translucent
|
|
287
|
+
# tube segments leave dark beads at every vertex.
|
|
288
|
+
fade = np.linspace(0.0, 1.0, window.size)[None, :, None]
|
|
289
|
+
rgb = self._background + (self._rgb[:, None, :] - self._background) * fade
|
|
290
|
+
mesh["rgb"] = np.round(rgb).astype(np.uint8).reshape(-1, 3)
|
|
291
|
+
return mesh
|
|
292
|
+
|
|
293
|
+
def _add_trails(self) -> None:
|
|
294
|
+
self._trails = self._trail_mesh(0)
|
|
295
|
+
self.plotter.add_mesh(
|
|
296
|
+
self._trails,
|
|
297
|
+
scalars="rgb",
|
|
298
|
+
rgb=True,
|
|
299
|
+
line_width=self.style.line_width, # flat lines: tube joints bead at vertices
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _ribbon_mesh(self, step: int) -> pv.PolyData:
|
|
303
|
+
order = np.argsort(self._offset_values)
|
|
304
|
+
mesh = _polylines(self.traj.m[step, self.idx[order]][None])
|
|
305
|
+
mesh["offset"] = self._offset_values[order]
|
|
306
|
+
return mesh
|
|
307
|
+
|
|
308
|
+
def _add_ribbon(self) -> pv.Actor:
|
|
309
|
+
self._ribbon = self._ribbon_mesh(0)
|
|
310
|
+
return self._add_coloured(
|
|
311
|
+
self._ribbon, render_lines_as_tubes=True, line_width=1.5 * self.style.line_width
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _add_scalar_bar(self, actor: pv.Actor) -> None:
|
|
315
|
+
st = self.style
|
|
316
|
+
self.plotter.add_scalar_bar(
|
|
317
|
+
title=f"offset ({self._offset_unit})",
|
|
318
|
+
mapper=actor.mapper,
|
|
319
|
+
vertical=True,
|
|
320
|
+
position_x=0.9,
|
|
321
|
+
position_y=0.5,
|
|
322
|
+
width=0.05,
|
|
323
|
+
height=0.4,
|
|
324
|
+
color=self.theme["text"],
|
|
325
|
+
font_family=st.font,
|
|
326
|
+
title_font_size=self._font_size,
|
|
327
|
+
label_font_size=max(8, round(0.8 * self._font_size)),
|
|
328
|
+
n_labels=3,
|
|
329
|
+
fmt="%.3g",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# -- per step ---------------------------------------------------------------------
|
|
333
|
+
# -- pulse inset --------------------------------------------------------------------
|
|
334
|
+
def _chart(self, loc: tuple[float, float], x_label: str, y_label: str) -> pv.Chart2D:
|
|
335
|
+
chart = pv.Chart2D(size=(0.35, 0.18), loc=loc, x_label=x_label, y_label=y_label, grid=False)
|
|
336
|
+
chart.background_color = (0, 0, 0, 0)
|
|
337
|
+
chart.border_color = (0, 0, 0, 0)
|
|
338
|
+
text_rgb = mcolors.to_rgb(self.theme["text"])
|
|
339
|
+
size = max(6, round(0.6 * self._font_size))
|
|
340
|
+
for axis in (chart.x_axis, chart.y_axis):
|
|
341
|
+
axis.pen.color = self.theme["text"]
|
|
342
|
+
axis.label_size = size
|
|
343
|
+
axis.tick_label_size = size
|
|
344
|
+
axis.GetTitleProperties().SetColor(text_rgb)
|
|
345
|
+
axis.GetLabelProperties().SetColor(text_rgb)
|
|
346
|
+
self._overlay.add_chart(chart)
|
|
347
|
+
return chart
|
|
348
|
+
|
|
349
|
+
def _add_inset(self, with_phase: bool) -> None:
|
|
350
|
+
"""Drive amplitude (and optionally wrapped phase) of the whole sequence, with a cursor."""
|
|
351
|
+
# Charts go on an overlay layer: under the main renderer's SSAA pass they would be
|
|
352
|
+
# laid out in the supersampled buffer and come out shrunk and misplaced.
|
|
353
|
+
self._overlay = pv.Renderer(self.plotter)
|
|
354
|
+
self._overlay.SetLayer(1)
|
|
355
|
+
self._overlay.SetInteractive(False)
|
|
356
|
+
self.plotter.ren_win.SetNumberOfLayers(2)
|
|
357
|
+
self.plotter.ren_win.AddRenderer(self._overlay)
|
|
358
|
+
seq = self.traj.sequence
|
|
359
|
+
t = self.traj.t[1:] * self._t_factor
|
|
360
|
+
amp = np.concatenate([s.drive for s in seq])
|
|
361
|
+
a_factor, a_unit = si_scale(amp.max(), "Hz")
|
|
362
|
+
t_label = f"t ({self._t_unit})"
|
|
363
|
+
panels = [(f"B1 ({a_unit})", amp * a_factor)]
|
|
364
|
+
if with_phase:
|
|
365
|
+
phase = np.rad2deg(np.mod(np.concatenate([s.phase for s in seq]), 2 * np.pi))
|
|
366
|
+
panels.append(("phase (°)", phase))
|
|
367
|
+
locs = [(0.6, 0.25), (0.6, 0.04)] if with_phase else [(0.6, 0.04)]
|
|
368
|
+
for (y_label, y), loc in zip(panels, locs):
|
|
369
|
+
last = loc == locs[-1]
|
|
370
|
+
chart = self._chart(loc, t_label if last else "", y_label)
|
|
371
|
+
top = 360.0 if y_label.startswith("phase") else max(1.1 * y.max(), 1e-12)
|
|
372
|
+
if y_label.startswith("phase"):
|
|
373
|
+
chart.scatter(t, y, color=self.theme["series"], size=2)
|
|
374
|
+
else:
|
|
375
|
+
chart.line(t, y, color=self.theme["series"], width=1.5)
|
|
376
|
+
chart.x_range = [0.0, t[-1]]
|
|
377
|
+
chart.y_range = [0.0, top]
|
|
378
|
+
cursor = chart.line([0.0, 0.0], [0.0, top], color=self.theme["text"], width=1.5)
|
|
379
|
+
self._cursors.append((cursor, top))
|
|
380
|
+
|
|
381
|
+
def _segment_name(self, step: int) -> str:
|
|
382
|
+
for name, start, stop in self.traj.segments:
|
|
383
|
+
if start < step <= stop:
|
|
384
|
+
return name
|
|
385
|
+
return self.traj.segments[0][0]
|
|
386
|
+
|
|
387
|
+
def update(self, step: int, fraction: float | None = None) -> None:
|
|
388
|
+
"""Move every dynamic actor to simulation ``step``.
|
|
389
|
+
|
|
390
|
+
``fraction`` ∈ [0, 1] of a video also places the camera (orbit); ``None`` leaves the
|
|
391
|
+
camera alone, so an interactive user's view survives scrubbing.
|
|
392
|
+
"""
|
|
393
|
+
if self.mode != "ribbon":
|
|
394
|
+
self._arrows.copy_from(self._arrow_glyphs(step))
|
|
395
|
+
if self.mode != "arrows":
|
|
396
|
+
self._ribbon.copy_from(self._ribbon_mesh(step))
|
|
397
|
+
if self.trail > 0:
|
|
398
|
+
self._trails.copy_from(self._trail_mesh(step))
|
|
399
|
+
for cursor, top in self._cursors:
|
|
400
|
+
tc = self.traj.t[step] * self._t_factor
|
|
401
|
+
cursor.update([tc, tc], [0.0, top])
|
|
402
|
+
t = self.traj.t[step] * self._t_factor
|
|
403
|
+
self._text.SetInput(f"t = {t:.2f} {self._t_unit}\n{self._segment_name(step)}")
|
|
404
|
+
if fraction is not None:
|
|
405
|
+
self._place_camera(fraction)
|
|
406
|
+
|
|
407
|
+
def _place_camera(self, fraction: float) -> None:
|
|
408
|
+
cam = self.plotter.camera
|
|
409
|
+
self.plotter.camera_position = [
|
|
410
|
+
_camera_position(self.camera, self._distance, fraction),
|
|
411
|
+
(0.0, 0.0, 0.0),
|
|
412
|
+
(0.0, 0.0, 1.0),
|
|
413
|
+
]
|
|
414
|
+
cam.view_angle = _VIEW_ANGLE
|
|
415
|
+
cam.SetWindowCenter(*self._window_center)
|
|
416
|
+
if self.camera.orthographic:
|
|
417
|
+
cam.parallel_scale = _FIT_RADIUS / self.camera.zoom
|
|
418
|
+
self.plotter.renderer.ResetCameraClippingRange()
|
|
419
|
+
|
|
420
|
+
def screenshot(self) -> np.ndarray:
|
|
421
|
+
self.plotter.render()
|
|
422
|
+
return self.plotter.screenshot(return_img=True)
|
nutate/b1.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Robustness to drive (B1) amplitude errors: offset × B1 maps and phase sensitivity.
|
|
2
|
+
|
|
3
|
+
A B1 scale ``z`` multiplies every drive amplitude of the sequence, i.e. ``Ω1 / Ω1(0)``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import ArrayLike
|
|
13
|
+
|
|
14
|
+
from nutate.segment import Segment
|
|
15
|
+
from nutate.simulate import PhaseCycle, _check_offsets, simulate_final
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, eq=False)
|
|
19
|
+
class B1Map:
|
|
20
|
+
"""Final magnetization over a grid of offsets and B1 scales.
|
|
21
|
+
|
|
22
|
+
Attributes
|
|
23
|
+
----------
|
|
24
|
+
offsets : offsets, Hz, shape ``(n_off,)``.
|
|
25
|
+
scales : B1 scale factors ``Ω1/Ω1(0)``, shape ``(n_scales,)``.
|
|
26
|
+
m : final magnetization, shape ``(n_scales, n_off, 3)``.
|
|
27
|
+
rabi_ref : reference Rabi frequency ``Ω1(0)``, Hz, used to normalise offsets in plots.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
offsets: np.ndarray
|
|
31
|
+
scales: np.ndarray
|
|
32
|
+
m: np.ndarray
|
|
33
|
+
rabi_ref: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_scales(scales: ArrayLike) -> np.ndarray:
|
|
37
|
+
scales = np.asarray(scales, dtype=float)
|
|
38
|
+
if scales.ndim != 1 or scales.size == 0:
|
|
39
|
+
raise ValueError(f"scales must be a non-empty 1-D array, got shape {scales.shape}")
|
|
40
|
+
if not np.isfinite(scales).all() or (scales < 0).any():
|
|
41
|
+
raise ValueError("scales must be finite and ≥ 0")
|
|
42
|
+
return scales
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def b1_map(
|
|
46
|
+
sequence: Sequence[Segment],
|
|
47
|
+
offsets: ArrayLike,
|
|
48
|
+
scales: ArrayLike,
|
|
49
|
+
*,
|
|
50
|
+
rabi_ref: float | None = None,
|
|
51
|
+
phase_cycle: PhaseCycle | None = None,
|
|
52
|
+
) -> B1Map:
|
|
53
|
+
"""Final magnetization for every (B1 scale, offset) pair.
|
|
54
|
+
|
|
55
|
+
``rabi_ref`` (Hz) defaults to the sequence's highest Rabi frequency; pass another reference
|
|
56
|
+
(e.g. one pulse's peak) to normalise the offset axis by it.
|
|
57
|
+
"""
|
|
58
|
+
sequence = list(sequence)
|
|
59
|
+
offsets = _check_offsets(offsets)
|
|
60
|
+
scales = _check_scales(scales)
|
|
61
|
+
final = simulate_final(
|
|
62
|
+
sequence,
|
|
63
|
+
np.tile(offsets, scales.size),
|
|
64
|
+
phase_cycle=phase_cycle,
|
|
65
|
+
b1=np.repeat(scales, offsets.size),
|
|
66
|
+
)
|
|
67
|
+
if rabi_ref is None:
|
|
68
|
+
rabi_ref = max(s.rabi for s in sequence)
|
|
69
|
+
return B1Map(offsets, scales, final.reshape(scales.size, offsets.size, 3), rabi_ref)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def b1_phase_sensitivity(
|
|
73
|
+
sequence: Sequence[Segment],
|
|
74
|
+
scales: ArrayLike,
|
|
75
|
+
*,
|
|
76
|
+
delta: float = 0.005,
|
|
77
|
+
phase_cycle: PhaseCycle | None = None,
|
|
78
|
+
) -> np.ndarray:
|
|
79
|
+
"""On-resonance phase change (degrees) for a ``±delta`` B1 change around each scale.
|
|
80
|
+
|
|
81
|
+
Phase is ``angle(My + i·Mx)``; the result is ``phase(z·(1+δ)) − phase(z·(1−δ))``
|
|
82
|
+
wrapped to ``[−180, 180)``.
|
|
83
|
+
"""
|
|
84
|
+
scales = _check_scales(scales)
|
|
85
|
+
if not 0 < delta < 1:
|
|
86
|
+
raise ValueError(f"delta must be in (0, 1), got {delta}")
|
|
87
|
+
b1 = np.concatenate([scales * (1 - delta), scales * (1 + delta)])
|
|
88
|
+
final = simulate_final(sequence, np.zeros(b1.size), phase_cycle=phase_cycle, b1=b1)
|
|
89
|
+
phase = np.rad2deg(np.angle(final[:, 1] + 1j * final[:, 0]))
|
|
90
|
+
change = phase[scales.size :] - phase[: scales.size]
|
|
91
|
+
return (change + 180) % 360 - 180
|