Skip to content

Commit 4d981e7

Browse files
committed
docs: add adiabatic RF pulse tutorial
1 parent 6b58e39 commit 4d981e7

5 files changed

Lines changed: 255 additions & 120 deletions

File tree

-29.1 KB
Binary file not shown.

examples/3.tutorials/adiabatic_pulses/BIR4InversionB0B1.jl

Lines changed: 0 additions & 53 deletions
This file was deleted.

examples/3.tutorials/adiabatic_pulses/BIR4InversionProfile.jl

Lines changed: 0 additions & 37 deletions
This file was deleted.

examples/3.tutorials/adiabatic_pulses/HSInversionProfile.jl

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# # Adiabatic RF Pulse
2+
3+
using KomaMRI #hide
4+
sys = Scanner(); #hide
5+
6+
# In this tutorial, we will build a hyperbolic-secant (HS) adiabatic inversion
7+
# pulse. The key point is that KomaMRI can keep the RF frequency modulation
8+
# explicit, then simulate the RF-frame dynamics and the resulting
9+
# ``B_0``/``B_1`` robustness.
10+
11+
# ## Defining a frequency-modulated pulse
12+
#
13+
# > Parameters follow the default ``\mathrm{HS}_{4,6}`` pulse in Figure 1(c) of this
14+
# > [JMRI adiabatic inversion example](https://doi.org/10.1002/jmri.26021).
15+
16+
b1max = 13.5e-6
17+
Trf = 18.3e-3
18+
β̂ = 4
19+
μ = 6
20+
β = 2 * β̂ / Trf;
21+
22+
# For an HS pulse, the hardest point for adiabatic following is near the
23+
# center of the sweep, where ``B_1`` is maximal and
24+
# ``|\mathrm{d}\Delta\omega/\mathrm{d}t|=\mu\beta^2``. Requiring the RF precession rate to
25+
# dominate that sweep rate gives the threshold used in the paper:
26+
#
27+
# ```math
28+
# \frac{(\gamma_\mathrm{rad} B_1)^2}{\mu\beta^2} \ge 1
29+
# \quad\Rightarrow\quad
30+
# B_1 \geq \frac{\sqrt{\mu}\beta}{\gamma_\mathrm{rad}}.
31+
# ```
32+
33+
b1_threshold = β * sqrt(μ) / (2π * γ)
34+
b1max > b1_threshold
35+
36+
# First, we define the RF amplitude as a hyperbolic secant and the frequency
37+
# sweep as a hyperbolic tangent.
38+
39+
t = range(-Trf / 2, Trf / 2, 201)
40+
B1 = b1max .* sech.(β .* t)
41+
Δf = -μ * β .* tanh.(β .* t) ./ (2π);
42+
43+
# The ``\Delta f`` argument of `RF` can be a scalar, for a constant RF offset
44+
# such as a slice offset, or a waveform, as in this frequency-modulated pulse.
45+
46+
f = range(-2e3, 2e3, 161) |> collect
47+
seq = Sequence()
48+
@addblock seq += RF(B1, Trf, Δf, 0);
49+
50+
# This representation is native to KomaMRI. Pulseq RF events do not store
51+
# frequency-modulation waveforms, so exporting this sequence with `write_seq`
52+
# requires first converting the sweep into RF phase samples. KomaMRI may do this
53+
# conversion automatically in the future.
54+
55+
# ## Plotting an adiabatic pulse
56+
#
57+
# `plot_seq` can show both views. The default plot keeps ``\Delta f(t)``
58+
# explicit; the `freq_in_phase` keyword shows the same pulse after moving the
59+
# frequency sweep into the RF phase.
60+
61+
using PlotlyJS #hide
62+
function show_only_traces!(p, names) #hide
63+
for trace in p.plot.data #hide
64+
name = get(trace.fields, :name, "") #hide
65+
trace.fields[:showlegend] = false #hide
66+
trace.fields[:visible] = name in names #hide
67+
end #hide
68+
return p #hide
69+
end #hide
70+
function scale_trace_y!(p, name, scale) #hide
71+
for trace in p.plot.data #hide
72+
get(trace.fields, :name, "") == name || continue #hide
73+
y = trace.fields[:y] #hide
74+
trace.fields[:customdata] = y #hide
75+
trace.fields[:y] = scale .* y #hide
76+
trace.fields[:hovertemplate] = "(%{x:.4f} ms, Δf_FM: %{customdata:.4f} kHz)" #hide
77+
end #hide
78+
return p #hide
79+
end #hide
80+
p_freq = plot_seq(seq; max_rf_samples=Inf, slider=false, height=360, title="Frequency-modulated RF", showlegend=false)
81+
p_phase = plot_seq(seq; freq_in_phase=true, max_rf_samples=Inf, slider=false, height=360, title="Phase-modulated RF", showlegend=false)
82+
show_only_traces!(p_freq, ("|B1|_AM", "Δf_FM")) #hide
83+
show_only_traces!(p_phase, ("|B1|_AM", "∠B1_AM")) #hide
84+
scale_trace_y!(p_freq, "Δf_FM", 9) #hide
85+
p_rf = [p_freq p_phase] #hide
86+
relayout!(p_rf, height=380, margin=attr(t=56)) #hide
87+
p_rf #hide
88+
#jl display(p_rf)
89+
90+
# Keeping the frequency sweep explicit is useful because KomaMRI simulates a
91+
# frequency-modulated RF pulse in the RF rotating frame. If the same pulse is
92+
# first converted to sampled phase modulation, the simulation is more sensitive
93+
# to the RF time sampling.
94+
95+
# ## Comparing RF and rotating frames
96+
#
97+
# Using a callback, we can record the magnetization during the RF pulse.
98+
99+
trajectory = NamedTuple[]
100+
call_every_N_blocks = 1
101+
102+
record_traj = Callback(
103+
call_every_N_blocks,
104+
(progress_info, sim_blocks_info, device_data, sim_params) -> begin
105+
j = last(sim_blocks_info.parts[progress_info.block])
106+
push!(trajectory, (;
107+
Mxy=device_data.Xt.xy[1], Mz=device_data.Xt.z[1],
108+
ψ=device_data.seqd.ψ[j], B1=device_data.seqd.B1[j], Δf=device_data.seqd.Δf[j],
109+
))
110+
end,
111+
)
112+
113+
sim_params = KomaMRICore.default_sim_params()
114+
sim_params["return_type"] = "state"
115+
sim_params["max_rf_block_length"] = 1; # very inefficient; just for plots
116+
obj0 = Phantom(; x=[0.0], Δw=[0.0]);
117+
simulate(obj0, seq, sys; sim_params, callbacks=(record_traj,), verbose=false);
118+
119+
function adiabatic_frame(p) #hide
120+
ωeff = (-real(p.B1), -imag(p.B1), p.Δf / γ) #hide
121+
ω̂rf = ωeff ./ sqrt(sum(abs2, ωeff)) #hide
122+
Mxy_rf = p.Mxy * cis(-p.ψ) #hide
123+
ω̂xy_rot = complex(ω̂rf[1], ω̂rf[2]) * cis(p.ψ) #hide
124+
return (; #hide
125+
Mrf=(real(Mxy_rf), imag(Mxy_rf), p.Mz), #hide
126+
Mrot=(real(p.Mxy), imag(p.Mxy), p.Mz), #hide
127+
ω̂rf, #hide
128+
ω̂rot=(real(ω̂xy_rot), imag(ω̂xy_rot), ω̂rf[3]), #hide
129+
) #hide
130+
end #hide
131+
trajectory_frames = adiabatic_frame.(trajectory) #hide
132+
xyz(points) = ntuple(i -> getindex.(points, i), 3) #hide
133+
Mrf_xyz = xyz(getproperty.(trajectory_frames, :Mrf)) #hide
134+
Mrot_xyz = xyz(getproperty.(trajectory_frames, :Mrot)) #hide
135+
ω̂rf_xyz = xyz(getproperty.(trajectory_frames, :ω̂rf)) #hide
136+
ω̂rot_xyz = xyz(getproperty.(trajectory_frames, :ω̂rot)) #hide
137+
anim_idx = unique(round.(Int, range(1, length(trajectory_frames), 36))) #hide
138+
function sphere_mesh(; nθ=36, nφ=18) #hide
139+
θ = range(0, 2π, nθ) #hide
140+
φ = range(0, π, nφ) #hide
141+
return ( #hide
142+
x=[sin(p) * cos(t) for p in φ, t in θ], #hide
143+
y=[sin(p) * sin(t) for p in φ, t in θ], #hide
144+
z=[cos(p) for p in φ, t in θ], #hide
145+
) #hide
146+
end #hide
147+
sphere = sphere_mesh() #hide
148+
function bloch_traces(i, M, ω̂; scene="scene", showlegend=true) #hide
149+
path = scatter3d(; #hide
150+
x=M[1][1:i], y=M[2][1:i], z=M[3][1:i], #hide
151+
mode="lines", name="M path", scene=scene, showlegend=showlegend, #hide
152+
line=attr(color="#111827", width=6), #hide
153+
) #hide
154+
magnetization = scatter3d(; #hide
155+
x=[0, M[1][i]], y=[0, M[2][i]], z=[0, M[3][i]], #hide
156+
mode="lines+markers", name="M", scene=scene, showlegend=showlegend, #hide
157+
line=attr(color="#dc2626", width=7), #hide
158+
marker=attr(color="#dc2626", size=4), #hide
159+
) #hide
160+
effective_field = scatter3d(; #hide
161+
x=[0, ω̂[1][i]], y=[0, ω̂[2][i]], z=[0, ω̂[3][i]], #hide
162+
mode="lines+markers", name="ωeff", scene=scene, showlegend=showlegend, #hide
163+
line=attr(color="#2563eb", width=7), #hide
164+
marker=attr(color="#2563eb", size=4), #hide
165+
) #hide
166+
return [ #hide
167+
path, #hide
168+
magnetization, #hide
169+
effective_field, #hide
170+
] #hide
171+
end #hide
172+
bloch_sphere(scene) = [surface(; x=sphere.x, y=sphere.y, z=sphere.z, opacity=0.14, showscale=false, name="Bloch sphere", scene=scene, colorscale=[[0, "#dbe4ef"], [1, "#dbe4ef"]])] #hide
173+
function animation_frame(i) #hide
174+
traces = [ #hide
175+
bloch_traces(i, Mrf_xyz, ω̂rf_xyz)..., #hide
176+
bloch_traces(i, Mrot_xyz, ω̂rot_xyz; scene="scene2", showlegend=false)..., #hide
177+
] #hide
178+
return frame(; name=string(i), data=traces, traces=[1, 2, 3, 5, 6, 7]) #hide
179+
end #hide
180+
function bloch_scene(domain) #hide
181+
axis(range) = attr(; #hide
182+
range=range, title=attr(text=""), ticks="", #hide
183+
showgrid=false, showbackground=false, showticklabels=false, zeroline=false, #hide
184+
) #hide
185+
return attr(; #hide
186+
domain=domain, aspectmode="cube", #hide
187+
xaxis=axis([-1, 1]), yaxis=axis([-1, 1]), zaxis=axis([-1, 1]), #hide
188+
camera=attr(eye=attr(x=1.31, y=1.31, z=0.75), up=attr(x=0, y=0, z=1)), #hide
189+
) #hide
190+
end #hide
191+
base_traces = [ #hide
192+
bloch_sphere("scene")..., #hide
193+
bloch_traces(first(anim_idx), Mrf_xyz, ω̂rf_xyz)..., #hide
194+
bloch_sphere("scene2")..., #hide
195+
bloch_traces(first(anim_idx), Mrot_xyz, ω̂rot_xyz; scene="scene2", showlegend=false)..., #hide
196+
] #hide
197+
frames = animation_frame.(anim_idx) #hide
198+
p_bloch = plot( #hide
199+
base_traces, #hide
200+
Layout(; #hide
201+
height=340, margin=attr(l=0, r=0, t=32, b=0), #hide
202+
scene=bloch_scene(attr(x=[0.0, 0.48], y=[0.0, 0.93])), #hide
203+
scene2=bloch_scene(attr(x=[0.52, 1.0], y=[0.0, 0.93])), #hide
204+
annotations=[ #hide
205+
attr(text="RF frame", x=0.24, y=0.98, xref="paper", yref="paper", xanchor="center", showarrow=false, font=attr(size=18)), #hide
206+
attr(text="Rotating frame", x=0.76, y=0.98, xref="paper", yref="paper", xanchor="center", showarrow=false, font=attr(size=18)), #hide
207+
], #hide
208+
updatemenus=[ #hide
209+
attr(; #hide
210+
type="buttons", direction="right", x=0, y=1.0, #hide
211+
xanchor="left", yanchor="top", #hide
212+
buttons=[attr(label="Play", method="animate", args=[nothing, attr(frame=attr(duration=80, redraw=true), fromcurrent=true)])], #hide
213+
), #hide
214+
], #hide
215+
), #hide
216+
frames, #hide
217+
) #hide
218+
#jl display(p_bloch)
219+
220+
# The blue vector is the normalized rotation axis
221+
# ``\hat{\boldsymbol{\omega}}_\mathrm{eff}``, where
222+
# ``\boldsymbol{\omega}_\mathrm{eff} = -\gamma \mathbf{B}_\mathrm{eff}``. With this sign convention,
223+
# the magnetization precesses right-handed around the plotted axis.
224+
225+
# ## HS adiabatic pulse B0 and B1 robustness
226+
#
227+
# To showcase the off-resonance and ``B_1`` robustness of this type of pulse, we
228+
# can show its effect in a heatmap with ``B_0 \in [-2, 2]\,\mathrm{kHz}`` and
229+
# ``B_1 \in [0, 16]\,\mu\mathrm{T}``:
230+
231+
obj = Phantom(; x=zeros(length(f)), Δw=2π .* f);
232+
b1_scales = range(0.05, 1.2, 47) |> collect;
233+
sim_params = Dict{String, Any}("return_type" => "state");
234+
235+
Mz = map(b1_scales) do scale
236+
seq_scale = Sequence()
237+
@addblock seq_scale += RF(scale .* B1, Trf, Δf, 0)
238+
simulate(obj, seq_scale, sys; sim_params, verbose=false).z
239+
end;
240+
241+
b1_axis = 1e6 .* b1max .* b1_scales #hide
242+
threshold = 1e6 * b1_threshold #hide
243+
Mz_map = round.(reduce(hcat, Mz); digits=3) #hide
244+
p_hs = plot( #hide
245+
[ #hide
246+
heatmap(; x=f, y=b1_axis, z=permutedims(Mz_map), zmin=-1, zmax=1, colorscale="RdBu", colorbar=attr(title="Mz")), #hide
247+
scatter(; x=f, y=fill(threshold, length(f)), name="threshold", mode="lines", hoverinfo="skip", line=attr(color="#a62023", dash="dash", width=3)), #hide
248+
], #hide
249+
Layout(; title="HS<sub>4,6</sub> inversion profile", xaxis_title="Off-resonance [Hz]", yaxis_title="B1,max [μT]", height=420, margin=attr(t=64)), #hide
250+
) #hide
251+
p_hs #hide
252+
#jl display(p_hs)
253+
254+
# The dashed line is the analytic adiabatic threshold for this HS pulse, where we
255+
# can see that the inversion is achieved after the threshold is surpassed.

0 commit comments

Comments
 (0)