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