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