This notebook illustrates the use of MCMRSimulator.jl to model the interplay between diffusion, magnetisation transfer, and magnetic susceptibility in explaining the T2 relaxation in white matter tissue.
Installation instructions and documentation of the simulator can be found at https://open.win.ox.ac.uk/pages/ndcn0236/mcmrsimulator.jl/dev/. MCMRSimulator.jl v0.3 was used to run this notebook.
This notebook was used to prepare figures for an abstract submitted to ISMRM 2023 in Toronto.
View this notebook online at https://users.fmrib.ox.ac.uk/~ndcn0236/notebooks/gradient_spin_echo.html
Download this notebook from https://users.fmrib.ox.ac.uk/~ndcn0236/notebooks/gradient_spin_echo.ipynb
using MCMRSimulator
using CairoMakie
import Random
First, we will select a scanner on which to run the sequence. This will set the B0 field strength as well as the maximum slew rate and gradient strength. For these simulations, only the B0 strength really matters (as it affects the size of the myelin-induced off-resonance field).
scanner = MCMRSimulator.Siemens_Prisma
Scanner(3.0, 80.0, 200.0)
Axons will be simulated as infinitely long cylinders all pointing in the y-direction. The configuration of axons will repeat every 30 micrometers. Outer radii are drawn from a gamma distribution with a mean of 1 micrometer and a variance of 1 micrometer squared. The final distribution will have a density of around 70% and be non-overlapping.
Random.seed!(123)
(positions, radii) = random_positions_radii((30., 30.), 0.7, 2, mean=1., variance=1.);
Based on this set of non-overlapping circles we create myelinated axons as a set of annuli, where the inner radius is 0.7 of the outer radius (i.e., all axons have a g-ratio of 0.7).
function get_tissue(; MT=0., myelin=false)
annuli(0.7 .* radii, radii; positions=positions, rotation=:y, repeats=(30., 30.), myelin=myelin, MT_fraction=MT)
end
get_tissue (generic function with 1 method)
Here we plot the cylinders with the off-resonance field generated by the myelin:
pp = PlotPlane(:y, size=30.)
f, ax, p = plot_off_resonance(pp, get_tissue(myelin=true), ngrid=200)
plot!(ax, pp, get_tissue(myelin=true))
f
pp_small = PlotPlane(:y, [7., 0., 7.], size=7.)
f, ax, p = plot_off_resonance(pp_small, get_tissue(myelin=true), ngrid=200)
plot!(ax, pp_small, get_tissue())
#Colorbar(f, p, label="off-resonance (ppm)")
# test particle
Random.seed!(1)
path = trajectory([7., 0., 7.], Simulation([], diffusivity=3., geometry=get_tissue(), timestep=1e-3), 0:1e-3:0.5)
plot!(pp_small, path)
f
The distribution of the off-resonance field in the three compartments (intra-axonal, myelin and extra-axonal) is shown below. The myelin magnetic susceptibility has both an isotropic ($\chi_I=100 {\rm ppb}$) and anisotropic ($\chi_A=-100 {\rm ppb}$) components. Because of the anisotropic component there is a net field offset in the intra-axonal space with an opposite sign to the magnetic field offset within the myelin water. In the extra-axonal space the dipole-like field does not produce an net offset.
snap = Snapshot(10000)
o = [MCMRSimulator.off_resonance(get_tissue(myelin=true), p) for p in MCMRSimulator.position.(snap)]
l = isinside(get_tissue(), snap)
f = hist(o[l .== 0], label="extra")
hist!(o[l .== 1], label="myelin")
hist!(o[l .== 2], label="intra")
axislegend()
f
function plot_all_signal(ax, times_plot, signal_plot; func=transverse, norm=false, ast=false, tissue=get_tissue())
label = isinside(tissue, signal_plot[1])
transform(use, signal_trans) = begin
x = func.(s[use] for s in signal_trans)
norm_by = sum(use)
if ast
@. -times_plot / log(x / norm_by)
elseif norm
x / norm_by
else
norm_alt = func === phase ? 1. : length(use)
x / norm_alt
end
end
plot_line!(use, label; linestyle=:solid) = lines!(ax, times_plot, transform(use, signal_plot), label=label, linestyle=linestyle)
plot_line!(label .== label, "total")
for (l_ind, l_text) in [(0, "extra"), (1, "myelin"), (2, "intra")]
use = label .== l_ind
plot_line!(use, l_text, linestyle=:dash)
end
plot_line!(label .!= 1, "no myelin")
end
plot_all_signal (generic function with 1 method)
function plot_all_signal(times_plot, signal_plot; xlog=false, ylog=false, kwargs...)
f = Figure(resolution=(400, 300))
ax = Axis(f[1, 1], xscale=xlog ? log10 : identity, yscale=ylog ? log10 : identity, )
plot_all_signal(ax, times_plot, signal_plot; kwargs...)
f
end
plot_all_signal (generic function with 2 methods)
The gradient echo sequence has a single 90 degree excitation pulse. The repetition time (TR) does not matter for these simulations.
ge_sequence = Sequence(TR=1000, pulses=[RFPulse(flip_angle=90)], scanner=scanner)
Sequence{1, RFPulse, LinearGradients}(Scanner(3.0, 80.0, 200.0), LinearGradients([0.0, 1000.0], StaticArraysCore.SVector{3, Float64}[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], [0.0, 0.0, 0.0]), RFPulse[RFPulse(0.0, 1.5707963267948966, 6.123233995736766e-17, 1.0, 0.0, 1.0, 0.0)], 1000.0)
We create a simulation based on the gradient echo sequence setting the following parameters:
get_tissue. The myelin in the tissue with generate an off-resonance field (myelin=true), but there will be no magnetisation transfer (MT=0.)gradient_echo = Simulation(ge_sequence, diffusivity=3., geometry=get_tissue(myelin=true, MT=5e-3), R1=1/4000, R2=1/2000, timestep=0.1);
The trajectory function returns the spin positions and orientations at the requested times (as defined in times_ge).
times_ge = 0.1:0.1:50
@time signal_ge = trajectory(1000, gradient_echo, times_ge);
3.036572 seconds (3.06 M allocations: 228.077 MiB, 1.95% gc time, 28.68% compilation time)
We check that there is no exchange between compartments between the first and last timestep
label = isinside(get_tissue(), signal_ge[1])
label2 = isinside(get_tissue(), signal_ge[end])
all(label .== label2)
true
The resulting signal profile. While the signal loss is small in individual compartments (dashed lines; magenta: intra; orange: extra; green:myelin), there is a large signal loss when adding the compartments together (light blue without myelin water; dark blue with myelin water).
f = plot_all_signal(times_ge, signal_ge, func=transverse)
plot!(ge_sequence)
f
plot_all_signal(times_ge, signal_ge, func=transverse, ast=true, ylog=true)
This rapid signal loss when the signal is summed across compartments is due to the dephasing of the various compartments.
plot_all_signal(times_ge, signal_ge, func=phase)
Here we produce a grid of T2* estimates with readouts at various echo times with the myelin off-resonance field turned off (left) or on (right) and without (top) or with (bottom) magnetisation transfer. The dominant effect on the estimated T2* is the myelin off-resonance field, which leads to T2* estimates of around 20 ms. Note that this T2* is not the T2* of the myelin water, but rather the T2* due to the difference in the off-resonance field of the myelin water and the remaining tissue water.
function ge_grid_plot(loc; kwargs...)
gradient_echo = Simulation(ge_sequence, diffusivity=3., geometry=get_tissue(; kwargs...), R1=1/4000, R2=1/2000, timestep=0.1)
@time signal_ge = trajectory(1000, gradient_echo, times_ge)
ax = Axis(loc, yscale=log10)
plot_all_signal(ax, times_ge, signal_ge, func=transverse, ast=true)
ylims!(ax, 8, 3000)
ticks = [10, 30, 100, 300, 1000, 2000]
ax.yticks = (ticks, string.(ticks))
end
ge_grid_plot (generic function with 1 method)
f = Figure()
ge_grid_plot(f[1, 1], myelin=false, MT=0.)
ge_grid_plot(f[1, 2], myelin=true, MT=0.)
ge_grid_plot(f[2, 1], myelin=false, MT=5e-3)
ge_grid_plot(f[2, 2], myelin=true, MT=5e-3)
f
1.527592 seconds (1.05 M allocations: 118.009 MiB, 2.70% gc time) 1.957466 seconds (1.05 M allocations: 118.027 MiB) 1.193477 seconds (1.05 M allocations: 118.016 MiB) 1.970595 seconds (1.05 M allocations: 118.022 MiB)
The spin echo sequence is identical to the gradient echo sequence, but with the addition of an 180 degree refocus pulse.
se_sequence = Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), RFPulse(time=25, flip_angle=180)], scanner=scanner)
spin_echo = Simulation(se_sequence, diffusivity=3., geometry=get_tissue(myelin=true, MT=5e-3), timestep=0.1, R1=1/4000, R2=1/2000);
times_se = 0.1:0.1:50
@time signal_se = trajectory(1000, spin_echo, times_se);
2.640547 seconds (1.89 M allocations: 162.956 MiB, 1.83% gc time, 20.14% compilation time)
f = plot_all_signal(times_se, signal_se, func=transverse)
plot!(se_sequence)
f
plot_all_signal(times_se, signal_se, func=phase)
Modeling the signal evolution for a range of different spin echo sequence is a bit more difficult than for the gradient echo. For the spin echo, we need to simulate multiple sequences as for each echo time the refocus pulse will be at a different time. We now also add a Readout to the sequence to indicate at what time the signal from that particular sequence should be read out.
function se_grid_plot(loc; kwargs...)
times_se = 1:50
se_sequence = [Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), RFPulse(time=t/2, flip_angle=180), Readout(time=t)]) for t in times_se]
spin_echo = Simulation(se_sequence, diffusivity=3., geometry=get_tissue(; kwargs...), R1=1/4000, R2=1/2000)
@time signal_se = [s[1] for s in readout(1000, spin_echo)]
ax = Axis(loc, yscale=log10)
plot_all_signal(ax, times_se, signal_se, func=transverse, ast=true)
ylims!(ax, 8, 3000)
ticks = [10, 30, 100, 300, 1000, 2000]
ax.yticks = (ticks, string.(ticks))
end
se_grid_plot (generic function with 1 method)
f = Figure()
se_grid_plot(f[1, 1], myelin=false, MT=0.)
se_grid_plot(f[1, 2], myelin=true, MT=0.)
se_grid_plot(f[2, 1], myelin=false, MT=5e-3)
se_grid_plot(f[2, 2], myelin=true, MT=5e-3)
f
7.500952 seconds (5.77 M allocations: 445.426 MiB, 1.53% gc time, 92.32% compilation time) 0.938407 seconds (938.04 k allocations: 184.965 MiB, 4.53% gc time) 0.866871 seconds (937.98 k allocations: 184.963 MiB, 5.72% gc time) 0.758667 seconds (937.99 k allocations: 184.963 MiB, 5.79% gc time)
For the spin echo the myelin has a very small effect, because the dephasing between compartments is fully recoverable with only a minor dephasing within compartments remaining. Instead to explain the T2 differences between compartments we need to add a signal loss due to cross-relaxation or magnetisation transfer whenever the spin hits any membrane.
This can explain the T2 in intra-axonal water ($\sim$80 ms) as well as for the intra-axonal water ($\sim$50-60 ms), although the T2 of myelin water is significantly overestimated (should be $\sim$20 ms). This overestimation might simply reflect that the myelin configuration is actually better represented by a spiral than the two cylinders modeled here
times_se = 1:50
se_sequence = [Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), RFPulse(time=t/2, flip_angle=180), Readout(time=t)], scanner=scanner) for t in times_se]
spin_echo = Simulation(se_sequence, diffusivity=3., geometry=get_tissue(; myelin=true, MT=5e-3), R1=1/4000, R2=1/2000)
@time signal_se = [s[1] for s in readout(1000, spin_echo)]
plot_all_signal(times_se, signal_se, func=transverse, ast=true)
1.075943 seconds (998.78 k allocations: 188.279 MiB, 5.44% gc time, 3.21% compilation time)
The increase of the T2 with echo time is due to the relatively larger contribution of the longer-T2 water in larger axons at the longer echo times.
Random.seed!(1)
(constant_positions, constant_radii) = random_positions_radii((30., 30.), 0.7, 2, variance=1e-8);
function constant_tissue(; MT=0., myelin=false)
annuli(0.7 .* constant_radii, constant_radii; positions=constant_positions, rotation=:y, repeats=(30., 30.), myelin=myelin, MT_fraction=MT)
end
constant_tissue (generic function with 1 method)
pp = PlotPlane(:y, size=30.)
f, ax, p = plot_off_resonance(pp, constant_tissue(myelin=true), ngrid=200)
plot!(ax, pp, constant_tissue(myelin=true))
f
times_se = 1:50
se_sequence = [Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), RFPulse(time=t/2, flip_angle=180), Readout(time=t)], scanner=scanner) for t in times_se]
spin_echo = Simulation(se_sequence, diffusivity=3., geometry=constant_tissue(; myelin=true, MT=5e-3), R1=1/4000, R2=1/2000)
@time signal_se = [s[1] for s in readout(1000, spin_echo)]
plot_all_signal(times_se, signal_se, func=transverse, ast=true, tissue=constant_tissue())
3.332547 seconds (1.85 M allocations: 233.531 MiB, 1.17% gc time, 65.99% compilation time)
"Plot gradient or spin echo signal at TE=30 ms for a range of geometries"
function plot_varying(x, geometries, spin_echo=false)
sequence = (
spin_echo ?
Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), RFPulse(time=15, flip_angle=180), Readout(time=30.)], scanner=scanner) :
Sequence(TR=1000, pulses=[RFPulse(flip_angle=90), Readout(time=30.)], scanner=scanner)
)
@time signal = [evolve(1000,
Simulation(sequence, geometry=geom, diffusivity=3., R1=1/4000, R2=1/2000),
30.,
) for geom in geometries];
function get_compartment(geometry, signal, index)
label = isinside(geometry, signal)
if index == -1
if maximum(label) == 1
return signal
else
return signal[label .!= 1]
end
end
if maximum(label) == 1
# no myelin
if index == 2
index = 1
elseif index == 1
index = 2
end
end
signal[label .== index]
end
function mytrans(s)
length(s) == 0 ? NaN : transverse(s) / length(s)
end
f = Figure(resolution=(400, 300))
ax = Axis(f[1, 1])
for (compartment, ls) in [(nothing, :solid), (0, :dash), (1, :dash), (2, :dash), (-1, :solid)]
part_signal = isnothing(compartment) ? signal : get_compartment.(geometries, signal, compartment)
attenuation = mytrans.(part_signal)
lines!(ax, x, -30 ./ log.(attenuation), linestyle=ls)
end
ylims!(0, 250)
f
end
plot_varying
grat = 0.6:0.1:1
geometries = TransformObstruction[annuli(0.6 .* radii, (0.6 / g) .* radii; positions=positions, rotation=:y, repeats=(30., 30.), myelin=true, MT_fraction=5e-3) for g in grat[1:end-1]]
# g-ratio of 1 is represented by a set of unmyelinated cylinders rather than annuli
push!(geometries, cylinders(0.6 .* radii; positions=positions, rotation=:y, repeats=(30., 30.), MT_fraction=5e-3));
plot_varying(grat, geometries, false)
2.511681 seconds (3.29 M allocations: 193.408 MiB, 1.40% gc time, 68.52% compilation time)
plot_varying(grat, geometries, true)
1.723285 seconds (1.99 M allocations: 121.078 MiB, 3.02% gc time, 61.09% compilation time)
Changing the axonal diameter also scales the size of the extra-axonal space (for a constant density)
cradii = [0.5, 1, 2, 3, 5]
constant_positions, constant_radii
geometries = [
annuli(0.7 .* constant_radii .* r, constant_radii .* r; positions=constant_positions .* r, rotation=:y, repeats=(30. * r, 30. * r), myelin=true, MT_fraction=5e-3)
for r in cradii
]
plot_varying(cradii, geometries, false)
2.824155 seconds (859.84 k allocations: 56.056 MiB, 13.87% compilation time)
plot_varying(cradii, geometries, true)
2.054231 seconds (832.51 k allocations: 54.883 MiB, 2.51% gc time, 18.46% compilation time)
angle = 0:10:90
orientation = [[0., sin(deg2rad(a)), cos(deg2rad(a))] for a in angle]
geometries = [annuli(0.7 .* radii, radii; positions=positions, rotation=o, repeats=(30., 30.), myelin=true, MT_fraction=5e-3) for o in orientation]
plot_varying(angle, geometries, false)
1.619376 seconds (871.93 k allocations: 64.913 MiB, 3.00% compilation time)
plot_varying(angle, geometries, true)
1.648471 seconds (932.85 k allocations: 68.787 MiB, 2.85% compilation time)
densities = 0.5:0.1:0.8
geometries = TransformObstruction[]
for d in densities
(p, r) = random_positions_radii((30., 30.), d, 2, mean=1., variance=1.);
push!(geometries,
annuli(0.7 .* r, r; positions=p, rotation=:y, repeats=(30., 30.), myelin=true, MT_fraction=5e-3)
)
end
plot_varying(densities, geometries, false)
2.064110 seconds (2.60 M allocations: 148.125 MiB, 76.30% compilation time)
plot_varying(densities, geometries, true)
1.309036 seconds (1.38 M allocations: 83.909 MiB, 61.59% compilation time)