From cbda5721ead922f3a6ad1064a2adb4a6138bf30b Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 11:33:23 +0200 Subject: [PATCH 01/51] Initial --- docs/src/assets/head_rotation_axis.png | Bin 0 -> 153690 bytes examples/3.tutorials/lit-05-SimpleMotion.jl | 36 ++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 docs/src/assets/head_rotation_axis.png create mode 100644 examples/3.tutorials/lit-05-SimpleMotion.jl diff --git a/docs/src/assets/head_rotation_axis.png b/docs/src/assets/head_rotation_axis.png new file mode 100644 index 0000000000000000000000000000000000000000..719435de56a73d3a481860e712bcf92be235ef7e GIT binary patch literal 153690 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


K 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 +#md # ``` + +# Now, we will make a copy of the phantom, to which we will add Rotation Motion +# to recreate the patient's movement inside the scanner. + +#md # Note how rotations are defined with respect to the 3 axes: +#md # ```@raw html +#md #

+#md # ``` + +obj2 = copy(obj1) +obj2.motion = SimpleMotion([Rotation(t_start=0.0, t_end=0.25, pitch=15.0, roll=0.0, yaw=45.0)]) +p2 = plot_phantom_map(obj2, :T2 ; height=400, intermediate_time_samples=4) +#md savefig(p2, "../assets/3-phantom.html") # hide +#jl display(p2) + +#md # ```@raw html +#md #
+#md # ``` From dfc6356c3dcf3f28ab307758688f2ba930243a44 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 12:48:17 +0200 Subject: [PATCH 02/51] Solve rotation bug in `displacement_z` --- .../datatypes/phantom/motion/simplemotion/PeriodicRotation.jl | 4 +++- .../src/datatypes/phantom/motion/simplemotion/Rotation.jl | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicRotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicRotation.jl index b80af6463..e2ff92e41 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicRotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicRotation.jl @@ -68,7 +68,9 @@ function displacement_z( α = t_unit .* motion_type.pitch β = t_unit .* motion_type.roll γ = t_unit .* motion_type.yaw - return -sind.(β) .* x + cosd.(β) .* sind.(α) .* y .- z + return -sind.(β) .* x + + cosd.(β) .* sind.(α) .* y + + cosd.(β) .* cosd.(α) .* z .- z end function times(motion_type::PeriodicRotation) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index a81920d6d..17fd38559 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -118,7 +118,9 @@ function displacement_z( α = t_unit .* motion_type.pitch β = t_unit .* motion_type.roll γ = t_unit .* motion_type.yaw - return -sind.(β) .* x + cosd.(β) .* sind.(α) .* y .- z + return -sind.(β) .* x + + cosd.(β) .* sind.(α) .* y + + cosd.(β) .* cosd.(α) .* z .- z end times(motion_type::Rotation) = begin From c812ab156e92feb341e1a4569e7de4a17725b7b6 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 12:48:44 +0200 Subject: [PATCH 03/51] Default `max_spins` to 100000 --- KomaMRIPlots/src/ui/DisplayFunctions.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KomaMRIPlots/src/ui/DisplayFunctions.jl b/KomaMRIPlots/src/ui/DisplayFunctions.jl index 4691b4256..528a1ce07 100644 --- a/KomaMRIPlots/src/ui/DisplayFunctions.jl +++ b/KomaMRIPlots/src/ui/DisplayFunctions.jl @@ -1028,7 +1028,7 @@ function plot_phantom_map( colorbar=true, intermediate_time_samples=0, max_time_samples=100, - max_spins=50000, + max_spins=100000, dt_frame=250, kwargs..., ) From 5cec38df4a6673ca42ec8d6f59a5b998a6120255 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 13:07:43 +0200 Subject: [PATCH 04/51] Improve example --- examples/3.tutorials/lit-05-SimpleMotion.jl | 71 +++++++++++++++------ 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 0d3db9d53..5cc1c2256 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -3,34 +3,69 @@ using KomaMRI # hide sys = Scanner() # hide -# This tutorial illustrates how we can add simple motion to phantoms. -# We will also see how phantoms can be stored and loaded from ``.phantom`` files. +# This tutorial illustrates how we can add simple motion to phantoms. We will also see how phantoms can be stored and loaded from ``.phantom`` files. # First, we load a static 3D brain phantom: -obj1 = brain_phantom3D() -obj1.Δw .= 0 # Removes the off-resonance -p1 = plot_phantom_map(obj1, :T2 ; height=400) -#md savefig(p1, "../assets/3-phantom.html") # hide +obj = brain_phantom3D() +obj.Δw .= 0 # Removes the off-resonance + +# Now, we will add Rotation Motion to recreate the patient's movement inside the scanner. + +#md # !!! note +#md # Note how rotations are defined with respect to the 3 axes: +#md # ```@raw html +#md #
+#md # ``` + +obj.motion = SimpleMotion([Rotation(t_start=0.0, t_end=0.5, pitch=15.0, roll=0.0, yaw=45.0)]) +p1 = plot_phantom_map(obj2, :T2 ; height=600, intermediate_time_samples=4) +#md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` -# Now, we will make a copy of the phantom, to which we will add Rotation Motion -# to recreate the patient's movement inside the scanner. +# Then, we will load an EPI sequence + +seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") +seq = read_seq(seq_file) +p2 = plot_seq(seq; range=[0 40], slider=true, height=300) +#md savefig(p3, "../assets/5-seq.html") # hide +#jl display(p3) -#md # Note how rotations are defined with respect to the 3 axes: #md # ```@raw html -#md #
+#md # #md # ``` -obj2 = copy(obj1) -obj2.motion = SimpleMotion([Rotation(t_start=0.0, t_end=0.25, pitch=15.0, roll=0.0, yaw=45.0)]) -p2 = plot_phantom_map(obj2, :T2 ; height=400, intermediate_time_samples=4) -#md savefig(p2, "../assets/3-phantom.html") # hide -#jl display(p2) +# Now, we will run two simulations: the first with the sequence starting at ``t=0.0``, +# and the second adding a 0.5s initial delay to the sequence: +## Simulate +raw1 = simulate(obj, seq, sys) +raw2 = simulate(obj, Delay(0.5) + seq, sys) + +# Let's note the effect of motion in both reconstructions:s +## Get the acquisition data +acq1 = AcquisitionData(raw1) +acq2 = AcquisitionData(raw2) +acq1.traj[1].circular = false #This is to remove the circular mask +acq2.traj[1].circular = false #This is to remove the circular mask + +## Setting up the reconstruction parameters +Nx, Ny = raw.params["reconSize"][1:2] +reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) + +image1 = reconstruction(acq1, reconParams) +image2 = reconstruction(acq1, reconParams) + +## Plotting the recon +p3 = plot_image(abs.(image1[:, :, 1]); height=400) +p4 = plot_image(abs.(image2[:, :, 1]); height=400) +#md savefig(p3, "../assets/5-recon1.html") # hide +#md savefig(p4, "../assets/5-recon2.html") # hide +#jl display(p3) +#jl display(p4) #md # ```@raw html -#md #
-#md # ``` +#md #
+#md # ``` \ No newline at end of file From d710be8ebc2893e069902add83d98fcbbc8737cc Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 13:48:57 +0200 Subject: [PATCH 05/51] Solve bugs --- examples/3.tutorials/lit-05-SimpleMotion.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 5cc1c2256..c98ece4be 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -18,7 +18,7 @@ obj.Δw .= 0 # Removes the off-resonance #md # ``` obj.motion = SimpleMotion([Rotation(t_start=0.0, t_end=0.5, pitch=15.0, roll=0.0, yaw=45.0)]) -p1 = plot_phantom_map(obj2, :T2 ; height=600, intermediate_time_samples=4) +p1 = plot_phantom_map(obj, :T2 ; height=600, intermediate_time_samples=4) #md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) @@ -44,12 +44,12 @@ p2 = plot_seq(seq; range=[0 40], slider=true, height=300) raw1 = simulate(obj, seq, sys) raw2 = simulate(obj, Delay(0.5) + seq, sys) -# Let's note the effect of motion in both reconstructions:s +# Let's note the effect of motion in both reconstructions: ## Get the acquisition data acq1 = AcquisitionData(raw1) acq2 = AcquisitionData(raw2) acq1.traj[1].circular = false #This is to remove the circular mask -acq2.traj[1].circular = false #This is to remove the circular mask +acq2.traj[1].circular = false ## Setting up the reconstruction parameters Nx, Ny = raw.params["reconSize"][1:2] From fb5655ca141ee1d6d294b29bbf0ff9f883acfb26 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 13:50:17 +0200 Subject: [PATCH 06/51] Comment all tests for easy docs preview --- KomaMRIBase/test/runtests.jl | 1118 ++++++++-------- KomaMRICore/test/runtests.jl | 1190 ++++++++--------- KomaMRIFiles/test/runtests.jl | 230 ++-- .../test/GUI_PlotlyJS_backend_test.jl | 270 ++-- .../test/GUI_PlutoPlotly_backend_test.jl | 54 +- test/runtests.jl | 194 +-- 6 files changed, 1528 insertions(+), 1528 deletions(-) diff --git a/KomaMRIBase/test/runtests.jl b/KomaMRIBase/test/runtests.jl index f15bfaca2..555e0f438 100644 --- a/KomaMRIBase/test/runtests.jl +++ b/KomaMRIBase/test/runtests.jl @@ -1,559 +1,559 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true - -@testitem "Sequence" tags=[:base] begin - @testset "Init" begin - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) - @test dur(EX) ≈ durRF #RF length matches what is supposed to be - - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0.0 - if d1 > 0 DELAY = Delay(d1) end - - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be - end - - @testset "Rot_and_Concat" begin - # Rotation 2D case - A1, A2, T, t = rand(4) - s = Sequence([Grad(A1,T); - Grad(A2,T)]) - θ = π*t - R = rotz(θ) - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - # Rotation 3D case - T, t1, t2, t3 = rand(4) - N = 100 - GR = [Grad(rand(),T) for i=1:3, j=1:N] - s = Sequence(GR) - α, β, γ = π*t1, π*t2, π*t3 - Rx = rotx(α) - Ry = roty(β) - Rz = rotz(γ) - R = Rx*Ry*Rz - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - - # Concatenation of sequences - A1, A2, A3, T1 = rand(4) - s1 = Sequence([Grad(A1,T1); - Grad(A2,T1)], - [RF(A3,T1)]) - B1, B2, B3, T2 = rand(4) - s2 = Sequence([Grad(B1,T2); - Grad(B2,T2)], - [RF(B3,T2)]) - s = s1 + s2 - @test s.GR.A ≈ [s1.GR.A s2.GR.A] - @test s.RF.A ≈ [s1.RF.A s2.RF.A] - @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] - end - - @testset "Grad" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - g1, g2 = Grad(A1,T), Grad(A2,T) - GR = [g1;g2;;] - GR2 = reshape([g1;g2],:,1) - @test GR.A ≈ GR2.A - - #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) - A, T = 0.1, 1e-3 - grad = Grad(A, T) - - A, T = rand(2) - g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) - @test g1 ≈ g2 - - A, T, ζ = rand(3) - g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) - @test g1 ≈ g2 - - A, T, delay, ζ = rand(4) - g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) - @test g1 ≈ g2 - - # Test construction with shape function - T, N = 1e-3, 100 - f = t -> sin(π*t / T) - gradw = Grad(f, T, N) - @test gradw.A ≈ f.(range(0.0, T; length=N)) - - # Test Grad operations - α = 3 - gradt = α * grad - @test size(grad, 1) == 1 - @test gradt.A ≈ α * grad.A - gradt = grad * α - @test gradt.A ≈ α * grad.A - gradt = grad / α - @test gradt.A ≈ grad.A / α - grads = grad + gradt - @test grads.A ≈ grad.A + gradt.A - A1, A2, A3 = 0.1, 0.2, 0.3 - v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] - v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] - v3 = v1 + v2 - @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] - gradr = grad - gradt - @test gradr.A ≈ grad.A - gradt.A - gradt = -grad - @test gradt.A ≈ -grad.A - vc = vcat(v1, v2) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - vc = vcat(v1, v2, v3) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] - delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 - gr = Grad(A, T, rise, fall, delay) - @test dur(gr) ≈ delay + rise + T + fall - T1, T2, T3 = 1e-3, 2e-3, 3e-3 - vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] - @test dur(vt) ≈ [maximum([T1, T2, T3])] - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", grad) - @test true - - end - - @testset "RF" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - r1, r2 = RF(A1,T), RF(A2,T) - R = [r1;r2;;] - R2 = reshape([r1;r2],:,1) - @test R.A ≈ R2.A - - #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) - A, T = rand(2) - r1, r2 = RF(A,T), RF(A,T,0.0,0.0) - @test r1 ≈ r2 - - A, T, Δf = rand(3) - r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) - @test r1 ≈ r2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", r1) - @test true - - # Test Grad operations - B1x, B1y, T = rand(3) - A = B1x + im*B1y - α = Complex(rand()) - rf = RF(A, T) - rft = α * rf - @test size(rf, 1) == 1 - @test rft.A ≈ α * rf.A - @test dur(rf) ≈ rf.T - B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) - rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) - rv = [rf1; rf2; rf3] - @test dur(rv) ≈ sum(dur.(rv)) - - end - - @testset "Delay" begin - - # Test delay construction - T = 1e-3 - delay = Delay(T) - @test delay.T ≈ T - - # Test delay construction error for negative values - err = Nothing - try Delay(-T) catch err end - @test err isa ErrorException - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", delay) - @test true - - # Test addition of a delay to a sequence - seq = Sequence([Grad(0.0, 0.0)]) - ds = delay + seq - @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 - sd = seq + delay - @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T - - end - @testset "ADC" begin - - # Test ADC construction - N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π - adc = ADC(N, T, delay, Δf, ϕ) - - adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) - @test adc1 ≈ adc2 - - # Test ADC construction errors for negative values - err = Nothing - try ADC(N, -T) catch err end - @test err isa ErrorException - try ADC(N, -T, delay) catch err end - @test err isa ErrorException - try ADC(N, T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - - # Test ADC getproperties - Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π - adb = ADC(Nb, Tb, delayb, Δfb, ϕb) - adcs = [adc, adb] - @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] - @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] - - end - - @testset "DiscreteSequence" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - sampling_params = KomaMRIBase.default_sampling_params() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) - seqd = KomaMRIBase.discretize(seq) - i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) - @test seqd[i1].t ≈ [t[i1]] - @test seqd[i1:i2-1].t ≈ t[i1:i2] - - T, N = 1.0, 4 - seq = RF(1.0e-6, 1.0) - seq += Sequence([Grad(1.0e-3, 1.0)]) - seq += ADC(N, 1.0) - sampling_params = KomaMRIBase.default_sampling_params() - sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N - seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) - seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) - seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) - # Block 1 - @test is_RF_on(seq[1]) == is_RF_on(seqd1) - @test is_GR_on(seq[1]) == is_GR_on(seqd1) - @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) - # Block 2 - @test is_RF_on(seq[2]) == is_RF_on(seqd2) - @test is_GR_on(seq[2]) == is_GR_on(seqd2) - @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) - # Block 3 - @test is_RF_on(seq[3]) == is_RF_on(seqd3) - @test is_GR_on(seq[3]) == is_GR_on(seqd3) - @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) - @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) - @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) - @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) - end - - @testset "SequenceFunctions" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) - t_adc = KomaMRIBase.get_adc_sampling_times(seq) - M2, M2_adc = KomaMRIBase.get_slew_rate(seq) - M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) - Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) - Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) - @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz - @test is_ADC_on(seq) == is_ADC_on(seq, t) - @test is_RF_on(seq) == is_RF_on(seq, t) - @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) - @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) - @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", seq) - @test true - - α = rand() - c = α + im*rand() - x = seq - y = PulseDesigner.EPI_example() - z = x + y - @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N - z = x - y - @test z.GR.A ≈ [x.GR -y.GR].A - z = -x - @test z.GR.A ≈ -x.GR.A - z = x * α - @test z.GR.A ≈ α*x.GR.A - z = α * x - @test z.GR.A ≈ α*x.GR.A - z = x * c - @test z.RF.A ≈ c*x.RF.A - z = c * x - @test z.RF.A ≈ c*x.RF.A - z = x / α - @test z.GR.A ≈ x.GR.A/α - @test size(y) == size(y.GR[1,:]) - z = x + x.GR[3,1] - @test z.GR.A[1, end] ≈ x.GR[3,1].A - z = x.GR[3,1] + x - @test z.GR.A[1, 1] ≈ x.GR[3,1].A - z = x + x.RF[1,1] - @test z.RF.A[1, end] ≈ x.RF[1,1].A - z = x.RF[1,1] + x - @test z.RF.A[1, 1] ≈ x.RF[1,1].A - z = x + x.ADC[3,1] - @test z.ADC.N[end] ≈ x.ADC[3,1].N - z = x.ADC[3,1] + x - @test z.ADC.N[1] ≈ x.ADC[3,1].N - end - -end - -@testitem "PulseDesigner" tags=[:base] begin - @testset "RF_sinc" begin - sys = Scanner() - B1 = 23.4e-6 # For 90 deg flip angle - Trf = 1e-3 - rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) - @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 - end - @testset "Spiral" begin - sys = Scanner() - sys.Smax = 150 # [mT/m/ms] - sys.Gmax = 500e-3 # [T/m] - sys.GR_Δt = 4e-6 # [s] - FOV = 0.2 # [m] - N = 80 # Reconstructed image N×N - Nint = 8 - λ = 2.1 - spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) - # Look at the k_space generated - @test spiral(0).DEF["λ"] ≈ λ - end - @testset "Radial" begin - sys = Scanner() - N = 80 - Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction - FOV = 0.2 - spoke = PulseDesigner.radial_base(FOV, N, sys) - @test spoke.DEF["Δθ"] ≈ π / Nspokes - end -end - -@testitem "Phantom" tags = [:base] begin - using Suppressor - # Test phantom struct creation - name = "Bulks" - x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] - y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] - z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] - ρ = [0.2; 0.4; 0.6; 0.8; 1.0] - T1 = [0.9; 0.9; 0.5; 0.25; 0.4] - T2 = [0.09; 0.05; 0.04; 0.07; 0.005] - T2s = [0.1; 0.06; 0.05; 0.08; 0.015] - Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] - Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] - Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] - Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] - obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test obj1 == obj2 - - # Test size and length definitions of a phantom - @test size(obj1) == size(ρ) - @test length(obj1) == length(ρ) - - # Test obtaining spin psositions - @testset "SimpleMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - t_start=0.0; t_end=1.0 - t = collect(range(t_start, t_end, 11)) - period = 2.0 - asymmetry = 0.5 - # Translation - dx, dy, dz = [1.0, 0.0, 0.0] - vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) - translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) - xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # PeriodicTranslation - periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # Rotation (2D) - pitch = 0.0 - roll = 0.0 - yaw = 45.0 - rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) - xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # PeriodicRotation (2D) - periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # HeartBeat - circumferential_strain = -0.1 - radial_strain = 0.0 - longitudinal_strain = -0.1 - heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - r = sqrt.(ph.x .^ 2 + ph.y .^ 2) - θ = atan.(ph.y, ph.x) - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - # PeriodicHeartBeat - periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - end - @testset "ArbitraryMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - Ns = length(ph) - period_durations = [1.0] - num_pieces = 10 - dx = dy = dz = rand(Ns, num_pieces - 1) - arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) - t = times(arbitrarymotion) - xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') - @test xt[:,2:end-1] == ph.x .+ dx - @test yt[:,2:end-1] == ph.y .+ dy - @test zt[:,2:end-1] == ph.z .+ dz - end - - simplemotion = SimpleMotion([ - PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), - Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), - ]) - - Ns = length(obj1) - K = 10 - arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) - - # Test phantom subset - obs1 = Phantom( - name, - x, - y, - z, - ρ, - T1, - T2, - T2s, - Δw, - Dλ1, - Dλ2, - Dθ, - simplemotion - ) - rng = 1:2:5 - obs2 = Phantom( - name, - x[rng], - y[rng], - z[rng], - ρ[rng], - T1[rng], - T2[rng], - T2s[rng], - Δw[rng], - Dλ1[rng], - Dλ2[rng], - Dθ[rng], - simplemotion[rng], - ) - @test obs1[rng] == obs2 - @test @view(obs1[rng]) == obs2 - - obs1.motion = arbitrarymotion - obs2.motion = arbitrarymotion[rng] - @test obs1[rng] == obs2 - # @test @view(obs1[rng]) == obs2 - - # Test addition of phantoms - oba = Phantom( - name, - [x; x[rng]], - [y; y[rng]], - [z; z[rng]], - [ρ; ρ[rng]], - [T1; T1[rng]], - [T2; T2[rng]], - [T2s; T2s[rng]], - [Δw; Δw[rng]], - [Dλ1; Dλ1[rng]], - [Dλ2; Dλ2[rng]], - [Dθ; Dθ[rng]], - [obs1.motion; obs2.motion] - ) - @test obs1 + obs2 == oba - - # Test scalar multiplication of a phantom - c = 7 - obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test c * obj1 == obc - - #Test brain phantom 2D - ph = brain_phantom2D() - @test ph.name == "brain2D_axial" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test brain phantom 3D - ph = brain_phantom3D() - @test ph.name == "brain3D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] - - #Test pelvis phantom 2D - ph = pelvis_phantom2D() - @test ph.name == "pelvis2D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test heart phantom - ph = heart_phantom() - @test ph.name == "LeftVentricle" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] -end - -@testitem "Scanner" tags=[:base] begin - B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 - ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 - RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 - sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) - @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax -end - -@testitem "TrapezoidalIntegration" tags=[:base] begin - dt = Float64[1 1 1 1] - x = Float64[0 1 2 1 0] - @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 - @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true + +# @testitem "Sequence" tags=[:base] begin +# @testset "Init" begin +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) +# @test dur(EX) ≈ durRF #RF length matches what is supposed to be + +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0.0 +# if d1 > 0 DELAY = Delay(d1) end + +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be +# end + +# @testset "Rot_and_Concat" begin +# # Rotation 2D case +# A1, A2, T, t = rand(4) +# s = Sequence([Grad(A1,T); +# Grad(A2,T)]) +# θ = π*t +# R = rotz(θ) +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 +# # Rotation 3D case +# T, t1, t2, t3 = rand(4) +# N = 100 +# GR = [Grad(rand(),T) for i=1:3, j=1:N] +# s = Sequence(GR) +# α, β, γ = π*t1, π*t2, π*t3 +# Rx = rotx(α) +# Ry = roty(β) +# Rz = rotz(γ) +# R = Rx*Ry*Rz +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 + +# # Concatenation of sequences +# A1, A2, A3, T1 = rand(4) +# s1 = Sequence([Grad(A1,T1); +# Grad(A2,T1)], +# [RF(A3,T1)]) +# B1, B2, B3, T2 = rand(4) +# s2 = Sequence([Grad(B1,T2); +# Grad(B2,T2)], +# [RF(B3,T2)]) +# s = s1 + s2 +# @test s.GR.A ≈ [s1.GR.A s2.GR.A] +# @test s.RF.A ≈ [s1.RF.A s2.RF.A] +# @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] +# end + +# @testset "Grad" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# g1, g2 = Grad(A1,T), Grad(A2,T) +# GR = [g1;g2;;] +# GR2 = reshape([g1;g2],:,1) +# @test GR.A ≈ GR2.A + +# #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) +# A, T = 0.1, 1e-3 +# grad = Grad(A, T) + +# A, T = rand(2) +# g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) +# @test g1 ≈ g2 + +# A, T, ζ = rand(3) +# g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) +# @test g1 ≈ g2 + +# A, T, delay, ζ = rand(4) +# g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) +# @test g1 ≈ g2 + +# # Test construction with shape function +# T, N = 1e-3, 100 +# f = t -> sin(π*t / T) +# gradw = Grad(f, T, N) +# @test gradw.A ≈ f.(range(0.0, T; length=N)) + +# # Test Grad operations +# α = 3 +# gradt = α * grad +# @test size(grad, 1) == 1 +# @test gradt.A ≈ α * grad.A +# gradt = grad * α +# @test gradt.A ≈ α * grad.A +# gradt = grad / α +# @test gradt.A ≈ grad.A / α +# grads = grad + gradt +# @test grads.A ≈ grad.A + gradt.A +# A1, A2, A3 = 0.1, 0.2, 0.3 +# v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] +# v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] +# v3 = v1 + v2 +# @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] +# gradr = grad - gradt +# @test gradr.A ≈ grad.A - gradt.A +# gradt = -grad +# @test gradt.A ≈ -grad.A +# vc = vcat(v1, v2) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# vc = vcat(v1, v2, v3) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] +# delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 +# gr = Grad(A, T, rise, fall, delay) +# @test dur(gr) ≈ delay + rise + T + fall +# T1, T2, T3 = 1e-3, 2e-3, 3e-3 +# vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] +# @test dur(vt) ≈ [maximum([T1, T2, T3])] + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", grad) +# @test true + +# end + +# @testset "RF" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# r1, r2 = RF(A1,T), RF(A2,T) +# R = [r1;r2;;] +# R2 = reshape([r1;r2],:,1) +# @test R.A ≈ R2.A + +# #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) +# A, T = rand(2) +# r1, r2 = RF(A,T), RF(A,T,0.0,0.0) +# @test r1 ≈ r2 + +# A, T, Δf = rand(3) +# r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) +# @test r1 ≈ r2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", r1) +# @test true + +# # Test Grad operations +# B1x, B1y, T = rand(3) +# A = B1x + im*B1y +# α = Complex(rand()) +# rf = RF(A, T) +# rft = α * rf +# @test size(rf, 1) == 1 +# @test rft.A ≈ α * rf.A +# @test dur(rf) ≈ rf.T +# B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) +# rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) +# rv = [rf1; rf2; rf3] +# @test dur(rv) ≈ sum(dur.(rv)) + +# end + +# @testset "Delay" begin + +# # Test delay construction +# T = 1e-3 +# delay = Delay(T) +# @test delay.T ≈ T + +# # Test delay construction error for negative values +# err = Nothing +# try Delay(-T) catch err end +# @test err isa ErrorException + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", delay) +# @test true + +# # Test addition of a delay to a sequence +# seq = Sequence([Grad(0.0, 0.0)]) +# ds = delay + seq +# @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 +# sd = seq + delay +# @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T + +# end +# @testset "ADC" begin + +# # Test ADC construction +# N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π +# adc = ADC(N, T, delay, Δf, ϕ) + +# adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) +# @test adc1 ≈ adc2 + +# # Test ADC construction errors for negative values +# err = Nothing +# try ADC(N, -T) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException + +# # Test ADC getproperties +# Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π +# adb = ADC(Nb, Tb, delayb, Δfb, ϕb) +# adcs = [adc, adb] +# @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] +# @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] + +# end + +# @testset "DiscreteSequence" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# sampling_params = KomaMRIBase.default_sampling_params() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) +# seqd = KomaMRIBase.discretize(seq) +# i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) +# @test seqd[i1].t ≈ [t[i1]] +# @test seqd[i1:i2-1].t ≈ t[i1:i2] + +# T, N = 1.0, 4 +# seq = RF(1.0e-6, 1.0) +# seq += Sequence([Grad(1.0e-3, 1.0)]) +# seq += ADC(N, 1.0) +# sampling_params = KomaMRIBase.default_sampling_params() +# sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N +# seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) +# seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) +# seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) +# # Block 1 +# @test is_RF_on(seq[1]) == is_RF_on(seqd1) +# @test is_GR_on(seq[1]) == is_GR_on(seqd1) +# @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) +# # Block 2 +# @test is_RF_on(seq[2]) == is_RF_on(seqd2) +# @test is_GR_on(seq[2]) == is_GR_on(seqd2) +# @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) +# # Block 3 +# @test is_RF_on(seq[3]) == is_RF_on(seqd3) +# @test is_GR_on(seq[3]) == is_GR_on(seqd3) +# @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) +# @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) +# @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) +# @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) +# end + +# @testset "SequenceFunctions" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) +# t_adc = KomaMRIBase.get_adc_sampling_times(seq) +# M2, M2_adc = KomaMRIBase.get_slew_rate(seq) +# M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) +# Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) +# Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) +# @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz +# @test is_ADC_on(seq) == is_ADC_on(seq, t) +# @test is_RF_on(seq) == is_RF_on(seq, t) +# @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) +# @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) +# @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", seq) +# @test true + +# α = rand() +# c = α + im*rand() +# x = seq +# y = PulseDesigner.EPI_example() +# z = x + y +# @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N +# z = x - y +# @test z.GR.A ≈ [x.GR -y.GR].A +# z = -x +# @test z.GR.A ≈ -x.GR.A +# z = x * α +# @test z.GR.A ≈ α*x.GR.A +# z = α * x +# @test z.GR.A ≈ α*x.GR.A +# z = x * c +# @test z.RF.A ≈ c*x.RF.A +# z = c * x +# @test z.RF.A ≈ c*x.RF.A +# z = x / α +# @test z.GR.A ≈ x.GR.A/α +# @test size(y) == size(y.GR[1,:]) +# z = x + x.GR[3,1] +# @test z.GR.A[1, end] ≈ x.GR[3,1].A +# z = x.GR[3,1] + x +# @test z.GR.A[1, 1] ≈ x.GR[3,1].A +# z = x + x.RF[1,1] +# @test z.RF.A[1, end] ≈ x.RF[1,1].A +# z = x.RF[1,1] + x +# @test z.RF.A[1, 1] ≈ x.RF[1,1].A +# z = x + x.ADC[3,1] +# @test z.ADC.N[end] ≈ x.ADC[3,1].N +# z = x.ADC[3,1] + x +# @test z.ADC.N[1] ≈ x.ADC[3,1].N +# end + +# end + +# @testitem "PulseDesigner" tags=[:base] begin +# @testset "RF_sinc" begin +# sys = Scanner() +# B1 = 23.4e-6 # For 90 deg flip angle +# Trf = 1e-3 +# rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) +# @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 +# end +# @testset "Spiral" begin +# sys = Scanner() +# sys.Smax = 150 # [mT/m/ms] +# sys.Gmax = 500e-3 # [T/m] +# sys.GR_Δt = 4e-6 # [s] +# FOV = 0.2 # [m] +# N = 80 # Reconstructed image N×N +# Nint = 8 +# λ = 2.1 +# spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) +# # Look at the k_space generated +# @test spiral(0).DEF["λ"] ≈ λ +# end +# @testset "Radial" begin +# sys = Scanner() +# N = 80 +# Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction +# FOV = 0.2 +# spoke = PulseDesigner.radial_base(FOV, N, sys) +# @test spoke.DEF["Δθ"] ≈ π / Nspokes +# end +# end + +# @testitem "Phantom" tags = [:base] begin +# using Suppressor +# # Test phantom struct creation +# name = "Bulks" +# x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] +# y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] +# z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] +# ρ = [0.2; 0.4; 0.6; 0.8; 1.0] +# T1 = [0.9; 0.9; 0.5; 0.25; 0.4] +# T2 = [0.09; 0.05; 0.04; 0.07; 0.005] +# T2s = [0.1; 0.06; 0.05; 0.08; 0.015] +# Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] +# Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] +# Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] +# Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] +# obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test obj1 == obj2 + +# # Test size and length definitions of a phantom +# @test size(obj1) == size(ρ) +# @test length(obj1) == length(ρ) + +# # Test obtaining spin psositions +# @testset "SimpleMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# t_start=0.0; t_end=1.0 +# t = collect(range(t_start, t_end, 11)) +# period = 2.0 +# asymmetry = 0.5 +# # Translation +# dx, dy, dz = [1.0, 0.0, 0.0] +# vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) +# translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # PeriodicTranslation +# periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # Rotation (2D) +# pitch = 0.0 +# roll = 0.0 +# yaw = 45.0 +# rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # PeriodicRotation (2D) +# periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # HeartBeat +# circumferential_strain = -0.1 +# radial_strain = 0.0 +# longitudinal_strain = -0.1 +# heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# r = sqrt.(ph.x .^ 2 + ph.y .^ 2) +# θ = atan.(ph.y, ph.x) +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# # PeriodicHeartBeat +# periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# end +# @testset "ArbitraryMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# Ns = length(ph) +# period_durations = [1.0] +# num_pieces = 10 +# dx = dy = dz = rand(Ns, num_pieces - 1) +# arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) +# t = times(arbitrarymotion) +# xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') +# @test xt[:,2:end-1] == ph.x .+ dx +# @test yt[:,2:end-1] == ph.y .+ dy +# @test zt[:,2:end-1] == ph.z .+ dz +# end + +# simplemotion = SimpleMotion([ +# PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), +# Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), +# ]) + +# Ns = length(obj1) +# K = 10 +# arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) + +# # Test phantom subset +# obs1 = Phantom( +# name, +# x, +# y, +# z, +# ρ, +# T1, +# T2, +# T2s, +# Δw, +# Dλ1, +# Dλ2, +# Dθ, +# simplemotion +# ) +# rng = 1:2:5 +# obs2 = Phantom( +# name, +# x[rng], +# y[rng], +# z[rng], +# ρ[rng], +# T1[rng], +# T2[rng], +# T2s[rng], +# Δw[rng], +# Dλ1[rng], +# Dλ2[rng], +# Dθ[rng], +# simplemotion[rng], +# ) +# @test obs1[rng] == obs2 +# @test @view(obs1[rng]) == obs2 + +# obs1.motion = arbitrarymotion +# obs2.motion = arbitrarymotion[rng] +# @test obs1[rng] == obs2 +# # @test @view(obs1[rng]) == obs2 + +# # Test addition of phantoms +# oba = Phantom( +# name, +# [x; x[rng]], +# [y; y[rng]], +# [z; z[rng]], +# [ρ; ρ[rng]], +# [T1; T1[rng]], +# [T2; T2[rng]], +# [T2s; T2s[rng]], +# [Δw; Δw[rng]], +# [Dλ1; Dλ1[rng]], +# [Dλ2; Dλ2[rng]], +# [Dθ; Dθ[rng]], +# [obs1.motion; obs2.motion] +# ) +# @test obs1 + obs2 == oba + +# # Test scalar multiplication of a phantom +# c = 7 +# obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test c * obj1 == obc + +# #Test brain phantom 2D +# ph = brain_phantom2D() +# @test ph.name == "brain2D_axial" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test brain phantom 3D +# ph = brain_phantom3D() +# @test ph.name == "brain3D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] + +# #Test pelvis phantom 2D +# ph = pelvis_phantom2D() +# @test ph.name == "pelvis2D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test heart phantom +# ph = heart_phantom() +# @test ph.name == "LeftVentricle" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] +# end + +# @testitem "Scanner" tags=[:base] begin +# B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 +# ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 +# RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 +# sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) +# @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax +# end + +# @testitem "TrapezoidalIntegration" tags=[:base] begin +# dt = Float64[1 1 1 1] +# x = Float64[0 1 2 1 0] +# @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 +# @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] +# end diff --git a/KomaMRICore/test/runtests.jl b/KomaMRICore/test/runtests.jl index 9306533e0..f58d553b7 100644 --- a/KomaMRICore/test/runtests.jl +++ b/KomaMRICore/test/runtests.jl @@ -1,595 +1,595 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true - -@testitem "Spinors×Mag" tags=[:core] begin - using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg - - ## Verifying that operators perform counter-clockwise rotations - v = [1, 2, 3] - m = Mag([complex(v[1:2]...)], [v[3]]) - # Rx - @test rotx(π/2) * v ≈ [1, -3, 2] - @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] - @test (Rx(π/2) * m).z ≈ [2.0] - # Ry - @test roty(π/2) * v ≈ [3, 2, -1] - @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] - @test (Ry(π/2) * m).z ≈ [-1.0] - # Rz - @test rotz(π/2) * v ≈ [-2, 1, 3] - @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] - @test (Rz(π/2) * m).z ≈ [3.0] - # Rn - @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v - @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v - @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v - @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy - @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z - @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy - @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z - @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy - @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z - - ## Verify that Spinor rotation = matrix rotation - v = rand(3) - n = rand(3); n = n ./ sqrt(sum(n.^2)) - m = Mag([complex(v[1:2]...)], [v[3]]) - φ, θ, φ1, φ2 = rand(4) * 2π - # Rx - vx = rotx(θ) * v - mx = Rx(θ) * m - @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx - # Ry - vy = roty(θ) * v - my = Ry(θ) * m - @test [real(my.xy); imag(my.xy); my.z] ≈ vy - # Rz - vz = rotz(θ) * v - mz = Rz(θ) * m - @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz - # Rφ - vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v - mφ = Rφ(φ,θ) * m - @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ - # Rg - vg = rotz(φ2) * roty(θ) * rotz(φ1) * v - mg = Rg(φ1,θ,φ2) * m - @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg - # Rn - vq = Un(θ, n) * v - mq = Q(θ, n[1]+n[2]*1im, n[3]) * m - @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq - - ## Spinors satify that |α|^2 + |β|^2 = 1 - @test abs(Rx(θ)) ≈ [1] - @test abs(Ry(θ)) ≈ [1] - @test abs(Rz(θ)) ≈ [1] - @test abs(Rφ(φ,θ)) ≈ [1] - @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] - - ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. - # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) - @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v - @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy - @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z - # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) - @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z - # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) - @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z - # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) - @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v - @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy - @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z - - ## Verify trivial identities - # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis - # Rφ φ=0 = Ry - @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy - @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z - # Rφ φ=π/2 = Rx - @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy - @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z - # General rotation Rn - # Rn n=[1,0,0] = Rx - @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v - @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy - @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z - # Rn n=[0,1,0] = Ry - @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v - @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy - @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z - # Rn n=[0,0,1] = Rz - @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v - @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy - @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z - - # Associativity - # Rx - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z - # Rφ - @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z - @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy - @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z - # Rg - @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z - @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z - - ## Other tests - # Test Spinor struct - α, β = rand(2) - s = Spinor(α, β) - @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", s) - @test true -end - -# Test ISMRMRD -@testitem "signal_to_raw_data" tags=[:core] begin - using Suppressor - - seq = PulseDesigner.EPI_example() - sys = Scanner() - obj = brain_phantom2D() - - sim_params = KomaMRICore.default_sim_params() - sim_params["return_type"] = "mat" - sig = @suppress simulate(obj, seq, sys; sim_params) - - # Test signal_to_raw_data - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - seq.DEF["FOV"] = [23e-2, 23e-2, 0] - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", raw) - @test true -end - -@testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - for i=1:2 - global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) - global seq += ADC(N, Tadc) - end - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_phase_compensation" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = 2π*rand() - seq1 = Sequence() - seq1 += RF(B1, Trf) - seq1 += ADC(N, Tadc) - - seq2 = Sequence() - seq2 += RF(B1 .* exp(1im*rf_phase), Trf) - seq2 += ADC(N, Tadc, 0, 0, rf_phase) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw1 = @suppress simulate(obj, seq1, sys; sim_params) - raw2 = @suppress simulate(obj, seq2, sys; sim_params) - - @test raw1.profiles[1].data ≈ raw2.profiles[1].data - -end - -@testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - seq = seq_epi_100x100_TE100_FOV230() - obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) - sys = Scanner() - sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - sim_params["sim_method"] = KomaMRICore.BlochDict() - sig2 = @suppress simulate(obj, seq, sys; sim_params) - sig2 = sig2 / prod(size(obj)) - @test sig ≈ sig2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) - @test true -end - -@testitem "simulate_slice_profile" tags=[:core] begin - using Suppressor - - # This is a sequence with a sinc RF 30° excitation pulse - sys = Scanner() - sys.Smax = 50 - B1 = 4.92e-6 - Trf = 3.2e-3 - zmax = 2e-2 - fmax = 5e3 - z = range(-zmax, zmax, 400) - Gz = fmax / (γ * zmax) - f = γ * Gz * z - seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) - - # Simulate the slice profile - sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) - M = @suppress simulate_slice_profile(seq; z, sim_params) - - # For the time being, always pass the test - @test true -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true + +# @testitem "Spinors×Mag" tags=[:core] begin +# using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg + +# ## Verifying that operators perform counter-clockwise rotations +# v = [1, 2, 3] +# m = Mag([complex(v[1:2]...)], [v[3]]) +# # Rx +# @test rotx(π/2) * v ≈ [1, -3, 2] +# @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] +# @test (Rx(π/2) * m).z ≈ [2.0] +# # Ry +# @test roty(π/2) * v ≈ [3, 2, -1] +# @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] +# @test (Ry(π/2) * m).z ≈ [-1.0] +# # Rz +# @test rotz(π/2) * v ≈ [-2, 1, 3] +# @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] +# @test (Rz(π/2) * m).z ≈ [3.0] +# # Rn +# @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v +# @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v +# @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z + +# ## Verify that Spinor rotation = matrix rotation +# v = rand(3) +# n = rand(3); n = n ./ sqrt(sum(n.^2)) +# m = Mag([complex(v[1:2]...)], [v[3]]) +# φ, θ, φ1, φ2 = rand(4) * 2π +# # Rx +# vx = rotx(θ) * v +# mx = Rx(θ) * m +# @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx +# # Ry +# vy = roty(θ) * v +# my = Ry(θ) * m +# @test [real(my.xy); imag(my.xy); my.z] ≈ vy +# # Rz +# vz = rotz(θ) * v +# mz = Rz(θ) * m +# @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz +# # Rφ +# vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v +# mφ = Rφ(φ,θ) * m +# @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ +# # Rg +# vg = rotz(φ2) * roty(θ) * rotz(φ1) * v +# mg = Rg(φ1,θ,φ2) * m +# @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg +# # Rn +# vq = Un(θ, n) * v +# mq = Q(θ, n[1]+n[2]*1im, n[3]) * m +# @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq + +# ## Spinors satify that |α|^2 + |β|^2 = 1 +# @test abs(Rx(θ)) ≈ [1] +# @test abs(Ry(θ)) ≈ [1] +# @test abs(Rz(θ)) ≈ [1] +# @test abs(Rφ(φ,θ)) ≈ [1] +# @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] + +# ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. +# # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) +# @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v +# @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy +# @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z +# # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) +# @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z +# # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z +# # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) +# @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v +# @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy +# @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z + +# ## Verify trivial identities +# # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis +# # Rφ φ=0 = Ry +# @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy +# @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z +# # Rφ φ=π/2 = Rx +# @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy +# @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z +# # General rotation Rn +# # Rn n=[1,0,0] = Rx +# @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v +# @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy +# @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z +# # Rn n=[0,1,0] = Ry +# @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v +# @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy +# @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z +# # Rn n=[0,0,1] = Rz +# @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v +# @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy +# @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z + +# # Associativity +# # Rx +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z +# # Rφ +# @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z +# @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z +# # Rg +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z +# @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z + +# ## Other tests +# # Test Spinor struct +# α, β = rand(2) +# s = Spinor(α, β) +# @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", s) +# @test true +# end + +# # Test ISMRMRD +# @testitem "signal_to_raw_data" tags=[:core] begin +# using Suppressor + +# seq = PulseDesigner.EPI_example() +# sys = Scanner() +# obj = brain_phantom2D() + +# sim_params = KomaMRICore.default_sim_params() +# sim_params["return_type"] = "mat" +# sig = @suppress simulate(obj, seq, sys; sim_params) + +# # Test signal_to_raw_data +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# seq.DEF["FOV"] = [23e-2, 23e-2, 0] +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", raw) +# @test true +# end + +# @testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# for i=1:2 +# global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) +# global seq += ADC(N, Tadc) +# end + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_phase_compensation" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = 2π*rand() +# seq1 = Sequence() +# seq1 += RF(B1, Trf) +# seq1 += ADC(N, Tadc) + +# seq2 = Sequence() +# seq2 += RF(B1 .* exp(1im*rf_phase), Trf) +# seq2 += ADC(N, Tadc, 0, 0, rf_phase) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw1 = @suppress simulate(obj, seq1, sys; sim_params) +# raw2 = @suppress simulate(obj, seq2, sys; sim_params) + +# @test raw1.profiles[1].data ≈ raw2.profiles[1].data + +# end + +# @testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# seq = seq_epi_100x100_TE100_FOV230() +# obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) +# sys = Scanner() +# sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# sim_params["sim_method"] = KomaMRICore.BlochDict() +# sig2 = @suppress simulate(obj, seq, sys; sim_params) +# sig2 = sig2 / prod(size(obj)) +# @test sig ≈ sig2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) +# @test true +# end + +# @testitem "simulate_slice_profile" tags=[:core] begin +# using Suppressor + +# # This is a sequence with a sinc RF 30° excitation pulse +# sys = Scanner() +# sys.Smax = 50 +# B1 = 4.92e-6 +# Trf = 3.2e-3 +# zmax = 2e-2 +# fmax = 5e3 +# z = range(-zmax, zmax, 400) +# Gz = fmax / (γ * zmax) +# f = γ * Gz * z +# seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) + +# # Simulate the slice profile +# sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) +# M = @suppress simulate_slice_profile(seq; z, sim_params) + +# # For the time being, always pass the test +# @test true +# end diff --git a/KomaMRIFiles/test/runtests.jl b/KomaMRIFiles/test/runtests.jl index deccbf82d..812914bd0 100644 --- a/KomaMRIFiles/test/runtests.jl +++ b/KomaMRIFiles/test/runtests.jl @@ -1,125 +1,125 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true -@testitem "Files" tags=[:files] begin - using Suppressor +# @testitem "Files" tags=[:files] begin +# using Suppressor - # Test Pulseq - @testset "Pulseq" begin - path = @__DIR__ - seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "epi.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" +# # Test Pulseq +# @testset "Pulseq" begin +# path = @__DIR__ +# seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "epi.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" - seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "spiral.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" +# seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "spiral.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" - seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "epi_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" +# seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "epi_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" - seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "radial_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" +# seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "radial_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" - # Test Pulseq compression-decompression - shape = ones(100) - num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) - shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) - @test shape == shape2 - end - # Test JEMRIS - @testset "JEMRIS" begin - path = @__DIR__ - obj = read_phantom_jemris(path*"/test_files/column1d.h5") - @test obj.name == "column1d.h5" - end - # Test MRiLab - @testset "MRiLab" begin - path = @__DIR__ - filename = path * "/test_files/brain_mrilab.mat" - FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness - obj = read_phantom_MRiLab(filename; FRange_filename) - @test obj.name == "brain_mrilab.mat" - end - # Test Phantom (.phantom) - @testset "Phantom" begin - using KomaMRIBase - path = @__DIR__ - # NoMotion - filename = path * "/test_files/brain_nomotion.phantom" - obj1 = brain_phantom2D() - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # SimpleMotion - filename = path * "/test_files/brain_simplemotion.phantom" - obj1 = brain_phantom2D() - obj1.motion = SimpleMotion([ - PeriodicRotation( - period=1.0, - yaw=45.0, - pitch=0.0, - roll=0.0), - Translation( - t_start=0.0, - t_end=0.5, - dx=0.0, - dy=0.02, - dz=0.0 - )]) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # ArbitraryMotion - filename = path * "/test_files/brain_arbitrarymotion.phantom" - obj1 = brain_phantom2D() - Ns = length(obj1) - K = 10 - obj1.motion = ArbitraryMotion( - [1.0], - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1)) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - end -end +# # Test Pulseq compression-decompression +# shape = ones(100) +# num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) +# shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) +# @test shape == shape2 +# end +# # Test JEMRIS +# @testset "JEMRIS" begin +# path = @__DIR__ +# obj = read_phantom_jemris(path*"/test_files/column1d.h5") +# @test obj.name == "column1d.h5" +# end +# # Test MRiLab +# @testset "MRiLab" begin +# path = @__DIR__ +# filename = path * "/test_files/brain_mrilab.mat" +# FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness +# obj = read_phantom_MRiLab(filename; FRange_filename) +# @test obj.name == "brain_mrilab.mat" +# end +# # Test Phantom (.phantom) +# @testset "Phantom" begin +# using KomaMRIBase +# path = @__DIR__ +# # NoMotion +# filename = path * "/test_files/brain_nomotion.phantom" +# obj1 = brain_phantom2D() +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # SimpleMotion +# filename = path * "/test_files/brain_simplemotion.phantom" +# obj1 = brain_phantom2D() +# obj1.motion = SimpleMotion([ +# PeriodicRotation( +# period=1.0, +# yaw=45.0, +# pitch=0.0, +# roll=0.0), +# Translation( +# t_start=0.0, +# t_end=0.5, +# dx=0.0, +# dy=0.02, +# dz=0.0 +# )]) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # ArbitraryMotion +# filename = path * "/test_files/brain_arbitrarymotion.phantom" +# obj1 = brain_phantom2D() +# Ns = length(obj1) +# K = 10 +# obj1.motion = ArbitraryMotion( +# [1.0], +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1)) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# end +# end -@testitem "Pulseq compat" tags=[:files, :pulseq] begin - using MAT, KomaMRIBase, Suppressor +# @testitem "Pulseq compat" tags=[:files, :pulseq] begin +# using MAT, KomaMRIBase, Suppressor - # Aux functions - inside(x) = x[2:end-1] - namedtuple(x) = x[:] - namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) - not_empty = ((ek, ep),) -> !isempty(ep.t) +# # Aux functions +# inside(x) = x[2:end-1] +# namedtuple(x) = x[:] +# namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) +# not_empty = ((ek, ep),) -> !isempty(ep.t) - # Reading files - path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") - pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] - for pulseq_file in pulseq_files - #@show pulseq_file - seq_koma = @suppress read_seq("$path/$pulseq_file.seq") - seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple - @testset "$pulseq_file" begin - for i in 1:length(seq_koma) - blk_koma = get_samples(seq_koma, i) - blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys - for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) - @test ev_koma.t ≈ ev_pulseq.t - @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) - @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] - @test last(ev_koma.A) ≈ last(ev_pulseq.A) - end - end - end - end -end +# # Reading files +# path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") +# pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] +# for pulseq_file in pulseq_files +# #@show pulseq_file +# seq_koma = @suppress read_seq("$path/$pulseq_file.seq") +# seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple +# @testset "$pulseq_file" begin +# for i in 1:length(seq_koma) +# blk_koma = get_samples(seq_koma, i) +# blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys +# for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) +# @test ev_koma.t ≈ ev_pulseq.t +# @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) +# @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] +# @test last(ev_koma.A) ≈ last(ev_pulseq.A) +# end +# end +# end +# end +# end diff --git a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl index 011ecb027..1ef8b4504 100644 --- a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl @@ -1,135 +1,135 @@ -#GUI tests -@testitem "PlotlyJS" tags=[:plots] begin - using KomaMRIBase, MRIFiles - - @testset "GUI_phantom" begin - ph = brain_phantom2D() #2D phantom - - @testset "plot_phantom_map_rho" begin - plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T1" begin - plot_phantom_map(ph, :T1) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T2" begin - plot_phantom_map(ph, :T2) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_x" begin - plot_phantom_map(ph, :x) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_w" begin - plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_2dview" begin - plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - end - - @testset "GUI_seq" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - - @testset "plot_seq" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - - @testset "plot_kspace" begin - #Plot k-space - plot_kspace(seq; width=800, height=600) #Plotting the k-space - @test true #If the previous line fails the test will fail - end - - @testset "plot_M0" begin - #Plot M0 - plot_M0(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M1" begin - #Plot M1 - plot_M1(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M2" begin - #Plot M2 - plot_M2(seq) #Plotting the M2 - @test true #If the previous line fails the test will fail - end - - @testset "plot_eddy_currents" begin - #Plot M2 - plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents - @test true #If the previous line fails the test will fail - end - - @testset "plot_slew_rate" begin - plot_slew_rate(seq) - @test true - end - - @testset "plot_seqd" begin - plot_seqd(seq) - @test true - end - end - - @testset "GUI_dict_html" begin - #Define a dictionary and Plot the dictionary table - sys = Scanner() - sys_dict = Dict("B0" => sys.B0, - "B1" => sys.B1, - "Gmax" => sys.Gmax, - "Smax" => sys.Smax, - "ADC_dt" => sys.ADC_Δt, - "seq_dt" => sys.seq_Δt, - "GR_dt" => sys.GR_Δt, - "RF_dt" => sys.RF_Δt, - "RF_ring_down_T" => sys.RF_ring_down_T, - "RF_dead_time_T" => sys.RF_dead_time_T, - "ADC_dead_time_T" => sys.ADC_dead_time_T) - plot_dict(sys_dict) - @test true - end - - @testset "GUI_signal" begin - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - plot_signal(raw, width=800, height=600) - @test true #If the previous line fails the test will fail - end - - @testset "GUI_recon" begin - #??? - end - -end +# #GUI tests +# @testitem "PlotlyJS" tags=[:plots] begin +# using KomaMRIBase, MRIFiles + +# @testset "GUI_phantom" begin +# ph = brain_phantom2D() #2D phantom + +# @testset "plot_phantom_map_rho" begin +# plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T1" begin +# plot_phantom_map(ph, :T1) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T2" begin +# plot_phantom_map(ph, :T2) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_x" begin +# plot_phantom_map(ph, :x) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_w" begin +# plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_2dview" begin +# plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end +# end + +# @testset "GUI_seq" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + +# @testset "plot_seq" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end + +# @testset "plot_kspace" begin +# #Plot k-space +# plot_kspace(seq; width=800, height=600) #Plotting the k-space +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M0" begin +# #Plot M0 +# plot_M0(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M1" begin +# #Plot M1 +# plot_M1(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M2" begin +# #Plot M2 +# plot_M2(seq) #Plotting the M2 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_eddy_currents" begin +# #Plot M2 +# plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_slew_rate" begin +# plot_slew_rate(seq) +# @test true +# end + +# @testset "plot_seqd" begin +# plot_seqd(seq) +# @test true +# end +# end + +# @testset "GUI_dict_html" begin +# #Define a dictionary and Plot the dictionary table +# sys = Scanner() +# sys_dict = Dict("B0" => sys.B0, +# "B1" => sys.B1, +# "Gmax" => sys.Gmax, +# "Smax" => sys.Smax, +# "ADC_dt" => sys.ADC_Δt, +# "seq_dt" => sys.seq_Δt, +# "GR_dt" => sys.GR_Δt, +# "RF_dt" => sys.RF_Δt, +# "RF_ring_down_T" => sys.RF_ring_down_T, +# "RF_dead_time_T" => sys.RF_dead_time_T, +# "ADC_dead_time_T" => sys.ADC_dead_time_T) +# plot_dict(sys_dict) +# @test true +# end + +# @testset "GUI_signal" begin +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# plot_signal(raw, width=800, height=600) +# @test true #If the previous line fails the test will fail +# end + +# @testset "GUI_recon" begin +# #??? +# end + +# end diff --git a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl index 4d63016b3..f90cc53a7 100644 --- a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl @@ -1,29 +1,29 @@ -@testitem "PlutoPlotly" tags=[:plots] begin - using KomaMRIBase, PlutoPlotly #Testing package extension +# @testitem "PlutoPlotly" tags=[:plots] begin +# using KomaMRIBase, PlutoPlotly #Testing package extension - @testset "GUI_seq_PlutoPlotly" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @testset "GUI_seq_PlutoPlotly" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @testset "plot_seq_PlutoPlotly" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - end -end +# @testset "plot_seq_PlutoPlotly" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end +# end +# end diff --git a/test/runtests.jl b/test/runtests.jl index 9806ff246..47f4c8a3a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,132 +1,132 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true -# include("../KomaMRICore/test/runtests.jl") -# include("../KomaMRIPlots/test/runtests.jl") +# # include("../KomaMRICore/test/runtests.jl") +# # include("../KomaMRIPlots/test/runtests.jl") -@testitem "MRIReco recon" tags=[:koma] begin - #Sanity check 1 - A = rand(5,5,3) - B = KomaMRI.fftc(KomaMRI.ifftc(A)) - @test A ≈ B +# @testitem "MRIReco recon" tags=[:koma] begin +# #Sanity check 1 +# A = rand(5,5,3) +# B = KomaMRI.fftc(KomaMRI.ifftc(A)) +# @test A ≈ B - #Sanity check 2 - B = KomaMRI.ifftc(KomaMRI.fftc(A)) - @test A ≈ B +# #Sanity check 2 +# B = KomaMRI.ifftc(KomaMRI.fftc(A)) +# @test A ≈ B - #MRIReco.jl - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - acq = AcquisitionData(raw) +# #MRIReco.jl +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# acq = AcquisitionData(raw) - @testset "MRIReco_direct" begin - Nx, Ny = raw.params["reconSize"][1:2] - recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) - img = reconstruction(acq, recParams) - @test true #If the previous line fails the test will fail - end +# @testset "MRIReco_direct" begin +# Nx, Ny = raw.params["reconSize"][1:2] +# recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) +# img = reconstruction(acq, recParams) +# @test true #If the previous line fails the test will fail +# end - #Test MRIReco regularized recon (with a λ) - @testset "MRIReco_standard" begin - #??? - end +# #Test MRIReco regularized recon (with a λ) +# @testset "MRIReco_standard" begin +# #??? +# end -end +# end -@testitem "KomaUI" tags=[:koma] begin +# @testitem "KomaUI" tags=[:koma] begin - using Blink +# using Blink - # Opens UI - w = KomaUI(return_window=true) +# # Opens UI +# w = KomaUI(return_window=true) - @testset "Open UI" begin - @test "index" == @js w document.getElementById("content").dataset.content - end +# @testset "Open UI" begin +# @test "index" == @js w document.getElementById("content").dataset.content +# end - @testset "PulsesGUI" begin - @js w document.getElementById("button_pulses_seq").click() - @test "sequence" == @js w document.getElementById("content").dataset.content +# @testset "PulsesGUI" begin +# @js w document.getElementById("button_pulses_seq").click() +# @test "sequence" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_kspace").click() - @test "kspace" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_kspace").click() +# @test "kspace" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M0").click() - @test "m0" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M0").click() +# @test "m0" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M1").click() - @test "m1" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M1").click() +# @test "m1" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M2").click() - @test "m2" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_pulses_M2").click() +# @test "m2" == @js w document.getElementById("content").dataset.content +# end - @testset "PhantomGUI" begin - @js w document.getElementById("button_phantom").click() - @test "phantom" == @js w document.getElementById("content").dataset.content - end +# @testset "PhantomGUI" begin +# @js w document.getElementById("button_phantom").click() +# @test "phantom" == @js w document.getElementById("content").dataset.content +# end - @testset "ParamsGUI" begin - @js w document.getElementById("button_scanner").click() - @test "scanneparams" == @js w document.getElementById("content").dataset.content +# @testset "ParamsGUI" begin +# @js w document.getElementById("button_scanner").click() +# @test "scanneparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_sim_params").click() - @test "simparams" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_sim_params").click() +# @test "simparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_rec_params").click() - @test "recparams" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_rec_params").click() +# @test "recparams" == @js w document.getElementById("content").dataset.content +# end - @testset "Simulation" begin - @js w document.getElementById("simulate!").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "Simulation" begin +# @js w document.getElementById("simulate!").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "SignalGUI" begin - @js w document.getElementById("button_sig").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "SignalGUI" begin +# @js w document.getElementById("button_sig").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "Reconstruction" begin - @js w document.getElementById("recon!").click() - @test "absi" == @js w document.getElementById("content").dataset.content - end +# @testset "Reconstruction" begin +# @js w document.getElementById("recon!").click() +# @test "absi" == @js w document.getElementById("content").dataset.content +# end - @testset "ReconGUI" begin - @js w document.getElementById("button_reconstruction_absI").click() - @test "absi" == @js w document.getElementById("content").dataset.content +# @testset "ReconGUI" begin +# @js w document.getElementById("button_reconstruction_absI").click() +# @test "absi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_angI").click() - @test "angi" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_reconstruction_angI").click() +# @test "angi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_absK").click() - @test "absk" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_reconstruction_absK").click() +# @test "absk" == @js w document.getElementById("content").dataset.content +# end - @testset "ExportToMAT" begin - @js w document.getElementById("button_matfolder").click() - @test "matfolder" == @js w document.getElementById("content").dataset.content +# @testset "ExportToMAT" begin +# @js w document.getElementById("button_matfolder").click() +# @test "matfolder" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderseq").click() - @test "matfolderseq" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderseq").click() +# @test "matfolderseq" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderpha").click() - @test "matfolderpha" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderpha").click() +# @test "matfolderpha" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfoldersca").click() - @test "matfoldersca" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfoldersca").click() +# @test "matfoldersca" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderraw").click() - @test "matfolderraw" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderraw").click() +# @test "matfolderraw" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderima").click() - @test "matfolderima" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_matfolderima").click() +# @test "matfolderima" == @js w document.getElementById("content").dataset.content +# end - if !isnothing(w) - close(w) - end +# if !isnothing(w) +# close(w) +# end -end +# end From cc42132a6759b93618fa4563ebcce08715ebe367 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 14:13:33 +0200 Subject: [PATCH 07/51] Revert comments --- KomaMRIBase/test/runtests.jl | 1118 ++++++++-------- KomaMRICore/test/runtests.jl | 1190 ++++++++--------- KomaMRIFiles/test/runtests.jl | 230 ++-- .../test/GUI_PlotlyJS_backend_test.jl | 270 ++-- .../test/GUI_PlutoPlotly_backend_test.jl | 54 +- test/runtests.jl | 194 +-- 6 files changed, 1528 insertions(+), 1528 deletions(-) diff --git a/KomaMRIBase/test/runtests.jl b/KomaMRIBase/test/runtests.jl index f15bfaca2..555e0f438 100644 --- a/KomaMRIBase/test/runtests.jl +++ b/KomaMRIBase/test/runtests.jl @@ -1,559 +1,559 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true - -@testitem "Sequence" tags=[:base] begin - @testset "Init" begin - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) - @test dur(EX) ≈ durRF #RF length matches what is supposed to be - - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0.0 - if d1 > 0 DELAY = Delay(d1) end - - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be - end - - @testset "Rot_and_Concat" begin - # Rotation 2D case - A1, A2, T, t = rand(4) - s = Sequence([Grad(A1,T); - Grad(A2,T)]) - θ = π*t - R = rotz(θ) - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - # Rotation 3D case - T, t1, t2, t3 = rand(4) - N = 100 - GR = [Grad(rand(),T) for i=1:3, j=1:N] - s = Sequence(GR) - α, β, γ = π*t1, π*t2, π*t3 - Rx = rotx(α) - Ry = roty(β) - Rz = rotz(γ) - R = Rx*Ry*Rz - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - - # Concatenation of sequences - A1, A2, A3, T1 = rand(4) - s1 = Sequence([Grad(A1,T1); - Grad(A2,T1)], - [RF(A3,T1)]) - B1, B2, B3, T2 = rand(4) - s2 = Sequence([Grad(B1,T2); - Grad(B2,T2)], - [RF(B3,T2)]) - s = s1 + s2 - @test s.GR.A ≈ [s1.GR.A s2.GR.A] - @test s.RF.A ≈ [s1.RF.A s2.RF.A] - @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] - end - - @testset "Grad" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - g1, g2 = Grad(A1,T), Grad(A2,T) - GR = [g1;g2;;] - GR2 = reshape([g1;g2],:,1) - @test GR.A ≈ GR2.A - - #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) - A, T = 0.1, 1e-3 - grad = Grad(A, T) - - A, T = rand(2) - g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) - @test g1 ≈ g2 - - A, T, ζ = rand(3) - g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) - @test g1 ≈ g2 - - A, T, delay, ζ = rand(4) - g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) - @test g1 ≈ g2 - - # Test construction with shape function - T, N = 1e-3, 100 - f = t -> sin(π*t / T) - gradw = Grad(f, T, N) - @test gradw.A ≈ f.(range(0.0, T; length=N)) - - # Test Grad operations - α = 3 - gradt = α * grad - @test size(grad, 1) == 1 - @test gradt.A ≈ α * grad.A - gradt = grad * α - @test gradt.A ≈ α * grad.A - gradt = grad / α - @test gradt.A ≈ grad.A / α - grads = grad + gradt - @test grads.A ≈ grad.A + gradt.A - A1, A2, A3 = 0.1, 0.2, 0.3 - v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] - v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] - v3 = v1 + v2 - @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] - gradr = grad - gradt - @test gradr.A ≈ grad.A - gradt.A - gradt = -grad - @test gradt.A ≈ -grad.A - vc = vcat(v1, v2) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - vc = vcat(v1, v2, v3) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] - delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 - gr = Grad(A, T, rise, fall, delay) - @test dur(gr) ≈ delay + rise + T + fall - T1, T2, T3 = 1e-3, 2e-3, 3e-3 - vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] - @test dur(vt) ≈ [maximum([T1, T2, T3])] - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", grad) - @test true - - end - - @testset "RF" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - r1, r2 = RF(A1,T), RF(A2,T) - R = [r1;r2;;] - R2 = reshape([r1;r2],:,1) - @test R.A ≈ R2.A - - #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) - A, T = rand(2) - r1, r2 = RF(A,T), RF(A,T,0.0,0.0) - @test r1 ≈ r2 - - A, T, Δf = rand(3) - r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) - @test r1 ≈ r2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", r1) - @test true - - # Test Grad operations - B1x, B1y, T = rand(3) - A = B1x + im*B1y - α = Complex(rand()) - rf = RF(A, T) - rft = α * rf - @test size(rf, 1) == 1 - @test rft.A ≈ α * rf.A - @test dur(rf) ≈ rf.T - B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) - rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) - rv = [rf1; rf2; rf3] - @test dur(rv) ≈ sum(dur.(rv)) - - end - - @testset "Delay" begin - - # Test delay construction - T = 1e-3 - delay = Delay(T) - @test delay.T ≈ T - - # Test delay construction error for negative values - err = Nothing - try Delay(-T) catch err end - @test err isa ErrorException - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", delay) - @test true - - # Test addition of a delay to a sequence - seq = Sequence([Grad(0.0, 0.0)]) - ds = delay + seq - @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 - sd = seq + delay - @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T - - end - @testset "ADC" begin - - # Test ADC construction - N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π - adc = ADC(N, T, delay, Δf, ϕ) - - adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) - @test adc1 ≈ adc2 - - # Test ADC construction errors for negative values - err = Nothing - try ADC(N, -T) catch err end - @test err isa ErrorException - try ADC(N, -T, delay) catch err end - @test err isa ErrorException - try ADC(N, T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - - # Test ADC getproperties - Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π - adb = ADC(Nb, Tb, delayb, Δfb, ϕb) - adcs = [adc, adb] - @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] - @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] - - end - - @testset "DiscreteSequence" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - sampling_params = KomaMRIBase.default_sampling_params() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) - seqd = KomaMRIBase.discretize(seq) - i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) - @test seqd[i1].t ≈ [t[i1]] - @test seqd[i1:i2-1].t ≈ t[i1:i2] - - T, N = 1.0, 4 - seq = RF(1.0e-6, 1.0) - seq += Sequence([Grad(1.0e-3, 1.0)]) - seq += ADC(N, 1.0) - sampling_params = KomaMRIBase.default_sampling_params() - sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N - seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) - seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) - seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) - # Block 1 - @test is_RF_on(seq[1]) == is_RF_on(seqd1) - @test is_GR_on(seq[1]) == is_GR_on(seqd1) - @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) - # Block 2 - @test is_RF_on(seq[2]) == is_RF_on(seqd2) - @test is_GR_on(seq[2]) == is_GR_on(seqd2) - @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) - # Block 3 - @test is_RF_on(seq[3]) == is_RF_on(seqd3) - @test is_GR_on(seq[3]) == is_GR_on(seqd3) - @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) - @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) - @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) - @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) - end - - @testset "SequenceFunctions" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) - t_adc = KomaMRIBase.get_adc_sampling_times(seq) - M2, M2_adc = KomaMRIBase.get_slew_rate(seq) - M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) - Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) - Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) - @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz - @test is_ADC_on(seq) == is_ADC_on(seq, t) - @test is_RF_on(seq) == is_RF_on(seq, t) - @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) - @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) - @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", seq) - @test true - - α = rand() - c = α + im*rand() - x = seq - y = PulseDesigner.EPI_example() - z = x + y - @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N - z = x - y - @test z.GR.A ≈ [x.GR -y.GR].A - z = -x - @test z.GR.A ≈ -x.GR.A - z = x * α - @test z.GR.A ≈ α*x.GR.A - z = α * x - @test z.GR.A ≈ α*x.GR.A - z = x * c - @test z.RF.A ≈ c*x.RF.A - z = c * x - @test z.RF.A ≈ c*x.RF.A - z = x / α - @test z.GR.A ≈ x.GR.A/α - @test size(y) == size(y.GR[1,:]) - z = x + x.GR[3,1] - @test z.GR.A[1, end] ≈ x.GR[3,1].A - z = x.GR[3,1] + x - @test z.GR.A[1, 1] ≈ x.GR[3,1].A - z = x + x.RF[1,1] - @test z.RF.A[1, end] ≈ x.RF[1,1].A - z = x.RF[1,1] + x - @test z.RF.A[1, 1] ≈ x.RF[1,1].A - z = x + x.ADC[3,1] - @test z.ADC.N[end] ≈ x.ADC[3,1].N - z = x.ADC[3,1] + x - @test z.ADC.N[1] ≈ x.ADC[3,1].N - end - -end - -@testitem "PulseDesigner" tags=[:base] begin - @testset "RF_sinc" begin - sys = Scanner() - B1 = 23.4e-6 # For 90 deg flip angle - Trf = 1e-3 - rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) - @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 - end - @testset "Spiral" begin - sys = Scanner() - sys.Smax = 150 # [mT/m/ms] - sys.Gmax = 500e-3 # [T/m] - sys.GR_Δt = 4e-6 # [s] - FOV = 0.2 # [m] - N = 80 # Reconstructed image N×N - Nint = 8 - λ = 2.1 - spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) - # Look at the k_space generated - @test spiral(0).DEF["λ"] ≈ λ - end - @testset "Radial" begin - sys = Scanner() - N = 80 - Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction - FOV = 0.2 - spoke = PulseDesigner.radial_base(FOV, N, sys) - @test spoke.DEF["Δθ"] ≈ π / Nspokes - end -end - -@testitem "Phantom" tags = [:base] begin - using Suppressor - # Test phantom struct creation - name = "Bulks" - x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] - y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] - z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] - ρ = [0.2; 0.4; 0.6; 0.8; 1.0] - T1 = [0.9; 0.9; 0.5; 0.25; 0.4] - T2 = [0.09; 0.05; 0.04; 0.07; 0.005] - T2s = [0.1; 0.06; 0.05; 0.08; 0.015] - Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] - Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] - Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] - Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] - obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test obj1 == obj2 - - # Test size and length definitions of a phantom - @test size(obj1) == size(ρ) - @test length(obj1) == length(ρ) - - # Test obtaining spin psositions - @testset "SimpleMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - t_start=0.0; t_end=1.0 - t = collect(range(t_start, t_end, 11)) - period = 2.0 - asymmetry = 0.5 - # Translation - dx, dy, dz = [1.0, 0.0, 0.0] - vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) - translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) - xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # PeriodicTranslation - periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # Rotation (2D) - pitch = 0.0 - roll = 0.0 - yaw = 45.0 - rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) - xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # PeriodicRotation (2D) - periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # HeartBeat - circumferential_strain = -0.1 - radial_strain = 0.0 - longitudinal_strain = -0.1 - heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - r = sqrt.(ph.x .^ 2 + ph.y .^ 2) - θ = atan.(ph.y, ph.x) - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - # PeriodicHeartBeat - periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - end - @testset "ArbitraryMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - Ns = length(ph) - period_durations = [1.0] - num_pieces = 10 - dx = dy = dz = rand(Ns, num_pieces - 1) - arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) - t = times(arbitrarymotion) - xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') - @test xt[:,2:end-1] == ph.x .+ dx - @test yt[:,2:end-1] == ph.y .+ dy - @test zt[:,2:end-1] == ph.z .+ dz - end - - simplemotion = SimpleMotion([ - PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), - Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), - ]) - - Ns = length(obj1) - K = 10 - arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) - - # Test phantom subset - obs1 = Phantom( - name, - x, - y, - z, - ρ, - T1, - T2, - T2s, - Δw, - Dλ1, - Dλ2, - Dθ, - simplemotion - ) - rng = 1:2:5 - obs2 = Phantom( - name, - x[rng], - y[rng], - z[rng], - ρ[rng], - T1[rng], - T2[rng], - T2s[rng], - Δw[rng], - Dλ1[rng], - Dλ2[rng], - Dθ[rng], - simplemotion[rng], - ) - @test obs1[rng] == obs2 - @test @view(obs1[rng]) == obs2 - - obs1.motion = arbitrarymotion - obs2.motion = arbitrarymotion[rng] - @test obs1[rng] == obs2 - # @test @view(obs1[rng]) == obs2 - - # Test addition of phantoms - oba = Phantom( - name, - [x; x[rng]], - [y; y[rng]], - [z; z[rng]], - [ρ; ρ[rng]], - [T1; T1[rng]], - [T2; T2[rng]], - [T2s; T2s[rng]], - [Δw; Δw[rng]], - [Dλ1; Dλ1[rng]], - [Dλ2; Dλ2[rng]], - [Dθ; Dθ[rng]], - [obs1.motion; obs2.motion] - ) - @test obs1 + obs2 == oba - - # Test scalar multiplication of a phantom - c = 7 - obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test c * obj1 == obc - - #Test brain phantom 2D - ph = brain_phantom2D() - @test ph.name == "brain2D_axial" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test brain phantom 3D - ph = brain_phantom3D() - @test ph.name == "brain3D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] - - #Test pelvis phantom 2D - ph = pelvis_phantom2D() - @test ph.name == "pelvis2D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test heart phantom - ph = heart_phantom() - @test ph.name == "LeftVentricle" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] -end - -@testitem "Scanner" tags=[:base] begin - B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 - ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 - RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 - sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) - @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax -end - -@testitem "TrapezoidalIntegration" tags=[:base] begin - dt = Float64[1 1 1 1] - x = Float64[0 1 2 1 0] - @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 - @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true + +# @testitem "Sequence" tags=[:base] begin +# @testset "Init" begin +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) +# @test dur(EX) ≈ durRF #RF length matches what is supposed to be + +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0.0 +# if d1 > 0 DELAY = Delay(d1) end + +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be +# end + +# @testset "Rot_and_Concat" begin +# # Rotation 2D case +# A1, A2, T, t = rand(4) +# s = Sequence([Grad(A1,T); +# Grad(A2,T)]) +# θ = π*t +# R = rotz(θ) +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 +# # Rotation 3D case +# T, t1, t2, t3 = rand(4) +# N = 100 +# GR = [Grad(rand(),T) for i=1:3, j=1:N] +# s = Sequence(GR) +# α, β, γ = π*t1, π*t2, π*t3 +# Rx = rotx(α) +# Ry = roty(β) +# Rz = rotz(γ) +# R = Rx*Ry*Rz +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 + +# # Concatenation of sequences +# A1, A2, A3, T1 = rand(4) +# s1 = Sequence([Grad(A1,T1); +# Grad(A2,T1)], +# [RF(A3,T1)]) +# B1, B2, B3, T2 = rand(4) +# s2 = Sequence([Grad(B1,T2); +# Grad(B2,T2)], +# [RF(B3,T2)]) +# s = s1 + s2 +# @test s.GR.A ≈ [s1.GR.A s2.GR.A] +# @test s.RF.A ≈ [s1.RF.A s2.RF.A] +# @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] +# end + +# @testset "Grad" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# g1, g2 = Grad(A1,T), Grad(A2,T) +# GR = [g1;g2;;] +# GR2 = reshape([g1;g2],:,1) +# @test GR.A ≈ GR2.A + +# #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) +# A, T = 0.1, 1e-3 +# grad = Grad(A, T) + +# A, T = rand(2) +# g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) +# @test g1 ≈ g2 + +# A, T, ζ = rand(3) +# g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) +# @test g1 ≈ g2 + +# A, T, delay, ζ = rand(4) +# g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) +# @test g1 ≈ g2 + +# # Test construction with shape function +# T, N = 1e-3, 100 +# f = t -> sin(π*t / T) +# gradw = Grad(f, T, N) +# @test gradw.A ≈ f.(range(0.0, T; length=N)) + +# # Test Grad operations +# α = 3 +# gradt = α * grad +# @test size(grad, 1) == 1 +# @test gradt.A ≈ α * grad.A +# gradt = grad * α +# @test gradt.A ≈ α * grad.A +# gradt = grad / α +# @test gradt.A ≈ grad.A / α +# grads = grad + gradt +# @test grads.A ≈ grad.A + gradt.A +# A1, A2, A3 = 0.1, 0.2, 0.3 +# v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] +# v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] +# v3 = v1 + v2 +# @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] +# gradr = grad - gradt +# @test gradr.A ≈ grad.A - gradt.A +# gradt = -grad +# @test gradt.A ≈ -grad.A +# vc = vcat(v1, v2) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# vc = vcat(v1, v2, v3) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] +# delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 +# gr = Grad(A, T, rise, fall, delay) +# @test dur(gr) ≈ delay + rise + T + fall +# T1, T2, T3 = 1e-3, 2e-3, 3e-3 +# vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] +# @test dur(vt) ≈ [maximum([T1, T2, T3])] + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", grad) +# @test true + +# end + +# @testset "RF" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# r1, r2 = RF(A1,T), RF(A2,T) +# R = [r1;r2;;] +# R2 = reshape([r1;r2],:,1) +# @test R.A ≈ R2.A + +# #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) +# A, T = rand(2) +# r1, r2 = RF(A,T), RF(A,T,0.0,0.0) +# @test r1 ≈ r2 + +# A, T, Δf = rand(3) +# r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) +# @test r1 ≈ r2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", r1) +# @test true + +# # Test Grad operations +# B1x, B1y, T = rand(3) +# A = B1x + im*B1y +# α = Complex(rand()) +# rf = RF(A, T) +# rft = α * rf +# @test size(rf, 1) == 1 +# @test rft.A ≈ α * rf.A +# @test dur(rf) ≈ rf.T +# B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) +# rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) +# rv = [rf1; rf2; rf3] +# @test dur(rv) ≈ sum(dur.(rv)) + +# end + +# @testset "Delay" begin + +# # Test delay construction +# T = 1e-3 +# delay = Delay(T) +# @test delay.T ≈ T + +# # Test delay construction error for negative values +# err = Nothing +# try Delay(-T) catch err end +# @test err isa ErrorException + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", delay) +# @test true + +# # Test addition of a delay to a sequence +# seq = Sequence([Grad(0.0, 0.0)]) +# ds = delay + seq +# @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 +# sd = seq + delay +# @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T + +# end +# @testset "ADC" begin + +# # Test ADC construction +# N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π +# adc = ADC(N, T, delay, Δf, ϕ) + +# adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) +# @test adc1 ≈ adc2 + +# # Test ADC construction errors for negative values +# err = Nothing +# try ADC(N, -T) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException + +# # Test ADC getproperties +# Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π +# adb = ADC(Nb, Tb, delayb, Δfb, ϕb) +# adcs = [adc, adb] +# @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] +# @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] + +# end + +# @testset "DiscreteSequence" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# sampling_params = KomaMRIBase.default_sampling_params() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) +# seqd = KomaMRIBase.discretize(seq) +# i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) +# @test seqd[i1].t ≈ [t[i1]] +# @test seqd[i1:i2-1].t ≈ t[i1:i2] + +# T, N = 1.0, 4 +# seq = RF(1.0e-6, 1.0) +# seq += Sequence([Grad(1.0e-3, 1.0)]) +# seq += ADC(N, 1.0) +# sampling_params = KomaMRIBase.default_sampling_params() +# sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N +# seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) +# seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) +# seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) +# # Block 1 +# @test is_RF_on(seq[1]) == is_RF_on(seqd1) +# @test is_GR_on(seq[1]) == is_GR_on(seqd1) +# @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) +# # Block 2 +# @test is_RF_on(seq[2]) == is_RF_on(seqd2) +# @test is_GR_on(seq[2]) == is_GR_on(seqd2) +# @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) +# # Block 3 +# @test is_RF_on(seq[3]) == is_RF_on(seqd3) +# @test is_GR_on(seq[3]) == is_GR_on(seqd3) +# @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) +# @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) +# @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) +# @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) +# end + +# @testset "SequenceFunctions" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) +# t_adc = KomaMRIBase.get_adc_sampling_times(seq) +# M2, M2_adc = KomaMRIBase.get_slew_rate(seq) +# M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) +# Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) +# Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) +# @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz +# @test is_ADC_on(seq) == is_ADC_on(seq, t) +# @test is_RF_on(seq) == is_RF_on(seq, t) +# @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) +# @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) +# @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", seq) +# @test true + +# α = rand() +# c = α + im*rand() +# x = seq +# y = PulseDesigner.EPI_example() +# z = x + y +# @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N +# z = x - y +# @test z.GR.A ≈ [x.GR -y.GR].A +# z = -x +# @test z.GR.A ≈ -x.GR.A +# z = x * α +# @test z.GR.A ≈ α*x.GR.A +# z = α * x +# @test z.GR.A ≈ α*x.GR.A +# z = x * c +# @test z.RF.A ≈ c*x.RF.A +# z = c * x +# @test z.RF.A ≈ c*x.RF.A +# z = x / α +# @test z.GR.A ≈ x.GR.A/α +# @test size(y) == size(y.GR[1,:]) +# z = x + x.GR[3,1] +# @test z.GR.A[1, end] ≈ x.GR[3,1].A +# z = x.GR[3,1] + x +# @test z.GR.A[1, 1] ≈ x.GR[3,1].A +# z = x + x.RF[1,1] +# @test z.RF.A[1, end] ≈ x.RF[1,1].A +# z = x.RF[1,1] + x +# @test z.RF.A[1, 1] ≈ x.RF[1,1].A +# z = x + x.ADC[3,1] +# @test z.ADC.N[end] ≈ x.ADC[3,1].N +# z = x.ADC[3,1] + x +# @test z.ADC.N[1] ≈ x.ADC[3,1].N +# end + +# end + +# @testitem "PulseDesigner" tags=[:base] begin +# @testset "RF_sinc" begin +# sys = Scanner() +# B1 = 23.4e-6 # For 90 deg flip angle +# Trf = 1e-3 +# rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) +# @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 +# end +# @testset "Spiral" begin +# sys = Scanner() +# sys.Smax = 150 # [mT/m/ms] +# sys.Gmax = 500e-3 # [T/m] +# sys.GR_Δt = 4e-6 # [s] +# FOV = 0.2 # [m] +# N = 80 # Reconstructed image N×N +# Nint = 8 +# λ = 2.1 +# spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) +# # Look at the k_space generated +# @test spiral(0).DEF["λ"] ≈ λ +# end +# @testset "Radial" begin +# sys = Scanner() +# N = 80 +# Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction +# FOV = 0.2 +# spoke = PulseDesigner.radial_base(FOV, N, sys) +# @test spoke.DEF["Δθ"] ≈ π / Nspokes +# end +# end + +# @testitem "Phantom" tags = [:base] begin +# using Suppressor +# # Test phantom struct creation +# name = "Bulks" +# x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] +# y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] +# z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] +# ρ = [0.2; 0.4; 0.6; 0.8; 1.0] +# T1 = [0.9; 0.9; 0.5; 0.25; 0.4] +# T2 = [0.09; 0.05; 0.04; 0.07; 0.005] +# T2s = [0.1; 0.06; 0.05; 0.08; 0.015] +# Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] +# Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] +# Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] +# Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] +# obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test obj1 == obj2 + +# # Test size and length definitions of a phantom +# @test size(obj1) == size(ρ) +# @test length(obj1) == length(ρ) + +# # Test obtaining spin psositions +# @testset "SimpleMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# t_start=0.0; t_end=1.0 +# t = collect(range(t_start, t_end, 11)) +# period = 2.0 +# asymmetry = 0.5 +# # Translation +# dx, dy, dz = [1.0, 0.0, 0.0] +# vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) +# translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # PeriodicTranslation +# periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # Rotation (2D) +# pitch = 0.0 +# roll = 0.0 +# yaw = 45.0 +# rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # PeriodicRotation (2D) +# periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # HeartBeat +# circumferential_strain = -0.1 +# radial_strain = 0.0 +# longitudinal_strain = -0.1 +# heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# r = sqrt.(ph.x .^ 2 + ph.y .^ 2) +# θ = atan.(ph.y, ph.x) +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# # PeriodicHeartBeat +# periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# end +# @testset "ArbitraryMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# Ns = length(ph) +# period_durations = [1.0] +# num_pieces = 10 +# dx = dy = dz = rand(Ns, num_pieces - 1) +# arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) +# t = times(arbitrarymotion) +# xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') +# @test xt[:,2:end-1] == ph.x .+ dx +# @test yt[:,2:end-1] == ph.y .+ dy +# @test zt[:,2:end-1] == ph.z .+ dz +# end + +# simplemotion = SimpleMotion([ +# PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), +# Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), +# ]) + +# Ns = length(obj1) +# K = 10 +# arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) + +# # Test phantom subset +# obs1 = Phantom( +# name, +# x, +# y, +# z, +# ρ, +# T1, +# T2, +# T2s, +# Δw, +# Dλ1, +# Dλ2, +# Dθ, +# simplemotion +# ) +# rng = 1:2:5 +# obs2 = Phantom( +# name, +# x[rng], +# y[rng], +# z[rng], +# ρ[rng], +# T1[rng], +# T2[rng], +# T2s[rng], +# Δw[rng], +# Dλ1[rng], +# Dλ2[rng], +# Dθ[rng], +# simplemotion[rng], +# ) +# @test obs1[rng] == obs2 +# @test @view(obs1[rng]) == obs2 + +# obs1.motion = arbitrarymotion +# obs2.motion = arbitrarymotion[rng] +# @test obs1[rng] == obs2 +# # @test @view(obs1[rng]) == obs2 + +# # Test addition of phantoms +# oba = Phantom( +# name, +# [x; x[rng]], +# [y; y[rng]], +# [z; z[rng]], +# [ρ; ρ[rng]], +# [T1; T1[rng]], +# [T2; T2[rng]], +# [T2s; T2s[rng]], +# [Δw; Δw[rng]], +# [Dλ1; Dλ1[rng]], +# [Dλ2; Dλ2[rng]], +# [Dθ; Dθ[rng]], +# [obs1.motion; obs2.motion] +# ) +# @test obs1 + obs2 == oba + +# # Test scalar multiplication of a phantom +# c = 7 +# obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test c * obj1 == obc + +# #Test brain phantom 2D +# ph = brain_phantom2D() +# @test ph.name == "brain2D_axial" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test brain phantom 3D +# ph = brain_phantom3D() +# @test ph.name == "brain3D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] + +# #Test pelvis phantom 2D +# ph = pelvis_phantom2D() +# @test ph.name == "pelvis2D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test heart phantom +# ph = heart_phantom() +# @test ph.name == "LeftVentricle" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] +# end + +# @testitem "Scanner" tags=[:base] begin +# B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 +# ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 +# RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 +# sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) +# @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax +# end + +# @testitem "TrapezoidalIntegration" tags=[:base] begin +# dt = Float64[1 1 1 1] +# x = Float64[0 1 2 1 0] +# @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 +# @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] +# end diff --git a/KomaMRICore/test/runtests.jl b/KomaMRICore/test/runtests.jl index 9306533e0..f58d553b7 100644 --- a/KomaMRICore/test/runtests.jl +++ b/KomaMRICore/test/runtests.jl @@ -1,595 +1,595 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true - -@testitem "Spinors×Mag" tags=[:core] begin - using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg - - ## Verifying that operators perform counter-clockwise rotations - v = [1, 2, 3] - m = Mag([complex(v[1:2]...)], [v[3]]) - # Rx - @test rotx(π/2) * v ≈ [1, -3, 2] - @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] - @test (Rx(π/2) * m).z ≈ [2.0] - # Ry - @test roty(π/2) * v ≈ [3, 2, -1] - @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] - @test (Ry(π/2) * m).z ≈ [-1.0] - # Rz - @test rotz(π/2) * v ≈ [-2, 1, 3] - @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] - @test (Rz(π/2) * m).z ≈ [3.0] - # Rn - @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v - @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v - @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v - @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy - @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z - @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy - @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z - @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy - @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z - - ## Verify that Spinor rotation = matrix rotation - v = rand(3) - n = rand(3); n = n ./ sqrt(sum(n.^2)) - m = Mag([complex(v[1:2]...)], [v[3]]) - φ, θ, φ1, φ2 = rand(4) * 2π - # Rx - vx = rotx(θ) * v - mx = Rx(θ) * m - @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx - # Ry - vy = roty(θ) * v - my = Ry(θ) * m - @test [real(my.xy); imag(my.xy); my.z] ≈ vy - # Rz - vz = rotz(θ) * v - mz = Rz(θ) * m - @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz - # Rφ - vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v - mφ = Rφ(φ,θ) * m - @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ - # Rg - vg = rotz(φ2) * roty(θ) * rotz(φ1) * v - mg = Rg(φ1,θ,φ2) * m - @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg - # Rn - vq = Un(θ, n) * v - mq = Q(θ, n[1]+n[2]*1im, n[3]) * m - @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq - - ## Spinors satify that |α|^2 + |β|^2 = 1 - @test abs(Rx(θ)) ≈ [1] - @test abs(Ry(θ)) ≈ [1] - @test abs(Rz(θ)) ≈ [1] - @test abs(Rφ(φ,θ)) ≈ [1] - @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] - - ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. - # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) - @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v - @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy - @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z - # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) - @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z - # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) - @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z - # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) - @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v - @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy - @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z - - ## Verify trivial identities - # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis - # Rφ φ=0 = Ry - @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy - @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z - # Rφ φ=π/2 = Rx - @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy - @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z - # General rotation Rn - # Rn n=[1,0,0] = Rx - @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v - @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy - @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z - # Rn n=[0,1,0] = Ry - @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v - @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy - @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z - # Rn n=[0,0,1] = Rz - @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v - @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy - @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z - - # Associativity - # Rx - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z - # Rφ - @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z - @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy - @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z - # Rg - @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z - @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z - - ## Other tests - # Test Spinor struct - α, β = rand(2) - s = Spinor(α, β) - @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", s) - @test true -end - -# Test ISMRMRD -@testitem "signal_to_raw_data" tags=[:core] begin - using Suppressor - - seq = PulseDesigner.EPI_example() - sys = Scanner() - obj = brain_phantom2D() - - sim_params = KomaMRICore.default_sim_params() - sim_params["return_type"] = "mat" - sig = @suppress simulate(obj, seq, sys; sim_params) - - # Test signal_to_raw_data - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - seq.DEF["FOV"] = [23e-2, 23e-2, 0] - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", raw) - @test true -end - -@testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - for i=1:2 - global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) - global seq += ADC(N, Tadc) - end - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_phase_compensation" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = 2π*rand() - seq1 = Sequence() - seq1 += RF(B1, Trf) - seq1 += ADC(N, Tadc) - - seq2 = Sequence() - seq2 += RF(B1 .* exp(1im*rf_phase), Trf) - seq2 += ADC(N, Tadc, 0, 0, rf_phase) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw1 = @suppress simulate(obj, seq1, sys; sim_params) - raw2 = @suppress simulate(obj, seq2, sys; sim_params) - - @test raw1.profiles[1].data ≈ raw2.profiles[1].data - -end - -@testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - seq = seq_epi_100x100_TE100_FOV230() - obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) - sys = Scanner() - sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - sim_params["sim_method"] = KomaMRICore.BlochDict() - sig2 = @suppress simulate(obj, seq, sys; sim_params) - sig2 = sig2 / prod(size(obj)) - @test sig ≈ sig2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) - @test true -end - -@testitem "simulate_slice_profile" tags=[:core] begin - using Suppressor - - # This is a sequence with a sinc RF 30° excitation pulse - sys = Scanner() - sys.Smax = 50 - B1 = 4.92e-6 - Trf = 3.2e-3 - zmax = 2e-2 - fmax = 5e3 - z = range(-zmax, zmax, 400) - Gz = fmax / (γ * zmax) - f = γ * Gz * z - seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) - - # Simulate the slice profile - sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) - M = @suppress simulate_slice_profile(seq; z, sim_params) - - # For the time being, always pass the test - @test true -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true + +# @testitem "Spinors×Mag" tags=[:core] begin +# using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg + +# ## Verifying that operators perform counter-clockwise rotations +# v = [1, 2, 3] +# m = Mag([complex(v[1:2]...)], [v[3]]) +# # Rx +# @test rotx(π/2) * v ≈ [1, -3, 2] +# @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] +# @test (Rx(π/2) * m).z ≈ [2.0] +# # Ry +# @test roty(π/2) * v ≈ [3, 2, -1] +# @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] +# @test (Ry(π/2) * m).z ≈ [-1.0] +# # Rz +# @test rotz(π/2) * v ≈ [-2, 1, 3] +# @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] +# @test (Rz(π/2) * m).z ≈ [3.0] +# # Rn +# @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v +# @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v +# @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z + +# ## Verify that Spinor rotation = matrix rotation +# v = rand(3) +# n = rand(3); n = n ./ sqrt(sum(n.^2)) +# m = Mag([complex(v[1:2]...)], [v[3]]) +# φ, θ, φ1, φ2 = rand(4) * 2π +# # Rx +# vx = rotx(θ) * v +# mx = Rx(θ) * m +# @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx +# # Ry +# vy = roty(θ) * v +# my = Ry(θ) * m +# @test [real(my.xy); imag(my.xy); my.z] ≈ vy +# # Rz +# vz = rotz(θ) * v +# mz = Rz(θ) * m +# @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz +# # Rφ +# vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v +# mφ = Rφ(φ,θ) * m +# @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ +# # Rg +# vg = rotz(φ2) * roty(θ) * rotz(φ1) * v +# mg = Rg(φ1,θ,φ2) * m +# @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg +# # Rn +# vq = Un(θ, n) * v +# mq = Q(θ, n[1]+n[2]*1im, n[3]) * m +# @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq + +# ## Spinors satify that |α|^2 + |β|^2 = 1 +# @test abs(Rx(θ)) ≈ [1] +# @test abs(Ry(θ)) ≈ [1] +# @test abs(Rz(θ)) ≈ [1] +# @test abs(Rφ(φ,θ)) ≈ [1] +# @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] + +# ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. +# # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) +# @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v +# @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy +# @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z +# # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) +# @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z +# # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z +# # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) +# @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v +# @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy +# @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z + +# ## Verify trivial identities +# # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis +# # Rφ φ=0 = Ry +# @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy +# @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z +# # Rφ φ=π/2 = Rx +# @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy +# @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z +# # General rotation Rn +# # Rn n=[1,0,0] = Rx +# @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v +# @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy +# @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z +# # Rn n=[0,1,0] = Ry +# @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v +# @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy +# @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z +# # Rn n=[0,0,1] = Rz +# @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v +# @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy +# @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z + +# # Associativity +# # Rx +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z +# # Rφ +# @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z +# @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z +# # Rg +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z +# @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z + +# ## Other tests +# # Test Spinor struct +# α, β = rand(2) +# s = Spinor(α, β) +# @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", s) +# @test true +# end + +# # Test ISMRMRD +# @testitem "signal_to_raw_data" tags=[:core] begin +# using Suppressor + +# seq = PulseDesigner.EPI_example() +# sys = Scanner() +# obj = brain_phantom2D() + +# sim_params = KomaMRICore.default_sim_params() +# sim_params["return_type"] = "mat" +# sig = @suppress simulate(obj, seq, sys; sim_params) + +# # Test signal_to_raw_data +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# seq.DEF["FOV"] = [23e-2, 23e-2, 0] +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", raw) +# @test true +# end + +# @testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# for i=1:2 +# global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) +# global seq += ADC(N, Tadc) +# end + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_phase_compensation" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = 2π*rand() +# seq1 = Sequence() +# seq1 += RF(B1, Trf) +# seq1 += ADC(N, Tadc) + +# seq2 = Sequence() +# seq2 += RF(B1 .* exp(1im*rf_phase), Trf) +# seq2 += ADC(N, Tadc, 0, 0, rf_phase) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw1 = @suppress simulate(obj, seq1, sys; sim_params) +# raw2 = @suppress simulate(obj, seq2, sys; sim_params) + +# @test raw1.profiles[1].data ≈ raw2.profiles[1].data + +# end + +# @testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# seq = seq_epi_100x100_TE100_FOV230() +# obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) +# sys = Scanner() +# sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# sim_params["sim_method"] = KomaMRICore.BlochDict() +# sig2 = @suppress simulate(obj, seq, sys; sim_params) +# sig2 = sig2 / prod(size(obj)) +# @test sig ≈ sig2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) +# @test true +# end + +# @testitem "simulate_slice_profile" tags=[:core] begin +# using Suppressor + +# # This is a sequence with a sinc RF 30° excitation pulse +# sys = Scanner() +# sys.Smax = 50 +# B1 = 4.92e-6 +# Trf = 3.2e-3 +# zmax = 2e-2 +# fmax = 5e3 +# z = range(-zmax, zmax, 400) +# Gz = fmax / (γ * zmax) +# f = γ * Gz * z +# seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) + +# # Simulate the slice profile +# sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) +# M = @suppress simulate_slice_profile(seq; z, sim_params) + +# # For the time being, always pass the test +# @test true +# end diff --git a/KomaMRIFiles/test/runtests.jl b/KomaMRIFiles/test/runtests.jl index deccbf82d..812914bd0 100644 --- a/KomaMRIFiles/test/runtests.jl +++ b/KomaMRIFiles/test/runtests.jl @@ -1,125 +1,125 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true -@testitem "Files" tags=[:files] begin - using Suppressor +# @testitem "Files" tags=[:files] begin +# using Suppressor - # Test Pulseq - @testset "Pulseq" begin - path = @__DIR__ - seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "epi.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" +# # Test Pulseq +# @testset "Pulseq" begin +# path = @__DIR__ +# seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "epi.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" - seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "spiral.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" +# seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "spiral.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" - seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "epi_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" +# seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "epi_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" - seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "radial_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" +# seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "radial_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" - # Test Pulseq compression-decompression - shape = ones(100) - num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) - shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) - @test shape == shape2 - end - # Test JEMRIS - @testset "JEMRIS" begin - path = @__DIR__ - obj = read_phantom_jemris(path*"/test_files/column1d.h5") - @test obj.name == "column1d.h5" - end - # Test MRiLab - @testset "MRiLab" begin - path = @__DIR__ - filename = path * "/test_files/brain_mrilab.mat" - FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness - obj = read_phantom_MRiLab(filename; FRange_filename) - @test obj.name == "brain_mrilab.mat" - end - # Test Phantom (.phantom) - @testset "Phantom" begin - using KomaMRIBase - path = @__DIR__ - # NoMotion - filename = path * "/test_files/brain_nomotion.phantom" - obj1 = brain_phantom2D() - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # SimpleMotion - filename = path * "/test_files/brain_simplemotion.phantom" - obj1 = brain_phantom2D() - obj1.motion = SimpleMotion([ - PeriodicRotation( - period=1.0, - yaw=45.0, - pitch=0.0, - roll=0.0), - Translation( - t_start=0.0, - t_end=0.5, - dx=0.0, - dy=0.02, - dz=0.0 - )]) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # ArbitraryMotion - filename = path * "/test_files/brain_arbitrarymotion.phantom" - obj1 = brain_phantom2D() - Ns = length(obj1) - K = 10 - obj1.motion = ArbitraryMotion( - [1.0], - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1)) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - end -end +# # Test Pulseq compression-decompression +# shape = ones(100) +# num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) +# shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) +# @test shape == shape2 +# end +# # Test JEMRIS +# @testset "JEMRIS" begin +# path = @__DIR__ +# obj = read_phantom_jemris(path*"/test_files/column1d.h5") +# @test obj.name == "column1d.h5" +# end +# # Test MRiLab +# @testset "MRiLab" begin +# path = @__DIR__ +# filename = path * "/test_files/brain_mrilab.mat" +# FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness +# obj = read_phantom_MRiLab(filename; FRange_filename) +# @test obj.name == "brain_mrilab.mat" +# end +# # Test Phantom (.phantom) +# @testset "Phantom" begin +# using KomaMRIBase +# path = @__DIR__ +# # NoMotion +# filename = path * "/test_files/brain_nomotion.phantom" +# obj1 = brain_phantom2D() +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # SimpleMotion +# filename = path * "/test_files/brain_simplemotion.phantom" +# obj1 = brain_phantom2D() +# obj1.motion = SimpleMotion([ +# PeriodicRotation( +# period=1.0, +# yaw=45.0, +# pitch=0.0, +# roll=0.0), +# Translation( +# t_start=0.0, +# t_end=0.5, +# dx=0.0, +# dy=0.02, +# dz=0.0 +# )]) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # ArbitraryMotion +# filename = path * "/test_files/brain_arbitrarymotion.phantom" +# obj1 = brain_phantom2D() +# Ns = length(obj1) +# K = 10 +# obj1.motion = ArbitraryMotion( +# [1.0], +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1)) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# end +# end -@testitem "Pulseq compat" tags=[:files, :pulseq] begin - using MAT, KomaMRIBase, Suppressor +# @testitem "Pulseq compat" tags=[:files, :pulseq] begin +# using MAT, KomaMRIBase, Suppressor - # Aux functions - inside(x) = x[2:end-1] - namedtuple(x) = x[:] - namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) - not_empty = ((ek, ep),) -> !isempty(ep.t) +# # Aux functions +# inside(x) = x[2:end-1] +# namedtuple(x) = x[:] +# namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) +# not_empty = ((ek, ep),) -> !isempty(ep.t) - # Reading files - path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") - pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] - for pulseq_file in pulseq_files - #@show pulseq_file - seq_koma = @suppress read_seq("$path/$pulseq_file.seq") - seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple - @testset "$pulseq_file" begin - for i in 1:length(seq_koma) - blk_koma = get_samples(seq_koma, i) - blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys - for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) - @test ev_koma.t ≈ ev_pulseq.t - @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) - @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] - @test last(ev_koma.A) ≈ last(ev_pulseq.A) - end - end - end - end -end +# # Reading files +# path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") +# pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] +# for pulseq_file in pulseq_files +# #@show pulseq_file +# seq_koma = @suppress read_seq("$path/$pulseq_file.seq") +# seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple +# @testset "$pulseq_file" begin +# for i in 1:length(seq_koma) +# blk_koma = get_samples(seq_koma, i) +# blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys +# for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) +# @test ev_koma.t ≈ ev_pulseq.t +# @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) +# @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] +# @test last(ev_koma.A) ≈ last(ev_pulseq.A) +# end +# end +# end +# end +# end diff --git a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl index 011ecb027..1ef8b4504 100644 --- a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl @@ -1,135 +1,135 @@ -#GUI tests -@testitem "PlotlyJS" tags=[:plots] begin - using KomaMRIBase, MRIFiles - - @testset "GUI_phantom" begin - ph = brain_phantom2D() #2D phantom - - @testset "plot_phantom_map_rho" begin - plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T1" begin - plot_phantom_map(ph, :T1) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T2" begin - plot_phantom_map(ph, :T2) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_x" begin - plot_phantom_map(ph, :x) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_w" begin - plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_2dview" begin - plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - end - - @testset "GUI_seq" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - - @testset "plot_seq" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - - @testset "plot_kspace" begin - #Plot k-space - plot_kspace(seq; width=800, height=600) #Plotting the k-space - @test true #If the previous line fails the test will fail - end - - @testset "plot_M0" begin - #Plot M0 - plot_M0(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M1" begin - #Plot M1 - plot_M1(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M2" begin - #Plot M2 - plot_M2(seq) #Plotting the M2 - @test true #If the previous line fails the test will fail - end - - @testset "plot_eddy_currents" begin - #Plot M2 - plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents - @test true #If the previous line fails the test will fail - end - - @testset "plot_slew_rate" begin - plot_slew_rate(seq) - @test true - end - - @testset "plot_seqd" begin - plot_seqd(seq) - @test true - end - end - - @testset "GUI_dict_html" begin - #Define a dictionary and Plot the dictionary table - sys = Scanner() - sys_dict = Dict("B0" => sys.B0, - "B1" => sys.B1, - "Gmax" => sys.Gmax, - "Smax" => sys.Smax, - "ADC_dt" => sys.ADC_Δt, - "seq_dt" => sys.seq_Δt, - "GR_dt" => sys.GR_Δt, - "RF_dt" => sys.RF_Δt, - "RF_ring_down_T" => sys.RF_ring_down_T, - "RF_dead_time_T" => sys.RF_dead_time_T, - "ADC_dead_time_T" => sys.ADC_dead_time_T) - plot_dict(sys_dict) - @test true - end - - @testset "GUI_signal" begin - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - plot_signal(raw, width=800, height=600) - @test true #If the previous line fails the test will fail - end - - @testset "GUI_recon" begin - #??? - end - -end +# #GUI tests +# @testitem "PlotlyJS" tags=[:plots] begin +# using KomaMRIBase, MRIFiles + +# @testset "GUI_phantom" begin +# ph = brain_phantom2D() #2D phantom + +# @testset "plot_phantom_map_rho" begin +# plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T1" begin +# plot_phantom_map(ph, :T1) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T2" begin +# plot_phantom_map(ph, :T2) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_x" begin +# plot_phantom_map(ph, :x) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_w" begin +# plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_2dview" begin +# plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end +# end + +# @testset "GUI_seq" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + +# @testset "plot_seq" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end + +# @testset "plot_kspace" begin +# #Plot k-space +# plot_kspace(seq; width=800, height=600) #Plotting the k-space +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M0" begin +# #Plot M0 +# plot_M0(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M1" begin +# #Plot M1 +# plot_M1(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M2" begin +# #Plot M2 +# plot_M2(seq) #Plotting the M2 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_eddy_currents" begin +# #Plot M2 +# plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_slew_rate" begin +# plot_slew_rate(seq) +# @test true +# end + +# @testset "plot_seqd" begin +# plot_seqd(seq) +# @test true +# end +# end + +# @testset "GUI_dict_html" begin +# #Define a dictionary and Plot the dictionary table +# sys = Scanner() +# sys_dict = Dict("B0" => sys.B0, +# "B1" => sys.B1, +# "Gmax" => sys.Gmax, +# "Smax" => sys.Smax, +# "ADC_dt" => sys.ADC_Δt, +# "seq_dt" => sys.seq_Δt, +# "GR_dt" => sys.GR_Δt, +# "RF_dt" => sys.RF_Δt, +# "RF_ring_down_T" => sys.RF_ring_down_T, +# "RF_dead_time_T" => sys.RF_dead_time_T, +# "ADC_dead_time_T" => sys.ADC_dead_time_T) +# plot_dict(sys_dict) +# @test true +# end + +# @testset "GUI_signal" begin +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# plot_signal(raw, width=800, height=600) +# @test true #If the previous line fails the test will fail +# end + +# @testset "GUI_recon" begin +# #??? +# end + +# end diff --git a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl index 4d63016b3..f90cc53a7 100644 --- a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl @@ -1,29 +1,29 @@ -@testitem "PlutoPlotly" tags=[:plots] begin - using KomaMRIBase, PlutoPlotly #Testing package extension +# @testitem "PlutoPlotly" tags=[:plots] begin +# using KomaMRIBase, PlutoPlotly #Testing package extension - @testset "GUI_seq_PlutoPlotly" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @testset "GUI_seq_PlutoPlotly" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @testset "plot_seq_PlutoPlotly" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - end -end +# @testset "plot_seq_PlutoPlotly" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end +# end +# end diff --git a/test/runtests.jl b/test/runtests.jl index 9806ff246..47f4c8a3a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,132 +1,132 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true -# include("../KomaMRICore/test/runtests.jl") -# include("../KomaMRIPlots/test/runtests.jl") +# # include("../KomaMRICore/test/runtests.jl") +# # include("../KomaMRIPlots/test/runtests.jl") -@testitem "MRIReco recon" tags=[:koma] begin - #Sanity check 1 - A = rand(5,5,3) - B = KomaMRI.fftc(KomaMRI.ifftc(A)) - @test A ≈ B +# @testitem "MRIReco recon" tags=[:koma] begin +# #Sanity check 1 +# A = rand(5,5,3) +# B = KomaMRI.fftc(KomaMRI.ifftc(A)) +# @test A ≈ B - #Sanity check 2 - B = KomaMRI.ifftc(KomaMRI.fftc(A)) - @test A ≈ B +# #Sanity check 2 +# B = KomaMRI.ifftc(KomaMRI.fftc(A)) +# @test A ≈ B - #MRIReco.jl - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - acq = AcquisitionData(raw) +# #MRIReco.jl +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# acq = AcquisitionData(raw) - @testset "MRIReco_direct" begin - Nx, Ny = raw.params["reconSize"][1:2] - recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) - img = reconstruction(acq, recParams) - @test true #If the previous line fails the test will fail - end +# @testset "MRIReco_direct" begin +# Nx, Ny = raw.params["reconSize"][1:2] +# recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) +# img = reconstruction(acq, recParams) +# @test true #If the previous line fails the test will fail +# end - #Test MRIReco regularized recon (with a λ) - @testset "MRIReco_standard" begin - #??? - end +# #Test MRIReco regularized recon (with a λ) +# @testset "MRIReco_standard" begin +# #??? +# end -end +# end -@testitem "KomaUI" tags=[:koma] begin +# @testitem "KomaUI" tags=[:koma] begin - using Blink +# using Blink - # Opens UI - w = KomaUI(return_window=true) +# # Opens UI +# w = KomaUI(return_window=true) - @testset "Open UI" begin - @test "index" == @js w document.getElementById("content").dataset.content - end +# @testset "Open UI" begin +# @test "index" == @js w document.getElementById("content").dataset.content +# end - @testset "PulsesGUI" begin - @js w document.getElementById("button_pulses_seq").click() - @test "sequence" == @js w document.getElementById("content").dataset.content +# @testset "PulsesGUI" begin +# @js w document.getElementById("button_pulses_seq").click() +# @test "sequence" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_kspace").click() - @test "kspace" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_kspace").click() +# @test "kspace" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M0").click() - @test "m0" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M0").click() +# @test "m0" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M1").click() - @test "m1" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M1").click() +# @test "m1" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M2").click() - @test "m2" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_pulses_M2").click() +# @test "m2" == @js w document.getElementById("content").dataset.content +# end - @testset "PhantomGUI" begin - @js w document.getElementById("button_phantom").click() - @test "phantom" == @js w document.getElementById("content").dataset.content - end +# @testset "PhantomGUI" begin +# @js w document.getElementById("button_phantom").click() +# @test "phantom" == @js w document.getElementById("content").dataset.content +# end - @testset "ParamsGUI" begin - @js w document.getElementById("button_scanner").click() - @test "scanneparams" == @js w document.getElementById("content").dataset.content +# @testset "ParamsGUI" begin +# @js w document.getElementById("button_scanner").click() +# @test "scanneparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_sim_params").click() - @test "simparams" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_sim_params").click() +# @test "simparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_rec_params").click() - @test "recparams" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_rec_params").click() +# @test "recparams" == @js w document.getElementById("content").dataset.content +# end - @testset "Simulation" begin - @js w document.getElementById("simulate!").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "Simulation" begin +# @js w document.getElementById("simulate!").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "SignalGUI" begin - @js w document.getElementById("button_sig").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "SignalGUI" begin +# @js w document.getElementById("button_sig").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "Reconstruction" begin - @js w document.getElementById("recon!").click() - @test "absi" == @js w document.getElementById("content").dataset.content - end +# @testset "Reconstruction" begin +# @js w document.getElementById("recon!").click() +# @test "absi" == @js w document.getElementById("content").dataset.content +# end - @testset "ReconGUI" begin - @js w document.getElementById("button_reconstruction_absI").click() - @test "absi" == @js w document.getElementById("content").dataset.content +# @testset "ReconGUI" begin +# @js w document.getElementById("button_reconstruction_absI").click() +# @test "absi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_angI").click() - @test "angi" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_reconstruction_angI").click() +# @test "angi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_absK").click() - @test "absk" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_reconstruction_absK").click() +# @test "absk" == @js w document.getElementById("content").dataset.content +# end - @testset "ExportToMAT" begin - @js w document.getElementById("button_matfolder").click() - @test "matfolder" == @js w document.getElementById("content").dataset.content +# @testset "ExportToMAT" begin +# @js w document.getElementById("button_matfolder").click() +# @test "matfolder" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderseq").click() - @test "matfolderseq" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderseq").click() +# @test "matfolderseq" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderpha").click() - @test "matfolderpha" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderpha").click() +# @test "matfolderpha" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfoldersca").click() - @test "matfoldersca" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfoldersca").click() +# @test "matfoldersca" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderraw").click() - @test "matfolderraw" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderraw").click() +# @test "matfolderraw" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderima").click() - @test "matfolderima" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_matfolderima").click() +# @test "matfolderima" == @js w document.getElementById("content").dataset.content +# end - if !isnothing(w) - close(w) - end +# if !isnothing(w) +# close(w) +# end -end +# end From 04c7c2f1ed21802cfc4b2ab5ce37328792348ebd Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 14:16:19 +0200 Subject: [PATCH 08/51] Revert comments --- KomaMRIBase/test/runtests.jl | 1118 ++++++++-------- KomaMRICore/test/runtests.jl | 1190 ++++++++--------- KomaMRIFiles/test/runtests.jl | 230 ++-- .../test/GUI_PlotlyJS_backend_test.jl | 270 ++-- .../test/GUI_PlutoPlotly_backend_test.jl | 54 +- test/runtests.jl | 194 +-- 6 files changed, 1528 insertions(+), 1528 deletions(-) diff --git a/KomaMRIBase/test/runtests.jl b/KomaMRIBase/test/runtests.jl index f15bfaca2..555e0f438 100644 --- a/KomaMRIBase/test/runtests.jl +++ b/KomaMRIBase/test/runtests.jl @@ -1,559 +1,559 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true - -@testitem "Sequence" tags=[:base] begin - @testset "Init" begin - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) - @test dur(EX) ≈ durRF #RF length matches what is supposed to be - - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0.0 - if d1 > 0 DELAY = Delay(d1) end - - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be - end - - @testset "Rot_and_Concat" begin - # Rotation 2D case - A1, A2, T, t = rand(4) - s = Sequence([Grad(A1,T); - Grad(A2,T)]) - θ = π*t - R = rotz(θ) - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - # Rotation 3D case - T, t1, t2, t3 = rand(4) - N = 100 - GR = [Grad(rand(),T) for i=1:3, j=1:N] - s = Sequence(GR) - α, β, γ = π*t1, π*t2, π*t3 - Rx = rotx(α) - Ry = roty(β) - Rz = rotz(γ) - R = Rx*Ry*Rz - s2 = R*s #Matrix-Matrix{Grad} multiplication - GR2 = R*s.GR.A #Matrix-vector multiplication - @test s2.GR.A ≈ GR2 - - # Concatenation of sequences - A1, A2, A3, T1 = rand(4) - s1 = Sequence([Grad(A1,T1); - Grad(A2,T1)], - [RF(A3,T1)]) - B1, B2, B3, T2 = rand(4) - s2 = Sequence([Grad(B1,T2); - Grad(B2,T2)], - [RF(B3,T2)]) - s = s1 + s2 - @test s.GR.A ≈ [s1.GR.A s2.GR.A] - @test s.RF.A ≈ [s1.RF.A s2.RF.A] - @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] - end - - @testset "Grad" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - g1, g2 = Grad(A1,T), Grad(A2,T) - GR = [g1;g2;;] - GR2 = reshape([g1;g2],:,1) - @test GR.A ≈ GR2.A - - #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) - A, T = 0.1, 1e-3 - grad = Grad(A, T) - - A, T = rand(2) - g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) - @test g1 ≈ g2 - - A, T, ζ = rand(3) - g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) - @test g1 ≈ g2 - - A, T, delay, ζ = rand(4) - g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) - @test g1 ≈ g2 - - # Test construction with shape function - T, N = 1e-3, 100 - f = t -> sin(π*t / T) - gradw = Grad(f, T, N) - @test gradw.A ≈ f.(range(0.0, T; length=N)) - - # Test Grad operations - α = 3 - gradt = α * grad - @test size(grad, 1) == 1 - @test gradt.A ≈ α * grad.A - gradt = grad * α - @test gradt.A ≈ α * grad.A - gradt = grad / α - @test gradt.A ≈ grad.A / α - grads = grad + gradt - @test grads.A ≈ grad.A + gradt.A - A1, A2, A3 = 0.1, 0.2, 0.3 - v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] - v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] - v3 = v1 + v2 - @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] - gradr = grad - gradt - @test gradr.A ≈ grad.A - gradt.A - gradt = -grad - @test gradt.A ≈ -grad.A - vc = vcat(v1, v2) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - vc = vcat(v1, v2, v3) - @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] - @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] - @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] - delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 - gr = Grad(A, T, rise, fall, delay) - @test dur(gr) ≈ delay + rise + T + fall - T1, T2, T3 = 1e-3, 2e-3, 3e-3 - vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] - @test dur(vt) ≈ [maximum([T1, T2, T3])] - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", grad) - @test true - - end - - @testset "RF" begin - #Testing gradient concatenation, breakes in some Julia versions - A1, A2, T = rand(3) - r1, r2 = RF(A1,T), RF(A2,T) - R = [r1;r2;;] - R2 = reshape([r1;r2],:,1) - @test R.A ≈ R2.A - - #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) - A, T = rand(2) - r1, r2 = RF(A,T), RF(A,T,0.0,0.0) - @test r1 ≈ r2 - - A, T, Δf = rand(3) - r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) - @test r1 ≈ r2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", r1) - @test true - - # Test Grad operations - B1x, B1y, T = rand(3) - A = B1x + im*B1y - α = Complex(rand()) - rf = RF(A, T) - rft = α * rf - @test size(rf, 1) == 1 - @test rft.A ≈ α * rf.A - @test dur(rf) ≈ rf.T - B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) - rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) - rv = [rf1; rf2; rf3] - @test dur(rv) ≈ sum(dur.(rv)) - - end - - @testset "Delay" begin - - # Test delay construction - T = 1e-3 - delay = Delay(T) - @test delay.T ≈ T - - # Test delay construction error for negative values - err = Nothing - try Delay(-T) catch err end - @test err isa ErrorException - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", delay) - @test true - - # Test addition of a delay to a sequence - seq = Sequence([Grad(0.0, 0.0)]) - ds = delay + seq - @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 - sd = seq + delay - @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T - - end - @testset "ADC" begin - - # Test ADC construction - N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π - adc = ADC(N, T, delay, Δf, ϕ) - - adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) - @test adc1 ≈ adc2 - - adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) - @test adc1 ≈ adc2 - - # Test ADC construction errors for negative values - err = Nothing - try ADC(N, -T) catch err end - @test err isa ErrorException - try ADC(N, -T, delay) catch err end - @test err isa ErrorException - try ADC(N, T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay) catch err end - @test err isa ErrorException - try ADC(N, -T, delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - try ADC(N, -T, -delay, Δf, ϕ) catch err end - @test err isa ErrorException - - # Test ADC getproperties - Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π - adb = ADC(Nb, Tb, delayb, Δfb, ϕb) - adcs = [adc, adb] - @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] - @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] - - end - - @testset "DiscreteSequence" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - sampling_params = KomaMRIBase.default_sampling_params() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) - seqd = KomaMRIBase.discretize(seq) - i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) - @test seqd[i1].t ≈ [t[i1]] - @test seqd[i1:i2-1].t ≈ t[i1:i2] - - T, N = 1.0, 4 - seq = RF(1.0e-6, 1.0) - seq += Sequence([Grad(1.0e-3, 1.0)]) - seq += ADC(N, 1.0) - sampling_params = KomaMRIBase.default_sampling_params() - sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N - seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) - seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) - seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) - # Block 1 - @test is_RF_on(seq[1]) == is_RF_on(seqd1) - @test is_GR_on(seq[1]) == is_GR_on(seqd1) - @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) - # Block 2 - @test is_RF_on(seq[2]) == is_RF_on(seqd2) - @test is_GR_on(seq[2]) == is_GR_on(seqd2) - @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) - # Block 3 - @test is_RF_on(seq[3]) == is_RF_on(seqd3) - @test is_GR_on(seq[3]) == is_GR_on(seqd3) - @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) - @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) - @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) - @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) - end - - @testset "SequenceFunctions" begin - path = joinpath(@__DIR__, "test_files") - seq = PulseDesigner.EPI_example() - t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) - t_adc = KomaMRIBase.get_adc_sampling_times(seq) - M2, M2_adc = KomaMRIBase.get_slew_rate(seq) - M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) - Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) - Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) - @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz - @test is_ADC_on(seq) == is_ADC_on(seq, t) - @test is_RF_on(seq) == is_RF_on(seq, t) - @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) - @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) - @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", seq) - @test true - - α = rand() - c = α + im*rand() - x = seq - y = PulseDesigner.EPI_example() - z = x + y - @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N - z = x - y - @test z.GR.A ≈ [x.GR -y.GR].A - z = -x - @test z.GR.A ≈ -x.GR.A - z = x * α - @test z.GR.A ≈ α*x.GR.A - z = α * x - @test z.GR.A ≈ α*x.GR.A - z = x * c - @test z.RF.A ≈ c*x.RF.A - z = c * x - @test z.RF.A ≈ c*x.RF.A - z = x / α - @test z.GR.A ≈ x.GR.A/α - @test size(y) == size(y.GR[1,:]) - z = x + x.GR[3,1] - @test z.GR.A[1, end] ≈ x.GR[3,1].A - z = x.GR[3,1] + x - @test z.GR.A[1, 1] ≈ x.GR[3,1].A - z = x + x.RF[1,1] - @test z.RF.A[1, end] ≈ x.RF[1,1].A - z = x.RF[1,1] + x - @test z.RF.A[1, 1] ≈ x.RF[1,1].A - z = x + x.ADC[3,1] - @test z.ADC.N[end] ≈ x.ADC[3,1].N - z = x.ADC[3,1] + x - @test z.ADC.N[1] ≈ x.ADC[3,1].N - end - -end - -@testitem "PulseDesigner" tags=[:base] begin - @testset "RF_sinc" begin - sys = Scanner() - B1 = 23.4e-6 # For 90 deg flip angle - Trf = 1e-3 - rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) - @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 - end - @testset "Spiral" begin - sys = Scanner() - sys.Smax = 150 # [mT/m/ms] - sys.Gmax = 500e-3 # [T/m] - sys.GR_Δt = 4e-6 # [s] - FOV = 0.2 # [m] - N = 80 # Reconstructed image N×N - Nint = 8 - λ = 2.1 - spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) - # Look at the k_space generated - @test spiral(0).DEF["λ"] ≈ λ - end - @testset "Radial" begin - sys = Scanner() - N = 80 - Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction - FOV = 0.2 - spoke = PulseDesigner.radial_base(FOV, N, sys) - @test spoke.DEF["Δθ"] ≈ π / Nspokes - end -end - -@testitem "Phantom" tags = [:base] begin - using Suppressor - # Test phantom struct creation - name = "Bulks" - x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] - y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] - z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] - ρ = [0.2; 0.4; 0.6; 0.8; 1.0] - T1 = [0.9; 0.9; 0.5; 0.25; 0.4] - T2 = [0.09; 0.05; 0.04; 0.07; 0.005] - T2s = [0.1; 0.06; 0.05; 0.08; 0.015] - Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] - Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] - Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] - Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] - obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test obj1 == obj2 - - # Test size and length definitions of a phantom - @test size(obj1) == size(ρ) - @test length(obj1) == length(ρ) - - # Test obtaining spin psositions - @testset "SimpleMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - t_start=0.0; t_end=1.0 - t = collect(range(t_start, t_end, 11)) - period = 2.0 - asymmetry = 0.5 - # Translation - dx, dy, dz = [1.0, 0.0, 0.0] - vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) - translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) - xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # PeriodicTranslation - periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') - @test xt == ph.x .+ vx.*t' - @test yt == ph.y .+ vy.*t' - @test zt == ph.z .+ vz.*t' - # Rotation (2D) - pitch = 0.0 - roll = 0.0 - yaw = 45.0 - rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) - xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # PeriodicRotation (2D) - periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) - xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) - @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) - @test zt[:,end] == ph.z - # HeartBeat - circumferential_strain = -0.1 - radial_strain = 0.0 - longitudinal_strain = -0.1 - heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - r = sqrt.(ph.x .^ 2 + ph.y .^ 2) - θ = atan.(ph.y, ph.x) - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - # PeriodicHeartBeat - periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) - xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') - @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) - @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) - @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) - end - @testset "ArbitraryMotion" begin - ph = Phantom(x=[1.0], y=[1.0]) - Ns = length(ph) - period_durations = [1.0] - num_pieces = 10 - dx = dy = dz = rand(Ns, num_pieces - 1) - arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) - t = times(arbitrarymotion) - xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') - @test xt[:,2:end-1] == ph.x .+ dx - @test yt[:,2:end-1] == ph.y .+ dy - @test zt[:,2:end-1] == ph.z .+ dz - end - - simplemotion = SimpleMotion([ - PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), - Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), - ]) - - Ns = length(obj1) - K = 10 - arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) - - # Test phantom subset - obs1 = Phantom( - name, - x, - y, - z, - ρ, - T1, - T2, - T2s, - Δw, - Dλ1, - Dλ2, - Dθ, - simplemotion - ) - rng = 1:2:5 - obs2 = Phantom( - name, - x[rng], - y[rng], - z[rng], - ρ[rng], - T1[rng], - T2[rng], - T2s[rng], - Δw[rng], - Dλ1[rng], - Dλ2[rng], - Dθ[rng], - simplemotion[rng], - ) - @test obs1[rng] == obs2 - @test @view(obs1[rng]) == obs2 - - obs1.motion = arbitrarymotion - obs2.motion = arbitrarymotion[rng] - @test obs1[rng] == obs2 - # @test @view(obs1[rng]) == obs2 - - # Test addition of phantoms - oba = Phantom( - name, - [x; x[rng]], - [y; y[rng]], - [z; z[rng]], - [ρ; ρ[rng]], - [T1; T1[rng]], - [T2; T2[rng]], - [T2s; T2s[rng]], - [Δw; Δw[rng]], - [Dλ1; Dλ1[rng]], - [Dλ2; Dλ2[rng]], - [Dθ; Dθ[rng]], - [obs1.motion; obs2.motion] - ) - @test obs1 + obs2 == oba - - # Test scalar multiplication of a phantom - c = 7 - obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) - @test c * obj1 == obc - - #Test brain phantom 2D - ph = brain_phantom2D() - @test ph.name == "brain2D_axial" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test brain phantom 3D - ph = brain_phantom3D() - @test ph.name == "brain3D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] - - #Test pelvis phantom 2D - ph = pelvis_phantom2D() - @test ph.name == "pelvis2D" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - - #Test heart phantom - ph = heart_phantom() - @test ph.name == "LeftVentricle" - @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] -end - -@testitem "Scanner" tags=[:base] begin - B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 - ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 - RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 - sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) - @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax -end - -@testitem "TrapezoidalIntegration" tags=[:base] begin - dt = Float64[1 1 1 1] - x = Float64[0 1 2 1 0] - @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 - @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true + +# @testitem "Sequence" tags=[:base] begin +# @testset "Init" begin +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) +# @test dur(EX) ≈ durRF #RF length matches what is supposed to be + +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0.0 +# if d1 > 0 DELAY = Delay(d1) end + +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be +# end + +# @testset "Rot_and_Concat" begin +# # Rotation 2D case +# A1, A2, T, t = rand(4) +# s = Sequence([Grad(A1,T); +# Grad(A2,T)]) +# θ = π*t +# R = rotz(θ) +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 +# # Rotation 3D case +# T, t1, t2, t3 = rand(4) +# N = 100 +# GR = [Grad(rand(),T) for i=1:3, j=1:N] +# s = Sequence(GR) +# α, β, γ = π*t1, π*t2, π*t3 +# Rx = rotx(α) +# Ry = roty(β) +# Rz = rotz(γ) +# R = Rx*Ry*Rz +# s2 = R*s #Matrix-Matrix{Grad} multiplication +# GR2 = R*s.GR.A #Matrix-vector multiplication +# @test s2.GR.A ≈ GR2 + +# # Concatenation of sequences +# A1, A2, A3, T1 = rand(4) +# s1 = Sequence([Grad(A1,T1); +# Grad(A2,T1)], +# [RF(A3,T1)]) +# B1, B2, B3, T2 = rand(4) +# s2 = Sequence([Grad(B1,T2); +# Grad(B2,T2)], +# [RF(B3,T2)]) +# s = s1 + s2 +# @test s.GR.A ≈ [s1.GR.A s2.GR.A] +# @test s.RF.A ≈ [s1.RF.A s2.RF.A] +# @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] +# end + +# @testset "Grad" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# g1, g2 = Grad(A1,T), Grad(A2,T) +# GR = [g1;g2;;] +# GR2 = reshape([g1;g2],:,1) +# @test GR.A ≈ GR2.A + +# #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) +# A, T = 0.1, 1e-3 +# grad = Grad(A, T) + +# A, T = rand(2) +# g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) +# @test g1 ≈ g2 + +# A, T, ζ = rand(3) +# g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) +# @test g1 ≈ g2 + +# A, T, delay, ζ = rand(4) +# g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) +# @test g1 ≈ g2 + +# # Test construction with shape function +# T, N = 1e-3, 100 +# f = t -> sin(π*t / T) +# gradw = Grad(f, T, N) +# @test gradw.A ≈ f.(range(0.0, T; length=N)) + +# # Test Grad operations +# α = 3 +# gradt = α * grad +# @test size(grad, 1) == 1 +# @test gradt.A ≈ α * grad.A +# gradt = grad * α +# @test gradt.A ≈ α * grad.A +# gradt = grad / α +# @test gradt.A ≈ grad.A / α +# grads = grad + gradt +# @test grads.A ≈ grad.A + gradt.A +# A1, A2, A3 = 0.1, 0.2, 0.3 +# v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] +# v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] +# v3 = v1 + v2 +# @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] +# gradr = grad - gradt +# @test gradr.A ≈ grad.A - gradt.A +# gradt = -grad +# @test gradt.A ≈ -grad.A +# vc = vcat(v1, v2) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# vc = vcat(v1, v2, v3) +# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] +# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] +# @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] +# delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 +# gr = Grad(A, T, rise, fall, delay) +# @test dur(gr) ≈ delay + rise + T + fall +# T1, T2, T3 = 1e-3, 2e-3, 3e-3 +# vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] +# @test dur(vt) ≈ [maximum([T1, T2, T3])] + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", grad) +# @test true + +# end + +# @testset "RF" begin +# #Testing gradient concatenation, breakes in some Julia versions +# A1, A2, T = rand(3) +# r1, r2 = RF(A1,T), RF(A2,T) +# R = [r1;r2;;] +# R2 = reshape([r1;r2],:,1) +# @test R.A ≈ R2.A + +# #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) +# A, T = rand(2) +# r1, r2 = RF(A,T), RF(A,T,0.0,0.0) +# @test r1 ≈ r2 + +# A, T, Δf = rand(3) +# r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) +# @test r1 ≈ r2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", r1) +# @test true + +# # Test Grad operations +# B1x, B1y, T = rand(3) +# A = B1x + im*B1y +# α = Complex(rand()) +# rf = RF(A, T) +# rft = α * rf +# @test size(rf, 1) == 1 +# @test rft.A ≈ α * rf.A +# @test dur(rf) ≈ rf.T +# B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) +# rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) +# rv = [rf1; rf2; rf3] +# @test dur(rv) ≈ sum(dur.(rv)) + +# end + +# @testset "Delay" begin + +# # Test delay construction +# T = 1e-3 +# delay = Delay(T) +# @test delay.T ≈ T + +# # Test delay construction error for negative values +# err = Nothing +# try Delay(-T) catch err end +# @test err isa ErrorException + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", delay) +# @test true + +# # Test addition of a delay to a sequence +# seq = Sequence([Grad(0.0, 0.0)]) +# ds = delay + seq +# @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 +# sd = seq + delay +# @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T + +# end +# @testset "ADC" begin + +# # Test ADC construction +# N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π +# adc = ADC(N, T, delay, Δf, ϕ) + +# adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) +# @test adc1 ≈ adc2 + +# adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) +# @test adc1 ≈ adc2 + +# # Test ADC construction errors for negative values +# err = Nothing +# try ADC(N, -T) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay) catch err end +# @test err isa ErrorException +# try ADC(N, -T, delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException +# try ADC(N, -T, -delay, Δf, ϕ) catch err end +# @test err isa ErrorException + +# # Test ADC getproperties +# Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π +# adb = ADC(Nb, Tb, delayb, Δfb, ϕb) +# adcs = [adc, adb] +# @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] +# @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] + +# end + +# @testset "DiscreteSequence" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# sampling_params = KomaMRIBase.default_sampling_params() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) +# seqd = KomaMRIBase.discretize(seq) +# i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) +# @test seqd[i1].t ≈ [t[i1]] +# @test seqd[i1:i2-1].t ≈ t[i1:i2] + +# T, N = 1.0, 4 +# seq = RF(1.0e-6, 1.0) +# seq += Sequence([Grad(1.0e-3, 1.0)]) +# seq += ADC(N, 1.0) +# sampling_params = KomaMRIBase.default_sampling_params() +# sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N +# seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) +# seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) +# seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) +# # Block 1 +# @test is_RF_on(seq[1]) == is_RF_on(seqd1) +# @test is_GR_on(seq[1]) == is_GR_on(seqd1) +# @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) +# # Block 2 +# @test is_RF_on(seq[2]) == is_RF_on(seqd2) +# @test is_GR_on(seq[2]) == is_GR_on(seqd2) +# @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) +# # Block 3 +# @test is_RF_on(seq[3]) == is_RF_on(seqd3) +# @test is_GR_on(seq[3]) == is_GR_on(seqd3) +# @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) +# @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) +# @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) +# @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) +# end + +# @testset "SequenceFunctions" begin +# path = joinpath(@__DIR__, "test_files") +# seq = PulseDesigner.EPI_example() +# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) +# t_adc = KomaMRIBase.get_adc_sampling_times(seq) +# M2, M2_adc = KomaMRIBase.get_slew_rate(seq) +# M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) +# Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) +# Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) +# @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz +# @test is_ADC_on(seq) == is_ADC_on(seq, t) +# @test is_RF_on(seq) == is_RF_on(seq, t) +# @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) +# @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) +# @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", seq) +# @test true + +# α = rand() +# c = α + im*rand() +# x = seq +# y = PulseDesigner.EPI_example() +# z = x + y +# @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N +# z = x - y +# @test z.GR.A ≈ [x.GR -y.GR].A +# z = -x +# @test z.GR.A ≈ -x.GR.A +# z = x * α +# @test z.GR.A ≈ α*x.GR.A +# z = α * x +# @test z.GR.A ≈ α*x.GR.A +# z = x * c +# @test z.RF.A ≈ c*x.RF.A +# z = c * x +# @test z.RF.A ≈ c*x.RF.A +# z = x / α +# @test z.GR.A ≈ x.GR.A/α +# @test size(y) == size(y.GR[1,:]) +# z = x + x.GR[3,1] +# @test z.GR.A[1, end] ≈ x.GR[3,1].A +# z = x.GR[3,1] + x +# @test z.GR.A[1, 1] ≈ x.GR[3,1].A +# z = x + x.RF[1,1] +# @test z.RF.A[1, end] ≈ x.RF[1,1].A +# z = x.RF[1,1] + x +# @test z.RF.A[1, 1] ≈ x.RF[1,1].A +# z = x + x.ADC[3,1] +# @test z.ADC.N[end] ≈ x.ADC[3,1].N +# z = x.ADC[3,1] + x +# @test z.ADC.N[1] ≈ x.ADC[3,1].N +# end + +# end + +# @testitem "PulseDesigner" tags=[:base] begin +# @testset "RF_sinc" begin +# sys = Scanner() +# B1 = 23.4e-6 # For 90 deg flip angle +# Trf = 1e-3 +# rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) +# @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 +# end +# @testset "Spiral" begin +# sys = Scanner() +# sys.Smax = 150 # [mT/m/ms] +# sys.Gmax = 500e-3 # [T/m] +# sys.GR_Δt = 4e-6 # [s] +# FOV = 0.2 # [m] +# N = 80 # Reconstructed image N×N +# Nint = 8 +# λ = 2.1 +# spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) +# # Look at the k_space generated +# @test spiral(0).DEF["λ"] ≈ λ +# end +# @testset "Radial" begin +# sys = Scanner() +# N = 80 +# Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction +# FOV = 0.2 +# spoke = PulseDesigner.radial_base(FOV, N, sys) +# @test spoke.DEF["Δθ"] ≈ π / Nspokes +# end +# end + +# @testitem "Phantom" tags = [:base] begin +# using Suppressor +# # Test phantom struct creation +# name = "Bulks" +# x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] +# y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] +# z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] +# ρ = [0.2; 0.4; 0.6; 0.8; 1.0] +# T1 = [0.9; 0.9; 0.5; 0.25; 0.4] +# T2 = [0.09; 0.05; 0.04; 0.07; 0.005] +# T2s = [0.1; 0.06; 0.05; 0.08; 0.015] +# Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] +# Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] +# Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] +# Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] +# obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test obj1 == obj2 + +# # Test size and length definitions of a phantom +# @test size(obj1) == size(ρ) +# @test length(obj1) == length(ρ) + +# # Test obtaining spin psositions +# @testset "SimpleMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# t_start=0.0; t_end=1.0 +# t = collect(range(t_start, t_end, 11)) +# period = 2.0 +# asymmetry = 0.5 +# # Translation +# dx, dy, dz = [1.0, 0.0, 0.0] +# vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) +# translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # PeriodicTranslation +# periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') +# @test xt == ph.x .+ vx.*t' +# @test yt == ph.y .+ vy.*t' +# @test zt == ph.z .+ vz.*t' +# # Rotation (2D) +# pitch = 0.0 +# roll = 0.0 +# yaw = 45.0 +# rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # PeriodicRotation (2D) +# periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) +# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) +# @test zt[:,end] == ph.z +# # HeartBeat +# circumferential_strain = -0.1 +# radial_strain = 0.0 +# longitudinal_strain = -0.1 +# heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# r = sqrt.(ph.x .^ 2 + ph.y .^ 2) +# θ = atan.(ph.y, ph.x) +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# # PeriodicHeartBeat +# periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) +# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') +# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) +# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) +# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) +# end +# @testset "ArbitraryMotion" begin +# ph = Phantom(x=[1.0], y=[1.0]) +# Ns = length(ph) +# period_durations = [1.0] +# num_pieces = 10 +# dx = dy = dz = rand(Ns, num_pieces - 1) +# arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) +# t = times(arbitrarymotion) +# xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') +# @test xt[:,2:end-1] == ph.x .+ dx +# @test yt[:,2:end-1] == ph.y .+ dy +# @test zt[:,2:end-1] == ph.z .+ dz +# end + +# simplemotion = SimpleMotion([ +# PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), +# Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), +# ]) + +# Ns = length(obj1) +# K = 10 +# arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) + +# # Test phantom subset +# obs1 = Phantom( +# name, +# x, +# y, +# z, +# ρ, +# T1, +# T2, +# T2s, +# Δw, +# Dλ1, +# Dλ2, +# Dθ, +# simplemotion +# ) +# rng = 1:2:5 +# obs2 = Phantom( +# name, +# x[rng], +# y[rng], +# z[rng], +# ρ[rng], +# T1[rng], +# T2[rng], +# T2s[rng], +# Δw[rng], +# Dλ1[rng], +# Dλ2[rng], +# Dθ[rng], +# simplemotion[rng], +# ) +# @test obs1[rng] == obs2 +# @test @view(obs1[rng]) == obs2 + +# obs1.motion = arbitrarymotion +# obs2.motion = arbitrarymotion[rng] +# @test obs1[rng] == obs2 +# # @test @view(obs1[rng]) == obs2 + +# # Test addition of phantoms +# oba = Phantom( +# name, +# [x; x[rng]], +# [y; y[rng]], +# [z; z[rng]], +# [ρ; ρ[rng]], +# [T1; T1[rng]], +# [T2; T2[rng]], +# [T2s; T2s[rng]], +# [Δw; Δw[rng]], +# [Dλ1; Dλ1[rng]], +# [Dλ2; Dλ2[rng]], +# [Dθ; Dθ[rng]], +# [obs1.motion; obs2.motion] +# ) +# @test obs1 + obs2 == oba + +# # Test scalar multiplication of a phantom +# c = 7 +# obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) +# @test c * obj1 == obc + +# #Test brain phantom 2D +# ph = brain_phantom2D() +# @test ph.name == "brain2D_axial" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test brain phantom 3D +# ph = brain_phantom3D() +# @test ph.name == "brain3D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] + +# #Test pelvis phantom 2D +# ph = pelvis_phantom2D() +# @test ph.name == "pelvis2D" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + +# #Test heart phantom +# ph = heart_phantom() +# @test ph.name == "LeftVentricle" +# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] +# end + +# @testitem "Scanner" tags=[:base] begin +# B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 +# ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 +# RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 +# sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) +# @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax +# end + +# @testitem "TrapezoidalIntegration" tags=[:base] begin +# dt = Float64[1 1 1 1] +# x = Float64[0 1 2 1 0] +# @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 +# @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] +# end diff --git a/KomaMRICore/test/runtests.jl b/KomaMRICore/test/runtests.jl index 9306533e0..f58d553b7 100644 --- a/KomaMRICore/test/runtests.jl +++ b/KomaMRICore/test/runtests.jl @@ -1,595 +1,595 @@ -using TestItems, TestItemRunner - -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true - -@testitem "Spinors×Mag" tags=[:core] begin - using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg - - ## Verifying that operators perform counter-clockwise rotations - v = [1, 2, 3] - m = Mag([complex(v[1:2]...)], [v[3]]) - # Rx - @test rotx(π/2) * v ≈ [1, -3, 2] - @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] - @test (Rx(π/2) * m).z ≈ [2.0] - # Ry - @test roty(π/2) * v ≈ [3, 2, -1] - @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] - @test (Ry(π/2) * m).z ≈ [-1.0] - # Rz - @test rotz(π/2) * v ≈ [-2, 1, 3] - @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] - @test (Rz(π/2) * m).z ≈ [3.0] - # Rn - @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v - @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v - @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v - @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy - @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z - @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy - @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z - @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy - @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z - - ## Verify that Spinor rotation = matrix rotation - v = rand(3) - n = rand(3); n = n ./ sqrt(sum(n.^2)) - m = Mag([complex(v[1:2]...)], [v[3]]) - φ, θ, φ1, φ2 = rand(4) * 2π - # Rx - vx = rotx(θ) * v - mx = Rx(θ) * m - @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx - # Ry - vy = roty(θ) * v - my = Ry(θ) * m - @test [real(my.xy); imag(my.xy); my.z] ≈ vy - # Rz - vz = rotz(θ) * v - mz = Rz(θ) * m - @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz - # Rφ - vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v - mφ = Rφ(φ,θ) * m - @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ - # Rg - vg = rotz(φ2) * roty(θ) * rotz(φ1) * v - mg = Rg(φ1,θ,φ2) * m - @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg - # Rn - vq = Un(θ, n) * v - mq = Q(θ, n[1]+n[2]*1im, n[3]) * m - @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq - - ## Spinors satify that |α|^2 + |β|^2 = 1 - @test abs(Rx(θ)) ≈ [1] - @test abs(Ry(θ)) ≈ [1] - @test abs(Rz(θ)) ≈ [1] - @test abs(Rφ(φ,θ)) ≈ [1] - @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] - - ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. - # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) - @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v - @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy - @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z - # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) - @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z - # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) - @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z - # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) - @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v - @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy - @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z - - ## Verify trivial identities - # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis - # Rφ φ=0 = Ry - @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy - @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z - # Rφ φ=π/2 = Rx - @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy - @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z - # General rotation Rn - # Rn n=[1,0,0] = Rx - @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v - @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy - @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z - # Rn n=[0,1,0] = Ry - @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v - @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy - @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z - # Rn n=[0,0,1] = Rz - @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v - @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy - @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z - - # Associativity - # Rx - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy - @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy - @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z - # Rφ - @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy - @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z - @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy - @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z - # Rg - @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z - @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy - @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z - - ## Other tests - # Test Spinor struct - α, β = rand(2) - s = Spinor(α, β) - @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", s) - @test true -end - -# Test ISMRMRD -@testitem "signal_to_raw_data" tags=[:core] begin - using Suppressor - - seq = PulseDesigner.EPI_example() - sys = Scanner() - obj = brain_phantom2D() - - sim_params = KomaMRICore.default_sim_params() - sim_params["return_type"] = "mat" - sig = @suppress simulate(obj, seq, sys; sim_params) - - # Test signal_to_raw_data - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - seq.DEF["FOV"] = [23e-2, 23e-2, 0] - raw = signal_to_raw_data(sig, seq) - sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) - sig_raw = reshape(sig_aux, length(sig_aux), 1) - @test all(sig .== sig_raw) - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", raw) - @test true -end - -@testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_sphere_jemris() - seq = seq_epi_100x100_TE100_FOV230() - obj = phantom_sphere() - sys = Scanner() - - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - for i=1:2 - global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) - global seq += ADC(N, Tadc) - end - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = [0, π/2] - seq = Sequence() - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) - seq += ADC(N, Tadc) - seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) - seq += ADC(N, Tadc) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) - raw = @suppress simulate(obj, seq, sys; sim_params) - - #Mathematica-simulated Bloch equation result - res1 = [0.153592+0.46505im, - 0.208571+0.437734im, - 0.259184+0.40408im, - 0.304722+0.364744im, - 0.344571+0.320455im, - 0.378217+0.272008im] - res2 = [-0.0153894+0.142582im, - 0.00257641+0.14196im, - 0.020146+0.13912im, - 0.037051+0.134149im, - 0.0530392+0.12717im, - 0.0678774+0.11833im] - norm2(x) = sqrt.(sum(abs.(x).^2)) - error0 = norm2(raw.profiles[1].data .- 0) - error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 - error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - - @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -end - -@testitem "Bloch_phase_compensation" tags=[:important, :core] begin - using Suppressor - - Tadc = 1e-3 - Trf = Tadc - T1 = 1000e-3 - T2 = 20e-3 - Δw = 2π * 100 - B1 = 2e-6 * (Tadc / Trf) - N = 6 - - sys = Scanner() - obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - - rf_phase = 2π*rand() - seq1 = Sequence() - seq1 += RF(B1, Trf) - seq1 += ADC(N, Tadc) - - seq2 = Sequence() - seq2 += RF(B1 .* exp(1im*rf_phase), Trf) - seq2 += ADC(N, Tadc, 0, 0, rf_phase) - - sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) - raw1 = @suppress simulate(obj, seq1, sys; sim_params) - raw2 = @suppress simulate(obj, seq2, sys; sim_params) - - @test raw1.profiles[1].data ≈ raw2.profiles[1].data - -end - -@testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "Nthreads"=>1, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>false, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - -@testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - sig_jemris = signal_brain_motion_jemris() - seq = seq_epi_100x100_TE100_FOV230() - sys = Scanner() - obj = phantom_brain() - Ns = length(obj) - period_durations=[20.0] - dx = dz = zeros(Ns, 1) - dy = 1.0 .* ones(Ns, 1) - obj.motion = @suppress ArbitraryMotion( - period_durations, - dx, - dy, - dz) - sim_params = Dict{String, Any}( - "gpu"=>true, - "sim_method"=>KomaMRICore.Bloch(), - "return_type"=>"mat", - "precision"=>"f64" - ) - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -end - - -@testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin - using Suppressor - include(joinpath(@__DIR__, "test_files", "utils.jl")) - - seq = seq_epi_100x100_TE100_FOV230() - obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) - sys = Scanner() - sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") - sig = @suppress simulate(obj, seq, sys; sim_params) - sig = sig / prod(size(obj)) - sim_params["sim_method"] = KomaMRICore.BlochDict() - sig2 = @suppress simulate(obj, seq, sys; sim_params) - sig2 = sig2 / prod(size(obj)) - @test sig ≈ sig2 - - # Just checking to ensure that show() doesn't get stuck and that it is covered - show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) - @test true -end - -@testitem "simulate_slice_profile" tags=[:core] begin - using Suppressor - - # This is a sequence with a sinc RF 30° excitation pulse - sys = Scanner() - sys.Smax = 50 - B1 = 4.92e-6 - Trf = 3.2e-3 - zmax = 2e-2 - fmax = 5e3 - z = range(-zmax, zmax, 400) - Gz = fmax / (γ * zmax) - f = γ * Gz * z - seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) - - # Simulate the slice profile - sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) - M = @suppress simulate_slice_profile(seq; z, sim_params) - - # For the time being, always pass the test - @test true -end +# using TestItems, TestItemRunner + +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true + +# @testitem "Spinors×Mag" tags=[:core] begin +# using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg + +# ## Verifying that operators perform counter-clockwise rotations +# v = [1, 2, 3] +# m = Mag([complex(v[1:2]...)], [v[3]]) +# # Rx +# @test rotx(π/2) * v ≈ [1, -3, 2] +# @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] +# @test (Rx(π/2) * m).z ≈ [2.0] +# # Ry +# @test roty(π/2) * v ≈ [3, 2, -1] +# @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] +# @test (Ry(π/2) * m).z ≈ [-1.0] +# # Rz +# @test rotz(π/2) * v ≈ [-2, 1, 3] +# @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] +# @test (Rz(π/2) * m).z ≈ [3.0] +# # Rn +# @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v +# @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v +# @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy +# @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy +# @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy +# @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z + +# ## Verify that Spinor rotation = matrix rotation +# v = rand(3) +# n = rand(3); n = n ./ sqrt(sum(n.^2)) +# m = Mag([complex(v[1:2]...)], [v[3]]) +# φ, θ, φ1, φ2 = rand(4) * 2π +# # Rx +# vx = rotx(θ) * v +# mx = Rx(θ) * m +# @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx +# # Ry +# vy = roty(θ) * v +# my = Ry(θ) * m +# @test [real(my.xy); imag(my.xy); my.z] ≈ vy +# # Rz +# vz = rotz(θ) * v +# mz = Rz(θ) * m +# @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz +# # Rφ +# vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v +# mφ = Rφ(φ,θ) * m +# @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ +# # Rg +# vg = rotz(φ2) * roty(θ) * rotz(φ1) * v +# mg = Rg(φ1,θ,φ2) * m +# @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg +# # Rn +# vq = Un(θ, n) * v +# mq = Q(θ, n[1]+n[2]*1im, n[3]) * m +# @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq + +# ## Spinors satify that |α|^2 + |β|^2 = 1 +# @test abs(Rx(θ)) ≈ [1] +# @test abs(Ry(θ)) ≈ [1] +# @test abs(Rz(θ)) ≈ [1] +# @test abs(Rφ(φ,θ)) ≈ [1] +# @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] + +# ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. +# # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) +# @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v +# @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy +# @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z +# # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) +# @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z +# # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z +# # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) +# @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v +# @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy +# @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z + +# ## Verify trivial identities +# # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis +# # Rφ φ=0 = Ry +# @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy +# @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z +# # Rφ φ=π/2 = Rx +# @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy +# @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z +# # General rotation Rn +# # Rn n=[1,0,0] = Rx +# @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v +# @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy +# @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z +# # Rn n=[0,1,0] = Ry +# @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v +# @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy +# @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z +# # Rn n=[0,0,1] = Rz +# @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v +# @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy +# @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z + +# # Associativity +# # Rx +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy +# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy +# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z +# # Rφ +# @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z +# @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy +# @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z +# # Rg +# @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z +# @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy +# @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z + +# ## Other tests +# # Test Spinor struct +# α, β = rand(2) +# s = Spinor(α, β) +# @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", s) +# @test true +# end + +# # Test ISMRMRD +# @testitem "signal_to_raw_data" tags=[:core] begin +# using Suppressor + +# seq = PulseDesigner.EPI_example() +# sys = Scanner() +# obj = brain_phantom2D() + +# sim_params = KomaMRICore.default_sim_params() +# sim_params["return_type"] = "mat" +# sig = @suppress simulate(obj, seq, sys; sim_params) + +# # Test signal_to_raw_data +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# seq.DEF["FOV"] = [23e-2, 23e-2, 0] +# raw = signal_to_raw_data(sig, seq) +# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) +# sig_raw = reshape(sig_aux, length(sig_aux), 1) +# @test all(sig .== sig_raw) + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", raw) +# @test true +# end + +# @testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_sphere_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# obj = phantom_sphere() +# sys = Scanner() + +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) + +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# for i=1:2 +# global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) +# global seq += ADC(N, Tadc) +# end + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = [0, π/2] +# seq = Sequence() +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) +# seq += ADC(N, Tadc) +# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) +# seq += ADC(N, Tadc) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) +# raw = @suppress simulate(obj, seq, sys; sim_params) + +# #Mathematica-simulated Bloch equation result +# res1 = [0.153592+0.46505im, +# 0.208571+0.437734im, +# 0.259184+0.40408im, +# 0.304722+0.364744im, +# 0.344571+0.320455im, +# 0.378217+0.272008im] +# res2 = [-0.0153894+0.142582im, +# 0.00257641+0.14196im, +# 0.020146+0.13912im, +# 0.037051+0.134149im, +# 0.0530392+0.12717im, +# 0.0678774+0.11833im] +# norm2(x) = sqrt.(sum(abs.(x).^2)) +# error0 = norm2(raw.profiles[1].data .- 0) +# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 +# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + +# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +# end + +# @testitem "Bloch_phase_compensation" tags=[:important, :core] begin +# using Suppressor + +# Tadc = 1e-3 +# Trf = Tadc +# T1 = 1000e-3 +# T2 = 20e-3 +# Δw = 2π * 100 +# B1 = 2e-6 * (Tadc / Trf) +# N = 6 + +# sys = Scanner() +# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + +# rf_phase = 2π*rand() +# seq1 = Sequence() +# seq1 += RF(B1, Trf) +# seq1 += ADC(N, Tadc) + +# seq2 = Sequence() +# seq2 += RF(B1 .* exp(1im*rf_phase), Trf) +# seq2 += ADC(N, Tadc, 0, 0, rf_phase) + +# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) +# raw1 = @suppress simulate(obj, seq1, sys; sim_params) +# raw2 = @suppress simulate(obj, seq2, sys; sim_params) + +# @test raw1.profiles[1].data ≈ raw2.profiles[1].data + +# end + +# @testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "Nthreads"=>1, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>false, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + +# @testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# sig_jemris = signal_brain_motion_jemris() +# seq = seq_epi_100x100_TE100_FOV230() +# sys = Scanner() +# obj = phantom_brain() +# Ns = length(obj) +# period_durations=[20.0] +# dx = dz = zeros(Ns, 1) +# dy = 1.0 .* ones(Ns, 1) +# obj.motion = @suppress ArbitraryMotion( +# period_durations, +# dx, +# dy, +# dz) +# sim_params = Dict{String, Any}( +# "gpu"=>true, +# "sim_method"=>KomaMRICore.Bloch(), +# "return_type"=>"mat", +# "precision"=>"f64" +# ) +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. +# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +# end + + +# @testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin +# using Suppressor +# include(joinpath(@__DIR__, "test_files", "utils.jl")) + +# seq = seq_epi_100x100_TE100_FOV230() +# obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) +# sys = Scanner() +# sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") +# sig = @suppress simulate(obj, seq, sys; sim_params) +# sig = sig / prod(size(obj)) +# sim_params["sim_method"] = KomaMRICore.BlochDict() +# sig2 = @suppress simulate(obj, seq, sys; sim_params) +# sig2 = sig2 / prod(size(obj)) +# @test sig ≈ sig2 + +# # Just checking to ensure that show() doesn't get stuck and that it is covered +# show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) +# @test true +# end + +# @testitem "simulate_slice_profile" tags=[:core] begin +# using Suppressor + +# # This is a sequence with a sinc RF 30° excitation pulse +# sys = Scanner() +# sys.Smax = 50 +# B1 = 4.92e-6 +# Trf = 3.2e-3 +# zmax = 2e-2 +# fmax = 5e3 +# z = range(-zmax, zmax, 400) +# Gz = fmax / (γ * zmax) +# f = γ * Gz * z +# seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) + +# # Simulate the slice profile +# sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) +# M = @suppress simulate_slice_profile(seq; z, sim_params) + +# # For the time being, always pass the test +# @test true +# end diff --git a/KomaMRIFiles/test/runtests.jl b/KomaMRIFiles/test/runtests.jl index deccbf82d..812914bd0 100644 --- a/KomaMRIFiles/test/runtests.jl +++ b/KomaMRIFiles/test/runtests.jl @@ -1,125 +1,125 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true +# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true -@testitem "Files" tags=[:files] begin - using Suppressor +# @testitem "Files" tags=[:files] begin +# using Suppressor - # Test Pulseq - @testset "Pulseq" begin - path = @__DIR__ - seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "epi.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" +# # Test Pulseq +# @testset "Pulseq" begin +# path = @__DIR__ +# seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "epi.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" - seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary - @test seq.DEF["FileName"] == "spiral.seq" - @test seq.DEF["PulseqVersion"] ≈ 1004000 - @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" +# seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary +# @test seq.DEF["FileName"] == "spiral.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1004000 +# @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" - seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "epi_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" +# seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "epi_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" - seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 - @test seq.DEF["FileName"] == "radial_JEMRIS.seq" - @test seq.DEF["PulseqVersion"] ≈ 1002001 - @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" +# seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 +# @test seq.DEF["FileName"] == "radial_JEMRIS.seq" +# @test seq.DEF["PulseqVersion"] ≈ 1002001 +# @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" - # Test Pulseq compression-decompression - shape = ones(100) - num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) - shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) - @test shape == shape2 - end - # Test JEMRIS - @testset "JEMRIS" begin - path = @__DIR__ - obj = read_phantom_jemris(path*"/test_files/column1d.h5") - @test obj.name == "column1d.h5" - end - # Test MRiLab - @testset "MRiLab" begin - path = @__DIR__ - filename = path * "/test_files/brain_mrilab.mat" - FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness - obj = read_phantom_MRiLab(filename; FRange_filename) - @test obj.name == "brain_mrilab.mat" - end - # Test Phantom (.phantom) - @testset "Phantom" begin - using KomaMRIBase - path = @__DIR__ - # NoMotion - filename = path * "/test_files/brain_nomotion.phantom" - obj1 = brain_phantom2D() - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # SimpleMotion - filename = path * "/test_files/brain_simplemotion.phantom" - obj1 = brain_phantom2D() - obj1.motion = SimpleMotion([ - PeriodicRotation( - period=1.0, - yaw=45.0, - pitch=0.0, - roll=0.0), - Translation( - t_start=0.0, - t_end=0.5, - dx=0.0, - dy=0.02, - dz=0.0 - )]) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - # ArbitraryMotion - filename = path * "/test_files/brain_arbitrarymotion.phantom" - obj1 = brain_phantom2D() - Ns = length(obj1) - K = 10 - obj1.motion = ArbitraryMotion( - [1.0], - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1), - 0.01.*rand(Ns, K-1)) - write_phantom(obj1, filename) - obj2 = read_phantom(filename) - @test obj1 == obj2 - end -end +# # Test Pulseq compression-decompression +# shape = ones(100) +# num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) +# shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) +# @test shape == shape2 +# end +# # Test JEMRIS +# @testset "JEMRIS" begin +# path = @__DIR__ +# obj = read_phantom_jemris(path*"/test_files/column1d.h5") +# @test obj.name == "column1d.h5" +# end +# # Test MRiLab +# @testset "MRiLab" begin +# path = @__DIR__ +# filename = path * "/test_files/brain_mrilab.mat" +# FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness +# obj = read_phantom_MRiLab(filename; FRange_filename) +# @test obj.name == "brain_mrilab.mat" +# end +# # Test Phantom (.phantom) +# @testset "Phantom" begin +# using KomaMRIBase +# path = @__DIR__ +# # NoMotion +# filename = path * "/test_files/brain_nomotion.phantom" +# obj1 = brain_phantom2D() +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # SimpleMotion +# filename = path * "/test_files/brain_simplemotion.phantom" +# obj1 = brain_phantom2D() +# obj1.motion = SimpleMotion([ +# PeriodicRotation( +# period=1.0, +# yaw=45.0, +# pitch=0.0, +# roll=0.0), +# Translation( +# t_start=0.0, +# t_end=0.5, +# dx=0.0, +# dy=0.02, +# dz=0.0 +# )]) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# # ArbitraryMotion +# filename = path * "/test_files/brain_arbitrarymotion.phantom" +# obj1 = brain_phantom2D() +# Ns = length(obj1) +# K = 10 +# obj1.motion = ArbitraryMotion( +# [1.0], +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1), +# 0.01.*rand(Ns, K-1)) +# write_phantom(obj1, filename) +# obj2 = read_phantom(filename) +# @test obj1 == obj2 +# end +# end -@testitem "Pulseq compat" tags=[:files, :pulseq] begin - using MAT, KomaMRIBase, Suppressor +# @testitem "Pulseq compat" tags=[:files, :pulseq] begin +# using MAT, KomaMRIBase, Suppressor - # Aux functions - inside(x) = x[2:end-1] - namedtuple(x) = x[:] - namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) - not_empty = ((ek, ep),) -> !isempty(ep.t) +# # Aux functions +# inside(x) = x[2:end-1] +# namedtuple(x) = x[:] +# namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) +# not_empty = ((ek, ep),) -> !isempty(ep.t) - # Reading files - path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") - pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] - for pulseq_file in pulseq_files - #@show pulseq_file - seq_koma = @suppress read_seq("$path/$pulseq_file.seq") - seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple - @testset "$pulseq_file" begin - for i in 1:length(seq_koma) - blk_koma = get_samples(seq_koma, i) - blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys - for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) - @test ev_koma.t ≈ ev_pulseq.t - @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) - @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] - @test last(ev_koma.A) ≈ last(ev_pulseq.A) - end - end - end - end -end +# # Reading files +# path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") +# pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] +# for pulseq_file in pulseq_files +# #@show pulseq_file +# seq_koma = @suppress read_seq("$path/$pulseq_file.seq") +# seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple +# @testset "$pulseq_file" begin +# for i in 1:length(seq_koma) +# blk_koma = get_samples(seq_koma, i) +# blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys +# for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) +# @test ev_koma.t ≈ ev_pulseq.t +# @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) +# @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] +# @test last(ev_koma.A) ≈ last(ev_pulseq.A) +# end +# end +# end +# end +# end diff --git a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl index 011ecb027..1ef8b4504 100644 --- a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl @@ -1,135 +1,135 @@ -#GUI tests -@testitem "PlotlyJS" tags=[:plots] begin - using KomaMRIBase, MRIFiles - - @testset "GUI_phantom" begin - ph = brain_phantom2D() #2D phantom - - @testset "plot_phantom_map_rho" begin - plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T1" begin - plot_phantom_map(ph, :T1) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_T2" begin - plot_phantom_map(ph, :T2) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_x" begin - plot_phantom_map(ph, :x) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_w" begin - plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - - @testset "plot_phantom_map_2dview" begin - plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map - @test true #If the previous line fails the test will fail - end - end - - @testset "GUI_seq" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - - @testset "plot_seq" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - - @testset "plot_kspace" begin - #Plot k-space - plot_kspace(seq; width=800, height=600) #Plotting the k-space - @test true #If the previous line fails the test will fail - end - - @testset "plot_M0" begin - #Plot M0 - plot_M0(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M1" begin - #Plot M1 - plot_M1(seq) #Plotting the M0 - @test true #If the previous line fails the test will fail - end - - @testset "plot_M2" begin - #Plot M2 - plot_M2(seq) #Plotting the M2 - @test true #If the previous line fails the test will fail - end - - @testset "plot_eddy_currents" begin - #Plot M2 - plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents - @test true #If the previous line fails the test will fail - end - - @testset "plot_slew_rate" begin - plot_slew_rate(seq) - @test true - end - - @testset "plot_seqd" begin - plot_seqd(seq) - @test true - end - end - - @testset "GUI_dict_html" begin - #Define a dictionary and Plot the dictionary table - sys = Scanner() - sys_dict = Dict("B0" => sys.B0, - "B1" => sys.B1, - "Gmax" => sys.Gmax, - "Smax" => sys.Smax, - "ADC_dt" => sys.ADC_Δt, - "seq_dt" => sys.seq_Δt, - "GR_dt" => sys.GR_Δt, - "RF_dt" => sys.RF_Δt, - "RF_ring_down_T" => sys.RF_ring_down_T, - "RF_dead_time_T" => sys.RF_dead_time_T, - "ADC_dead_time_T" => sys.ADC_dead_time_T) - plot_dict(sys_dict) - @test true - end - - @testset "GUI_signal" begin - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - plot_signal(raw, width=800, height=600) - @test true #If the previous line fails the test will fail - end - - @testset "GUI_recon" begin - #??? - end - -end +# #GUI tests +# @testitem "PlotlyJS" tags=[:plots] begin +# using KomaMRIBase, MRIFiles + +# @testset "GUI_phantom" begin +# ph = brain_phantom2D() #2D phantom + +# @testset "plot_phantom_map_rho" begin +# plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T1" begin +# plot_phantom_map(ph, :T1) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_T2" begin +# plot_phantom_map(ph, :T2) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_x" begin +# plot_phantom_map(ph, :x) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_w" begin +# plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_phantom_map_2dview" begin +# plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map +# @test true #If the previous line fails the test will fail +# end +# end + +# @testset "GUI_seq" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + +# @testset "plot_seq" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end + +# @testset "plot_kspace" begin +# #Plot k-space +# plot_kspace(seq; width=800, height=600) #Plotting the k-space +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M0" begin +# #Plot M0 +# plot_M0(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M1" begin +# #Plot M1 +# plot_M1(seq) #Plotting the M0 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_M2" begin +# #Plot M2 +# plot_M2(seq) #Plotting the M2 +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_eddy_currents" begin +# #Plot M2 +# plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents +# @test true #If the previous line fails the test will fail +# end + +# @testset "plot_slew_rate" begin +# plot_slew_rate(seq) +# @test true +# end + +# @testset "plot_seqd" begin +# plot_seqd(seq) +# @test true +# end +# end + +# @testset "GUI_dict_html" begin +# #Define a dictionary and Plot the dictionary table +# sys = Scanner() +# sys_dict = Dict("B0" => sys.B0, +# "B1" => sys.B1, +# "Gmax" => sys.Gmax, +# "Smax" => sys.Smax, +# "ADC_dt" => sys.ADC_Δt, +# "seq_dt" => sys.seq_Δt, +# "GR_dt" => sys.GR_Δt, +# "RF_dt" => sys.RF_Δt, +# "RF_ring_down_T" => sys.RF_ring_down_T, +# "RF_dead_time_T" => sys.RF_dead_time_T, +# "ADC_dead_time_T" => sys.ADC_dead_time_T) +# plot_dict(sys_dict) +# @test true +# end + +# @testset "GUI_signal" begin +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# plot_signal(raw, width=800, height=600) +# @test true #If the previous line fails the test will fail +# end + +# @testset "GUI_recon" begin +# #??? +# end + +# end diff --git a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl index 4d63016b3..f90cc53a7 100644 --- a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl @@ -1,29 +1,29 @@ -@testitem "PlutoPlotly" tags=[:plots] begin - using KomaMRIBase, PlutoPlotly #Testing package extension +# @testitem "PlutoPlotly" tags=[:plots] begin +# using KomaMRIBase, PlutoPlotly #Testing package extension - @testset "GUI_seq_PlutoPlotly" begin - #KomaCore definition of a sequence: - #RF construction - sys = Scanner() - B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse - EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) - #ACQ construction - N = 101 - FOV = 23e-2 - EPI = PulseDesigner.EPI(FOV, N, sys) - TE = 30e-3 - d1 = TE-dur(EPI)/2-dur(EX) - d1 = d1 > 0 ? d1 : 0 - if d1 > 0 DELAY = Delay(d1) end - #Sequence construction - seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) - seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 +# @testset "GUI_seq_PlutoPlotly" begin +# #KomaCore definition of a sequence: +# #RF construction +# sys = Scanner() +# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse +# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) +# #ACQ construction +# N = 101 +# FOV = 23e-2 +# EPI = PulseDesigner.EPI(FOV, N, sys) +# TE = 30e-3 +# d1 = TE-dur(EPI)/2-dur(EX) +# d1 = d1 > 0 ? d1 : 0 +# if d1 > 0 DELAY = Delay(d1) end +# #Sequence construction +# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) +# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - @testset "plot_seq_PlutoPlotly" begin - #Plot sequence - plot_seq(seq) #Plotting the sequence - plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) - @test true #If the previous lines fail the test will fail - end - end -end +# @testset "plot_seq_PlutoPlotly" begin +# #Plot sequence +# plot_seq(seq) #Plotting the sequence +# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) +# @test true #If the previous lines fail the test will fail +# end +# end +# end diff --git a/test/runtests.jl b/test/runtests.jl index 9806ff246..47f4c8a3a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,132 +1,132 @@ -using TestItems, TestItemRunner +# using TestItems, TestItemRunner -@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true +# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true -# include("../KomaMRICore/test/runtests.jl") -# include("../KomaMRIPlots/test/runtests.jl") +# # include("../KomaMRICore/test/runtests.jl") +# # include("../KomaMRIPlots/test/runtests.jl") -@testitem "MRIReco recon" tags=[:koma] begin - #Sanity check 1 - A = rand(5,5,3) - B = KomaMRI.fftc(KomaMRI.ifftc(A)) - @test A ≈ B +# @testitem "MRIReco recon" tags=[:koma] begin +# #Sanity check 1 +# A = rand(5,5,3) +# B = KomaMRI.fftc(KomaMRI.ifftc(A)) +# @test A ≈ B - #Sanity check 2 - B = KomaMRI.ifftc(KomaMRI.fftc(A)) - @test A ≈ B +# #Sanity check 2 +# B = KomaMRI.ifftc(KomaMRI.fftc(A)) +# @test A ≈ B - #MRIReco.jl - path = @__DIR__ - fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") - raw = RawAcquisitionData(fraw) - acq = AcquisitionData(raw) +# #MRIReco.jl +# path = @__DIR__ +# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") +# raw = RawAcquisitionData(fraw) +# acq = AcquisitionData(raw) - @testset "MRIReco_direct" begin - Nx, Ny = raw.params["reconSize"][1:2] - recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) - img = reconstruction(acq, recParams) - @test true #If the previous line fails the test will fail - end +# @testset "MRIReco_direct" begin +# Nx, Ny = raw.params["reconSize"][1:2] +# recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) +# img = reconstruction(acq, recParams) +# @test true #If the previous line fails the test will fail +# end - #Test MRIReco regularized recon (with a λ) - @testset "MRIReco_standard" begin - #??? - end +# #Test MRIReco regularized recon (with a λ) +# @testset "MRIReco_standard" begin +# #??? +# end -end +# end -@testitem "KomaUI" tags=[:koma] begin +# @testitem "KomaUI" tags=[:koma] begin - using Blink +# using Blink - # Opens UI - w = KomaUI(return_window=true) +# # Opens UI +# w = KomaUI(return_window=true) - @testset "Open UI" begin - @test "index" == @js w document.getElementById("content").dataset.content - end +# @testset "Open UI" begin +# @test "index" == @js w document.getElementById("content").dataset.content +# end - @testset "PulsesGUI" begin - @js w document.getElementById("button_pulses_seq").click() - @test "sequence" == @js w document.getElementById("content").dataset.content +# @testset "PulsesGUI" begin +# @js w document.getElementById("button_pulses_seq").click() +# @test "sequence" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_kspace").click() - @test "kspace" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_kspace").click() +# @test "kspace" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M0").click() - @test "m0" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M0").click() +# @test "m0" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M1").click() - @test "m1" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_pulses_M1").click() +# @test "m1" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_pulses_M2").click() - @test "m2" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_pulses_M2").click() +# @test "m2" == @js w document.getElementById("content").dataset.content +# end - @testset "PhantomGUI" begin - @js w document.getElementById("button_phantom").click() - @test "phantom" == @js w document.getElementById("content").dataset.content - end +# @testset "PhantomGUI" begin +# @js w document.getElementById("button_phantom").click() +# @test "phantom" == @js w document.getElementById("content").dataset.content +# end - @testset "ParamsGUI" begin - @js w document.getElementById("button_scanner").click() - @test "scanneparams" == @js w document.getElementById("content").dataset.content +# @testset "ParamsGUI" begin +# @js w document.getElementById("button_scanner").click() +# @test "scanneparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_sim_params").click() - @test "simparams" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_sim_params").click() +# @test "simparams" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_rec_params").click() - @test "recparams" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_rec_params").click() +# @test "recparams" == @js w document.getElementById("content").dataset.content +# end - @testset "Simulation" begin - @js w document.getElementById("simulate!").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "Simulation" begin +# @js w document.getElementById("simulate!").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "SignalGUI" begin - @js w document.getElementById("button_sig").click() - @test "sig" == @js w document.getElementById("content").dataset.content - end +# @testset "SignalGUI" begin +# @js w document.getElementById("button_sig").click() +# @test "sig" == @js w document.getElementById("content").dataset.content +# end - @testset "Reconstruction" begin - @js w document.getElementById("recon!").click() - @test "absi" == @js w document.getElementById("content").dataset.content - end +# @testset "Reconstruction" begin +# @js w document.getElementById("recon!").click() +# @test "absi" == @js w document.getElementById("content").dataset.content +# end - @testset "ReconGUI" begin - @js w document.getElementById("button_reconstruction_absI").click() - @test "absi" == @js w document.getElementById("content").dataset.content +# @testset "ReconGUI" begin +# @js w document.getElementById("button_reconstruction_absI").click() +# @test "absi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_angI").click() - @test "angi" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_reconstruction_angI").click() +# @test "angi" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_reconstruction_absK").click() - @test "absk" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_reconstruction_absK").click() +# @test "absk" == @js w document.getElementById("content").dataset.content +# end - @testset "ExportToMAT" begin - @js w document.getElementById("button_matfolder").click() - @test "matfolder" == @js w document.getElementById("content").dataset.content +# @testset "ExportToMAT" begin +# @js w document.getElementById("button_matfolder").click() +# @test "matfolder" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderseq").click() - @test "matfolderseq" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderseq").click() +# @test "matfolderseq" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderpha").click() - @test "matfolderpha" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderpha").click() +# @test "matfolderpha" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfoldersca").click() - @test "matfoldersca" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfoldersca").click() +# @test "matfoldersca" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderraw").click() - @test "matfolderraw" == @js w document.getElementById("content").dataset.content +# @js w document.getElementById("button_matfolderraw").click() +# @test "matfolderraw" == @js w document.getElementById("content").dataset.content - @js w document.getElementById("button_matfolderima").click() - @test "matfolderima" == @js w document.getElementById("content").dataset.content - end +# @js w document.getElementById("button_matfolderima").click() +# @test "matfolderima" == @js w document.getElementById("content").dataset.content +# end - if !isnothing(w) - close(w) - end +# if !isnothing(w) +# close(w) +# end -end +# end From 86bb9e4e1eefc46e742f31815ba136dee560ae99 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 14:18:35 +0200 Subject: [PATCH 09/51] Revert comments --- KomaMRIBase/test/runtests.jl | 1118 ++++++++-------- KomaMRICore/test/runtests.jl | 1190 ++++++++--------- KomaMRIFiles/test/runtests.jl | 230 ++-- .../test/GUI_PlotlyJS_backend_test.jl | 270 ++-- .../test/GUI_PlutoPlotly_backend_test.jl | 54 +- test/runtests.jl | 194 +-- 6 files changed, 1528 insertions(+), 1528 deletions(-) diff --git a/KomaMRIBase/test/runtests.jl b/KomaMRIBase/test/runtests.jl index 555e0f438..f15bfaca2 100644 --- a/KomaMRIBase/test/runtests.jl +++ b/KomaMRIBase/test/runtests.jl @@ -1,559 +1,559 @@ -# using TestItems, TestItemRunner - -# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true - -# @testitem "Sequence" tags=[:base] begin -# @testset "Init" begin -# sys = Scanner() -# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse -# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) -# @test dur(EX) ≈ durRF #RF length matches what is supposed to be - -# #ACQ construction -# N = 101 -# FOV = 23e-2 -# EPI = PulseDesigner.EPI(FOV, N, sys) -# TE = 30e-3 -# d1 = TE-dur(EPI)/2-dur(EX) -# d1 = d1 > 0 ? d1 : 0.0 -# if d1 > 0 DELAY = Delay(d1) end - -# #Sequence construction -# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) -# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 -# @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be -# end - -# @testset "Rot_and_Concat" begin -# # Rotation 2D case -# A1, A2, T, t = rand(4) -# s = Sequence([Grad(A1,T); -# Grad(A2,T)]) -# θ = π*t -# R = rotz(θ) -# s2 = R*s #Matrix-Matrix{Grad} multiplication -# GR2 = R*s.GR.A #Matrix-vector multiplication -# @test s2.GR.A ≈ GR2 -# # Rotation 3D case -# T, t1, t2, t3 = rand(4) -# N = 100 -# GR = [Grad(rand(),T) for i=1:3, j=1:N] -# s = Sequence(GR) -# α, β, γ = π*t1, π*t2, π*t3 -# Rx = rotx(α) -# Ry = roty(β) -# Rz = rotz(γ) -# R = Rx*Ry*Rz -# s2 = R*s #Matrix-Matrix{Grad} multiplication -# GR2 = R*s.GR.A #Matrix-vector multiplication -# @test s2.GR.A ≈ GR2 - -# # Concatenation of sequences -# A1, A2, A3, T1 = rand(4) -# s1 = Sequence([Grad(A1,T1); -# Grad(A2,T1)], -# [RF(A3,T1)]) -# B1, B2, B3, T2 = rand(4) -# s2 = Sequence([Grad(B1,T2); -# Grad(B2,T2)], -# [RF(B3,T2)]) -# s = s1 + s2 -# @test s.GR.A ≈ [s1.GR.A s2.GR.A] -# @test s.RF.A ≈ [s1.RF.A s2.RF.A] -# @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] -# end - -# @testset "Grad" begin -# #Testing gradient concatenation, breakes in some Julia versions -# A1, A2, T = rand(3) -# g1, g2 = Grad(A1,T), Grad(A2,T) -# GR = [g1;g2;;] -# GR2 = reshape([g1;g2],:,1) -# @test GR.A ≈ GR2.A - -# #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) -# A, T = 0.1, 1e-3 -# grad = Grad(A, T) - -# A, T = rand(2) -# g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) -# @test g1 ≈ g2 - -# A, T, ζ = rand(3) -# g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) -# @test g1 ≈ g2 - -# A, T, delay, ζ = rand(4) -# g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) -# @test g1 ≈ g2 - -# # Test construction with shape function -# T, N = 1e-3, 100 -# f = t -> sin(π*t / T) -# gradw = Grad(f, T, N) -# @test gradw.A ≈ f.(range(0.0, T; length=N)) - -# # Test Grad operations -# α = 3 -# gradt = α * grad -# @test size(grad, 1) == 1 -# @test gradt.A ≈ α * grad.A -# gradt = grad * α -# @test gradt.A ≈ α * grad.A -# gradt = grad / α -# @test gradt.A ≈ grad.A / α -# grads = grad + gradt -# @test grads.A ≈ grad.A + gradt.A -# A1, A2, A3 = 0.1, 0.2, 0.3 -# v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] -# v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] -# v3 = v1 + v2 -# @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] -# gradr = grad - gradt -# @test gradr.A ≈ grad.A - gradt.A -# gradt = -grad -# @test gradt.A ≈ -grad.A -# vc = vcat(v1, v2) -# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] -# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] -# vc = vcat(v1, v2, v3) -# @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] -# @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] -# @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] -# delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 -# gr = Grad(A, T, rise, fall, delay) -# @test dur(gr) ≈ delay + rise + T + fall -# T1, T2, T3 = 1e-3, 2e-3, 3e-3 -# vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] -# @test dur(vt) ≈ [maximum([T1, T2, T3])] - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", grad) -# @test true - -# end - -# @testset "RF" begin -# #Testing gradient concatenation, breakes in some Julia versions -# A1, A2, T = rand(3) -# r1, r2 = RF(A1,T), RF(A2,T) -# R = [r1;r2;;] -# R2 = reshape([r1;r2],:,1) -# @test R.A ≈ R2.A - -# #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) -# A, T = rand(2) -# r1, r2 = RF(A,T), RF(A,T,0.0,0.0) -# @test r1 ≈ r2 - -# A, T, Δf = rand(3) -# r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) -# @test r1 ≈ r2 - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", r1) -# @test true - -# # Test Grad operations -# B1x, B1y, T = rand(3) -# A = B1x + im*B1y -# α = Complex(rand()) -# rf = RF(A, T) -# rft = α * rf -# @test size(rf, 1) == 1 -# @test rft.A ≈ α * rf.A -# @test dur(rf) ≈ rf.T -# B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) -# rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) -# rv = [rf1; rf2; rf3] -# @test dur(rv) ≈ sum(dur.(rv)) - -# end - -# @testset "Delay" begin - -# # Test delay construction -# T = 1e-3 -# delay = Delay(T) -# @test delay.T ≈ T - -# # Test delay construction error for negative values -# err = Nothing -# try Delay(-T) catch err end -# @test err isa ErrorException - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", delay) -# @test true - -# # Test addition of a delay to a sequence -# seq = Sequence([Grad(0.0, 0.0)]) -# ds = delay + seq -# @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 -# sd = seq + delay -# @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T - -# end -# @testset "ADC" begin - -# # Test ADC construction -# N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π -# adc = ADC(N, T, delay, Δf, ϕ) - -# adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) -# @test adc1 ≈ adc2 - -# adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) -# @test adc1 ≈ adc2 - -# adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) -# @test adc1 ≈ adc2 - -# # Test ADC construction errors for negative values -# err = Nothing -# try ADC(N, -T) catch err end -# @test err isa ErrorException -# try ADC(N, -T, delay) catch err end -# @test err isa ErrorException -# try ADC(N, T, -delay) catch err end -# @test err isa ErrorException -# try ADC(N, -T, -delay) catch err end -# @test err isa ErrorException -# try ADC(N, -T, delay, Δf, ϕ) catch err end -# @test err isa ErrorException -# try ADC(N, T, -delay, Δf, ϕ) catch err end -# @test err isa ErrorException -# try ADC(N, -T, -delay, Δf, ϕ) catch err end -# @test err isa ErrorException - -# # Test ADC getproperties -# Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π -# adb = ADC(Nb, Tb, delayb, Δfb, ϕb) -# adcs = [adc, adb] -# @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] -# @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] - -# end - -# @testset "DiscreteSequence" begin -# path = joinpath(@__DIR__, "test_files") -# seq = PulseDesigner.EPI_example() -# sampling_params = KomaMRIBase.default_sampling_params() -# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) -# seqd = KomaMRIBase.discretize(seq) -# i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) -# @test seqd[i1].t ≈ [t[i1]] -# @test seqd[i1:i2-1].t ≈ t[i1:i2] - -# T, N = 1.0, 4 -# seq = RF(1.0e-6, 1.0) -# seq += Sequence([Grad(1.0e-3, 1.0)]) -# seq += ADC(N, 1.0) -# sampling_params = KomaMRIBase.default_sampling_params() -# sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N -# seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) -# seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) -# seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) -# # Block 1 -# @test is_RF_on(seq[1]) == is_RF_on(seqd1) -# @test is_GR_on(seq[1]) == is_GR_on(seqd1) -# @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) -# # Block 2 -# @test is_RF_on(seq[2]) == is_RF_on(seqd2) -# @test is_GR_on(seq[2]) == is_GR_on(seqd2) -# @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) -# # Block 3 -# @test is_RF_on(seq[3]) == is_RF_on(seqd3) -# @test is_GR_on(seq[3]) == is_GR_on(seqd3) -# @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) -# @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) -# @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) -# @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) -# end - -# @testset "SequenceFunctions" begin -# path = joinpath(@__DIR__, "test_files") -# seq = PulseDesigner.EPI_example() -# t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) -# t_adc = KomaMRIBase.get_adc_sampling_times(seq) -# M2, M2_adc = KomaMRIBase.get_slew_rate(seq) -# M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) -# Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) -# Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) -# @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz -# @test is_ADC_on(seq) == is_ADC_on(seq, t) -# @test is_RF_on(seq) == is_RF_on(seq, t) -# @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) -# @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) -# @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", seq) -# @test true - -# α = rand() -# c = α + im*rand() -# x = seq -# y = PulseDesigner.EPI_example() -# z = x + y -# @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N -# z = x - y -# @test z.GR.A ≈ [x.GR -y.GR].A -# z = -x -# @test z.GR.A ≈ -x.GR.A -# z = x * α -# @test z.GR.A ≈ α*x.GR.A -# z = α * x -# @test z.GR.A ≈ α*x.GR.A -# z = x * c -# @test z.RF.A ≈ c*x.RF.A -# z = c * x -# @test z.RF.A ≈ c*x.RF.A -# z = x / α -# @test z.GR.A ≈ x.GR.A/α -# @test size(y) == size(y.GR[1,:]) -# z = x + x.GR[3,1] -# @test z.GR.A[1, end] ≈ x.GR[3,1].A -# z = x.GR[3,1] + x -# @test z.GR.A[1, 1] ≈ x.GR[3,1].A -# z = x + x.RF[1,1] -# @test z.RF.A[1, end] ≈ x.RF[1,1].A -# z = x.RF[1,1] + x -# @test z.RF.A[1, 1] ≈ x.RF[1,1].A -# z = x + x.ADC[3,1] -# @test z.ADC.N[end] ≈ x.ADC[3,1].N -# z = x.ADC[3,1] + x -# @test z.ADC.N[1] ≈ x.ADC[3,1].N -# end - -# end - -# @testitem "PulseDesigner" tags=[:base] begin -# @testset "RF_sinc" begin -# sys = Scanner() -# B1 = 23.4e-6 # For 90 deg flip angle -# Trf = 1e-3 -# rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) -# @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 -# end -# @testset "Spiral" begin -# sys = Scanner() -# sys.Smax = 150 # [mT/m/ms] -# sys.Gmax = 500e-3 # [T/m] -# sys.GR_Δt = 4e-6 # [s] -# FOV = 0.2 # [m] -# N = 80 # Reconstructed image N×N -# Nint = 8 -# λ = 2.1 -# spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) -# # Look at the k_space generated -# @test spiral(0).DEF["λ"] ≈ λ -# end -# @testset "Radial" begin -# sys = Scanner() -# N = 80 -# Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction -# FOV = 0.2 -# spoke = PulseDesigner.radial_base(FOV, N, sys) -# @test spoke.DEF["Δθ"] ≈ π / Nspokes -# end -# end - -# @testitem "Phantom" tags = [:base] begin -# using Suppressor -# # Test phantom struct creation -# name = "Bulks" -# x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] -# y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] -# z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] -# ρ = [0.2; 0.4; 0.6; 0.8; 1.0] -# T1 = [0.9; 0.9; 0.5; 0.25; 0.4] -# T2 = [0.09; 0.05; 0.04; 0.07; 0.005] -# T2s = [0.1; 0.06; 0.05; 0.08; 0.015] -# Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] -# Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] -# Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] -# Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] -# obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) -# obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) -# @test obj1 == obj2 - -# # Test size and length definitions of a phantom -# @test size(obj1) == size(ρ) -# @test length(obj1) == length(ρ) - -# # Test obtaining spin psositions -# @testset "SimpleMotion" begin -# ph = Phantom(x=[1.0], y=[1.0]) -# t_start=0.0; t_end=1.0 -# t = collect(range(t_start, t_end, 11)) -# period = 2.0 -# asymmetry = 0.5 -# # Translation -# dx, dy, dz = [1.0, 0.0, 0.0] -# vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) -# translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) -# xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') -# @test xt == ph.x .+ vx.*t' -# @test yt == ph.y .+ vy.*t' -# @test zt == ph.z .+ vz.*t' -# # PeriodicTranslation -# periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) -# xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') -# @test xt == ph.x .+ vx.*t' -# @test yt == ph.y .+ vy.*t' -# @test zt == ph.z .+ vz.*t' -# # Rotation (2D) -# pitch = 0.0 -# roll = 0.0 -# yaw = 45.0 -# rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) -# xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') -# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) -# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) -# @test zt[:,end] == ph.z -# # PeriodicRotation (2D) -# periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) -# xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') -# @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) -# @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) -# @test zt[:,end] == ph.z -# # HeartBeat -# circumferential_strain = -0.1 -# radial_strain = 0.0 -# longitudinal_strain = -0.1 -# heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) -# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') -# r = sqrt.(ph.x .^ 2 + ph.y .^ 2) -# θ = atan.(ph.y, ph.x) -# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) -# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) -# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) -# # PeriodicHeartBeat -# periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) -# xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') -# @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) -# @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) -# @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) -# end -# @testset "ArbitraryMotion" begin -# ph = Phantom(x=[1.0], y=[1.0]) -# Ns = length(ph) -# period_durations = [1.0] -# num_pieces = 10 -# dx = dy = dz = rand(Ns, num_pieces - 1) -# arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) -# t = times(arbitrarymotion) -# xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') -# @test xt[:,2:end-1] == ph.x .+ dx -# @test yt[:,2:end-1] == ph.y .+ dy -# @test zt[:,2:end-1] == ph.z .+ dz -# end - -# simplemotion = SimpleMotion([ -# PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), -# Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), -# ]) - -# Ns = length(obj1) -# K = 10 -# arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) - -# # Test phantom subset -# obs1 = Phantom( -# name, -# x, -# y, -# z, -# ρ, -# T1, -# T2, -# T2s, -# Δw, -# Dλ1, -# Dλ2, -# Dθ, -# simplemotion -# ) -# rng = 1:2:5 -# obs2 = Phantom( -# name, -# x[rng], -# y[rng], -# z[rng], -# ρ[rng], -# T1[rng], -# T2[rng], -# T2s[rng], -# Δw[rng], -# Dλ1[rng], -# Dλ2[rng], -# Dθ[rng], -# simplemotion[rng], -# ) -# @test obs1[rng] == obs2 -# @test @view(obs1[rng]) == obs2 - -# obs1.motion = arbitrarymotion -# obs2.motion = arbitrarymotion[rng] -# @test obs1[rng] == obs2 -# # @test @view(obs1[rng]) == obs2 - -# # Test addition of phantoms -# oba = Phantom( -# name, -# [x; x[rng]], -# [y; y[rng]], -# [z; z[rng]], -# [ρ; ρ[rng]], -# [T1; T1[rng]], -# [T2; T2[rng]], -# [T2s; T2s[rng]], -# [Δw; Δw[rng]], -# [Dλ1; Dλ1[rng]], -# [Dλ2; Dλ2[rng]], -# [Dθ; Dθ[rng]], -# [obs1.motion; obs2.motion] -# ) -# @test obs1 + obs2 == oba - -# # Test scalar multiplication of a phantom -# c = 7 -# obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) -# @test c * obj1 == obc - -# #Test brain phantom 2D -# ph = brain_phantom2D() -# @test ph.name == "brain2D_axial" -# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - -# #Test brain phantom 3D -# ph = brain_phantom3D() -# @test ph.name == "brain3D" -# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] - -# #Test pelvis phantom 2D -# ph = pelvis_phantom2D() -# @test ph.name == "pelvis2D" -# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] - -# #Test heart phantom -# ph = heart_phantom() -# @test ph.name == "LeftVentricle" -# @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] -# end - -# @testitem "Scanner" tags=[:base] begin -# B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 -# ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 -# RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 -# sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) -# @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax -# end - -# @testitem "TrapezoidalIntegration" tags=[:base] begin -# dt = Float64[1 1 1 1] -# x = Float64[0 1 2 1 0] -# @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 -# @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] -# end +using TestItems, TestItemRunner + +@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:base in t_start.tags) #verbose=true + +@testitem "Sequence" tags=[:base] begin + @testset "Init" begin + sys = Scanner() + B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse + EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0.0,0.0,0.0]) + @test dur(EX) ≈ durRF #RF length matches what is supposed to be + + #ACQ construction + N = 101 + FOV = 23e-2 + EPI = PulseDesigner.EPI(FOV, N, sys) + TE = 30e-3 + d1 = TE-dur(EPI)/2-dur(EX) + d1 = d1 > 0 ? d1 : 0.0 + if d1 > 0 DELAY = Delay(d1) end + + #Sequence construction + seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) + seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + @test dur(seq) ≈ dur(EX) + d1 + dur(EPI) #Sequence duration matches what is supposed to be + end + + @testset "Rot_and_Concat" begin + # Rotation 2D case + A1, A2, T, t = rand(4) + s = Sequence([Grad(A1,T); + Grad(A2,T)]) + θ = π*t + R = rotz(θ) + s2 = R*s #Matrix-Matrix{Grad} multiplication + GR2 = R*s.GR.A #Matrix-vector multiplication + @test s2.GR.A ≈ GR2 + # Rotation 3D case + T, t1, t2, t3 = rand(4) + N = 100 + GR = [Grad(rand(),T) for i=1:3, j=1:N] + s = Sequence(GR) + α, β, γ = π*t1, π*t2, π*t3 + Rx = rotx(α) + Ry = roty(β) + Rz = rotz(γ) + R = Rx*Ry*Rz + s2 = R*s #Matrix-Matrix{Grad} multiplication + GR2 = R*s.GR.A #Matrix-vector multiplication + @test s2.GR.A ≈ GR2 + + # Concatenation of sequences + A1, A2, A3, T1 = rand(4) + s1 = Sequence([Grad(A1,T1); + Grad(A2,T1)], + [RF(A3,T1)]) + B1, B2, B3, T2 = rand(4) + s2 = Sequence([Grad(B1,T2); + Grad(B2,T2)], + [RF(B3,T2)]) + s = s1 + s2 + @test s.GR.A ≈ [s1.GR.A s2.GR.A] + @test s.RF.A ≈ [s1.RF.A s2.RF.A] + @test s.ADC.N ≈ [s1.ADC.N ; s2.ADC.N] + end + + @testset "Grad" begin + #Testing gradient concatenation, breakes in some Julia versions + A1, A2, T = rand(3) + g1, g2 = Grad(A1,T), Grad(A2,T) + GR = [g1;g2;;] + GR2 = reshape([g1;g2],:,1) + @test GR.A ≈ GR2.A + + #Sanity checks of contructors (A [T], T[s], rise[s], fall[s], delay[s]) + A, T = 0.1, 1e-3 + grad = Grad(A, T) + + A, T = rand(2) + g1, g2 = Grad(A,T), Grad(A,T,0.0,0.0,0.0) + @test g1 ≈ g2 + + A, T, ζ = rand(3) + g1, g2 = Grad(A,T,ζ), Grad(A,T,ζ,ζ,0.0) + @test g1 ≈ g2 + + A, T, delay, ζ = rand(4) + g1, g2 = Grad(A,T,ζ,delay), Grad(A,T,ζ,ζ,delay) + @test g1 ≈ g2 + + # Test construction with shape function + T, N = 1e-3, 100 + f = t -> sin(π*t / T) + gradw = Grad(f, T, N) + @test gradw.A ≈ f.(range(0.0, T; length=N)) + + # Test Grad operations + α = 3 + gradt = α * grad + @test size(grad, 1) == 1 + @test gradt.A ≈ α * grad.A + gradt = grad * α + @test gradt.A ≈ α * grad.A + gradt = grad / α + @test gradt.A ≈ grad.A / α + grads = grad + gradt + @test grads.A ≈ grad.A + gradt.A + A1, A2, A3 = 0.1, 0.2, 0.3 + v1 = [Grad(A1,T); Grad(A2,T); Grad(A3,T)] + v2 = [Grad(A2,T); Grad(A3,T); Grad(A1,T)] + v3 = v1 + v2 + @test [v3[i].A for i=1:length(v3)] ≈ [v1[i].A + v2[i].A for i=1:length(v1)] + gradr = grad - gradt + @test gradr.A ≈ grad.A - gradt.A + gradt = -grad + @test gradt.A ≈ -grad.A + vc = vcat(v1, v2) + @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] + @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] + vc = vcat(v1, v2, v3) + @test [vc[1,j].A for j=1:length(v1)] ≈ [v1[i].A for i=1:length(v1)] + @test [vc[2,j].A for j=1:length(v2)] ≈ [v2[i].A for i=1:length(v2)] + @test [vc[3,j].A for j=1:length(v3)] ≈ [v3[i].A for i=1:length(v3)] + delay, rise, T, fall = 1e-6, 2e-6, 10e-3, 3e-6 + gr = Grad(A, T, rise, fall, delay) + @test dur(gr) ≈ delay + rise + T + fall + T1, T2, T3 = 1e-3, 2e-3, 3e-3 + vt = [Grad(A1,T1); Grad(A2,T2); Grad(A3,T3)] + @test dur(vt) ≈ [maximum([T1, T2, T3])] + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", grad) + @test true + + end + + @testset "RF" begin + #Testing gradient concatenation, breakes in some Julia versions + A1, A2, T = rand(3) + r1, r2 = RF(A1,T), RF(A2,T) + R = [r1;r2;;] + R2 = reshape([r1;r2],:,1) + @test R.A ≈ R2.A + + #Sanity checks of constructors (A [T], T [s], Δf[Hz], delay [s]) + A, T = rand(2) + r1, r2 = RF(A,T), RF(A,T,0.0,0.0) + @test r1 ≈ r2 + + A, T, Δf = rand(3) + r1, r2 = RF(A,T,Δf), RF(A,T,Δf,0.0) + @test r1 ≈ r2 + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", r1) + @test true + + # Test Grad operations + B1x, B1y, T = rand(3) + A = B1x + im*B1y + α = Complex(rand()) + rf = RF(A, T) + rft = α * rf + @test size(rf, 1) == 1 + @test rft.A ≈ α * rf.A + @test dur(rf) ≈ rf.T + B1x, B1y, B2x, B2y, B3x, B3y, T1, T2, T3 = rand(9) + rf1, rf2, rf3 = RF(B1x + im*B1y, T1), RF(B1x + im*B1y, T2), RF(B3x + im*B3y, T3) + rv = [rf1; rf2; rf3] + @test dur(rv) ≈ sum(dur.(rv)) + + end + + @testset "Delay" begin + + # Test delay construction + T = 1e-3 + delay = Delay(T) + @test delay.T ≈ T + + # Test delay construction error for negative values + err = Nothing + try Delay(-T) catch err end + @test err isa ErrorException + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", delay) + @test true + + # Test addition of a delay to a sequence + seq = Sequence([Grad(0.0, 0.0)]) + ds = delay + seq + @test dur(ds[1]) ≈ delay.T && dur(ds[2]) ≈ .0 + sd = seq + delay + @test dur(sd[1]) ≈ .0 && dur(sd[2]) ≈ delay.T + + end + @testset "ADC" begin + + # Test ADC construction + N, T, delay, Δf, ϕ = 64, 1e-3, 2e-3, 1e-6, .25*π + adc = ADC(N, T, delay, Δf, ϕ) + + adc1, adc2 = ADC(N, T), ADC(N,T,0,0,0) + @test adc1 ≈ adc2 + + adc1, adc2 = ADC(N, T, delay), ADC(N, T, delay, 0, 0) + @test adc1 ≈ adc2 + + adc1, adc2 = ADC(N, T, delay, Δf, ϕ), ADC(N, T, delay, Δf, ϕ) + @test adc1 ≈ adc2 + + # Test ADC construction errors for negative values + err = Nothing + try ADC(N, -T) catch err end + @test err isa ErrorException + try ADC(N, -T, delay) catch err end + @test err isa ErrorException + try ADC(N, T, -delay) catch err end + @test err isa ErrorException + try ADC(N, -T, -delay) catch err end + @test err isa ErrorException + try ADC(N, -T, delay, Δf, ϕ) catch err end + @test err isa ErrorException + try ADC(N, T, -delay, Δf, ϕ) catch err end + @test err isa ErrorException + try ADC(N, -T, -delay, Δf, ϕ) catch err end + @test err isa ErrorException + + # Test ADC getproperties + Nb, Tb, delayb, Δfb, ϕb = 128, 2e-3, 4e-3, 2e-6, .125*π + adb = ADC(Nb, Tb, delayb, Δfb, ϕb) + adcs = [adc, adb] + @test adcs.N ≈ [adc.N, adb.N] && adcs.T ≈ [adc.T, adb.T] && adcs.delay ≈ [adc.delay, adb.delay] + @test adcs.Δf ≈ [adc.Δf, adb.Δf] && adcs.ϕ ≈ [adc.ϕ, adb.ϕ] && adcs.dur ≈ [adc.T + adc.delay, adb.T + adb.delay] + + end + + @testset "DiscreteSequence" begin + path = joinpath(@__DIR__, "test_files") + seq = PulseDesigner.EPI_example() + sampling_params = KomaMRIBase.default_sampling_params() + t, Δt = KomaMRIBase.get_variable_times(seq; Δt=sampling_params["Δt"], Δt_rf=sampling_params["Δt_rf"]) + seqd = KomaMRIBase.discretize(seq) + i1, i2 = rand(1:Int(floor(0.5*length(seqd)))), rand(Int(ceil(0.5*length(seqd))):length(seqd)) + @test seqd[i1].t ≈ [t[i1]] + @test seqd[i1:i2-1].t ≈ t[i1:i2] + + T, N = 1.0, 4 + seq = RF(1.0e-6, 1.0) + seq += Sequence([Grad(1.0e-3, 1.0)]) + seq += ADC(N, 1.0) + sampling_params = KomaMRIBase.default_sampling_params() + sampling_params["Δt"], sampling_params["Δt_rf"] = T/N, T/N + seqd1 = KomaMRIBase.discretize(seq[1]; sampling_params) + seqd2 = KomaMRIBase.discretize(seq[2]; sampling_params) + seqd3 = KomaMRIBase.discretize(seq[3]; sampling_params) + # Block 1 + @test is_RF_on(seq[1]) == is_RF_on(seqd1) + @test is_GR_on(seq[1]) == is_GR_on(seqd1) + @test is_ADC_on(seq[1]) == is_ADC_on(seqd1) + # Block 2 + @test is_RF_on(seq[2]) == is_RF_on(seqd2) + @test is_GR_on(seq[2]) == is_GR_on(seqd2) + @test is_ADC_on(seq[2]) == is_ADC_on(seqd2) + # Block 3 + @test is_RF_on(seq[3]) == is_RF_on(seqd3) + @test is_GR_on(seq[3]) == is_GR_on(seqd3) + @test is_ADC_on(seq[3]) == is_ADC_on(seqd3) + @test KomaMRIBase.is_GR_off(seqd) == !KomaMRIBase.is_GR_on(seqd) + @test KomaMRIBase.is_RF_off(seqd) == !KomaMRIBase.is_RF_on(seqd) + @test KomaMRIBase.is_ADC_off(seqd) == !KomaMRIBase.is_ADC_on(seqd) + end + + @testset "SequenceFunctions" begin + path = joinpath(@__DIR__, "test_files") + seq = PulseDesigner.EPI_example() + t, Δt = KomaMRIBase.get_variable_times(seq; Δt=1) + t_adc = KomaMRIBase.get_adc_sampling_times(seq) + M2, M2_adc = KomaMRIBase.get_slew_rate(seq) + M2eddy, M2eddy_adc = KomaMRIBase.get_eddy_currents(seq) + Gx, Gy, Gz = KomaMRIBase.get_grads(seq, t) + Gmx, Gmy, Gmz = KomaMRIBase.get_grads(seq, reshape(t, 1, :)) + @test reshape(Gmx, :, 1) ≈ Gx && reshape(Gmy, :, 1) ≈ Gy && reshape(Gmz, :, 1) ≈ Gz + @test is_ADC_on(seq) == is_ADC_on(seq, t) + @test is_RF_on(seq) == is_RF_on(seq, t) + @test KomaMRIBase.is_Delay(seq) == !(is_GR_on(seq) || is_RF_on(seq) || is_ADC_on(seq)) + @test size(M2, 1) == length(Δt) && size(M2_adc, 1) == length(t_adc) + @test size(M2eddy, 1) == length(Δt) && size(M2eddy_adc, 1) == length(t_adc) + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", seq) + @test true + + α = rand() + c = α + im*rand() + x = seq + y = PulseDesigner.EPI_example() + z = x + y + @test z.GR.A ≈ [x.GR y.GR].A && z.RF.A ≈ [x.RF y.RF].A && z.ADC.N ≈ [x.ADC; y.ADC].N + z = x - y + @test z.GR.A ≈ [x.GR -y.GR].A + z = -x + @test z.GR.A ≈ -x.GR.A + z = x * α + @test z.GR.A ≈ α*x.GR.A + z = α * x + @test z.GR.A ≈ α*x.GR.A + z = x * c + @test z.RF.A ≈ c*x.RF.A + z = c * x + @test z.RF.A ≈ c*x.RF.A + z = x / α + @test z.GR.A ≈ x.GR.A/α + @test size(y) == size(y.GR[1,:]) + z = x + x.GR[3,1] + @test z.GR.A[1, end] ≈ x.GR[3,1].A + z = x.GR[3,1] + x + @test z.GR.A[1, 1] ≈ x.GR[3,1].A + z = x + x.RF[1,1] + @test z.RF.A[1, end] ≈ x.RF[1,1].A + z = x.RF[1,1] + x + @test z.RF.A[1, 1] ≈ x.RF[1,1].A + z = x + x.ADC[3,1] + @test z.ADC.N[end] ≈ x.ADC[3,1].N + z = x.ADC[3,1] + x + @test z.ADC.N[1] ≈ x.ADC[3,1].N + end + +end + +@testitem "PulseDesigner" tags=[:base] begin + @testset "RF_sinc" begin + sys = Scanner() + B1 = 23.4e-6 # For 90 deg flip angle + Trf = 1e-3 + rf = PulseDesigner.RF_sinc(B1, Trf, sys; TBP=4) + @test round(KomaMRIBase.get_flip_angles(rf)[1]) ≈ 90 + end + @testset "Spiral" begin + sys = Scanner() + sys.Smax = 150 # [mT/m/ms] + sys.Gmax = 500e-3 # [T/m] + sys.GR_Δt = 4e-6 # [s] + FOV = 0.2 # [m] + N = 80 # Reconstructed image N×N + Nint = 8 + λ = 2.1 + spiral = PulseDesigner.spiral_base(FOV, N, sys; λ=λ, BW=120e3, Nint) + # Look at the k_space generated + @test spiral(0).DEF["λ"] ≈ λ + end + @testset "Radial" begin + sys = Scanner() + N = 80 + Nspokes = ceil(Int64, π/2 * N ) #Nyquist in the radial direction + FOV = 0.2 + spoke = PulseDesigner.radial_base(FOV, N, sys) + @test spoke.DEF["Δθ"] ≈ π / Nspokes + end +end + +@testitem "Phantom" tags = [:base] begin + using Suppressor + # Test phantom struct creation + name = "Bulks" + x = [-2e-3; -1e-3; 0.0; 1e-3; 2e-3] + y = [-4e-3; -2e-3; 0.0; 2e-3; 4e-3] + z = [-6e-3; -3e-3; 0.0; 3e-3; 6e-3] + ρ = [0.2; 0.4; 0.6; 0.8; 1.0] + T1 = [0.9; 0.9; 0.5; 0.25; 0.4] + T2 = [0.09; 0.05; 0.04; 0.07; 0.005] + T2s = [0.1; 0.06; 0.05; 0.08; 0.015] + Δw = [-2e-6; -1e-6; 0.0; 1e-6; 2e-6] + Dλ1 = [-4e-6; -2e-6; 0.0; 2e-6; 4e-6] + Dλ2 = [-6e-6; -3e-6; 0.0; 3e-6; 6e-6] + Dθ = [-8e-6; -4e-6; 0.0; 4e-6; 8e-6] + obj1 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) + obj2 = Phantom(name=name, x=x, y=y, z=z, ρ=ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) + @test obj1 == obj2 + + # Test size and length definitions of a phantom + @test size(obj1) == size(ρ) + @test length(obj1) == length(ρ) + + # Test obtaining spin psositions + @testset "SimpleMotion" begin + ph = Phantom(x=[1.0], y=[1.0]) + t_start=0.0; t_end=1.0 + t = collect(range(t_start, t_end, 11)) + period = 2.0 + asymmetry = 0.5 + # Translation + dx, dy, dz = [1.0, 0.0, 0.0] + vx, vy, vz = [dx, dy, dz] ./ (t_end - t_start) + translation = SimpleMotion([Translation(dx, dy, dz, t_start, t_end)]) + xt, yt, zt = get_spin_coords(translation, ph.x, ph.y, ph.z, t') + @test xt == ph.x .+ vx.*t' + @test yt == ph.y .+ vy.*t' + @test zt == ph.z .+ vz.*t' + # PeriodicTranslation + periodictranslation = SimpleMotion([PeriodicTranslation(dx, dy, dz, period, asymmetry)]) + xt, yt, zt = get_spin_coords(periodictranslation, ph.x, ph.y, ph.z, t') + @test xt == ph.x .+ vx.*t' + @test yt == ph.y .+ vy.*t' + @test zt == ph.z .+ vz.*t' + # Rotation (2D) + pitch = 0.0 + roll = 0.0 + yaw = 45.0 + rotation = SimpleMotion([Rotation(pitch, roll, yaw, t_start, t_end)]) + xt, yt, zt = get_spin_coords(rotation, ph.x, ph.y, ph.z, t') + @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) + @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) + @test zt[:,end] == ph.z + # PeriodicRotation (2D) + periodicrotation = SimpleMotion([PeriodicRotation(pitch, roll, yaw, period, asymmetry)]) + xt, yt, zt = get_spin_coords(periodicrotation, ph.x, ph.y, ph.z, t') + @test xt[:,end] == ph.x .* cosd(yaw) - ph.y .* sind(yaw) + @test yt[:,end] == ph.x .* sind(yaw) + ph.y .* cosd(yaw) + @test zt[:,end] == ph.z + # HeartBeat + circumferential_strain = -0.1 + radial_strain = 0.0 + longitudinal_strain = -0.1 + heartbeat = SimpleMotion([HeartBeat(circumferential_strain, radial_strain, longitudinal_strain, t_start, t_end)]) + xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') + r = sqrt.(ph.x .^ 2 + ph.y .^ 2) + θ = atan.(ph.y, ph.x) + @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) + @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) + @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) + # PeriodicHeartBeat + periodicheartbeat = SimpleMotion([PeriodicHeartBeat(circumferential_strain, radial_strain, longitudinal_strain, period, asymmetry)]) + xt, yt, zt = get_spin_coords(heartbeat, ph.x, ph.y, ph.z, t') + @test xt[:,end] == ph.x .* (1 .+ circumferential_strain * maximum(r) .* cos.(θ)) + @test yt[:,end] == ph.y .* (1 .+ circumferential_strain * maximum(r) .* sin.(θ)) + @test zt[:,end] == ph.z .* (1 .+ longitudinal_strain) + end + @testset "ArbitraryMotion" begin + ph = Phantom(x=[1.0], y=[1.0]) + Ns = length(ph) + period_durations = [1.0] + num_pieces = 10 + dx = dy = dz = rand(Ns, num_pieces - 1) + arbitrarymotion = @suppress ArbitraryMotion(period_durations, dx, dy, dz) + t = times(arbitrarymotion) + xt, yt, zt = get_spin_coords(arbitrarymotion, ph.x, ph.y, ph.z, t') + @test xt[:,2:end-1] == ph.x .+ dx + @test yt[:,2:end-1] == ph.y .+ dy + @test zt[:,2:end-1] == ph.z .+ dz + end + + simplemotion = SimpleMotion([ + PeriodicTranslation(dx=0.05, dy=0.05, dz=0.0, period=0.5, asymmetry=0.5), + Rotation(pitch=0.0, roll=0.0, yaw=π / 2, t_start=0.05, t_end=0.5), + ]) + + Ns = length(obj1) + K = 10 + arbitrarymotion = @suppress ArbitraryMotion([1.0], 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1), 0.01 .* rand(Ns, K - 1)) + + # Test phantom subset + obs1 = Phantom( + name, + x, + y, + z, + ρ, + T1, + T2, + T2s, + Δw, + Dλ1, + Dλ2, + Dθ, + simplemotion + ) + rng = 1:2:5 + obs2 = Phantom( + name, + x[rng], + y[rng], + z[rng], + ρ[rng], + T1[rng], + T2[rng], + T2s[rng], + Δw[rng], + Dλ1[rng], + Dλ2[rng], + Dθ[rng], + simplemotion[rng], + ) + @test obs1[rng] == obs2 + @test @view(obs1[rng]) == obs2 + + obs1.motion = arbitrarymotion + obs2.motion = arbitrarymotion[rng] + @test obs1[rng] == obs2 + # @test @view(obs1[rng]) == obs2 + + # Test addition of phantoms + oba = Phantom( + name, + [x; x[rng]], + [y; y[rng]], + [z; z[rng]], + [ρ; ρ[rng]], + [T1; T1[rng]], + [T2; T2[rng]], + [T2s; T2s[rng]], + [Δw; Δw[rng]], + [Dλ1; Dλ1[rng]], + [Dλ2; Dλ2[rng]], + [Dθ; Dθ[rng]], + [obs1.motion; obs2.motion] + ) + @test obs1 + obs2 == oba + + # Test scalar multiplication of a phantom + c = 7 + obc = Phantom(name=name, x=x, y=y, z=z, ρ=c*ρ, T1=T1, T2=T2, T2s=T2s, Δw=Δw, Dλ1=Dλ1, Dλ2=Dλ2, Dθ=Dθ) + @test c * obj1 == obc + + #Test brain phantom 2D + ph = brain_phantom2D() + @test ph.name == "brain2D_axial" + @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + + #Test brain phantom 3D + ph = brain_phantom3D() + @test ph.name == "brain3D" + @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 1] + + #Test pelvis phantom 2D + ph = pelvis_phantom2D() + @test ph.name == "pelvis2D" + @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] + + #Test heart phantom + ph = heart_phantom() + @test ph.name == "LeftVentricle" + @test KomaMRIBase.get_dims(ph) == Bool[1, 1, 0] +end + +@testitem "Scanner" tags=[:base] begin + B0, B1, Gmax, Smax = 1.5, 10e-6, 60e-3, 500 + ADC_Δt, seq_Δt, GR_Δt, RF_Δt = 2e-6, 1e-5, 1e-5, 1e-6 + RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T = 20e-6, 100e-6, 10e-6 + sys = Scanner(B0, B1, Gmax, Smax, ADC_Δt, seq_Δt, GR_Δt, RF_Δt, RF_ring_down_T, RF_dead_time_T, ADC_dead_time_T) + @test sys.B0 ≈ B0 && sys.B1 ≈ B1 && sys.Gmax ≈ Gmax && sys.Smax ≈ Smax +end + +@testitem "TrapezoidalIntegration" tags=[:base] begin + dt = Float64[1 1 1 1] + x = Float64[0 1 2 1 0] + @test KomaMRIBase.trapz(dt, x)[1] ≈ 4 #Triangle area = bh/2, with b = 4 and h = 2 + @test KomaMRIBase.cumtrapz(dt, x) ≈ [0.5 2 3.5 4] +end diff --git a/KomaMRICore/test/runtests.jl b/KomaMRICore/test/runtests.jl index f58d553b7..9306533e0 100644 --- a/KomaMRICore/test/runtests.jl +++ b/KomaMRICore/test/runtests.jl @@ -1,595 +1,595 @@ -# using TestItems, TestItemRunner - -# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true - -# @testitem "Spinors×Mag" tags=[:core] begin -# using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg - -# ## Verifying that operators perform counter-clockwise rotations -# v = [1, 2, 3] -# m = Mag([complex(v[1:2]...)], [v[3]]) -# # Rx -# @test rotx(π/2) * v ≈ [1, -3, 2] -# @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] -# @test (Rx(π/2) * m).z ≈ [2.0] -# # Ry -# @test roty(π/2) * v ≈ [3, 2, -1] -# @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] -# @test (Ry(π/2) * m).z ≈ [-1.0] -# # Rz -# @test rotz(π/2) * v ≈ [-2, 1, 3] -# @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] -# @test (Rz(π/2) * m).z ≈ [3.0] -# # Rn -# @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v -# @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v -# @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v -# @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy -# @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z -# @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy -# @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z -# @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy -# @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z - -# ## Verify that Spinor rotation = matrix rotation -# v = rand(3) -# n = rand(3); n = n ./ sqrt(sum(n.^2)) -# m = Mag([complex(v[1:2]...)], [v[3]]) -# φ, θ, φ1, φ2 = rand(4) * 2π -# # Rx -# vx = rotx(θ) * v -# mx = Rx(θ) * m -# @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx -# # Ry -# vy = roty(θ) * v -# my = Ry(θ) * m -# @test [real(my.xy); imag(my.xy); my.z] ≈ vy -# # Rz -# vz = rotz(θ) * v -# mz = Rz(θ) * m -# @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz -# # Rφ -# vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v -# mφ = Rφ(φ,θ) * m -# @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ -# # Rg -# vg = rotz(φ2) * roty(θ) * rotz(φ1) * v -# mg = Rg(φ1,θ,φ2) * m -# @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg -# # Rn -# vq = Un(θ, n) * v -# mq = Q(θ, n[1]+n[2]*1im, n[3]) * m -# @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq - -# ## Spinors satify that |α|^2 + |β|^2 = 1 -# @test abs(Rx(θ)) ≈ [1] -# @test abs(Ry(θ)) ≈ [1] -# @test abs(Rz(θ)) ≈ [1] -# @test abs(Rφ(φ,θ)) ≈ [1] -# @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] - -# ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. -# # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) -# @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v -# @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy -# @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z -# # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) -# @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy -# @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z -# # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) -# @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy -# @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z -# # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) -# @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v -# @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy -# @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z - -# ## Verify trivial identities -# # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis -# # Rφ φ=0 = Ry -# @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy -# @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z -# # Rφ φ=π/2 = Rx -# @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy -# @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z -# # General rotation Rn -# # Rn n=[1,0,0] = Rx -# @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v -# @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy -# @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z -# # Rn n=[0,1,0] = Ry -# @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v -# @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy -# @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z -# # Rn n=[0,0,1] = Rz -# @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v -# @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy -# @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z - -# # Associativity -# # Rx -# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy -# @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z -# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy -# @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z -# # Rφ -# @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy -# @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z -# @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy -# @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z -# # Rg -# @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy -# @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z -# @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy -# @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z - -# ## Other tests -# # Test Spinor struct -# α, β = rand(2) -# s = Spinor(α, β) -# @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", s) -# @test true -# end - -# # Test ISMRMRD -# @testitem "signal_to_raw_data" tags=[:core] begin -# using Suppressor - -# seq = PulseDesigner.EPI_example() -# sys = Scanner() -# obj = brain_phantom2D() - -# sim_params = KomaMRICore.default_sim_params() -# sim_params["return_type"] = "mat" -# sig = @suppress simulate(obj, seq, sys; sim_params) - -# # Test signal_to_raw_data -# raw = signal_to_raw_data(sig, seq) -# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) -# sig_raw = reshape(sig_aux, length(sig_aux), 1) -# @test all(sig .== sig_raw) - -# seq.DEF["FOV"] = [23e-2, 23e-2, 0] -# raw = signal_to_raw_data(sig, seq) -# sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) -# sig_raw = reshape(sig_aux, length(sig_aux), 1) -# @test all(sig .== sig_raw) - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", raw) -# @test true -# end - -# @testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_sphere_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# obj = phantom_sphere() -# sys = Scanner() - -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "Nthreads"=>1, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) - -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_sphere_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# obj = phantom_sphere() -# sys = Scanner() - -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) - -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - - -# @testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_sphere_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# obj = phantom_sphere() -# sys = Scanner() - -# sim_params = Dict{String, Any}( -# "gpu"=>true, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat", -# "precision"=>"f64" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) - -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. - -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin -# using Suppressor - -# Tadc = 1e-3 -# Trf = Tadc -# T1 = 1000e-3 -# T2 = 20e-3 -# Δw = 2π * 100 -# B1 = 2e-6 * (Tadc / Trf) -# N = 6 - -# sys = Scanner() -# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - -# rf_phase = [0, π/2] -# seq = Sequence() -# seq += ADC(N, Tadc) -# for i=1:2 -# global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) -# global seq += ADC(N, Tadc) -# end - -# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) -# raw = @suppress simulate(obj, seq, sys; sim_params) - -# #Mathematica-simulated Bloch equation result -# res1 = [0.153592+0.46505im, -# 0.208571+0.437734im, -# 0.259184+0.40408im, -# 0.304722+0.364744im, -# 0.344571+0.320455im, -# 0.378217+0.272008im] -# res2 = [-0.0153894+0.142582im, -# 0.00257641+0.14196im, -# 0.020146+0.13912im, -# 0.037051+0.134149im, -# 0.0530392+0.12717im, -# 0.0678774+0.11833im] -# norm2(x) = sqrt.(sum(abs.(x).^2)) -# error0 = norm2(raw.profiles[1].data .- 0) -# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 -# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - -# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -# end - -# @testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin -# using Suppressor - -# Tadc = 1e-3 -# Trf = Tadc -# T1 = 1000e-3 -# T2 = 20e-3 -# Δw = 2π * 100 -# B1 = 2e-6 * (Tadc / Trf) -# N = 6 - -# sys = Scanner() -# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - -# rf_phase = [0, π/2] -# seq = Sequence() -# seq += ADC(N, Tadc) -# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) -# seq += ADC(N, Tadc) -# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) -# seq += ADC(N, Tadc) - - -# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) -# raw = @suppress simulate(obj, seq, sys; sim_params) - -# #Mathematica-simulated Bloch equation result -# res1 = [0.153592+0.46505im, -# 0.208571+0.437734im, -# 0.259184+0.40408im, -# 0.304722+0.364744im, -# 0.344571+0.320455im, -# 0.378217+0.272008im] -# res2 = [-0.0153894+0.142582im, -# 0.00257641+0.14196im, -# 0.020146+0.13912im, -# 0.037051+0.134149im, -# 0.0530392+0.12717im, -# 0.0678774+0.11833im] -# norm2(x) = sqrt.(sum(abs.(x).^2)) -# error0 = norm2(raw.profiles[1].data .- 0) -# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 -# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - -# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -# end - -# @testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin -# using Suppressor - -# Tadc = 1e-3 -# Trf = Tadc -# T1 = 1000e-3 -# T2 = 20e-3 -# Δw = 2π * 100 -# B1 = 2e-6 * (Tadc / Trf) -# N = 6 - -# sys = Scanner() -# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - -# rf_phase = [0, π/2] -# seq = Sequence() -# seq += ADC(N, Tadc) -# seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) -# seq += ADC(N, Tadc) -# seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) -# seq += ADC(N, Tadc) - -# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) -# raw = @suppress simulate(obj, seq, sys; sim_params) - -# #Mathematica-simulated Bloch equation result -# res1 = [0.153592+0.46505im, -# 0.208571+0.437734im, -# 0.259184+0.40408im, -# 0.304722+0.364744im, -# 0.344571+0.320455im, -# 0.378217+0.272008im] -# res2 = [-0.0153894+0.142582im, -# 0.00257641+0.14196im, -# 0.020146+0.13912im, -# 0.037051+0.134149im, -# 0.0530392+0.12717im, -# 0.0678774+0.11833im] -# norm2(x) = sqrt.(sum(abs.(x).^2)) -# error0 = norm2(raw.profiles[1].data .- 0) -# error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 -# error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 - -# @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% -# end - -# @testitem "Bloch_phase_compensation" tags=[:important, :core] begin -# using Suppressor - -# Tadc = 1e-3 -# Trf = Tadc -# T1 = 1000e-3 -# T2 = 20e-3 -# Δw = 2π * 100 -# B1 = 2e-6 * (Tadc / Trf) -# N = 6 - -# sys = Scanner() -# obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) - -# rf_phase = 2π*rand() -# seq1 = Sequence() -# seq1 += RF(B1, Trf) -# seq1 += ADC(N, Tadc) - -# seq2 = Sequence() -# seq2 += RF(B1 .* exp(1im*rf_phase), Trf) -# seq2 += ADC(N, Tadc, 0, 0, rf_phase) - -# sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) -# raw1 = @suppress simulate(obj, seq1, sys; sim_params) -# raw2 = @suppress simulate(obj, seq2, sys; sim_params) - -# @test raw1.profiles[1].data ≈ raw2.profiles[1].data - -# end - -# @testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "Nthreads"=>1, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# Ns = length(obj) -# period_durations=[20.0] -# dx = dz = zeros(Ns, 1) -# dy = 1.0 .* ones(Ns, 1) -# obj.motion = @suppress ArbitraryMotion( -# period_durations, -# dx, -# dy, -# dz) -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "Nthreads"=>1, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - - -# @testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# Ns = length(obj) -# period_durations=[20.0] -# dx = dz = zeros(Ns, 1) -# dy = 1.0 .* ones(Ns, 1) -# obj.motion = @suppress ArbitraryMotion( -# period_durations, -# dx, -# dy, -# dz) -# sim_params = Dict{String, Any}( -# "gpu"=>false, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) -# sim_params = Dict{String, Any}( -# "gpu"=>true, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat", -# "precision"=>"f64" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - -# @testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# sig_jemris = signal_brain_motion_jemris() -# seq = seq_epi_100x100_TE100_FOV230() -# sys = Scanner() -# obj = phantom_brain() -# Ns = length(obj) -# period_durations=[20.0] -# dx = dz = zeros(Ns, 1) -# dy = 1.0 .* ones(Ns, 1) -# obj.motion = @suppress ArbitraryMotion( -# period_durations, -# dx, -# dy, -# dz) -# sim_params = Dict{String, Any}( -# "gpu"=>true, -# "sim_method"=>KomaMRICore.Bloch(), -# "return_type"=>"mat", -# "precision"=>"f64" -# ) -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. -# @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% -# end - - -# @testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin -# using Suppressor -# include(joinpath(@__DIR__, "test_files", "utils.jl")) - -# seq = seq_epi_100x100_TE100_FOV230() -# obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) -# sys = Scanner() -# sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") -# sig = @suppress simulate(obj, seq, sys; sim_params) -# sig = sig / prod(size(obj)) -# sim_params["sim_method"] = KomaMRICore.BlochDict() -# sig2 = @suppress simulate(obj, seq, sys; sim_params) -# sig2 = sig2 / prod(size(obj)) -# @test sig ≈ sig2 - -# # Just checking to ensure that show() doesn't get stuck and that it is covered -# show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) -# @test true -# end - -# @testitem "simulate_slice_profile" tags=[:core] begin -# using Suppressor - -# # This is a sequence with a sinc RF 30° excitation pulse -# sys = Scanner() -# sys.Smax = 50 -# B1 = 4.92e-6 -# Trf = 3.2e-3 -# zmax = 2e-2 -# fmax = 5e3 -# z = range(-zmax, zmax, 400) -# Gz = fmax / (γ * zmax) -# f = γ * Gz * z -# seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) - -# # Simulate the slice profile -# sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) -# M = @suppress simulate_slice_profile(seq; z, sim_params) - -# # For the time being, always pass the test -# @test true -# end +using TestItems, TestItemRunner + +@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:core in ti.tags) #verbose=true + +@testitem "Spinors×Mag" tags=[:core] begin + using KomaMRICore: Rx, Ry, Rz, Q, rotx, roty, rotz, Un, Rφ, Rg + + ## Verifying that operators perform counter-clockwise rotations + v = [1, 2, 3] + m = Mag([complex(v[1:2]...)], [v[3]]) + # Rx + @test rotx(π/2) * v ≈ [1, -3, 2] + @test (Rx(π/2) * m).xy ≈ [1.0 - 3.0im] + @test (Rx(π/2) * m).z ≈ [2.0] + # Ry + @test roty(π/2) * v ≈ [3, 2, -1] + @test (Ry(π/2) * m).xy ≈ [3.0 + 2.0im] + @test (Ry(π/2) * m).z ≈ [-1.0] + # Rz + @test rotz(π/2) * v ≈ [-2, 1, 3] + @test (Rz(π/2) * m).xy ≈ [-2.0 + 1.0im] + @test (Rz(π/2) * m).z ≈ [3.0] + # Rn + @test Un(π/2, [1,0,0]) * v ≈ rotx(π/2) * v + @test Un(π/2, [0,1,0]) * v ≈ roty(π/2) * v + @test Un(π/2, [0,0,1]) * v ≈ rotz(π/2) * v + @test (Q(π/2, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(π/2) * m).xy + @test (Q(π/2, 1.0+0.0im, 0.0) * m).z ≈ (Rx(π/2) * m).z + @test (Q(π/2, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(π/2) * m).xy + @test (Q(π/2, 0.0+1.0im, 0.0) * m).z ≈ (Ry(π/2) * m).z + @test (Q(π/2, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(π/2) * m).xy + @test (Q(π/2, 0.0+0.0im, 1.0) * m).z ≈ (Rz(π/2) * m).z + + ## Verify that Spinor rotation = matrix rotation + v = rand(3) + n = rand(3); n = n ./ sqrt(sum(n.^2)) + m = Mag([complex(v[1:2]...)], [v[3]]) + φ, θ, φ1, φ2 = rand(4) * 2π + # Rx + vx = rotx(θ) * v + mx = Rx(θ) * m + @test [real(mx.xy); imag(mx.xy); mx.z] ≈ vx + # Ry + vy = roty(θ) * v + my = Ry(θ) * m + @test [real(my.xy); imag(my.xy); my.z] ≈ vy + # Rz + vz = rotz(θ) * v + mz = Rz(θ) * m + @test [real(mz.xy); imag(mz.xy); mz.z] ≈ vz + # Rφ + vφ = Un(θ, [sin(φ); cos(φ); 0.0]) * v + mφ = Rφ(φ,θ) * m + @test [real(mφ.xy); imag(mφ.xy); mφ.z] ≈ vφ + # Rg + vg = rotz(φ2) * roty(θ) * rotz(φ1) * v + mg = Rg(φ1,θ,φ2) * m + @test [real(mg.xy); imag(mg.xy); mg.z] ≈ vg + # Rn + vq = Un(θ, n) * v + mq = Q(θ, n[1]+n[2]*1im, n[3]) * m + @test [real(mq.xy); imag(mq.xy); mq.z] ≈ vq + + ## Spinors satify that |α|^2 + |β|^2 = 1 + @test abs(Rx(θ)) ≈ [1] + @test abs(Ry(θ)) ≈ [1] + @test abs(Rz(θ)) ≈ [1] + @test abs(Rφ(φ,θ)) ≈ [1] + @test abs(Q(θ, n[1]+n[2]*1im, n[3])) ≈ [1] + + ## Checking properties of Introduction to the Shinnar-Le Roux algorithm. + # Rx = Rz(-π/2) * Ry(θ) * Rz(π/2) + @test rotx(θ) * v ≈ rotz(-π/2) * roty(θ) * rotz(π/2) * v + @test (Rx(θ) * m).xy ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).xy + @test (Rx(θ) * m).z ≈ (Rz(-π/2) * Ry(θ) * Rz(π/2) * m).z + # Rφ(φ,θ) = Rz(-φ) Ry(θ) Rz(φ) + @test (Rφ(φ,θ) * m).xy ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).xy + @test (Rφ(φ,θ) * m).z ≈ (Rz(-φ) * Ry(θ) * Rz(φ) * m).z + # Rg(φ1, θ, φ2) = Rz(φ2) Ry(θ) Rz(φ1) + @test (Rg(φ1,θ,φ2) * m).xy ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).xy + @test (Rg(φ1,θ,φ2) * m).z ≈ (Rz(φ2) * Ry(θ) * Rz(φ1) * m).z + # Rg(-φ, θ, φ) = Rz(-φ) Ry(θ) Rz(φ) = Rφ(φ,θ) + @test rotz(-φ) * roty(θ) * rotz(φ) * v ≈ Un(θ, [sin(φ); cos(φ); 0.0]) * v + @test (Rg(φ,θ,-φ) * m).xy ≈ (Rφ(φ,θ) * m).xy + @test (Rg(φ,θ,-φ) * m).z ≈ (Rφ(φ,θ) * m).z + + ## Verify trivial identities + # Rφ is an xy-plane rotation of θ around an axis making an angle of φ with respect to the y-axis + # Rφ φ=0 = Ry + @test (Rφ(0,θ) * m).xy ≈ (Ry(θ) * m).xy + @test (Rφ(0,θ) * m).z ≈ (Ry(θ) * m).z + # Rφ φ=π/2 = Rx + @test (Rφ(π/2,θ) * m).xy ≈ (Rx(θ) * m).xy + @test (Rφ(π/2,θ) * m).z ≈ (Rx(θ) * m).z + # General rotation Rn + # Rn n=[1,0,0] = Rx + @test Un(θ, [1,0,0]) * v ≈ rotx(θ) * v + @test (Q(θ, 1.0+0.0im, 0.0) * m).xy ≈ (Rx(θ) * m).xy + @test (Q(θ, 1.0+0.0im, 0.0) * m).z ≈ (Rx(θ) * m).z + # Rn n=[0,1,0] = Ry + @test Un(θ, [0,1,0]) * v ≈ roty(θ) * v + @test (Q(θ, 0.0+1.0im, 0.0) * m).xy ≈ (Ry(θ) * m).xy + @test (Q(θ, 0.0+1.0im, 0.0) * m).z ≈ (Ry(θ) * m).z + # Rn n=[0,0,1] = Rz + @test Un(θ, [0,0,1]) * v ≈ rotz(θ) * v + @test (Q(θ, 0.0+0.0im, 1.0) * m).xy ≈ (Rz(θ) * m).xy + @test (Q(θ, 0.0+0.0im, 1.0) * m).z ≈ (Rz(θ) * m).z + + # Associativity + # Rx + @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).xy ≈ (Rx(θ) * m).xy + @test (((Rz(-π/2) * Ry(θ)) * Rz(π/2)) * m).z ≈ (Rx(θ) * m).z + @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).xy ≈ (Rx(θ) * m).xy + @test (Rz(-π/2) * (Ry(θ) * (Rz(π/2) * m))).z ≈ (Rx(θ) * m).z + # Rφ + @test (Rφ(φ,θ) * m).xy ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).xy + @test (Rφ(φ,θ) * m).z ≈ (((Rz(-φ) * Ry(θ)) * Rz(φ)) * m).z + @test (Rφ(φ,θ) * m).xy ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).xy + @test (Rφ(φ,θ) * m).z ≈ ((Rz(-φ) * (Ry(θ) * Rz(φ))) * m).z + # Rg + @test (Rg(φ1,θ,φ2) * m).xy ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).xy + @test (Rg(φ1,θ,φ2) * m).z ≈ (((Rz(φ2) * Ry(θ)) * Rz(φ1)) * m).z + @test (Rg(φ1,θ,φ2) * m).xy ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).xy + @test (Rg(φ1,θ,φ2) * m).z ≈ ((Rz(φ2) * (Ry(θ) * Rz(φ1))) * m).z + + ## Other tests + # Test Spinor struct + α, β = rand(2) + s = Spinor(α, β) + @test s[1].α ≈ [Complex(α)] && s[1].β ≈ [Complex(β)] + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", s) + @test true +end + +# Test ISMRMRD +@testitem "signal_to_raw_data" tags=[:core] begin + using Suppressor + + seq = PulseDesigner.EPI_example() + sys = Scanner() + obj = brain_phantom2D() + + sim_params = KomaMRICore.default_sim_params() + sim_params["return_type"] = "mat" + sig = @suppress simulate(obj, seq, sys; sim_params) + + # Test signal_to_raw_data + raw = signal_to_raw_data(sig, seq) + sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) + sig_raw = reshape(sig_aux, length(sig_aux), 1) + @test all(sig .== sig_raw) + + seq.DEF["FOV"] = [23e-2, 23e-2, 0] + raw = signal_to_raw_data(sig, seq) + sig_aux = vcat([vec(profile.data) for profile in raw.profiles]...) + sig_raw = reshape(sig_aux, length(sig_aux), 1) + @test all(sig .== sig_raw) + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", raw) + @test true +end + +@testitem "Bloch_CPU_single_thread" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_sphere_jemris() + seq = seq_epi_100x100_TE100_FOV230() + obj = phantom_sphere() + sys = Scanner() + + sim_params = Dict{String, Any}( + "gpu"=>false, + "Nthreads"=>1, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch_CPU_multi_thread" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_sphere_jemris() + seq = seq_epi_100x100_TE100_FOV230() + obj = phantom_sphere() + sys = Scanner() + + sim_params = Dict{String, Any}( + "gpu"=>false, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + + +@testitem "Bloch_GPU" tags=[:important, :skipci, :core, :gpu] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_sphere_jemris() + seq = seq_epi_100x100_TE100_FOV230() + obj = phantom_sphere() + sys = Scanner() + + sim_params = Dict{String, Any}( + "gpu"=>true, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat", + "precision"=>"f64" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch_CPU_RF_accuracy_single_thread" tags=[:important, :core] begin + using Suppressor + + Tadc = 1e-3 + Trf = Tadc + T1 = 1000e-3 + T2 = 20e-3 + Δw = 2π * 100 + B1 = 2e-6 * (Tadc / Trf) + N = 6 + + sys = Scanner() + obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + + rf_phase = [0, π/2] + seq = Sequence() + seq += ADC(N, Tadc) + for i=1:2 + global seq += RF(B1 .* exp(1im*rf_phase[i]), Trf) + global seq += ADC(N, Tadc) + end + + sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) + raw = @suppress simulate(obj, seq, sys; sim_params) + + #Mathematica-simulated Bloch equation result + res1 = [0.153592+0.46505im, + 0.208571+0.437734im, + 0.259184+0.40408im, + 0.304722+0.364744im, + 0.344571+0.320455im, + 0.378217+0.272008im] + res2 = [-0.0153894+0.142582im, + 0.00257641+0.14196im, + 0.020146+0.13912im, + 0.037051+0.134149im, + 0.0530392+0.12717im, + 0.0678774+0.11833im] + norm2(x) = sqrt.(sum(abs.(x).^2)) + error0 = norm2(raw.profiles[1].data .- 0) + error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 + error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + + @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +end + +@testitem "Bloch_CPU_RF_accuracy_multi_thread" tags=[:important, :core] begin + using Suppressor + + Tadc = 1e-3 + Trf = Tadc + T1 = 1000e-3 + T2 = 20e-3 + Δw = 2π * 100 + B1 = 2e-6 * (Tadc / Trf) + N = 6 + + sys = Scanner() + obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + + rf_phase = [0, π/2] + seq = Sequence() + seq += ADC(N, Tadc) + seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) + seq += ADC(N, Tadc) + seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) + seq += ADC(N, Tadc) + + + sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false) + raw = @suppress simulate(obj, seq, sys; sim_params) + + #Mathematica-simulated Bloch equation result + res1 = [0.153592+0.46505im, + 0.208571+0.437734im, + 0.259184+0.40408im, + 0.304722+0.364744im, + 0.344571+0.320455im, + 0.378217+0.272008im] + res2 = [-0.0153894+0.142582im, + 0.00257641+0.14196im, + 0.020146+0.13912im, + 0.037051+0.134149im, + 0.0530392+0.12717im, + 0.0678774+0.11833im] + norm2(x) = sqrt.(sum(abs.(x).^2)) + error0 = norm2(raw.profiles[1].data .- 0) + error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 + error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + + @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +end + +@testitem "Bloch_GPU_RF_accuracy" tags=[:important, :core, :skipci, :gpu] begin + using Suppressor + + Tadc = 1e-3 + Trf = Tadc + T1 = 1000e-3 + T2 = 20e-3 + Δw = 2π * 100 + B1 = 2e-6 * (Tadc / Trf) + N = 6 + + sys = Scanner() + obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + + rf_phase = [0, π/2] + seq = Sequence() + seq += ADC(N, Tadc) + seq += RF(B1 .* exp(1im*rf_phase[1]), Trf) + seq += ADC(N, Tadc) + seq += RF(B1 .* exp(1im*rf_phase[2]), Trf) + seq += ADC(N, Tadc) + + sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>true) + raw = @suppress simulate(obj, seq, sys; sim_params) + + #Mathematica-simulated Bloch equation result + res1 = [0.153592+0.46505im, + 0.208571+0.437734im, + 0.259184+0.40408im, + 0.304722+0.364744im, + 0.344571+0.320455im, + 0.378217+0.272008im] + res2 = [-0.0153894+0.142582im, + 0.00257641+0.14196im, + 0.020146+0.13912im, + 0.037051+0.134149im, + 0.0530392+0.12717im, + 0.0678774+0.11833im] + norm2(x) = sqrt.(sum(abs.(x).^2)) + error0 = norm2(raw.profiles[1].data .- 0) + error1 = norm2(raw.profiles[2].data .- res1) ./ norm2(res1) * 100 + error2 = norm2(raw.profiles[3].data .- res2) ./ norm2(res2) * 100 + + @test error0 + error1 + error2 < 0.1 #NMRSE < 0.1% +end + +@testitem "Bloch_phase_compensation" tags=[:important, :core] begin + using Suppressor + + Tadc = 1e-3 + Trf = Tadc + T1 = 1000e-3 + T2 = 20e-3 + Δw = 2π * 100 + B1 = 2e-6 * (Tadc / Trf) + N = 6 + + sys = Scanner() + obj = Phantom{Float64}(x=[0.],T1=[T1],T2=[T2],Δw=[Δw]) + + rf_phase = 2π*rand() + seq1 = Sequence() + seq1 += RF(B1, Trf) + seq1 += ADC(N, Tadc) + + seq2 = Sequence() + seq2 += RF(B1 .* exp(1im*rf_phase), Trf) + seq2 += ADC(N, Tadc, 0, 0, rf_phase) + + sim_params = Dict{String, Any}("Δt_rf"=>1e-5, "gpu"=>false, "Nthreads"=>1) + raw1 = @suppress simulate(obj, seq1, sys; sim_params) + raw2 = @suppress simulate(obj, seq2, sys; sim_params) + + @test raw1.profiles[1].data ≈ raw2.profiles[1].data + +end + +@testitem "Bloch CPU_single_thread SimpleMotion" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) + sim_params = Dict{String, Any}( + "gpu"=>false, + "Nthreads"=>1, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch CPU_single_thread ArbitraryMotion" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + Ns = length(obj) + period_durations=[20.0] + dx = dz = zeros(Ns, 1) + dy = 1.0 .* ones(Ns, 1) + obj.motion = @suppress ArbitraryMotion( + period_durations, + dx, + dy, + dz) + sim_params = Dict{String, Any}( + "gpu"=>false, + "Nthreads"=>1, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + + +@testitem "Bloch CPU_multi_thread SimpleMotion" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) + sim_params = Dict{String, Any}( + "gpu"=>false, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch CPU_multi_thread ArbitraryMotion" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + Ns = length(obj) + period_durations=[20.0] + dx = dz = zeros(Ns, 1) + dy = 1.0 .* ones(Ns, 1) + obj.motion = @suppress ArbitraryMotion( + period_durations, + dx, + dy, + dz) + sim_params = Dict{String, Any}( + "gpu"=>false, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch GPU SimpleMotion" tags=[:important, :core, :skipci, :gpu] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + obj.motion = SimpleMotion([Translation(t_end=10.0, dx=0.0, dy=1.0, dz=0.0)]) + sim_params = Dict{String, Any}( + "gpu"=>true, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat", + "precision"=>"f64" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + +@testitem "Bloch GPU ArbitraryMotion" tags=[:important, :core, :skipci, :gpu] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + sig_jemris = signal_brain_motion_jemris() + seq = seq_epi_100x100_TE100_FOV230() + sys = Scanner() + obj = phantom_brain() + Ns = length(obj) + period_durations=[20.0] + dx = dz = zeros(Ns, 1) + dy = 1.0 .* ones(Ns, 1) + obj.motion = @suppress ArbitraryMotion( + period_durations, + dx, + dy, + dz) + sim_params = Dict{String, Any}( + "gpu"=>true, + "sim_method"=>KomaMRICore.Bloch(), + "return_type"=>"mat", + "precision"=>"f64" + ) + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + NMRSE(x, x_true) = sqrt.( sum(abs.(x .- x_true).^2) ./ sum(abs.(x_true).^2) ) * 100. + @test NMRSE(sig, sig_jemris) < 1 #NMRSE < 1% +end + + +@testitem "BlochDict_CPU_single_thread" tags=[:important, :core] begin + using Suppressor + include(joinpath(@__DIR__, "test_files", "utils.jl")) + + seq = seq_epi_100x100_TE100_FOV230() + obj = Phantom{Float64}(x=[0.], T1=[1000e-3], T2=[100e-3]) + sys = Scanner() + sim_params = Dict("gpu"=>false, "Nthreads"=>1, "sim_method"=>KomaMRICore.Bloch(), "return_type"=>"mat") + sig = @suppress simulate(obj, seq, sys; sim_params) + sig = sig / prod(size(obj)) + sim_params["sim_method"] = KomaMRICore.BlochDict() + sig2 = @suppress simulate(obj, seq, sys; sim_params) + sig2 = sig2 / prod(size(obj)) + @test sig ≈ sig2 + + # Just checking to ensure that show() doesn't get stuck and that it is covered + show(IOBuffer(), "text/plain", KomaMRICore.BlochDict()) + @test true +end + +@testitem "simulate_slice_profile" tags=[:core] begin + using Suppressor + + # This is a sequence with a sinc RF 30° excitation pulse + sys = Scanner() + sys.Smax = 50 + B1 = 4.92e-6 + Trf = 3.2e-3 + zmax = 2e-2 + fmax = 5e3 + z = range(-zmax, zmax, 400) + Gz = fmax / (γ * zmax) + f = γ * Gz * z + seq = PulseDesigner.RF_sinc(B1, Trf, sys; G=[0; 0; Gz], TBP=8) + + # Simulate the slice profile + sim_params = Dict{String, Any}("Δt_rf" => Trf / length(seq.RF.A[1])) + M = @suppress simulate_slice_profile(seq; z, sim_params) + + # For the time being, always pass the test + @test true +end diff --git a/KomaMRIFiles/test/runtests.jl b/KomaMRIFiles/test/runtests.jl index 812914bd0..deccbf82d 100644 --- a/KomaMRIFiles/test/runtests.jl +++ b/KomaMRIFiles/test/runtests.jl @@ -1,125 +1,125 @@ -# using TestItems, TestItemRunner +using TestItems, TestItemRunner -# @run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true +@run_package_tests filter=t_start->!(:skipci in t_start.tags)&&(:files in t_start.tags) #verbose=true -# @testitem "Files" tags=[:files] begin -# using Suppressor +@testitem "Files" tags=[:files] begin + using Suppressor -# # Test Pulseq -# @testset "Pulseq" begin -# path = @__DIR__ -# seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary -# @test seq.DEF["FileName"] == "epi.seq" -# @test seq.DEF["PulseqVersion"] ≈ 1004000 -# @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" + # Test Pulseq + @testset "Pulseq" begin + path = @__DIR__ + seq = @suppress read_seq(path*"/test_files/epi.seq") #Pulseq v1.4.0, RF arbitrary + @test seq.DEF["FileName"] == "epi.seq" + @test seq.DEF["PulseqVersion"] ≈ 1004000 + @test seq.DEF["signature"] == "67ebeffe6afdf0c393834101c14f3990" -# seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary -# @test seq.DEF["FileName"] == "spiral.seq" -# @test seq.DEF["PulseqVersion"] ≈ 1004000 -# @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" + seq = @suppress read_seq(path*"/test_files/spiral.seq") #Pulseq v1.4.0, RF arbitrary + @test seq.DEF["FileName"] == "spiral.seq" + @test seq.DEF["PulseqVersion"] ≈ 1004000 + @test seq.DEF["signature"] == "efc5eb7dbaa82aba627a31ff689c8649" -# seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 -# @test seq.DEF["FileName"] == "epi_JEMRIS.seq" -# @test seq.DEF["PulseqVersion"] ≈ 1002001 -# @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" + seq = @suppress read_seq(path*"/test_files/epi_JEMRIS.seq") #Pulseq v1.2.1 + @test seq.DEF["FileName"] == "epi_JEMRIS.seq" + @test seq.DEF["PulseqVersion"] ≈ 1002001 + @test seq.DEF["signature"] == "f291a24409c3e8de01ddb93e124d9ff2" -# seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 -# @test seq.DEF["FileName"] == "radial_JEMRIS.seq" -# @test seq.DEF["PulseqVersion"] ≈ 1002001 -# @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" + seq = @suppress read_seq(path*"/test_files/radial_JEMRIS.seq") #Pulseq v1.2.1 + @test seq.DEF["FileName"] == "radial_JEMRIS.seq" + @test seq.DEF["PulseqVersion"] ≈ 1002001 + @test seq.DEF["signature"] == "e827cfff4436b65a6341a4fa0f6deb07" -# # Test Pulseq compression-decompression -# shape = ones(100) -# num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) -# shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) -# @test shape == shape2 -# end -# # Test JEMRIS -# @testset "JEMRIS" begin -# path = @__DIR__ -# obj = read_phantom_jemris(path*"/test_files/column1d.h5") -# @test obj.name == "column1d.h5" -# end -# # Test MRiLab -# @testset "MRiLab" begin -# path = @__DIR__ -# filename = path * "/test_files/brain_mrilab.mat" -# FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness -# obj = read_phantom_MRiLab(filename; FRange_filename) -# @test obj.name == "brain_mrilab.mat" -# end -# # Test Phantom (.phantom) -# @testset "Phantom" begin -# using KomaMRIBase -# path = @__DIR__ -# # NoMotion -# filename = path * "/test_files/brain_nomotion.phantom" -# obj1 = brain_phantom2D() -# write_phantom(obj1, filename) -# obj2 = read_phantom(filename) -# @test obj1 == obj2 -# # SimpleMotion -# filename = path * "/test_files/brain_simplemotion.phantom" -# obj1 = brain_phantom2D() -# obj1.motion = SimpleMotion([ -# PeriodicRotation( -# period=1.0, -# yaw=45.0, -# pitch=0.0, -# roll=0.0), -# Translation( -# t_start=0.0, -# t_end=0.5, -# dx=0.0, -# dy=0.02, -# dz=0.0 -# )]) -# write_phantom(obj1, filename) -# obj2 = read_phantom(filename) -# @test obj1 == obj2 -# # ArbitraryMotion -# filename = path * "/test_files/brain_arbitrarymotion.phantom" -# obj1 = brain_phantom2D() -# Ns = length(obj1) -# K = 10 -# obj1.motion = ArbitraryMotion( -# [1.0], -# 0.01.*rand(Ns, K-1), -# 0.01.*rand(Ns, K-1), -# 0.01.*rand(Ns, K-1)) -# write_phantom(obj1, filename) -# obj2 = read_phantom(filename) -# @test obj1 == obj2 -# end -# end + # Test Pulseq compression-decompression + shape = ones(100) + num_samples, compressed_data = KomaMRIFiles.compress_shape(shape) + shape2 = KomaMRIFiles.decompress_shape(num_samples, compressed_data) + @test shape == shape2 + end + # Test JEMRIS + @testset "JEMRIS" begin + path = @__DIR__ + obj = read_phantom_jemris(path*"/test_files/column1d.h5") + @test obj.name == "column1d.h5" + end + # Test MRiLab + @testset "MRiLab" begin + path = @__DIR__ + filename = path * "/test_files/brain_mrilab.mat" + FRange_filename = path * "/test_files/FRange.mat" #Slab within slice thickness + obj = read_phantom_MRiLab(filename; FRange_filename) + @test obj.name == "brain_mrilab.mat" + end + # Test Phantom (.phantom) + @testset "Phantom" begin + using KomaMRIBase + path = @__DIR__ + # NoMotion + filename = path * "/test_files/brain_nomotion.phantom" + obj1 = brain_phantom2D() + write_phantom(obj1, filename) + obj2 = read_phantom(filename) + @test obj1 == obj2 + # SimpleMotion + filename = path * "/test_files/brain_simplemotion.phantom" + obj1 = brain_phantom2D() + obj1.motion = SimpleMotion([ + PeriodicRotation( + period=1.0, + yaw=45.0, + pitch=0.0, + roll=0.0), + Translation( + t_start=0.0, + t_end=0.5, + dx=0.0, + dy=0.02, + dz=0.0 + )]) + write_phantom(obj1, filename) + obj2 = read_phantom(filename) + @test obj1 == obj2 + # ArbitraryMotion + filename = path * "/test_files/brain_arbitrarymotion.phantom" + obj1 = brain_phantom2D() + Ns = length(obj1) + K = 10 + obj1.motion = ArbitraryMotion( + [1.0], + 0.01.*rand(Ns, K-1), + 0.01.*rand(Ns, K-1), + 0.01.*rand(Ns, K-1)) + write_phantom(obj1, filename) + obj2 = read_phantom(filename) + @test obj1 == obj2 + end +end -# @testitem "Pulseq compat" tags=[:files, :pulseq] begin -# using MAT, KomaMRIBase, Suppressor +@testitem "Pulseq compat" tags=[:files, :pulseq] begin + using MAT, KomaMRIBase, Suppressor -# # Aux functions -# inside(x) = x[2:end-1] -# namedtuple(x) = x[:] -# namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) -# not_empty = ((ek, ep),) -> !isempty(ep.t) + # Aux functions + inside(x) = x[2:end-1] + namedtuple(x) = x[:] + namedtuple(d::Dict) = (; (Symbol(k == "df" ? "Δf" : k) => namedtuple(v) for (k,v) in d)...) + not_empty = ((ek, ep),) -> !isempty(ep.t) -# # Reading files -# path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") -# pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] -# for pulseq_file in pulseq_files -# #@show pulseq_file -# seq_koma = @suppress read_seq("$path/$pulseq_file.seq") -# seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple -# @testset "$pulseq_file" begin -# for i in 1:length(seq_koma) -# blk_koma = get_samples(seq_koma, i) -# blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys -# for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) -# @test ev_koma.t ≈ ev_pulseq.t -# @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) -# @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] -# @test last(ev_koma.A) ≈ last(ev_pulseq.A) -# end -# end -# end -# end -# end + # Reading files + path = joinpath(@__DIR__, "test_files/pulseq_read_comparison") + pulseq_files = filter(endswith(".seq"), readdir(path)) .|> x -> splitext(x)[1] + for pulseq_file in pulseq_files + #@show pulseq_file + seq_koma = @suppress read_seq("$path/$pulseq_file.seq") + seq_pulseq = matread("$path/$pulseq_file.mat")["sequence"] .|> namedtuple + @testset "$pulseq_file" begin + for i in 1:length(seq_koma) + blk_koma = get_samples(seq_koma, i) + blk_pulseq = NamedTuple{keys(blk_koma)}(seq_pulseq[i]) # Reorder keys + for (ev_koma, ev_pulseq) in Iterators.filter(not_empty, zip(blk_koma, blk_pulseq)) + @test ev_koma.t ≈ ev_pulseq.t + @test inside(ev_koma.A) ≈ inside(ev_pulseq.A) + @test first(ev_koma.A) ≈ first(ev_pulseq.A) || ev_koma.t[2] ≈ ev_koma.t[1] + @test last(ev_koma.A) ≈ last(ev_pulseq.A) + end + end + end + end +end diff --git a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl index 1ef8b4504..011ecb027 100644 --- a/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlotlyJS_backend_test.jl @@ -1,135 +1,135 @@ -# #GUI tests -# @testitem "PlotlyJS" tags=[:plots] begin -# using KomaMRIBase, MRIFiles - -# @testset "GUI_phantom" begin -# ph = brain_phantom2D() #2D phantom - -# @testset "plot_phantom_map_rho" begin -# plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_phantom_map_T1" begin -# plot_phantom_map(ph, :T1) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_phantom_map_T2" begin -# plot_phantom_map(ph, :T2) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_phantom_map_x" begin -# plot_phantom_map(ph, :x) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_phantom_map_w" begin -# plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_phantom_map_2dview" begin -# plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map -# @test true #If the previous line fails the test will fail -# end -# end - -# @testset "GUI_seq" begin -# #KomaCore definition of a sequence: -# #RF construction -# sys = Scanner() -# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse -# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) -# #ACQ construction -# N = 101 -# FOV = 23e-2 -# EPI = PulseDesigner.EPI(FOV, N, sys) -# TE = 30e-3 -# d1 = TE-dur(EPI)/2-dur(EX) -# d1 = d1 > 0 ? d1 : 0 -# if d1 > 0 DELAY = Delay(d1) end -# #Sequence construction -# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) -# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 - -# @testset "plot_seq" begin -# #Plot sequence -# plot_seq(seq) #Plotting the sequence -# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) -# @test true #If the previous lines fail the test will fail -# end - -# @testset "plot_kspace" begin -# #Plot k-space -# plot_kspace(seq; width=800, height=600) #Plotting the k-space -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_M0" begin -# #Plot M0 -# plot_M0(seq) #Plotting the M0 -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_M1" begin -# #Plot M1 -# plot_M1(seq) #Plotting the M0 -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_M2" begin -# #Plot M2 -# plot_M2(seq) #Plotting the M2 -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_eddy_currents" begin -# #Plot M2 -# plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents -# @test true #If the previous line fails the test will fail -# end - -# @testset "plot_slew_rate" begin -# plot_slew_rate(seq) -# @test true -# end - -# @testset "plot_seqd" begin -# plot_seqd(seq) -# @test true -# end -# end - -# @testset "GUI_dict_html" begin -# #Define a dictionary and Plot the dictionary table -# sys = Scanner() -# sys_dict = Dict("B0" => sys.B0, -# "B1" => sys.B1, -# "Gmax" => sys.Gmax, -# "Smax" => sys.Smax, -# "ADC_dt" => sys.ADC_Δt, -# "seq_dt" => sys.seq_Δt, -# "GR_dt" => sys.GR_Δt, -# "RF_dt" => sys.RF_Δt, -# "RF_ring_down_T" => sys.RF_ring_down_T, -# "RF_dead_time_T" => sys.RF_dead_time_T, -# "ADC_dead_time_T" => sys.ADC_dead_time_T) -# plot_dict(sys_dict) -# @test true -# end - -# @testset "GUI_signal" begin -# path = @__DIR__ -# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") -# raw = RawAcquisitionData(fraw) -# plot_signal(raw, width=800, height=600) -# @test true #If the previous line fails the test will fail -# end - -# @testset "GUI_recon" begin -# #??? -# end - -# end +#GUI tests +@testitem "PlotlyJS" tags=[:plots] begin + using KomaMRIBase, MRIFiles + + @testset "GUI_phantom" begin + ph = brain_phantom2D() #2D phantom + + @testset "plot_phantom_map_rho" begin + plot_phantom_map(ph, :ρ, width=800, height=600) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + + @testset "plot_phantom_map_T1" begin + plot_phantom_map(ph, :T1) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + + @testset "plot_phantom_map_T2" begin + plot_phantom_map(ph, :T2) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + + @testset "plot_phantom_map_x" begin + plot_phantom_map(ph, :x) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + + @testset "plot_phantom_map_w" begin + plot_phantom_map(ph, :Δw) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + + @testset "plot_phantom_map_2dview" begin + plot_phantom_map(ph, :ρ, view_2d=true) #Plotting the phantom's rho map + @test true #If the previous line fails the test will fail + end + end + + @testset "GUI_seq" begin + #KomaCore definition of a sequence: + #RF construction + sys = Scanner() + B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse + EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) + #ACQ construction + N = 101 + FOV = 23e-2 + EPI = PulseDesigner.EPI(FOV, N, sys) + TE = 30e-3 + d1 = TE-dur(EPI)/2-dur(EX) + d1 = d1 > 0 ? d1 : 0 + if d1 > 0 DELAY = Delay(d1) end + #Sequence construction + seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) + seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + + @testset "plot_seq" begin + #Plot sequence + plot_seq(seq) #Plotting the sequence + plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) + @test true #If the previous lines fail the test will fail + end + + @testset "plot_kspace" begin + #Plot k-space + plot_kspace(seq; width=800, height=600) #Plotting the k-space + @test true #If the previous line fails the test will fail + end + + @testset "plot_M0" begin + #Plot M0 + plot_M0(seq) #Plotting the M0 + @test true #If the previous line fails the test will fail + end + + @testset "plot_M1" begin + #Plot M1 + plot_M1(seq) #Plotting the M0 + @test true #If the previous line fails the test will fail + end + + @testset "plot_M2" begin + #Plot M2 + plot_M2(seq) #Plotting the M2 + @test true #If the previous line fails the test will fail + end + + @testset "plot_eddy_currents" begin + #Plot M2 + plot_eddy_currents(seq, 80e-3) #Plotting the plot_eddy_currents + @test true #If the previous line fails the test will fail + end + + @testset "plot_slew_rate" begin + plot_slew_rate(seq) + @test true + end + + @testset "plot_seqd" begin + plot_seqd(seq) + @test true + end + end + + @testset "GUI_dict_html" begin + #Define a dictionary and Plot the dictionary table + sys = Scanner() + sys_dict = Dict("B0" => sys.B0, + "B1" => sys.B1, + "Gmax" => sys.Gmax, + "Smax" => sys.Smax, + "ADC_dt" => sys.ADC_Δt, + "seq_dt" => sys.seq_Δt, + "GR_dt" => sys.GR_Δt, + "RF_dt" => sys.RF_Δt, + "RF_ring_down_T" => sys.RF_ring_down_T, + "RF_dead_time_T" => sys.RF_dead_time_T, + "ADC_dead_time_T" => sys.ADC_dead_time_T) + plot_dict(sys_dict) + @test true + end + + @testset "GUI_signal" begin + path = @__DIR__ + fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") + raw = RawAcquisitionData(fraw) + plot_signal(raw, width=800, height=600) + @test true #If the previous line fails the test will fail + end + + @testset "GUI_recon" begin + #??? + end + +end diff --git a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl index f90cc53a7..4d63016b3 100644 --- a/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl +++ b/KomaMRIPlots/test/GUI_PlutoPlotly_backend_test.jl @@ -1,29 +1,29 @@ -# @testitem "PlutoPlotly" tags=[:plots] begin -# using KomaMRIBase, PlutoPlotly #Testing package extension +@testitem "PlutoPlotly" tags=[:plots] begin + using KomaMRIBase, PlutoPlotly #Testing package extension -# @testset "GUI_seq_PlutoPlotly" begin -# #KomaCore definition of a sequence: -# #RF construction -# sys = Scanner() -# B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse -# EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) -# #ACQ construction -# N = 101 -# FOV = 23e-2 -# EPI = PulseDesigner.EPI(FOV, N, sys) -# TE = 30e-3 -# d1 = TE-dur(EPI)/2-dur(EX) -# d1 = d1 > 0 ? d1 : 0 -# if d1 > 0 DELAY = Delay(d1) end -# #Sequence construction -# seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) -# seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 + @testset "GUI_seq_PlutoPlotly" begin + #KomaCore definition of a sequence: + #RF construction + sys = Scanner() + B1 = sys.B1; durRF = π/2/(2π*γ*B1) #90-degree hard excitation pulse + EX = PulseDesigner.RF_hard(B1, durRF, sys; G=[0,0,0]) + #ACQ construction + N = 101 + FOV = 23e-2 + EPI = PulseDesigner.EPI(FOV, N, sys) + TE = 30e-3 + d1 = TE-dur(EPI)/2-dur(EX) + d1 = d1 > 0 ? d1 : 0 + if d1 > 0 DELAY = Delay(d1) end + #Sequence construction + seq = d1 > 0 ? EX + DELAY + EPI : EX + EPI #Only add delay if d1 is positive (enough space) + seq.DEF["TE"] = round(d1 > 0 ? TE : TE - d1, digits=4)*1e3 -# @testset "plot_seq_PlutoPlotly" begin -# #Plot sequence -# plot_seq(seq) #Plotting the sequence -# plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) -# @test true #If the previous lines fail the test will fail -# end -# end -# end + @testset "plot_seq_PlutoPlotly" begin + #Plot sequence + plot_seq(seq) #Plotting the sequence + plot_seq(seq; width=800, height=600, slider=true, show_seq_blocks=true) + @test true #If the previous lines fail the test will fail + end + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 47f4c8a3a..9806ff246 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,132 +1,132 @@ -# using TestItems, TestItemRunner +using TestItems, TestItemRunner -# @run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true +@run_package_tests filter=ti->!(:skipci in ti.tags)&&(:koma in ti.tags) #verbose=true -# # include("../KomaMRICore/test/runtests.jl") -# # include("../KomaMRIPlots/test/runtests.jl") +# include("../KomaMRICore/test/runtests.jl") +# include("../KomaMRIPlots/test/runtests.jl") -# @testitem "MRIReco recon" tags=[:koma] begin -# #Sanity check 1 -# A = rand(5,5,3) -# B = KomaMRI.fftc(KomaMRI.ifftc(A)) -# @test A ≈ B +@testitem "MRIReco recon" tags=[:koma] begin + #Sanity check 1 + A = rand(5,5,3) + B = KomaMRI.fftc(KomaMRI.ifftc(A)) + @test A ≈ B -# #Sanity check 2 -# B = KomaMRI.ifftc(KomaMRI.fftc(A)) -# @test A ≈ B + #Sanity check 2 + B = KomaMRI.ifftc(KomaMRI.fftc(A)) + @test A ≈ B -# #MRIReco.jl -# path = @__DIR__ -# fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") -# raw = RawAcquisitionData(fraw) -# acq = AcquisitionData(raw) + #MRIReco.jl + path = @__DIR__ + fraw = ISMRMRDFile(path*"/test_files/Koma_signal.mrd") + raw = RawAcquisitionData(fraw) + acq = AcquisitionData(raw) -# @testset "MRIReco_direct" begin -# Nx, Ny = raw.params["reconSize"][1:2] -# recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) -# img = reconstruction(acq, recParams) -# @test true #If the previous line fails the test will fail -# end + @testset "MRIReco_direct" begin + Nx, Ny = raw.params["reconSize"][1:2] + recParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx,Ny), :densityWeighting=>true) + img = reconstruction(acq, recParams) + @test true #If the previous line fails the test will fail + end -# #Test MRIReco regularized recon (with a λ) -# @testset "MRIReco_standard" begin -# #??? -# end + #Test MRIReco regularized recon (with a λ) + @testset "MRIReco_standard" begin + #??? + end -# end +end -# @testitem "KomaUI" tags=[:koma] begin +@testitem "KomaUI" tags=[:koma] begin -# using Blink + using Blink -# # Opens UI -# w = KomaUI(return_window=true) + # Opens UI + w = KomaUI(return_window=true) -# @testset "Open UI" begin -# @test "index" == @js w document.getElementById("content").dataset.content -# end + @testset "Open UI" begin + @test "index" == @js w document.getElementById("content").dataset.content + end -# @testset "PulsesGUI" begin -# @js w document.getElementById("button_pulses_seq").click() -# @test "sequence" == @js w document.getElementById("content").dataset.content + @testset "PulsesGUI" begin + @js w document.getElementById("button_pulses_seq").click() + @test "sequence" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_pulses_kspace").click() -# @test "kspace" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_pulses_kspace").click() + @test "kspace" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_pulses_M0").click() -# @test "m0" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_pulses_M0").click() + @test "m0" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_pulses_M1").click() -# @test "m1" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_pulses_M1").click() + @test "m1" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_pulses_M2").click() -# @test "m2" == @js w document.getElementById("content").dataset.content -# end + @js w document.getElementById("button_pulses_M2").click() + @test "m2" == @js w document.getElementById("content").dataset.content + end -# @testset "PhantomGUI" begin -# @js w document.getElementById("button_phantom").click() -# @test "phantom" == @js w document.getElementById("content").dataset.content -# end + @testset "PhantomGUI" begin + @js w document.getElementById("button_phantom").click() + @test "phantom" == @js w document.getElementById("content").dataset.content + end -# @testset "ParamsGUI" begin -# @js w document.getElementById("button_scanner").click() -# @test "scanneparams" == @js w document.getElementById("content").dataset.content + @testset "ParamsGUI" begin + @js w document.getElementById("button_scanner").click() + @test "scanneparams" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_sim_params").click() -# @test "simparams" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_sim_params").click() + @test "simparams" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_rec_params").click() -# @test "recparams" == @js w document.getElementById("content").dataset.content -# end + @js w document.getElementById("button_rec_params").click() + @test "recparams" == @js w document.getElementById("content").dataset.content + end -# @testset "Simulation" begin -# @js w document.getElementById("simulate!").click() -# @test "sig" == @js w document.getElementById("content").dataset.content -# end + @testset "Simulation" begin + @js w document.getElementById("simulate!").click() + @test "sig" == @js w document.getElementById("content").dataset.content + end -# @testset "SignalGUI" begin -# @js w document.getElementById("button_sig").click() -# @test "sig" == @js w document.getElementById("content").dataset.content -# end + @testset "SignalGUI" begin + @js w document.getElementById("button_sig").click() + @test "sig" == @js w document.getElementById("content").dataset.content + end -# @testset "Reconstruction" begin -# @js w document.getElementById("recon!").click() -# @test "absi" == @js w document.getElementById("content").dataset.content -# end + @testset "Reconstruction" begin + @js w document.getElementById("recon!").click() + @test "absi" == @js w document.getElementById("content").dataset.content + end -# @testset "ReconGUI" begin -# @js w document.getElementById("button_reconstruction_absI").click() -# @test "absi" == @js w document.getElementById("content").dataset.content + @testset "ReconGUI" begin + @js w document.getElementById("button_reconstruction_absI").click() + @test "absi" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_reconstruction_angI").click() -# @test "angi" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_reconstruction_angI").click() + @test "angi" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_reconstruction_absK").click() -# @test "absk" == @js w document.getElementById("content").dataset.content -# end + @js w document.getElementById("button_reconstruction_absK").click() + @test "absk" == @js w document.getElementById("content").dataset.content + end -# @testset "ExportToMAT" begin -# @js w document.getElementById("button_matfolder").click() -# @test "matfolder" == @js w document.getElementById("content").dataset.content + @testset "ExportToMAT" begin + @js w document.getElementById("button_matfolder").click() + @test "matfolder" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_matfolderseq").click() -# @test "matfolderseq" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_matfolderseq").click() + @test "matfolderseq" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_matfolderpha").click() -# @test "matfolderpha" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_matfolderpha").click() + @test "matfolderpha" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_matfoldersca").click() -# @test "matfoldersca" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_matfoldersca").click() + @test "matfoldersca" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_matfolderraw").click() -# @test "matfolderraw" == @js w document.getElementById("content").dataset.content + @js w document.getElementById("button_matfolderraw").click() + @test "matfolderraw" == @js w document.getElementById("content").dataset.content -# @js w document.getElementById("button_matfolderima").click() -# @test "matfolderima" == @js w document.getElementById("content").dataset.content -# end + @js w document.getElementById("button_matfolderima").click() + @test "matfolderima" == @js w document.getElementById("content").dataset.content + end -# if !isnothing(w) -# close(w) -# end + if !isnothing(w) + close(w) + end -# end +end From adf567864ffdaa29f1b34991b78f47b39eb5d6b9 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 14:20:30 +0200 Subject: [PATCH 10/51] Comment CI --- .github/workflows/ci.yml | 382 +++++++++++++++++++-------------------- 1 file changed, 191 insertions(+), 191 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7cb09a7c..096327834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,191 +1,191 @@ -name: CI -on: - pull_request: - branches: - - master - push: - branches: - - master - tags: '*' -jobs: - ci: - if: "!contains(github.event.head_commit.message, '[skip ci]')" - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - version: - - '1.9' # Replace this with the minimum Julia version that your package supports. E.g. if your package requires Julia 1.5 or higher, change this to '1.5'. - - '1' # Leave this line unchanged. '1' will automatically expand to the latest stable 1.x release of Julia. - os: [ubuntu-latest, windows-latest, macos-12] # macos-latest] - arch: [x64] - include: - - os: ubuntu-latest - prefix: xvfb-run # julia-actions/julia-runtest/blob/master/README.md - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/setup-julia@v1 - with: - version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - - uses: actions/cache@v4 - env: - cache-name: cache-artifacts - with: - path: ~/.julia/artifacts - key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} - restore-keys: | - ${{ runner.os }}-test-${{ env.cache-name }}- - ${{ runner.os }}-test- - ${{ runner.os }}- - - name: "KomaMRIBase: Build" - if: '!cancelled()' - uses: julia-actions/julia-buildpkg@v1 - with: - project: KomaMRIBase - - name: "KomaMRIBase: Run Tests" - if: '!cancelled()' - uses: julia-actions/julia-runtest@v1 - with: - project: KomaMRIBase - - name: "KomaMRICore: Development Setup" - if: '!cancelled()' - shell: bash - run: | - julia --color=yes --project="KomaMRICore" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" - - name: "KomaMRICore: Build" - if: '!cancelled()' - uses: julia-actions/julia-buildpkg@v1 - with: - project: KomaMRICore - - name: "KomaMRICore: Run Tests" - if: '!cancelled()' - uses: julia-actions/julia-runtest@v1 - with: - project: KomaMRICore - - name: "KomaMRIFiles: Development Setup" - if: '!cancelled()' - shell: bash - run: | - julia --color=yes --project="KomaMRIFiles" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" - - name: "KomaMRIFiles: Build" - if: '!cancelled()' - uses: julia-actions/julia-buildpkg@v1 - with: - project: KomaMRIFiles - - name: "KomaMRIFiles: Run Tests" - if: '!cancelled()' - uses: julia-actions/julia-runtest@v1 - with: - project: KomaMRIFiles - - name: "KomaMRIPlots: Development Setup" - if: '!cancelled()' - shell: bash - run: | - julia --color=yes --project="KomaMRIPlots" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" - - name: "KomaMRIPlots: Build" - if: '!cancelled()' - uses: julia-actions/julia-buildpkg@v1 - with: - project: KomaMRIPlots - - name: "KomaMRIPlots: Run Tests" - if: '!cancelled()' - uses: julia-actions/julia-runtest@v1 - with: - project: KomaMRIPlots - prefix: ${{ matrix.prefix }} # for `xvfb-run` - - name: "KomaMRI: Development Setup" - if: '!cancelled()' - shell: julia --color=yes --project {0} - run: | - using Pkg - Pkg.develop([ - PackageSpec(path=pwd(), subdir="KomaMRIBase"), - PackageSpec(path=pwd(), subdir="KomaMRICore"), - PackageSpec(path=pwd(), subdir="KomaMRIFiles"), - PackageSpec(path=pwd(), subdir="KomaMRIPlots") - ]) - - name: "KomaMRI: Build" - if: '!cancelled()' - uses: julia-actions/julia-buildpkg@v1 - - name: "KomaMRI: Run Tests" - if: '!cancelled()' - uses: julia-actions/julia-runtest@v1 - with: - prefix: ${{ matrix.prefix }} # for `xvfb-run` - - uses: julia-actions/julia-processcoverage@v1 - with: - directories: src - - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 - with: - files: lcov.info - flags: komamri - token: ${{ secrets.CODECOV_TOKEN }} # required - - uses: julia-actions/julia-processcoverage@v1 - with: - directories: KomaMRIBase/src - - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 - with: - files: lcov.info - flags: base - token: ${{ secrets.CODECOV_TOKEN }} # required - - uses: julia-actions/julia-processcoverage@v1 - with: - directories: KomaMRICore/src - - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 - with: - files: lcov.info - flags: core - token: ${{ secrets.CODECOV_TOKEN }} # required - - uses: julia-actions/julia-processcoverage@v1 - with: - directories: KomaMRIPlots/src - - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 - with: - files: lcov.info - flags: plots - token: ${{ secrets.CODECOV_TOKEN }} # required - - uses: julia-actions/julia-processcoverage@v1 - with: - directories: KomaMRIFiles/src - - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 - with: - files: lcov.info - flags: files - token: ${{ secrets.CODECOV_TOKEN }} # required - docs: - name: Documentation - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: julia-actions/setup-julia@v1 - with: - version: '1' - - run: | - julia --project=docs -e ' - using Pkg - Pkg.develop([ - PackageSpec(path=pwd(), subdir="."), - PackageSpec(path=pwd(), subdir="KomaMRIBase"), - PackageSpec(path=pwd(), subdir="KomaMRICore"), - PackageSpec(path=pwd(), subdir="KomaMRIFiles"), - PackageSpec(path=pwd(), subdir="KomaMRIPlots") - ]) - Pkg.instantiate()' - - run: | - julia --project=docs -e ' - using Documenter: doctest - using KomaMRI - doctest(KomaMRI)' # change MYPACKAGE to the name of your package - - run: julia --project=docs docs/make.jl - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} - Skip: - if: contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - steps: - - name: Skip CI 🚫 - run: echo skip ci +# name: CI +# on: +# pull_request: +# branches: +# - master +# push: +# branches: +# - master +# tags: '*' +# jobs: +# ci: +# if: "!contains(github.event.head_commit.message, '[skip ci]')" +# name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} +# runs-on: ${{ matrix.os }} +# timeout-minutes: 60 +# strategy: +# fail-fast: false +# matrix: +# version: +# - '1.9' # Replace this with the minimum Julia version that your package supports. E.g. if your package requires Julia 1.5 or higher, change this to '1.5'. +# - '1' # Leave this line unchanged. '1' will automatically expand to the latest stable 1.x release of Julia. +# os: [ubuntu-latest, windows-latest, macos-12] # macos-latest] +# arch: [x64] +# include: +# - os: ubuntu-latest +# prefix: xvfb-run # julia-actions/julia-runtest/blob/master/README.md +# steps: +# - uses: actions/checkout@v4 +# - uses: julia-actions/setup-julia@v1 +# with: +# version: ${{ matrix.version }} +# arch: ${{ matrix.arch }} +# - uses: actions/cache@v4 +# env: +# cache-name: cache-artifacts +# with: +# path: ~/.julia/artifacts +# key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} +# restore-keys: | +# ${{ runner.os }}-test-${{ env.cache-name }}- +# ${{ runner.os }}-test- +# ${{ runner.os }}- +# - name: "KomaMRIBase: Build" +# if: '!cancelled()' +# uses: julia-actions/julia-buildpkg@v1 +# with: +# project: KomaMRIBase +# - name: "KomaMRIBase: Run Tests" +# if: '!cancelled()' +# uses: julia-actions/julia-runtest@v1 +# with: +# project: KomaMRIBase +# - name: "KomaMRICore: Development Setup" +# if: '!cancelled()' +# shell: bash +# run: | +# julia --color=yes --project="KomaMRICore" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" +# - name: "KomaMRICore: Build" +# if: '!cancelled()' +# uses: julia-actions/julia-buildpkg@v1 +# with: +# project: KomaMRICore +# - name: "KomaMRICore: Run Tests" +# if: '!cancelled()' +# uses: julia-actions/julia-runtest@v1 +# with: +# project: KomaMRICore +# - name: "KomaMRIFiles: Development Setup" +# if: '!cancelled()' +# shell: bash +# run: | +# julia --color=yes --project="KomaMRIFiles" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" +# - name: "KomaMRIFiles: Build" +# if: '!cancelled()' +# uses: julia-actions/julia-buildpkg@v1 +# with: +# project: KomaMRIFiles +# - name: "KomaMRIFiles: Run Tests" +# if: '!cancelled()' +# uses: julia-actions/julia-runtest@v1 +# with: +# project: KomaMRIFiles +# - name: "KomaMRIPlots: Development Setup" +# if: '!cancelled()' +# shell: bash +# run: | +# julia --color=yes --project="KomaMRIPlots" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" +# - name: "KomaMRIPlots: Build" +# if: '!cancelled()' +# uses: julia-actions/julia-buildpkg@v1 +# with: +# project: KomaMRIPlots +# - name: "KomaMRIPlots: Run Tests" +# if: '!cancelled()' +# uses: julia-actions/julia-runtest@v1 +# with: +# project: KomaMRIPlots +# prefix: ${{ matrix.prefix }} # for `xvfb-run` +# - name: "KomaMRI: Development Setup" +# if: '!cancelled()' +# shell: julia --color=yes --project {0} +# run: | +# using Pkg +# Pkg.develop([ +# PackageSpec(path=pwd(), subdir="KomaMRIBase"), +# PackageSpec(path=pwd(), subdir="KomaMRICore"), +# PackageSpec(path=pwd(), subdir="KomaMRIFiles"), +# PackageSpec(path=pwd(), subdir="KomaMRIPlots") +# ]) +# - name: "KomaMRI: Build" +# if: '!cancelled()' +# uses: julia-actions/julia-buildpkg@v1 +# - name: "KomaMRI: Run Tests" +# if: '!cancelled()' +# uses: julia-actions/julia-runtest@v1 +# with: +# prefix: ${{ matrix.prefix }} # for `xvfb-run` +# - uses: julia-actions/julia-processcoverage@v1 +# with: +# directories: src +# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 +# with: +# files: lcov.info +# flags: komamri +# token: ${{ secrets.CODECOV_TOKEN }} # required +# - uses: julia-actions/julia-processcoverage@v1 +# with: +# directories: KomaMRIBase/src +# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 +# with: +# files: lcov.info +# flags: base +# token: ${{ secrets.CODECOV_TOKEN }} # required +# - uses: julia-actions/julia-processcoverage@v1 +# with: +# directories: KomaMRICore/src +# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 +# with: +# files: lcov.info +# flags: core +# token: ${{ secrets.CODECOV_TOKEN }} # required +# - uses: julia-actions/julia-processcoverage@v1 +# with: +# directories: KomaMRIPlots/src +# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 +# with: +# files: lcov.info +# flags: plots +# token: ${{ secrets.CODECOV_TOKEN }} # required +# - uses: julia-actions/julia-processcoverage@v1 +# with: +# directories: KomaMRIFiles/src +# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 +# with: +# files: lcov.info +# flags: files +# token: ${{ secrets.CODECOV_TOKEN }} # required +# docs: +# name: Documentation +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@v4 +# - uses: julia-actions/setup-julia@v1 +# with: +# version: '1' +# - run: | +# julia --project=docs -e ' +# using Pkg +# Pkg.develop([ +# PackageSpec(path=pwd(), subdir="."), +# PackageSpec(path=pwd(), subdir="KomaMRIBase"), +# PackageSpec(path=pwd(), subdir="KomaMRICore"), +# PackageSpec(path=pwd(), subdir="KomaMRIFiles"), +# PackageSpec(path=pwd(), subdir="KomaMRIPlots") +# ]) +# Pkg.instantiate()' +# - run: | +# julia --project=docs -e ' +# using Documenter: doctest +# using KomaMRI +# doctest(KomaMRI)' # change MYPACKAGE to the name of your package +# - run: julia --project=docs docs/make.jl +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} +# Skip: +# if: contains(github.event.head_commit.message, '[skip ci]') +# runs-on: ubuntu-latest +# steps: +# - name: Skip CI 🚫 +# run: echo skip ci From 4e5a42e1bf927ecab8f49d761e380b0e7f3a7bbd Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 14:23:15 +0200 Subject: [PATCH 11/51] Revert comment --- .github/workflows/ci.yml | 382 +++++++++++++++++++-------------------- 1 file changed, 191 insertions(+), 191 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 096327834..c7cb09a7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,191 +1,191 @@ -# name: CI -# on: -# pull_request: -# branches: -# - master -# push: -# branches: -# - master -# tags: '*' -# jobs: -# ci: -# if: "!contains(github.event.head_commit.message, '[skip ci]')" -# name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} -# runs-on: ${{ matrix.os }} -# timeout-minutes: 60 -# strategy: -# fail-fast: false -# matrix: -# version: -# - '1.9' # Replace this with the minimum Julia version that your package supports. E.g. if your package requires Julia 1.5 or higher, change this to '1.5'. -# - '1' # Leave this line unchanged. '1' will automatically expand to the latest stable 1.x release of Julia. -# os: [ubuntu-latest, windows-latest, macos-12] # macos-latest] -# arch: [x64] -# include: -# - os: ubuntu-latest -# prefix: xvfb-run # julia-actions/julia-runtest/blob/master/README.md -# steps: -# - uses: actions/checkout@v4 -# - uses: julia-actions/setup-julia@v1 -# with: -# version: ${{ matrix.version }} -# arch: ${{ matrix.arch }} -# - uses: actions/cache@v4 -# env: -# cache-name: cache-artifacts -# with: -# path: ~/.julia/artifacts -# key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} -# restore-keys: | -# ${{ runner.os }}-test-${{ env.cache-name }}- -# ${{ runner.os }}-test- -# ${{ runner.os }}- -# - name: "KomaMRIBase: Build" -# if: '!cancelled()' -# uses: julia-actions/julia-buildpkg@v1 -# with: -# project: KomaMRIBase -# - name: "KomaMRIBase: Run Tests" -# if: '!cancelled()' -# uses: julia-actions/julia-runtest@v1 -# with: -# project: KomaMRIBase -# - name: "KomaMRICore: Development Setup" -# if: '!cancelled()' -# shell: bash -# run: | -# julia --color=yes --project="KomaMRICore" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" -# - name: "KomaMRICore: Build" -# if: '!cancelled()' -# uses: julia-actions/julia-buildpkg@v1 -# with: -# project: KomaMRICore -# - name: "KomaMRICore: Run Tests" -# if: '!cancelled()' -# uses: julia-actions/julia-runtest@v1 -# with: -# project: KomaMRICore -# - name: "KomaMRIFiles: Development Setup" -# if: '!cancelled()' -# shell: bash -# run: | -# julia --color=yes --project="KomaMRIFiles" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" -# - name: "KomaMRIFiles: Build" -# if: '!cancelled()' -# uses: julia-actions/julia-buildpkg@v1 -# with: -# project: KomaMRIFiles -# - name: "KomaMRIFiles: Run Tests" -# if: '!cancelled()' -# uses: julia-actions/julia-runtest@v1 -# with: -# project: KomaMRIFiles -# - name: "KomaMRIPlots: Development Setup" -# if: '!cancelled()' -# shell: bash -# run: | -# julia --color=yes --project="KomaMRIPlots" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" -# - name: "KomaMRIPlots: Build" -# if: '!cancelled()' -# uses: julia-actions/julia-buildpkg@v1 -# with: -# project: KomaMRIPlots -# - name: "KomaMRIPlots: Run Tests" -# if: '!cancelled()' -# uses: julia-actions/julia-runtest@v1 -# with: -# project: KomaMRIPlots -# prefix: ${{ matrix.prefix }} # for `xvfb-run` -# - name: "KomaMRI: Development Setup" -# if: '!cancelled()' -# shell: julia --color=yes --project {0} -# run: | -# using Pkg -# Pkg.develop([ -# PackageSpec(path=pwd(), subdir="KomaMRIBase"), -# PackageSpec(path=pwd(), subdir="KomaMRICore"), -# PackageSpec(path=pwd(), subdir="KomaMRIFiles"), -# PackageSpec(path=pwd(), subdir="KomaMRIPlots") -# ]) -# - name: "KomaMRI: Build" -# if: '!cancelled()' -# uses: julia-actions/julia-buildpkg@v1 -# - name: "KomaMRI: Run Tests" -# if: '!cancelled()' -# uses: julia-actions/julia-runtest@v1 -# with: -# prefix: ${{ matrix.prefix }} # for `xvfb-run` -# - uses: julia-actions/julia-processcoverage@v1 -# with: -# directories: src -# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 -# with: -# files: lcov.info -# flags: komamri -# token: ${{ secrets.CODECOV_TOKEN }} # required -# - uses: julia-actions/julia-processcoverage@v1 -# with: -# directories: KomaMRIBase/src -# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 -# with: -# files: lcov.info -# flags: base -# token: ${{ secrets.CODECOV_TOKEN }} # required -# - uses: julia-actions/julia-processcoverage@v1 -# with: -# directories: KomaMRICore/src -# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 -# with: -# files: lcov.info -# flags: core -# token: ${{ secrets.CODECOV_TOKEN }} # required -# - uses: julia-actions/julia-processcoverage@v1 -# with: -# directories: KomaMRIPlots/src -# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 -# with: -# files: lcov.info -# flags: plots -# token: ${{ secrets.CODECOV_TOKEN }} # required -# - uses: julia-actions/julia-processcoverage@v1 -# with: -# directories: KomaMRIFiles/src -# - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 -# with: -# files: lcov.info -# flags: files -# token: ${{ secrets.CODECOV_TOKEN }} # required -# docs: -# name: Documentation -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# - uses: julia-actions/setup-julia@v1 -# with: -# version: '1' -# - run: | -# julia --project=docs -e ' -# using Pkg -# Pkg.develop([ -# PackageSpec(path=pwd(), subdir="."), -# PackageSpec(path=pwd(), subdir="KomaMRIBase"), -# PackageSpec(path=pwd(), subdir="KomaMRICore"), -# PackageSpec(path=pwd(), subdir="KomaMRIFiles"), -# PackageSpec(path=pwd(), subdir="KomaMRIPlots") -# ]) -# Pkg.instantiate()' -# - run: | -# julia --project=docs -e ' -# using Documenter: doctest -# using KomaMRI -# doctest(KomaMRI)' # change MYPACKAGE to the name of your package -# - run: julia --project=docs docs/make.jl -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -# DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} -# Skip: -# if: contains(github.event.head_commit.message, '[skip ci]') -# runs-on: ubuntu-latest -# steps: -# - name: Skip CI 🚫 -# run: echo skip ci +name: CI +on: + pull_request: + branches: + - master + push: + branches: + - master + tags: '*' +jobs: + ci: + if: "!contains(github.event.head_commit.message, '[skip ci]')" + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + version: + - '1.9' # Replace this with the minimum Julia version that your package supports. E.g. if your package requires Julia 1.5 or higher, change this to '1.5'. + - '1' # Leave this line unchanged. '1' will automatically expand to the latest stable 1.x release of Julia. + os: [ubuntu-latest, windows-latest, macos-12] # macos-latest] + arch: [x64] + include: + - os: ubuntu-latest + prefix: xvfb-run # julia-actions/julia-runtest/blob/master/README.md + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: actions/cache@v4 + env: + cache-name: cache-artifacts + with: + path: ~/.julia/artifacts + key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} + restore-keys: | + ${{ runner.os }}-test-${{ env.cache-name }}- + ${{ runner.os }}-test- + ${{ runner.os }}- + - name: "KomaMRIBase: Build" + if: '!cancelled()' + uses: julia-actions/julia-buildpkg@v1 + with: + project: KomaMRIBase + - name: "KomaMRIBase: Run Tests" + if: '!cancelled()' + uses: julia-actions/julia-runtest@v1 + with: + project: KomaMRIBase + - name: "KomaMRICore: Development Setup" + if: '!cancelled()' + shell: bash + run: | + julia --color=yes --project="KomaMRICore" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" + - name: "KomaMRICore: Build" + if: '!cancelled()' + uses: julia-actions/julia-buildpkg@v1 + with: + project: KomaMRICore + - name: "KomaMRICore: Run Tests" + if: '!cancelled()' + uses: julia-actions/julia-runtest@v1 + with: + project: KomaMRICore + - name: "KomaMRIFiles: Development Setup" + if: '!cancelled()' + shell: bash + run: | + julia --color=yes --project="KomaMRIFiles" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" + - name: "KomaMRIFiles: Build" + if: '!cancelled()' + uses: julia-actions/julia-buildpkg@v1 + with: + project: KomaMRIFiles + - name: "KomaMRIFiles: Run Tests" + if: '!cancelled()' + uses: julia-actions/julia-runtest@v1 + with: + project: KomaMRIFiles + - name: "KomaMRIPlots: Development Setup" + if: '!cancelled()' + shell: bash + run: | + julia --color=yes --project="KomaMRIPlots" -e "using Pkg; Pkg.develop(PackageSpec(; path=\"./KomaMRIBase\"));" + - name: "KomaMRIPlots: Build" + if: '!cancelled()' + uses: julia-actions/julia-buildpkg@v1 + with: + project: KomaMRIPlots + - name: "KomaMRIPlots: Run Tests" + if: '!cancelled()' + uses: julia-actions/julia-runtest@v1 + with: + project: KomaMRIPlots + prefix: ${{ matrix.prefix }} # for `xvfb-run` + - name: "KomaMRI: Development Setup" + if: '!cancelled()' + shell: julia --color=yes --project {0} + run: | + using Pkg + Pkg.develop([ + PackageSpec(path=pwd(), subdir="KomaMRIBase"), + PackageSpec(path=pwd(), subdir="KomaMRICore"), + PackageSpec(path=pwd(), subdir="KomaMRIFiles"), + PackageSpec(path=pwd(), subdir="KomaMRIPlots") + ]) + - name: "KomaMRI: Build" + if: '!cancelled()' + uses: julia-actions/julia-buildpkg@v1 + - name: "KomaMRI: Run Tests" + if: '!cancelled()' + uses: julia-actions/julia-runtest@v1 + with: + prefix: ${{ matrix.prefix }} # for `xvfb-run` + - uses: julia-actions/julia-processcoverage@v1 + with: + directories: src + - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 + with: + files: lcov.info + flags: komamri + token: ${{ secrets.CODECOV_TOKEN }} # required + - uses: julia-actions/julia-processcoverage@v1 + with: + directories: KomaMRIBase/src + - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 + with: + files: lcov.info + flags: base + token: ${{ secrets.CODECOV_TOKEN }} # required + - uses: julia-actions/julia-processcoverage@v1 + with: + directories: KomaMRICore/src + - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 + with: + files: lcov.info + flags: core + token: ${{ secrets.CODECOV_TOKEN }} # required + - uses: julia-actions/julia-processcoverage@v1 + with: + directories: KomaMRIPlots/src + - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 + with: + files: lcov.info + flags: plots + token: ${{ secrets.CODECOV_TOKEN }} # required + - uses: julia-actions/julia-processcoverage@v1 + with: + directories: KomaMRIFiles/src + - uses: codecov/codecov-action@c16abc29c95fcf9174b58eb7e1abf4c866893bc8 # v4.1.1 + with: + files: lcov.info + flags: files + token: ${{ secrets.CODECOV_TOKEN }} # required + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: '1' + - run: | + julia --project=docs -e ' + using Pkg + Pkg.develop([ + PackageSpec(path=pwd(), subdir="."), + PackageSpec(path=pwd(), subdir="KomaMRIBase"), + PackageSpec(path=pwd(), subdir="KomaMRICore"), + PackageSpec(path=pwd(), subdir="KomaMRIFiles"), + PackageSpec(path=pwd(), subdir="KomaMRIPlots") + ]) + Pkg.instantiate()' + - run: | + julia --project=docs -e ' + using Documenter: doctest + using KomaMRI + doctest(KomaMRI)' # change MYPACKAGE to the name of your package + - run: julia --project=docs docs/make.jl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} + Skip: + if: contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-latest + steps: + - name: Skip CI 🚫 + run: echo skip ci From 435fbe45b07a16a9d2e17ce98f61b36e850047a3 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 1 May 2024 15:43:43 +0200 Subject: [PATCH 12/51] Minor change --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index c98ece4be..a3f482fe3 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -56,7 +56,7 @@ Nx, Ny = raw.params["reconSize"][1:2] reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) image1 = reconstruction(acq1, reconParams) -image2 = reconstruction(acq1, reconParams) +image2 = reconstruction(acq2, reconParams) ## Plotting the recon p3 = plot_image(abs.(image1[:, :, 1]); height=400) From 5fc9305d12ed25baae6146e4cdb42f3ec87bdd4a Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 2 May 2024 13:45:11 +0200 Subject: [PATCH 13/51] Correct bugs in lit-05-SimpleMotion.jl --- examples/3.tutorials/lit-05-SimpleMotion.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index a3f482fe3..1a877b250 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -31,8 +31,8 @@ p1 = plot_phantom_map(obj, :T2 ; height=600, intermediate_time_samples=4) seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") seq = read_seq(seq_file) p2 = plot_seq(seq; range=[0 40], slider=true, height=300) -#md savefig(p3, "../assets/5-seq.html") # hide -#jl display(p3) +#md savefig(p2, "../assets/5-seq.html") # hide +#jl display(p2) #md # ```@raw html #md # From 2a0bb1b012cfe5f1eacef838c3a6b762ef77a8e3 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 2 May 2024 15:08:07 +0200 Subject: [PATCH 14/51] Solve another bug --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 1a877b250..83ee532f3 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -52,7 +52,7 @@ acq1.traj[1].circular = false #This is to remove the circular mask acq2.traj[1].circular = false ## Setting up the reconstruction parameters -Nx, Ny = raw.params["reconSize"][1:2] +Nx, Ny = raw1.params["reconSize"][1:2] reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) image1 = reconstruction(acq1, reconParams) From 0cd06a13c3e72c1b54cf1f2373c538372f06dda8 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:03:59 +0800 Subject: [PATCH 15/51] Fix literate badges --- docs/utils.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utils.jl b/docs/utils.jl index 0952f5578..10c1dcba6 100644 --- a/docs/utils.jl +++ b/docs/utils.jl @@ -27,7 +27,7 @@ function _link_example(filename) #md # [![](https://img.shields.io/badge/jupyter-notebook-blue?logo=jupyter)](./$filename.ipynb) """ - return replace(content, line => line * badges) + return replace(content, line => badges * line) end return _link_example_for_filename end From 8062bdb1a9bceee340e14663f761acf2fbf1eee1 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:04:18 +0800 Subject: [PATCH 16/51] Add InteractiveUtils compat to KomaFiles --- KomaMRIFiles/Project.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/KomaMRIFiles/Project.toml b/KomaMRIFiles/Project.toml index 8957ee4c2..240c9da81 100644 --- a/KomaMRIFiles/Project.toml +++ b/KomaMRIFiles/Project.toml @@ -17,6 +17,7 @@ Scanf = "6ef1bc8b-493b-44e1-8d40-549aa65c4b41" [compat] FileIO = "1" HDF5 = "0.16, 0.17" +InteractiveUtils = "1" KomaMRIBase = "0.8" MAT = "0.10" MRIFiles = "0.1, 0.2, 0.3" From 592b85f345fa7e3701f436ef985b25a5d2043eaf Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:05:00 +0800 Subject: [PATCH 17/51] Small changes in plot_phantom_map --- KomaMRIPlots/src/ui/DisplayFunctions.jl | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/KomaMRIPlots/src/ui/DisplayFunctions.jl b/KomaMRIPlots/src/ui/DisplayFunctions.jl index 528a1ce07..eabdceeb9 100644 --- a/KomaMRIPlots/src/ui/DisplayFunctions.jl +++ b/KomaMRIPlots/src/ui/DisplayFunctions.jl @@ -1028,8 +1028,8 @@ function plot_phantom_map( colorbar=true, intermediate_time_samples=0, max_time_samples=100, - max_spins=100000, - dt_frame=250, + max_spins=100_000, + frame_duration_ms=250, kwargs..., ) function process_times(motion::SimpleMotion) @@ -1075,12 +1075,9 @@ function plot_phantom_map( return ph[idx] end - n_spins_before_decimate = length(ph) - ph = decimate_uniform_phantom(ph, max_spins) - n_spins_after_decimate = length(ph) - if n_spins_before_decimate > n_spins_after_decimate - @warn "Only $(n_spins_after_decimate) (approximately evenly spaced) of $(n_spins_before_decimate) spins are displayed in order to avoid CPU overload. - This affects display functions but not simulation functions." + if length(ph) > max_spins + ph = decimate_uniform_phantom(ph, max_spins) + @warn "For performance reasons, the number of displayed spins was capped to `max_spins`=$(max_spins)." maxlog=1 end path = @__DIR__ @@ -1252,8 +1249,8 @@ function plot_phantom_map( nothing, attr(; fromcurrent=true, - transition=(duration=dt_frame,), - frame=attr(; duration=dt_frame, redraw=true), + transition=(duration=frame_duration_ms,), + frame=attr(; duration=frame_duration_ms, redraw=true), ), ], ), @@ -1265,8 +1262,8 @@ function plot_phantom_map( attr(; mode="immediate", fromcurrent=true, - transition=attr(; duration=dt_frame), - frame=attr(; duration=dt_frame, redraw=true), + transition=attr(; duration=frame_duration_ms), + frame=attr(; duration=frame_duration_ms, redraw=true), ), ], ), From f8ae5df783d2635cc443776fae95ec3b518461c8 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:05:09 +0800 Subject: [PATCH 18/51] Changes in motion example --- examples/3.tutorials/lit-05-SimpleMotion.jl | 93 +++++++++++---------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 83ee532f3..c7c3302bf 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -1,71 +1,72 @@ -# # Simple Motion Definition and Simulation +# # Patient's Motion During Acquisition using KomaMRI # hide sys = Scanner() # hide -# This tutorial illustrates how we can add simple motion to phantoms. We will also see how phantoms can be stored and loaded from ``.phantom`` files. +# It can also be interesting to see the effect of the patient's motion during an MRI scan. +# For this, Koma provides the ability to add [`MotionModel`](@ref)'s to the phantom. +# In this tutorial, we will show how to add a [`SimpleMotion`](@ref) model to a 2D brain phantom. -# First, we load a static 3D brain phantom: -obj = brain_phantom3D() -obj.Δw .= 0 # Removes the off-resonance +# First, let's load the 2D brain phantom used in the previous tutorials: +obj = brain_phantom2D() +obj.Δw .= 0 # hide -# Now, we will add Rotation Motion to recreate the patient's movement inside the scanner. +# The `SimpleMotion` model includes a list of `SimpleMotionType`'s, to enabling mix-and-matching simple motions. +# In this example, we will add a [`Rotation`](@ref) of 20 degrees around the z-axis with duration of 200 ms. -#md # !!! note -#md # Note how rotations are defined with respect to the 3 axes: -#md # ```@raw html -#md #
-#md # ``` - -obj.motion = SimpleMotion([Rotation(t_start=0.0, t_end=0.5, pitch=15.0, roll=0.0, yaw=45.0)]) -p1 = plot_phantom_map(obj, :T2 ; height=600, intermediate_time_samples=4) +obj.motion = SimpleMotion([ + Rotation(t_start=0.0, t_end=200e-3, yaw=20.0, pitch=0.0, roll=0.0) + ]) +p1 = plot_phantom_map(obj, :T2 ; height=600, motion_samples=4) #md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` -# Then, we will load an EPI sequence - -seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") -seq = read_seq(seq_file) -p2 = plot_seq(seq; range=[0 40], slider=true, height=300) -#md savefig(p2, "../assets/5-seq.html") # hide -#jl display(p2) - -#md # ```@raw html -#md # -#md # ``` +## Read Sequence +seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") # hide +seq = read_seq(seq_file) # hide -# Now, we will run two simulations: the first with the sequence starting at ``t=0.0``, -# and the second adding a 0.5s initial delay to the sequence: -## Simulate +## Simulate raw1 = simulate(obj, seq, sys) -raw2 = simulate(obj, Delay(0.5) + seq, sys) -# Let's note the effect of motion in both reconstructions: -## Get the acquisition data +## Recon acq1 = AcquisitionData(raw1) -acq2 = AcquisitionData(raw2) acq1.traj[1].circular = false #This is to remove the circular mask -acq2.traj[1].circular = false - -## Setting up the reconstruction parameters -Nx, Ny = raw1.params["reconSize"][1:2] -reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) - -image1 = reconstruction(acq1, reconParams) -image2 = reconstruction(acq2, reconParams) +Nx, Ny = raw1.params["reconSize"][1:2] # hide +reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide +image1 = reconstruction(acq1, reconParams) # hide +# If we simulate an EPI sequence with acquisition duration (183.989 ms) comparable with the motion's duration (200 ms), +# we will observe motion-induced artifacts in the reconstructed image. ## Plotting the recon -p3 = plot_image(abs.(image1[:, :, 1]); height=400) -p4 = plot_image(abs.(image2[:, :, 1]); height=400) +p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md savefig(p3, "../assets/5-recon1.html") # hide -#md savefig(p4, "../assets/5-recon2.html") # hide #jl display(p3) -#jl display(p4) #md # ```@raw html #md #
-#md # ``` \ No newline at end of file +#md # ``` + +# The severity of the artifacts can vary depending on the used acquisition duration and `k`-space trajectory. +# Below, we show the effect of the same motion in an spiral acquisition (dur. 39 ms, which is 5 times faster than the motion.) + +## Read Sequence +seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/Spiral/spiral_100x100_FOV230_SPZ_INTER1.seq") # hide +seq = read_seq(seq_file) # hide + +## Simulate +raw1 = simulate(obj, seq, sys) + +## Recon +acq1 = AcquisitionData(raw1) +Nx, Ny = raw1.params["reconSize"][1:2] # hide +reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide +image1 = reconstruction(acq1, reconParams) # hide + +## Plotting the recon +p4 = plot_image(abs.(image1[:, :, 1]); height=400) # hide +#md savefig(p4, "../assets/5-recon2.html") # hide +#jl display(p4) From 6711697b312fe96cac245546e798bb9d2cf7b410 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:08:06 +0800 Subject: [PATCH 19/51] Removed some boilerplate --- docs/src/index.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index 24f7e909b..12a4437a4 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -33,7 +33,6 @@ We organized the documentation following the philosophy presented by [David Lain ## Features -Some of the features of **KomaMRI** are: * Fast simulations by using CPU and GPU parallelization 🏃💨. * Open Source, so anyone can include additional features 🆙. * Compatibility with community-standards 🤝 like Pulseq `.seq` and ISMRMRD `.mrd`. @@ -45,7 +44,6 @@ Some of the features of **KomaMRI** are: ## Potential Use Cases -We see Koma being used in: * The generation of synthetic data to train Machine Learning models. * To test novel pulse sequences before implementing them directly in a real scanner (with a Pulseq sequence). * Teaching exercises for **MRI** acquisition or reconstruction. \ No newline at end of file From 6e74e2e21e88bde99c57bc4f092d768e7911168e Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:41:43 +0800 Subject: [PATCH 20/51] Syntax highlight in docs --- docs/make.jl | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/make.jl b/docs/make.jl index aca133204..cc5d3bf15 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -63,6 +63,7 @@ makedocs(; sidebar_sitename=false, collapselevel=1, assets=["assets/extra-styles.css"], + prerender=true ), ) deploydocs(; repo="github.com/JuliaHealth/KomaMRI.jl.git", push_preview=true) From 6f409c574840afc979c59de9dfe02b87c52dc9dc Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:42:00 +0800 Subject: [PATCH 21/51] Minor modifications --- examples/3.tutorials/lit-05-SimpleMotion.jl | 36 +++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index c7c3302bf..4716bcaf7 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -22,19 +22,19 @@ p1 = plot_phantom_map(obj, :T2 ; height=600, motion_samples=4) #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` -## Read Sequence +## Read Sequence # hide seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") # hide seq = read_seq(seq_file) # hide -## Simulate -raw1 = simulate(obj, seq, sys) +## Simulate # hide +raw1 = simulate(obj, seq, sys) # hide -## Recon -acq1 = AcquisitionData(raw1) -acq1.traj[1].circular = false #This is to remove the circular mask +## Recon # hide +acq1 = AcquisitionData(raw1) # hide +acq1.traj[1].circular = false # hide Nx, Ny = raw1.params["reconSize"][1:2] # hide reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide image1 = reconstruction(acq1, reconParams) # hide @@ -47,26 +47,34 @@ p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #jl display(p3) #md # ```@raw html -#md #
+#md #
+#md # +#md #
#md # ``` # The severity of the artifacts can vary depending on the used acquisition duration and `k`-space trajectory. # Below, we show the effect of the same motion in an spiral acquisition (dur. 39 ms, which is 5 times faster than the motion.) -## Read Sequence +## Read Sequence # hide seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/Spiral/spiral_100x100_FOV230_SPZ_INTER1.seq") # hide seq = read_seq(seq_file) # hide -## Simulate -raw1 = simulate(obj, seq, sys) +## Simulate # hide +raw1 = simulate(obj, seq, sys) # hide -## Recon -acq1 = AcquisitionData(raw1) +## Recon # hide +acq1 = AcquisitionData(raw1) # hide Nx, Ny = raw1.params["reconSize"][1:2] # hide reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide image1 = reconstruction(acq1, reconParams) # hide -## Plotting the recon +## Plotting the recon # hide p4 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md savefig(p4, "../assets/5-recon2.html") # hide #jl display(p4) + +#md # ```@raw html +#md #
+#md # +#md #
+#md # ``` From 62ee46293e39eef2e0334490e9982d40ef28db63 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 14:43:45 +0800 Subject: [PATCH 22/51] Syntax hightlight docs --- docs/Project.toml | 1 + docs/make.jl | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index 01dada63c..04397dfc2 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -2,6 +2,7 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" KomaMRI = "6a340f8b-2cdf-4c04-99be-4953d9b66d0a" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" +NodeJS_16_jll = "a4b94fbf-73f5-58ff-888d-48a8396e17f6" PlotlyJS = "f0f68f2c-4968-5e81-91da-67840de0976a" PlutoSliderServer = "2fc8631c-6f24-4c5b-bca7-cbb509c42db4" diff --git a/docs/make.jl b/docs/make.jl index cc5d3bf15..3fd009d8f 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,4 +1,4 @@ -using Documenter, Literate, KomaMRI, PlutoSliderServer +using Documenter, Literate, KomaMRI, PlutoSliderServer, NodeJS_16_jll # Setup for Literate and Pluto repo_base = "JuliaHealth/KomaMRI.jl" @@ -63,7 +63,8 @@ makedocs(; sidebar_sitename=false, collapselevel=1, assets=["assets/extra-styles.css"], - prerender=true + prerender=true, + node = NodeJS_16_jll.node() ), ) deploydocs(; repo="github.com/JuliaHealth/KomaMRI.jl.git", push_preview=true) From 58d0ae080ac5c433178824175810f810dd5a1313 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 15:04:07 +0800 Subject: [PATCH 23/51] Change figure size --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 4716bcaf7..f8e028072 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -17,7 +17,7 @@ obj.Δw .= 0 # hide obj.motion = SimpleMotion([ Rotation(t_start=0.0, t_end=200e-3, yaw=20.0, pitch=0.0, roll=0.0) ]) -p1 = plot_phantom_map(obj, :T2 ; height=600, motion_samples=4) +p1 = plot_phantom_map(obj, :T2 ; height=400, intermediate_time_samples=4) #md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) From 8534e809fe666a52bf96fd978bf361cea7b664cf Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 17:08:30 +0800 Subject: [PATCH 24/51] Updated motion example --- docs/src/index.md | 2 +- examples/3.tutorials/lit-05-SimpleMotion.jl | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index 12a4437a4..fdd6b22da 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -19,7 +19,7 @@ We organized the documentation following the philosophy presented by [David Lain **BibTex:** - ```bibtex + ``` @article{https://doi.org/10.1002/mrm.29635, author = {Castillo-Passi, Carlos and Coronado, Ronal and Varela-Mattatall, Gabriel and Alberola-López, Carlos and Botnar, René and Irarrazaval, Pablo}, title = {KomaMRI.jl: An open-source framework for general MRI simulations with GPU acceleration}, diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index f8e028072..b18576c91 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -12,17 +12,17 @@ obj = brain_phantom2D() obj.Δw .= 0 # hide # The `SimpleMotion` model includes a list of `SimpleMotionType`'s, to enabling mix-and-matching simple motions. -# In this example, we will add a [`Rotation`](@ref) of 20 degrees around the z-axis with duration of 200 ms. +# In this example, we will add a [`Rotation`](@ref) of 45 degrees around the z-axis with duration of 200 ms. obj.motion = SimpleMotion([ - Rotation(t_start=0.0, t_end=200e-3, yaw=20.0, pitch=0.0, roll=0.0) + Rotation(t_start=0.0, t_end=200e-3, yaw=45.0, pitch=0.0, roll=0.0) ]) p1 = plot_phantom_map(obj, :T2 ; height=400, intermediate_time_samples=4) #md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` ## Read Sequence # hide @@ -41,18 +41,18 @@ image1 = reconstruction(acq1, reconParams) # hide # If we simulate an EPI sequence with acquisition duration (183.989 ms) comparable with the motion's duration (200 ms), # we will observe motion-induced artifacts in the reconstructed image. -## Plotting the recon +## Plotting the recon # hide p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md savefig(p3, "../assets/5-recon1.html") # hide #jl display(p3) #md # ```@raw html #md #
-#md # +#md # #md #
#md # ``` -# The severity of the artifacts can vary depending on the used acquisition duration and `k`-space trajectory. +# The severity of the artifacts can vary depending on the acquisition duration and `k`-space trajectory. # Below, we show the effect of the same motion in an spiral acquisition (dur. 39 ms, which is 5 times faster than the motion.) ## Read Sequence # hide @@ -75,6 +75,6 @@ p4 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md # ```@raw html #md #
-#md # +#md # #md #
#md # ``` From bc1b6153c8e9b67021baaf8f5f324dece13d0aea Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Sun, 5 May 2024 17:10:10 +0800 Subject: [PATCH 25/51] Plain text citation, now copy-pastable --- docs/src/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/index.md b/docs/src/index.md index fdd6b22da..c1d86ac7e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -15,7 +15,9 @@ We organized the documentation following the philosophy presented by [David Lain **Plain Text:** + ``` Castillo-Passi, C, Coronado, R, Varela-Mattatall, G, Alberola-López, C, Botnar, R, Irarrazaval, P. KomaMRI.jl: An open-source framework for general MRI simulations with GPU acceleration. Magn Reson Med. 2023; 1- 14. doi: 10.1002/mrm.29635 + ``` **BibTex:** From 1534ddcd94fb03cf9eb1aa2844560ca337a202ee Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Mon, 6 May 2024 10:46:22 +0800 Subject: [PATCH 26/51] Organize make --- docs/make.jl | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/make.jl b/docs/make.jl index 3fd009d8f..6437ff424 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,4 +1,4 @@ -using Documenter, Literate, KomaMRI, PlutoSliderServer, NodeJS_16_jll +using Documenter, Literate, KomaMRI, PlutoSliderServer # Setup for Literate and Pluto repo_base = "JuliaHealth/KomaMRI.jl" @@ -13,16 +13,17 @@ doc_tutorial_rep = joinpath(dirname(@__DIR__), "docs/src/tutorial-pluto") doc_howto = joinpath(dirname(@__DIR__), "docs/src/how-to") doc_explanation = joinpath(dirname(@__DIR__), "docs/src/explanation") doc_reference = joinpath(dirname(@__DIR__), "docs/src/reference") +# For Tutorials: Literate and Pluto +koma_assets = joinpath(dirname(@__DIR__), "assets") +doc_assets = joinpath(dirname(@__DIR__), "docs/src/assets") +koma_tutorials_lit = joinpath(dirname(@__DIR__), "examples/3.tutorials") +koma_tutorials_plu = joinpath(dirname(@__DIR__), "examples/4.reproducible_notebooks") # Copying files from KomaMRI.jl/ to the documentation folder KomaMRI.jl/docs/ # Assets -koma_assets = joinpath(dirname(@__DIR__), "assets") -doc_assets = joinpath(dirname(@__DIR__), "docs/src/assets") cp(joinpath(koma_assets, "logo.svg"), joinpath(doc_assets, "logo.svg"); force=true) cp(joinpath(koma_assets, "logo-dark.svg"), joinpath(doc_assets, "logo-dark.svg"); force=true) -# Tutorials -koma_tutorials_lit = joinpath(dirname(@__DIR__), "examples/3.tutorials") -koma_tutorials_plu = joinpath(dirname(@__DIR__), "examples/4.reproducible_notebooks") +# Tutorials: Literate and Pluto move_examples_to_docs!(koma_tutorials_lit, doc_tutorial, lit_pattern) move_examples_to_docs!(koma_tutorials_plu, doc_tutorial_rep, plu_pattern; remove_pattern=true) @@ -38,7 +39,7 @@ lit_reference_list = literate_doc_folder(doc_reference, "reference") # Tutorials (Literate only), and reproducible tutorials (Pluto only) tutorial_list = literate_doc_folder(doc_tutorial, "tutorial"; lit_pattern) reproducible_list = pluto_directory_to_html(doc_tutorial_rep, "tutorial-pluto"; plu_pattern) -# Combine md files in docs/src/section with Literate-generated md files +# Combine md files in docs/src/section with Literate/Pluto-generated md files append!(howto_list, lit_howto_list) append!(explanation_list, lit_explanation_list) append!(reference_list, lit_reference_list) @@ -47,7 +48,7 @@ append!(reference_list, lit_reference_list) makedocs(; modules=[KomaMRI, KomaMRIBase, KomaMRICore, KomaMRIFiles, KomaMRIPlots], sitename="KomaMRI.jl", - authors="Boris Orostica Navarrete and Carlos Castillo Passi", + authors="Carlos Castillo Passi and Boris Orostica Navarrete", checkdocs=:exports, pages=[ "🏠 Home" => "index.md", @@ -63,8 +64,6 @@ makedocs(; sidebar_sitename=false, collapselevel=1, assets=["assets/extra-styles.css"], - prerender=true, - node = NodeJS_16_jll.node() ), ) deploydocs(; repo="github.com/JuliaHealth/KomaMRI.jl.git", push_preview=true) From 049a0ca6548d50aa0b8b0dc253bb957ea17d67bd Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Mon, 6 May 2024 10:46:35 +0800 Subject: [PATCH 27/51] removed node dep --- docs/Project.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/Project.toml b/docs/Project.toml index 04397dfc2..01dada63c 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -2,7 +2,6 @@ Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" KomaMRI = "6a340f8b-2cdf-4c04-99be-4953d9b66d0a" Literate = "98b081ad-f1c9-55d3-8b20-4c87d4299306" -NodeJS_16_jll = "a4b94fbf-73f5-58ff-888d-48a8396e17f6" PlotlyJS = "f0f68f2c-4968-5e81-91da-67840de0976a" PlutoSliderServer = "2fc8631c-6f24-4c5b-bca7-cbb509c42db4" From 125c81ad4475e6ffe96bb9a835828eea2b5243d6 Mon Sep 17 00:00:00 2001 From: Carlos Castillo Passi Date: Mon, 6 May 2024 10:46:51 +0800 Subject: [PATCH 28/51] update simple motion example --- examples/3.tutorials/lit-05-SimpleMotion.jl | 37 ++++----------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index b18576c91..2b73cd7c4 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -17,20 +17,20 @@ obj.Δw .= 0 # hide obj.motion = SimpleMotion([ Rotation(t_start=0.0, t_end=200e-3, yaw=45.0, pitch=0.0, roll=0.0) ]) -p1 = plot_phantom_map(obj, :T2 ; height=400, intermediate_time_samples=4) +p1 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) #md savefig(p1, "../assets/5-phantom.html") # hide #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` ## Read Sequence # hide -seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") # hide -seq = read_seq(seq_file) # hide +seq_file1 = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/EPI/epi_100x100_TE100_FOV230.seq") # hide +seq1 = read_seq(seq_file1) # hide ## Simulate # hide -raw1 = simulate(obj, seq, sys) # hide +raw1 = simulate(obj, seq1, sys) # hide ## Recon # hide acq1 = AcquisitionData(raw1) # hide @@ -52,29 +52,4 @@ p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md # #md # ``` -# The severity of the artifacts can vary depending on the acquisition duration and `k`-space trajectory. -# Below, we show the effect of the same motion in an spiral acquisition (dur. 39 ms, which is 5 times faster than the motion.) - -## Read Sequence # hide -seq_file = joinpath(dirname(pathof(KomaMRI)), "../examples/5.koma_paper/comparison_accuracy/sequences/Spiral/spiral_100x100_FOV230_SPZ_INTER1.seq") # hide -seq = read_seq(seq_file) # hide - -## Simulate # hide -raw1 = simulate(obj, seq, sys) # hide - -## Recon # hide -acq1 = AcquisitionData(raw1) # hide -Nx, Ny = raw1.params["reconSize"][1:2] # hide -reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide -image1 = reconstruction(acq1, reconParams) # hide - -## Plotting the recon # hide -p4 = plot_image(abs.(image1[:, :, 1]); height=400) # hide -#md savefig(p4, "../assets/5-recon2.html") # hide -#jl display(p4) - -#md # ```@raw html -#md #
-#md # -#md #
-#md # ``` +# The severity of the artifacts can vary depending on the acquisition duration and $k$-space trajectory. From 8293ca16c58b2084cb096a317234ba5a92b4c798 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Tue, 28 May 2024 13:37:08 +0200 Subject: [PATCH 29/51] Improve SimpleMotion example --- examples/3.tutorials/lit-05-SimpleMotion.jl | 101 ++++++++++++++++++-- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 2b73cd7c4..c1817a47f 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -12,17 +12,18 @@ obj = brain_phantom2D() obj.Δw .= 0 # hide # The `SimpleMotion` model includes a list of `SimpleMotionType`'s, to enabling mix-and-matching simple motions. -# In this example, we will add a [`Rotation`](@ref) of 45 degrees around the z-axis with duration of 200 ms. +# In this example, we will add a [`Rotation`](@ref) of 45 degrees around the z-axis with duration of 200 ms: obj.motion = SimpleMotion([ Rotation(t_start=0.0, t_end=200e-3, yaw=45.0, pitch=0.0, roll=0.0) ]) -p1 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) -#md savefig(p1, "../assets/5-phantom.html") # hide +p1 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) # hide + +#md savefig(p1, "../assets/5-phantom1.html") # hide #jl display(p1) #md # ```@raw html -#md #
+#md #
#md # ``` ## Read Sequence # hide @@ -42,9 +43,9 @@ image1 = reconstruction(acq1, reconParams) # hide # If we simulate an EPI sequence with acquisition duration (183.989 ms) comparable with the motion's duration (200 ms), # we will observe motion-induced artifacts in the reconstructed image. ## Plotting the recon # hide -p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide -#md savefig(p3, "../assets/5-recon1.html") # hide -#jl display(p3) +p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide +#md savefig(p2, "../assets/5-recon1.html") # hide +#jl display(p2) #md # ```@raw html #md #
@@ -53,3 +54,89 @@ p3 = plot_image(abs.(image1[:, :, 1]); height=400) # hide #md # ``` # The severity of the artifacts can vary depending on the acquisition duration and $k$-space trajectory. + +# ### Corrected reconstruction +# +# Now, let's redefine the phantom's motion with a [`Translation`](@ref) of 2 cm in x, with duration of 200 ms (v = 0.1 m/s): +obj.motion = SimpleMotion([ + Translation(t_start=0.0, t_end=200e-3, dx=2e-2, dy=0.0, dz=0.0) +]) +p3 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) # hide +#md savefig(p3, "../assets/5-phantom2.html") # hide +#jl display(p3) + +#md # ```@raw html +#md #
+#md # ``` + +## Simulate # hide +raw1 = simulate(obj, seq1, sys) # hide + +## Recon # hide +acq1 = AcquisitionData(raw1) # hide +acq1.traj[1].circular = false # hide +Nx, Ny = raw1.params["reconSize"][1:2] # hide +reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide +image1 = reconstruction(acq1, reconParams) # hide + +# Once simulation is done, it is possible to perform a corrected reconstrution +# in order to revert the motion effect in the final image. +# This can be achieved by multiplying each sample of the acquired signal +# by a phase which is proportional to the displacement in each direction (Δx, Δy, Δz) +# at the time instant when the sample was acquired: + +# ```math +# S(k_x, k_y, k_z)_{\text{cor}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i \Delta \phi_{\text{cor}}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i 2 \pi (k_x \Delta x + k_y \Delta y + k_z \Delta z)} +# ``` + +# We need to obtain the displacements in every ADC sampling time of the sequence: +sample_times = get_adc_sampling_times(seq1) + +## Since translation is a rigid motion, +## we can obtain the displacements only for one spin, +## as the displacements of the rest will be the same. +displacements = hcat(get_spin_coords(obj.motion, [0.0], [0.0], [0.0], sample_times)...) + +p4 = KomaMRIPlots.plot( # hide + sample_times, # hide + displacements .* 1e2, # hide + KomaMRIPlots.Layout( # hide + title = "Head displacement in x, y and z", # hide + xaxis_title = "time (s)", # hide + yaxis_title = "Displacement (cm)" # hide + )) # hide +KomaMRIPlots.restyle!(p4,1:3, name=["Δx", "Δy", "Δz"]) # hide + +#md savefig(p4, "../assets/5-displacements.html") # hide +#jl display(p4) + +#md # ```@raw html +#md #
+#md # ``` + +# We can now get the necessary phase shift for each sample: +## Get k-space +_, kspace = get_kspace(seq1) +## Phase correction: ΔΦcor = 2π(kx*Δx + ky*Δy + kz*Δz) +ΔΦ = 2π*sum(kspace .* displacements, dims=2) +## Apply phase correction +acq2 = copy(acq1) +acq2.kdata[1] .*= exp.(im*ΔΦ) +## Reconstruct +image2 = reconstruction(acq2, reconParams) + +p5 = plot_image(abs.(image1[:, :, 1]); height=400) # hide +p6 = plot_image(abs.(image2[:, :, 1]); height=400) # hide + +#md savefig(p5, "../assets/5-recon2.html") # hide +#md savefig(p6, "../assets/5-recon3.html") # hide +#jl display(p5) +#jl display(p6) + +# On the left, you can see the original reconstructed image, +# with no motion correction. On the right, the result +# of the corrected reconstruction we have just seen. + +#md # ```@raw html +#md # +#md # ``` From 4252bf46d07d9cab987b01f11029a64ad7ae8e14 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Tue, 28 May 2024 20:47:15 +0200 Subject: [PATCH 30/51] Minor changes in Simple Motion tutorial --- examples/3.tutorials/lit-05-SimpleMotion.jl | 29 +++++++++++---------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index c1817a47f..176170196 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -11,12 +11,14 @@ sys = Scanner() # hide obj = brain_phantom2D() obj.Δw .= 0 # hide +# ### Head Rotation +# # The `SimpleMotion` model includes a list of `SimpleMotionType`'s, to enabling mix-and-matching simple motions. # In this example, we will add a [`Rotation`](@ref) of 45 degrees around the z-axis with duration of 200 ms: obj.motion = SimpleMotion([ Rotation(t_start=0.0, t_end=200e-3, yaw=45.0, pitch=0.0, roll=0.0) - ]) +]) p1 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) # hide #md savefig(p1, "../assets/5-phantom1.html") # hide @@ -55,7 +57,7 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # The severity of the artifacts can vary depending on the acquisition duration and $k$-space trajectory. -# ### Corrected reconstruction +# ### Head Translation and Corrected reconstruction # # Now, let's redefine the phantom's motion with a [`Translation`](@ref) of 2 cm in x, with duration of 200 ms (v = 0.1 m/s): obj.motion = SimpleMotion([ @@ -83,18 +85,17 @@ image1 = reconstruction(acq1, reconParams) # hide # in order to revert the motion effect in the final image. # This can be achieved by multiplying each sample of the acquired signal # by a phase which is proportional to the displacement in each direction (Δx, Δy, Δz) -# at the time instant when the sample was acquired: +# at the time instant when the sample was acquired [[Godenschweger, 2016]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4930872/): # ```math # S(k_x, k_y, k_z)_{\text{cor}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i \Delta \phi_{\text{cor}}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i 2 \pi (k_x \Delta x + k_y \Delta y + k_z \Delta z)} # ``` -# We need to obtain the displacements in every ADC sampling time of the sequence: +# We need to obtain the displacements in every ADC sampling time of the sequence. +# Since translation is a rigid motion, +# we can obtain the displacements only for one spin, +# as the displacements of the rest will be the same. sample_times = get_adc_sampling_times(seq1) - -## Since translation is a rigid motion, -## we can obtain the displacements only for one spin, -## as the displacements of the rest will be the same. displacements = hcat(get_spin_coords(obj.motion, [0.0], [0.0], [0.0], sample_times)...) p4 = KomaMRIPlots.plot( # hide @@ -117,13 +118,13 @@ KomaMRIPlots.restyle!(p4,1:3, name=["Δx", "Δy", "Δz"]) # hide # We can now get the necessary phase shift for each sample: ## Get k-space _, kspace = get_kspace(seq1) -## Phase correction: ΔΦcor = 2π(kx*Δx + ky*Δy + kz*Δz) +## Phase shift: ΔΦcor = 2π(kx*Δx + ky*Δy + kz*Δz) ΔΦ = 2π*sum(kspace .* displacements, dims=2) -## Apply phase correction -acq2 = copy(acq1) -acq2.kdata[1] .*= exp.(im*ΔΦ) -## Reconstruct -image2 = reconstruction(acq2, reconParams) + +# And we apply the phase correction: +acq1.kdata[1] .*= exp.(im*ΔΦ) +## Reconstruct # hide +image2 = reconstruction(acq1, reconParams) # hide p5 = plot_image(abs.(image1[:, :, 1]); height=400) # hide p6 = plot_image(abs.(image2[:, :, 1]); height=400) # hide From f914592e4bd038400a0778100634ed397b10d01c Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Tue, 28 May 2024 21:24:25 +0200 Subject: [PATCH 31/51] Minor change --- examples/3.tutorials/lit-05-SimpleMotion.jl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 176170196..0b720a87d 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -116,14 +116,12 @@ KomaMRIPlots.restyle!(p4,1:3, name=["Δx", "Δy", "Δz"]) # hide #md # ``` # We can now get the necessary phase shift for each sample: -## Get k-space _, kspace = get_kspace(seq1) -## Phase shift: ΔΦcor = 2π(kx*Δx + ky*Δy + kz*Δz) ΔΦ = 2π*sum(kspace .* displacements, dims=2) # And we apply the phase correction: acq1.kdata[1] .*= exp.(im*ΔΦ) -## Reconstruct # hide + image2 = reconstruction(acq1, reconParams) # hide p5 = plot_image(abs.(image1[:, :, 1]); height=400) # hide From bbdb64b9179356e87075fda5f8fa8026fa0e2d48 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Wed, 29 May 2024 17:33:01 +0200 Subject: [PATCH 32/51] Docstrings --- KomaMRIBase/src/datatypes/Phantom.jl | 10 +- .../phantom/motion/ArbitraryMotion.jl | 50 +++- .../datatypes/phantom/motion/SimpleMotion.jl | 19 +- .../phantom/motion/simplemotion/HeartBeat.jl | 11 +- .../motion/simplemotion/PeriodicHeartBeat.jl | 6 +- .../phantom/motion/simplemotion/Rotation.jl | 112 ++++--- .../motion/simplemotion/Translation.jl | 32 +- docs/src/assets/head-rotation-axis.svg | 281 ++++++++++++++++++ docs/src/assets/head_rotation_axis.png | Bin 153690 -> 0 bytes docs/src/reference/2-koma-base.md | 15 +- 10 files changed, 421 insertions(+), 115 deletions(-) create mode 100644 docs/src/assets/head-rotation-axis.svg delete mode 100644 docs/src/assets/head_rotation_axis.png diff --git a/KomaMRIBase/src/datatypes/Phantom.jl b/KomaMRIBase/src/datatypes/Phantom.jl index 62c1ac81f..08e33acfb 100644 --- a/KomaMRIBase/src/datatypes/Phantom.jl +++ b/KomaMRIBase/src/datatypes/Phantom.jl @@ -78,9 +78,7 @@ Base.:(≈)(obj1::Phantom, obj2::Phantom) = reduce(&, [getfield(obj1, field Base.:(==)(m1::MotionModel, m2::MotionModel) = false Base.:(≈)(m1::MotionModel, m2::MotionModel) = false -""" -Separate object spins in a sub-group -""" +"""Separate object spins in a sub-group""" Base.getindex(obj::Phantom, p::Union{AbstractRange,AbstractVector,Colon}) = begin fields = [] for field in Iterators.filter(x -> !(x == :name), fieldnames(Phantom)) @@ -110,9 +108,7 @@ end return obj1 end -""" -dims = get_dims(obj) -""" +"""dims = get_dims(obj)""" function get_dims(obj::Phantom) dims = Bool[] push!(dims, any(x -> x != 0, obj.x)) @@ -128,7 +124,7 @@ end """ obj = heart_phantom(...) -Heart-like LV phantom. The variable `circumferential_strain` and `radial_strain` are for streching (if positive) +Heart-like LV 2D phantom. The variable `circumferential_strain` and `radial_strain` are for streching (if positive) or contraction (if negative). `rotation_angle` is for rotation. # Arguments diff --git a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl index a3c3bc644..2ac602033 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl @@ -11,9 +11,37 @@ const LinearInterpolator = Interpolations.Extrapolation{ } where {T<:Real,V<:AbstractVector{T}} """ -Arbitrary Motion + motion = ArbitraryMotion(period_durations, dx, dy, dz) -x = x + ux +ArbitraryMotion model. For this motion model, it is necessary to define +motion for each spin independently, in x (`dx`), y (`dy`) and z (`dz`). +`dx`, `dy` and `dz` are three matrixes, of (``N_{\text{spins}}`` x ``N_{\text{discrete times}}``) each. +This means that each row corresponds to a spin trajectory over a set of discrete time instants. +`period_durations` is a vector that contains the period for periodic (one element) or +pseudo-periodic (two or more elements) motion. +The discrete time instants are calculated diving `period_durations` by ``N_{\text{discrete times}}``. + +This motion model is useful for defining arbitrarly complex motion, specially +for importing the spin trajectories from another source, like XCAT or a CFD. + +# Arguments +- `period_durations`: (`Vector{T}`) +- `dx`: (`::Array{T,2}`) matrix for displacements in x +- `dy`: (`::Array{T,2}`) matrix for displacements in y +- `dz`: (`::Array{T,2}`) matrix for displacements in z + +# Returns +- `motion`: (`::ArbitraryMotion`) ArbitraryMotion struct + +# Examples +```julia-repl +julia> motion = ArbitraryMotion( + [1.0], + 0.01.*rand(1000, 10), + 0.01.*rand(1000, 10), + 0.01.*rand(1000, 10) + ) +``` """ struct ArbitraryMotion{T<:Real,V<:AbstractVector{T}} <: MotionModel{T} period_durations::Vector{T} @@ -27,27 +55,27 @@ end function ArbitraryMotion( period_durations::AbstractVector{T}, - Δx::AbstractArray{T,2}, - Δy::AbstractArray{T,2}, - Δz::AbstractArray{T,2}, + dx::AbstractArray{T,2}, + dy::AbstractArray{T,2}, + dz::AbstractArray{T,2}, ) where {T<:Real} @warn "Note that ArbitraryMotion is under development so it is not optimized so far" maxlog = 1 - Ns = size(Δx)[1] - num_pieces = size(Δx)[2] + 1 + Ns = size(dx)[1] + num_pieces = size(dx)[2] + 1 limits = times(period_durations, num_pieces) #! format: off Δ = zeros(Ns,length(limits),4) - Δ[:,:,1] = hcat(repeat(hcat(zeros(Ns,1),Δx),1,length(period_durations)),zeros(Ns,1)) - Δ[:,:,2] = hcat(repeat(hcat(zeros(Ns,1),Δy),1,length(period_durations)),zeros(Ns,1)) - Δ[:,:,3] = hcat(repeat(hcat(zeros(Ns,1),Δz),1,length(period_durations)),zeros(Ns,1)) + Δ[:,:,1] = hcat(repeat(hcat(zeros(Ns,1),dx),1,length(period_durations)),zeros(Ns,1)) + Δ[:,:,2] = hcat(repeat(hcat(zeros(Ns,1),dy),1,length(period_durations)),zeros(Ns,1)) + Δ[:,:,3] = hcat(repeat(hcat(zeros(Ns,1),dz),1,length(period_durations)),zeros(Ns,1)) etpx = [extrapolate(interpolate((limits,), Δ[i,:,1], Gridded(Linear())), Periodic()) for i in 1:Ns] etpy = [extrapolate(interpolate((limits,), Δ[i,:,2], Gridded(Linear())), Periodic()) for i in 1:Ns] etpz = [extrapolate(interpolate((limits,), Δ[i,:,3], Gridded(Linear())), Periodic()) for i in 1:Ns] #! format: on - return ArbitraryMotion(period_durations, Δx, Δy, Δz, etpx, etpy, etpz) + return ArbitraryMotion(period_durations, dx, dy, dz, etpx, etpy, etpz) end function Base.getindex( diff --git a/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl b/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl index c9387f337..36ad9bea6 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl @@ -6,13 +6,25 @@ is_composable(motion_type::SimpleMotionType{T}) where {T<:Real} = false """ motion = SimpleMotion(types) -SimpleMotion model +SimpleMotion model. It allows for the definition of motion by means of simple parameters. +The `SimpleMotion` struct is composed by only one field, called `types`, +which is a vector of simple motion types. This vector will contain as many elements +as simple motions we want to combine. # Arguments - `types`: (`::Vector{<:SimpleMotionType{T}}`) vector of simple motion types # Returns - `motion`: (`::SimpleMotion`) SimpleMotion struct + +# Examples +```julia-repl +julia> motion = SimpleMotion([ + Translation(dx=0.01, dy=0.02, dz=0.0, t_start=0.0, t_end=0.5), + Rotation(pitch=15.0, roll=0.0, yaw=20.0, t_start=0.1, t_end=0.5), + HeartBeat(circumferential_strain=-0.3, radial_strain=-0.2, longitudinal_strain=0.0, t_start=0.2, t_end=0.5) + ]) +``` """ struct SimpleMotion{T<:Real} <: MotionModel{T} types::Vector{<:SimpleMotionType{T}} @@ -39,9 +51,10 @@ Base.:(==)(t1::SimpleMotionType, t2::SimpleMotionType) = false Base.:(≈)(t1::SimpleMotionType, t2::SimpleMotionType) = false """ - x, y, z = het_spin_coords(motion, x, y, z, t') + x, y, z = get_spin_coords(motion, x, y, z, t') Calculates the position of each spin at a set of arbitrary time instants, i.e. the time steps of the simulation. +For each dimension (x, y, z), the output matrix has ``N_{\text{spins}}`` rows and `length(t)` columns. # Arguments - `motion`: (`::MotionModel`) phantom motion @@ -51,7 +64,7 @@ Calculates the position of each spin at a set of arbitrary time instants, i.e. t - `t`: (`::AbstractArray{T<:Real}`) horizontal array of time instants # Returns -- `z, y, z`: (`::Tuple{AbstractArray, AbstractArray, AbstractArray}`) spin positions over time +- `x, y, z`: (`::Tuple{AbstractArray, AbstractArray, AbstractArray}`) spin positions over time """ function get_spin_coords( motion::SimpleMotion{T}, diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/HeartBeat.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/HeartBeat.jl index b9e6b30b1..4db9f8a7c 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/HeartBeat.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/HeartBeat.jl @@ -5,14 +5,19 @@ HeartBeat struct. It produces a heartbeat-like motion, characterised by three ty Circumferential, Radial and Longitudinal # Arguments -- `circumferential_strain`: (`::Real`, `=-0.3`) contraction parameter -- `radial_strain`: (`::Real`, `=-0.3`) contraction parameter -- `longitudinal_strain`: (`::Real`, `=1`) contraction parameter +- `circumferential_strain`: (`::Real`) contraction parameter +- `radial_strain`: (`::Real`) contraction parameter +- `longitudinal_strain`: (`::Real`) contraction parameter - `t_start`: (`::Real`, `[s]`) initial time - `t_end`: (`::Real`, `[s]`) final time # Returns - `heartbeat`: (`::HeartBeat`) HeartBeat struct + +# Examples +```julia-repl +julia> hb = HeartBeat(circumferential_strain=-0.3, radial_strain=-0.2, longitudinal_strain=0.0, t_start=0.2, t_end=0.5) +``` """ @with_kw struct HeartBeat{T<:Real} <: SimpleMotionType{T} circumferential_strain :: T diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicHeartBeat.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicHeartBeat.jl index 17c201848..d26a3f394 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicHeartBeat.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/PeriodicHeartBeat.jl @@ -5,9 +5,9 @@ HeartBeat struct. It produces a heartbeat-like motion, characterised by three ty Circumferential, Radial and Longitudinal # Arguments -- `circumferential_strain`: (`::Real`, `=-0.3`) contraction parameter -- `radial_strain`: (`::Real`, `=-0.3`) contraction parameter -- `longitudinal_strain`: (`::Real`, `=1`) contraction parameter +- `circumferential_strain`: (`::Real`) contraction parameter +- `radial_strain`: (`::Real`) contraction parameter +- `longitudinal_strain`: (`::Real`) contraction parameter - `period`: (`::Real`, `[s]`) period - `asymmetry`: (`::Real`) asymmetry factor, between 0 and 1 diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index 17fd38559..d56c1ad24 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -2,55 +2,41 @@ rotation = Rotation(t_start, t_end, pitch, roll, yaw) Rotation motion struct. It produces a rotation of the phantom in the three axes: -x (pitch), y (roll), and z (yaw) +x (pitch), y (roll), and z (yaw). +We follow the RAS (Right-Anterior-Superior) orientation, +and the rotations are applied following the right-hand rule (counter-clockwise): -```math -\begin{align*} -ux &= cos(\gamma)cos(\beta)x \\ - &+ (cos(\gamma)sin(\beta)sin(\alpha) - sin(\gamma)cos(\alpha))y\\ - &+ (cos(\gamma)sin(\beta)cos(\alpha) + sin(\gamma)sin(\alpha))z\\ - &- x -\end{align*} -``` - -```math -\begin{align*} -uy &= sin(\gamma)cos(\beta)x \\ - &+ (sin(\gamma)sin(\beta)sin(\alpha) + cos(\gamma)cos(\alpha))y\\ - &+ (sin(\gamma)sin(\beta)cos(\alpha) - cos(\gamma)sin(\alpha))z\\ - &- y -\end{align*} -``` - -```math -\begin{align*} -uz &= -sin(\beta)x \\ - &+ cos(\beta)sin(\alpha)y\\ - &+ cos(\beta)cos(\alpha)z\\ - &- z -\end{align*} +```@raw html +

``` -where: - +The applied rotation matrix is obtained as follows: ```math -\alpha = \left\{\begin{matrix} -0, & t <= t_start \\ -\frac{pitch}{t_end-t_start}(t-t_start), & t_start < t < t_end \\ -pitch, & t >= t_end -\end{matrix}\right. -,\qquad -\beta = \left\{\begin{matrix} -0, & t <= t_start \\ -\frac{roll}{t_end-t_start}(t-t_start), & t_start < t < t_end \\ -roll, & t >= t_end -\end{matrix}\right. -,\qquad -\gamma = \left\{\begin{matrix} -0, & t <= t_start \\ -\frac{yaw}{t_end-t_start}(t-t_start), & t_start < t < t_end \\ -yaw, & t >= t_end -\end{matrix}\right. +\begin{equation} +\begin{aligned} +R &= R_z(\alpha) R_y(\beta) R_x(\gamma) \\ + &= \begin{bmatrix} +\cos \alpha & -\sin \alpha & 0 \\ +\sin \alpha & \cos \alpha & 0 \\ +0 & 0 & 1 +\end{bmatrix} +\begin{bmatrix} +\cos \beta & 0 & \sin \beta \\ +0 & 1 & 0 \\ +-\sin \beta & 0 & \cos \beta +\end{bmatrix} +\begin{bmatrix} +1 & 0 & 0 \\ +0 & \cos \gamma & -\sin \gamma \\ +0 & \sin \gamma & \cos \gamma +\end{bmatrix} \\ + &= \begin{bmatrix} +\cos \alpha \cos \beta & \cos \alpha \sin \beta \sin \gamma - \sin \alpha \cos \gamma & \cos \alpha \sin \beta \cos \gamma + \sin \alpha \sin \gamma \\ +\sin \alpha \cos \beta & \sin \alpha \sin \beta \sin \gamma + \cos \alpha \cos \gamma & \sin \alpha \sin \beta \cos \gamma - \cos \alpha \sin \gamma \\ +-\sin \beta & \cos \beta \sin \gamma & \cos \beta \cos \gamma +\end{bmatrix} +\end{aligned} +\end{equation} ``` # Arguments @@ -63,6 +49,10 @@ yaw, & t >= t_end # Returns - `rotation`: (`::Rotation`) Rotation struct +# Examples +```julia-repl +julia> rt = Rotation(pitch=15.0, roll=0.0, yaw=20.0, t_start=0.1, t_end=0.5) +``` """ @with_kw struct Rotation{T<:Real} <: SimpleMotionType{T} pitch :: T @@ -83,12 +73,12 @@ function displacement_x( t::AbstractArray{T}, ) where {T<:Real} t_unit = unit_time(t, motion_type.t_start, motion_type.t_end) - α = t_unit .* (motion_type.pitch) + α = t_unit .* (motion_type.yaw) β = t_unit .* (motion_type.roll) - γ = t_unit .* (motion_type.yaw) - return cosd.(γ) .* cosd.(β) .* x + - (cosd.(γ) .* sind.(β) .* sind.(α) .- sind.(γ) .* cosd.(α)) .* y + - (cosd.(γ) .* sind.(β) .* cosd.(α) .+ sind.(γ) .* sind.(α)) .* z .- x + γ = t_unit .* (motion_type.pitch) + return cosd.(α) .* cosd.(β) .* x + + (cosd.(α) .* sind.(β) .* sind.(γ) .- sind.(α) .* cosd.(γ)) .* y + + (cosd.(α) .* sind.(β) .* cosd.(γ) .+ sind.(α) .* sind.(γ)) .* z .- x end function displacement_y( @@ -99,12 +89,12 @@ function displacement_y( t::AbstractArray{T}, ) where {T<:Real} t_unit = unit_time(t, motion_type.t_start, motion_type.t_end) - α = t_unit .* motion_type.pitch - β = t_unit .* motion_type.roll - γ = t_unit .* motion_type.yaw - return sind.(γ) .* cosd.(β) .* x + - (sind.(γ) .* sind.(β) .* sind.(α) .+ cosd.(γ) .* cosd.(α)) .* y + - (sind.(γ) .* sind.(β) .* cosd.(α) .- cosd.(γ) .* sind.(α)) .* z .- y + α = t_unit .* (motion_type.yaw) + β = t_unit .* (motion_type.roll) + γ = t_unit .* (motion_type.pitch) + return sind.(α) .* cosd.(β) .* x + + (sind.(α) .* sind.(β) .* sind.(γ) .+ cosd.(α) .* cosd.(γ)) .* y + + (sind.(α) .* sind.(β) .* cosd.(γ) .- cosd.(α) .* sind.(γ)) .* z .- y end function displacement_z( @@ -115,12 +105,12 @@ function displacement_z( t::AbstractArray{T}, ) where {T<:Real} t_unit = unit_time(t, motion_type.t_start, motion_type.t_end) - α = t_unit .* motion_type.pitch - β = t_unit .* motion_type.roll - γ = t_unit .* motion_type.yaw + α = t_unit .* (motion_type.yaw) + β = t_unit .* (motion_type.roll) + γ = t_unit .* (motion_type.pitch) return -sind.(β) .* x + - cosd.(β) .* sind.(α) .* y + - cosd.(β) .* cosd.(α) .* z .- z + cosd.(β) .* sind.(γ) .* y + + cosd.(β) .* cosd.(γ) .* z .- z end times(motion_type::Rotation) = begin diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Translation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Translation.jl index c002be489..f0832e3c1 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Translation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Translation.jl @@ -1,31 +1,9 @@ @doc raw""" translation = Translation(t_start, t_end, dx, dy, dz) -Translation motion struct. It produces a translation of the phantom in the three directions x, y and z. - -```math -ux=\left\{\begin{matrix} -0, & t <= t_start\\ -\frac{dx}{t_end-t_start}(t-t_start), & t_start < t < t_end\\ -dx, & t >= t_end -\end{matrix}\right. -``` - -```math -uy=\left\{\begin{matrix} -0, & t <= t_start\\ -\frac{dy}{t_end-t_start}(t-t_start), & t_start < t < t_end\\ -dy, & t >= t_end -\end{matrix}\right. -``` - -```math -uz=\left\{\begin{matrix} -0, & t <= t_start\\ -\frac{dz}{t_end-t_start}(t-t_start), & t_start < t < t_end\\ -dz, & t >= t_end -\end{matrix}\right. -``` +Translation motion struct. It produces a linear translation of the phantom. +Its fields are the final displacements in the three axes (dx, dy, dz) +and the start and end times of the translation. # Arguments - `dx`: (`::Real`, `[m]`) translation in x @@ -37,6 +15,10 @@ dz, & t >= t_end # Returns - `translation`: (`::Translation`) Translation struct +# Examples +```julia-repl +julia> tr = Translation(dx=0.01, dy=0.02, dz=0.03, t_start=0.0, t_end=0.5) +``` """ @with_kw struct Translation{T<:Real} <: SimpleMotionType{T} dx :: T diff --git a/docs/src/assets/head-rotation-axis.svg b/docs/src/assets/head-rotation-axis.svg new file mode 100644 index 000000000..1826a6b17 --- /dev/null +++ b/docs/src/assets/head-rotation-axis.svg @@ -0,0 +1,281 @@ + + + +xyzyaw(α)pitch (γ)roll (β) diff --git a/docs/src/assets/head_rotation_axis.png b/docs/src/assets/head_rotation_axis.png deleted file mode 100644 index 719435de56a73d3a481860e712bcf92be235ef7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 153690 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


K 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

-``` +![Head Rotation Axis](../assets/head-rotation-axis.svg) The applied rotation matrix is obtained as follows: ```math diff --git a/docs/src/reference/2-koma-base.md b/docs/src/reference/2-koma-base.md index 1e2359493..c600d54ff 100644 --- a/docs/src/reference/2-koma-base.md +++ b/docs/src/reference/2-koma-base.md @@ -32,7 +32,7 @@ SimpleMotion ``` ### `SimpleMotion types` - +[comment]: <> (This should be eventually located at Explanation section) There are two main types of simple motions: periodic and non-periodic. - **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. From e025248253ba72611681796a216133ec29f78f82 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 30 May 2024 10:35:16 +0200 Subject: [PATCH 35/51] Minor change --- docs/src/reference/2-koma-base.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/src/reference/2-koma-base.md b/docs/src/reference/2-koma-base.md index c600d54ff..a3a023c99 100644 --- a/docs/src/reference/2-koma-base.md +++ b/docs/src/reference/2-koma-base.md @@ -34,12 +34,8 @@ SimpleMotion ### `SimpleMotion types` [comment]: <> (This should be eventually located at Explanation section) There are two main types of simple motions: periodic and non-periodic. -- **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), -and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. -Examples of these non-periodic motions are [`Translation`](@ref), [`Rotation`](@ref) and [`HeartBeat`](@ref). -- **Periodic** motions are defined by the `period`, the time `asymmetry` factor, -and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. -Examples of these periodic motions are [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). +- **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. Examples of these non-periodic motions are [`Translation`](@ref), [`Rotation`](@ref) and [`HeartBeat`](@ref). +- **Periodic** motions are defined by the `period`, the time `asymmetry` factor, and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. Examples of these periodic motions are [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). ```@docs Translation From 7cdf263357ce23611d7bca101aa5a74b6d8778f2 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 30 May 2024 10:41:03 +0200 Subject: [PATCH 36/51] Fix LaTex syntax --- KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl index 2ac602033..e4808d5b3 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl @@ -15,11 +15,11 @@ const LinearInterpolator = Interpolations.Extrapolation{ ArbitraryMotion model. For this motion model, it is necessary to define motion for each spin independently, in x (`dx`), y (`dy`) and z (`dz`). -`dx`, `dy` and `dz` are three matrixes, of (``N_{\text{spins}}`` x ``N_{\text{discrete times}}``) each. +`dx`, `dy` and `dz` are three matrixes, of (``N_{spins}`` x ``N_{discrete\,times}``) each. This means that each row corresponds to a spin trajectory over a set of discrete time instants. `period_durations` is a vector that contains the period for periodic (one element) or pseudo-periodic (two or more elements) motion. -The discrete time instants are calculated diving `period_durations` by ``N_{\text{discrete times}}``. +The discrete time instants are calculated diving `period_durations` by ``N_{discrete\,times}``. This motion model is useful for defining arbitrarly complex motion, specially for importing the spin trajectories from another source, like XCAT or a CFD. From 871ed6afc22588c51bd6db5e908ff8ad57794c42 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 30 May 2024 10:54:51 +0200 Subject: [PATCH 37/51] Fix scape character --- KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl index e4808d5b3..e3f3256dc 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/ArbitraryMotion.jl @@ -15,11 +15,11 @@ const LinearInterpolator = Interpolations.Extrapolation{ ArbitraryMotion model. For this motion model, it is necessary to define motion for each spin independently, in x (`dx`), y (`dy`) and z (`dz`). -`dx`, `dy` and `dz` are three matrixes, of (``N_{spins}`` x ``N_{discrete\,times}``) each. +`dx`, `dy` and `dz` are three matrixes, of (``N_{spins}`` x ``N_{discrete\\,times}``) each. This means that each row corresponds to a spin trajectory over a set of discrete time instants. `period_durations` is a vector that contains the period for periodic (one element) or pseudo-periodic (two or more elements) motion. -The discrete time instants are calculated diving `period_durations` by ``N_{discrete\,times}``. +The discrete time instants are calculated diving `period_durations` by ``N_{discrete\\,times}``. This motion model is useful for defining arbitrarly complex motion, specially for importing the spin trajectories from another source, like XCAT or a CFD. From 08bc2a583a09603b5a21f3f4c109c892d33db91a Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 30 May 2024 11:26:17 +0200 Subject: [PATCH 38/51] Remove comment --- docs/src/reference/2-koma-base.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/src/reference/2-koma-base.md b/docs/src/reference/2-koma-base.md index a3a023c99..29ec6b16d 100644 --- a/docs/src/reference/2-koma-base.md +++ b/docs/src/reference/2-koma-base.md @@ -27,12 +27,13 @@ get_spin_coords ``` ### `SimpleMotion <: MotionModel` + ```@docs SimpleMotion ``` ### `SimpleMotion types` -[comment]: <> (This should be eventually located at Explanation section) + There are two main types of simple motions: periodic and non-periodic. - **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. Examples of these non-periodic motions are [`Translation`](@ref), [`Rotation`](@ref) and [`HeartBeat`](@ref). - **Periodic** motions are defined by the `period`, the time `asymmetry` factor, and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. Examples of these periodic motions are [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). From 1e22d54f10f16b7c9bc67e4fdebdfa36f79018d0 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Fri, 31 May 2024 19:24:53 +0200 Subject: [PATCH 39/51] Try reducing image size --- .../src/datatypes/phantom/motion/simplemotion/Rotation.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index 6b48a6917..e41878f1a 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -6,7 +6,7 @@ x (pitch), y (roll), and z (yaw). We follow the RAS (Right-Anterior-Superior) orientation, and the rotations are applied following the right-hand rule (counter-clockwise): -![Head Rotation Axis](../assets/head-rotation-axis.svg) + The applied rotation matrix is obtained as follows: ```math From 0565ce3b40ca3c4b100e3499076a7c54d6a3cb53 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Fri, 31 May 2024 19:38:07 +0200 Subject: [PATCH 40/51] Fix reducing image size --- .../phantom/motion/simplemotion/Rotation.jl | 3 +- docs/src/assets/head-rotation-axis.svg | 222 ++++++++++-------- 2 files changed, 130 insertions(+), 95 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index e41878f1a..073da3f14 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -6,7 +6,8 @@ x (pitch), y (roll), and z (yaw). We follow the RAS (Right-Anterior-Superior) orientation, and the rotations are applied following the right-hand rule (counter-clockwise): - +| ![Head Rotation Axis](../assets/head-rotation-axis.svg) | +| ------------------------------------------------------- | The applied rotation matrix is obtained as follows: ```math diff --git a/docs/src/assets/head-rotation-axis.svg b/docs/src/assets/head-rotation-axis.svg index 1826a6b17..b29de97b1 100644 --- a/docs/src/assets/head-rotation-axis.svg +++ b/docs/src/assets/head-rotation-axis.svg @@ -2,9 +2,9 @@ xxyyzzyyawaw((αα))pitch (pitch (γγ))roll (roll (ββ) + font-size="4.13813px" + id="text7552" + x="166.94592" + y="524.27216" + style="stroke-width:0.517266">) From 5913158fa9f74fe487c33a9ba118053f2e5140a4 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Fri, 31 May 2024 19:49:59 +0200 Subject: [PATCH 41/51] Requested changes --- examples/3.tutorials/lit-05-SimpleMotion.jl | 56 +++++++-------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 1510efdb0..bc3170a5f 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -11,13 +11,16 @@ sys = Scanner() # hide obj = brain_phantom2D() obj.Δw .= 0 # hide -# ### Head Rotation -# # The `SimpleMotion` model includes a list of `SimpleMotionType`'s, to enabling mix-and-matching simple motions. -# In this example, we will add a [`Rotation`](@ref) of 45 degrees around the z-axis with duration of 200 ms: +# These are [`Translation`](@ref), [`Rotation`](@ref), [`HeartBeat`](@ref) and their periodic versions +# [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). + +# ### Head Translation +# +# In this example, we will add a [`Translation`](@ref) of 2 cm in x, with duration of 200 ms (v = 0.1 m/s): obj.motion = SimpleMotion([ - Rotation(t_start=0.0, t_end=200e-3, yaw=45.0, pitch=0.0, roll=0.0) + Translation(t_start=0.0, t_end=200e-3, dx=2e-2, dy=0.0, dz=0.0) ]) p1 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) # hide @@ -57,30 +60,6 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # The severity of the artifacts can vary depending on the acquisition duration and $k$-space trajectory. -# ### Head Translation -# -# Now, let's redefine the phantom's motion with a [`Translation`](@ref) of 2 cm in x, with duration of 200 ms (v = 0.1 m/s): -obj.motion = SimpleMotion([ - Translation(t_start=0.0, t_end=200e-3, dx=2e-2, dy=0.0, dz=0.0) -]) -p3 = plot_phantom_map(obj, :T2 ; height=450, intermediate_time_samples=4) # hide -#md savefig(p3, "../assets/5-phantom2.html") # hide -#jl display(p3) - -#md # ```@raw html -#md #
-#md # ``` - -## Simulate # hide -raw1 = simulate(obj, seq1, sys) # hide - -## Recon # hide -acq1 = AcquisitionData(raw1) # hide -acq1.traj[1].circular = false # hide -Nx, Ny = raw1.params["reconSize"][1:2] # hide -reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide -image1 = reconstruction(acq1, reconParams) # hide - # ### Motion-Corrected Reconstruction # # Once simulation is done, it is possible to perform a corrected reconstrution @@ -100,7 +79,7 @@ image1 = reconstruction(acq1, reconParams) # hide sample_times = get_adc_sampling_times(seq1) displacements = hcat(get_spin_coords(obj.motion, [0.0], [0.0], [0.0], sample_times)...) -p4 = KomaMRIPlots.plot( # hide +p3 = KomaMRIPlots.plot( # hide sample_times, # hide displacements .* 1e2, # hide KomaMRIPlots.Layout( # hide @@ -108,10 +87,10 @@ p4 = KomaMRIPlots.plot( # hide xaxis_title = "time (s)", # hide yaxis_title = "Displacement (cm)" # hide )) # hide -KomaMRIPlots.restyle!(p4,1:3, name=["Δx", "Δy", "Δz"]) # hide +KomaMRIPlots.restyle!(p3,1:3, name=["Δx", "Δy", "Δz"]) # hide -#md savefig(p4, "../assets/5-displacements.html") # hide -#jl display(p4) +#md savefig(p3, "../assets/5-displacements.html") # hide +#jl display(p3) #md # ```@raw html #md #
@@ -126,13 +105,12 @@ acq1.kdata[1] .*= exp.(im*ΔΦ) image2 = reconstruction(acq1, reconParams) # hide -p5 = plot_image(abs.(image1[:, :, 1]); height=400) # hide -p6 = plot_image(abs.(image2[:, :, 1]); height=400) # hide +p4 = plot_image(abs.(image2[:, :, 1]); height=400) # hide -#md savefig(p5, "../assets/5-recon2.html") # hide -#md savefig(p6, "../assets/5-recon3.html") # hide -#jl display(p5) -#jl display(p6) +#md savefig(p4, "../assets/5-recon2.html") # hide + +#jl display(p2) +#jl display(p4) # On the left, you can see the original reconstructed image # and the artifact produced by the translation in x. @@ -141,5 +119,5 @@ p6 = plot_image(abs.(image2[:, :, 1]); height=400) # hide # we would have obtained from simulating over a static phantom. #md # ```@raw html -#md # +#md # #md # ``` From 3c2c8c510c5b777660064fefda9ef9e9cc990d19 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Fri, 31 May 2024 20:32:00 +0200 Subject: [PATCH 42/51] Fix table does not work on docstrings --- .../src/datatypes/phantom/motion/simplemotion/Rotation.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index 073da3f14..6b48a6917 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -6,8 +6,7 @@ x (pitch), y (roll), and z (yaw). We follow the RAS (Right-Anterior-Superior) orientation, and the rotations are applied following the right-hand rule (counter-clockwise): -| ![Head Rotation Axis](../assets/head-rotation-axis.svg) | -| ------------------------------------------------------- | +![Head Rotation Axis](../assets/head-rotation-axis.svg) The applied rotation matrix is obtained as follows: ```math From 378664a9fe4f99138ae595c4e85fac75374c9910 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:45:28 +0200 Subject: [PATCH 43/51] Change LaTex equation syntax Co-authored-by: Carlos Castillo Passi --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index bc3170a5f..5b6de4d3a 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -69,7 +69,7 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # at the time instant when the sample was acquired [[Godenschweger, 2016]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4930872/): # ```math -# S(k_x, k_y, k_z)_{\text{cor}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i \Delta \phi_{\text{cor}}} = S(k_x, k_y, k_z)_{\text{orig}} \cdot e^{i 2 \pi (k_x \Delta x + k_y \Delta y + k_z \Delta z)} +# S_{\mathrm{MC}}\left(t\right)=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}\Delta\phi_{\mathrm{corr}}}=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}2\pi\boldsymbol{k}\left(t\right)\cdot\boldsymbol{u}\left(t\right)} # ``` # We need to obtain the displacements in every ADC sampling time of the sequence. From 58cd73be2d5904338e9140647048fafbc9ef57d2 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:46:23 +0200 Subject: [PATCH 44/51] Change explanation Co-authored-by: Carlos Castillo Passi --- examples/3.tutorials/lit-05-SimpleMotion.jl | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 5b6de4d3a..f9b92a820 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -62,11 +62,10 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # ### Motion-Corrected Reconstruction # -# Once simulation is done, it is possible to perform a corrected reconstrution -# in order to revert the motion effect in the final image. -# This can be achieved by multiplying each sample of the acquired signal -# by a phase which is proportional to the displacement in each direction (Δx, Δy, Δz) -# at the time instant when the sample was acquired [[Godenschweger, 2016]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4930872/): +# To correct for the motion-induced artifacts we can perform a motion-corrected reconstruction. +# This can be achieved by multiplying each sample of the acquired signal ``S(t)`` +# by a phase shift ``\Delta\phi_{\mathrm{corr}}`` proportional to the displacement ``\boldsymbol{u}(t)`` +# [[Godenschweger, 2016]](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4930872/): # ```math # S_{\mathrm{MC}}\left(t\right)=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}\Delta\phi_{\mathrm{corr}}}=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}2\pi\boldsymbol{k}\left(t\right)\cdot\boldsymbol{u}\left(t\right)} From 9460d17681a0f5746c29f83eb8908f3d0d93f1b0 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:46:42 +0200 Subject: [PATCH 45/51] Change explanation Co-authored-by: Carlos Castillo Passi --- examples/3.tutorials/lit-05-SimpleMotion.jl | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index f9b92a820..330225166 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -71,10 +71,8 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # S_{\mathrm{MC}}\left(t\right)=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}\Delta\phi_{\mathrm{corr}}}=S\left(t\right)\cdot\mathrm{e}^{\mathrm{i}2\pi\boldsymbol{k}\left(t\right)\cdot\boldsymbol{u}\left(t\right)} # ``` -# We need to obtain the displacements in every ADC sampling time of the sequence. -# Since translation is a rigid motion, -# we can obtain the displacements only for one spin, -# as the displacements of the rest will be the same. +# In practice, we would need to estimate or measure the motion before performing a motion-corrected reconstruction, but for this example, we will directly use the displacement functions ``\boldsymbol{u}(\boldsymbol{x}, t)`` defined by `obj.motion::SimpleMotion`. +# Since translations are rigid motions (``\boldsymbol{u}(\boldsymbol{x}, t)=\boldsymbol{u}(t)`` no position dependence), we can obtain the required displacements by calculating ``\boldsymbol{u}(\boldsymbol{x}=\boldsymbol{0},\ t=t_{\mathrm{adc}})``. sample_times = get_adc_sampling_times(seq1) displacements = hcat(get_spin_coords(obj.motion, [0.0], [0.0], [0.0], sample_times)...) From eeea1dc4cf1f2f6fa5ebefe0e37b56386ea018d8 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:48:07 +0200 Subject: [PATCH 46/51] Change explanation Co-authored-by: Carlos Castillo Passi --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 330225166..260c69c94 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -97,7 +97,7 @@ KomaMRIPlots.restyle!(p3,1:3, name=["Δx", "Δy", "Δz"]) # hide _, kspace = get_kspace(seq1) ΔΦ = 2π*sum(kspace .* displacements, dims=2) -# And we apply the phase correction: +# And apply it to the acquired signal to correct its phase: acq1.kdata[1] .*= exp.(im*ΔΦ) image2 = reconstruction(acq1, reconParams) # hide From 662c0fb92efcb626965dba841a3d6e4fa57b9b6b Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:48:23 +0200 Subject: [PATCH 47/51] Change explanation Co-authored-by: Carlos Castillo Passi --- examples/3.tutorials/lit-05-SimpleMotion.jl | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 260c69c94..45536f90b 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -109,11 +109,7 @@ p4 = plot_image(abs.(image2[:, :, 1]); height=400) # hide #jl display(p2) #jl display(p4) -# On the left, you can see the original reconstructed image -# and the artifact produced by the translation in x. -# On the right, the result of the motion-corrected reconstruction, -# where we have achieved an image similar to the one -# we would have obtained from simulating over a static phantom. +# Finally, we compare the original image ▶️ and the motion-corrected reconstruction ⏸️: #md # ```@raw html #md # From 9eedd1d52b9dcecc2c31ba1a04555acd4278781d Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:48:50 +0200 Subject: [PATCH 48/51] Minor change in docstring Co-authored-by: Carlos Castillo Passi --- KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl b/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl index 36ad9bea6..166832509 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/SimpleMotion.jl @@ -51,7 +51,7 @@ Base.:(==)(t1::SimpleMotionType, t2::SimpleMotionType) = false Base.:(≈)(t1::SimpleMotionType, t2::SimpleMotionType) = false """ - x, y, z = get_spin_coords(motion, x, y, z, t') + x, y, z = get_spin_coords(motion, x, y, z, t) Calculates the position of each spin at a set of arbitrary time instants, i.e. the time steps of the simulation. For each dimension (x, y, z), the output matrix has ``N_{\text{spins}}`` rows and `length(t)` columns. From 4223e7cb21a7426aa770663bdf1a0b62e3a37e80 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 19:51:10 +0200 Subject: [PATCH 49/51] Reduce figure size --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 45536f90b..bf50182ad 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -90,7 +90,7 @@ KomaMRIPlots.restyle!(p3,1:3, name=["Δx", "Δy", "Δz"]) # hide #jl display(p3) #md # ```@raw html -#md #
+#md #
#md # ``` # We can now get the necessary phase shift for each sample: From 099080c6fd320fe6a305dd378d5e701b3123a428 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Sun, 2 Jun 2024 20:07:24 +0200 Subject: [PATCH 50/51] Remove explanation from API docs, leave for "Explanations" --- docs/src/reference/2-koma-base.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/src/reference/2-koma-base.md b/docs/src/reference/2-koma-base.md index 29ec6b16d..9d37e4797 100644 --- a/docs/src/reference/2-koma-base.md +++ b/docs/src/reference/2-koma-base.md @@ -34,10 +34,6 @@ SimpleMotion ### `SimpleMotion types` -There are two main types of simple motions: periodic and non-periodic. -- **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. Examples of these non-periodic motions are [`Translation`](@ref), [`Rotation`](@ref) and [`HeartBeat`](@ref). -- **Periodic** motions are defined by the `period`, the time `asymmetry` factor, and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. Examples of these periodic motions are [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). - ```@docs Translation Rotation From f6074ecc3baac499dec1e85fed1bafa8c24a4a76 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Mon, 3 Jun 2024 12:27:26 +0200 Subject: [PATCH 51/51] =?UTF-8?q?=CE=94x=20->=20ux(t)=20in=20plot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/3.tutorials/lit-05-SimpleMotion.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index bf50182ad..c26a8aa3f 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -84,7 +84,7 @@ p3 = KomaMRIPlots.plot( # hide xaxis_title = "time (s)", # hide yaxis_title = "Displacement (cm)" # hide )) # hide -KomaMRIPlots.restyle!(p3,1:3, name=["Δx", "Δy", "Δz"]) # hide +KomaMRIPlots.restyle!(p3,1:3, name=["ux(t)", "uy(t)", "uz(t)"]) # hide #md savefig(p3, "../assets/5-displacements.html") # hide #jl display(p3)

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 Date: Thu, 30 May 2024 09:42:13 +0200 Subject: [PATCH 33/51] Minor changes --- docs/src/reference/2-koma-base.md | 7 +++++-- examples/3.tutorials/lit-05-SimpleMotion.jl | 14 +++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/src/reference/2-koma-base.md b/docs/src/reference/2-koma-base.md index a7fd9bec6..1e2359493 100644 --- a/docs/src/reference/2-koma-base.md +++ b/docs/src/reference/2-koma-base.md @@ -32,11 +32,14 @@ SimpleMotion ``` ### `SimpleMotion types` + There are two main types of simple motions: periodic and non-periodic. - **Non-periodic** motions are defined by the start time (`t_start`), the end time (`t_end`), -and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. Examples of these non-periodic motions are `Translation`, `Rotation` and `HeartBeat`. +and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`t_end`. +Examples of these non-periodic motions are [`Translation`](@ref), [`Rotation`](@ref) and [`HeartBeat`](@ref). - **Periodic** motions are defined by the `period`, the time `asymmetry` factor, -and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. Examples of these periodic motions are `PeriodicTranslation`, `PeriodicRotation` and `PeriodicHeartBeat`. +and the maximum amplitude of the movement, which is reached (with constant velocity) at t=`period`*`asymmetry`. +Examples of these periodic motions are [`PeriodicTranslation`](@ref), [`PeriodicRotation`](@ref) and [`PeriodicHeartBeat`](@ref). ```@docs Translation diff --git a/examples/3.tutorials/lit-05-SimpleMotion.jl b/examples/3.tutorials/lit-05-SimpleMotion.jl index 0b720a87d..1510efdb0 100644 --- a/examples/3.tutorials/lit-05-SimpleMotion.jl +++ b/examples/3.tutorials/lit-05-SimpleMotion.jl @@ -4,7 +4,7 @@ using KomaMRI # hide sys = Scanner() # hide # It can also be interesting to see the effect of the patient's motion during an MRI scan. -# For this, Koma provides the ability to add [`MotionModel`](@ref)'s to the phantom. +# For this, Koma provides the ability to add `motion <: MotionModel` to the phantom. # In this tutorial, we will show how to add a [`SimpleMotion`](@ref) model to a 2D brain phantom. # First, let's load the 2D brain phantom used in the previous tutorials: @@ -57,7 +57,7 @@ p2 = plot_image(abs.(image1[:, :, 1]); height=400) # hide # The severity of the artifacts can vary depending on the acquisition duration and $k$-space trajectory. -# ### Head Translation and Corrected reconstruction +# ### Head Translation # # Now, let's redefine the phantom's motion with a [`Translation`](@ref) of 2 cm in x, with duration of 200 ms (v = 0.1 m/s): obj.motion = SimpleMotion([ @@ -81,6 +81,8 @@ Nx, Ny = raw1.params["reconSize"][1:2] # hide reconParams = Dict{Symbol,Any}(:reco=>"direct", :reconSize=>(Nx, Ny)) # hide image1 = reconstruction(acq1, reconParams) # hide +# ### Motion-Corrected Reconstruction +# # Once simulation is done, it is possible to perform a corrected reconstrution # in order to revert the motion effect in the final image. # This can be achieved by multiplying each sample of the acquired signal @@ -132,9 +134,11 @@ p6 = plot_image(abs.(image2[:, :, 1]); height=400) # hide #jl display(p5) #jl display(p6) -# On the left, you can see the original reconstructed image, -# with no motion correction. On the right, the result -# of the corrected reconstruction we have just seen. +# On the left, you can see the original reconstructed image +# and the artifact produced by the translation in x. +# On the right, the result of the motion-corrected reconstruction, +# where we have achieved an image similar to the one +# we would have obtained from simulating over a static phantom. #md # ```@raw html #md # From 255090cefbd97ca55cf100b9261d837a94cd42c3 Mon Sep 17 00:00:00 2001 From: Pablo Villacorta Aylagas Date: Thu, 30 May 2024 10:30:15 +0200 Subject: [PATCH 34/51] Try adding image into docstring --- .../src/datatypes/phantom/motion/simplemotion/Rotation.jl | 4 +--- docs/src/reference/2-koma-base.md | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl index d56c1ad24..6b48a6917 100644 --- a/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl +++ b/KomaMRIBase/src/datatypes/phantom/motion/simplemotion/Rotation.jl @@ -6,9 +6,7 @@ x (pitch), y (roll), and z (yaw). We follow the RAS (Right-Anterior-Superior) orientation, and the rotations are applied following the right-hand rule (counter-clockwise): -```@raw html -