Skip to content
Snippets Groups Projects

Define variables through new @defvar macro

Merged Michiel Cottaar requested to merge new_variables into main
1 file
+ 11
17
Compare changes
  • Side-by-side
  • Inline
+ 336
196
@@ -2,18 +2,18 @@
Defines the functions that can be called on parts of an MRI sequence to query or constrain any variables.
In addition this defines:
- [`variables`](@ref): dictionary containing all variables.
- [`variables`](@ref): module containing all variables.
- [`VariableType`](@ref): parent type for any variables (whether number or JuMP variable).
- [`get_free_variable`](@ref): helper function to create new JuMP variables.
- [`VariableNotAvailable`](@ref): error raised if variable is not defined for specific [`AbstractBlock`](@ref).
- [`set_simple_constraints!`](@ref): call [`apply_simple_constraint!`](@ref) for each keyword argument.
- [`apply_simple_constraint!`](@ref): set a simple equality constraint.
- [`get_pulse`](@ref)/[`get_gradient`](@ref)/[`get_readout`](@ref): Used to get the pulse/gradient/readout part of a building block
- [`gradient_orientation`](@ref): returns the gradient orientation of a waveform if fixed.
"""
module Variables
import JuMP: @constraint, @variable, Model, @objective, objective_function, AbstractJuMPScalar
import JuMP: @constraint, @variable, Model, @objective, objective_function, AbstractJuMPScalar, QuadExpr, AffExpr
import StaticArrays: SVector
import MacroTools
import ..Scanners: gradient_strength, slew_rate, Scanner
import ..BuildSequences: global_model, global_scanner, fixed
@@ -53,98 +53,272 @@ Can return one of:
adjustable(::AbstractBlock) = :false
abstract type AnyVariable end
all_variables_symbols = [
:block => [
:duration => "duration of the building block in ms.",
],
:sequence => [
:TR => "Time on which an MRI sequence repeats itself in ms. Defaults to the result of [`repetition_time`](@ref).",
:repetition_time => "Time on which an MRI sequence repeats itself in ms.",
:TE => "Echo time of the sequence in ms. Defaults to the result of [`echo_time`](@ref).",
:echo_time => "Echo time of the sequence in ms.",
:diffusion_time => "Diffusion time in ms (i.e., time between start of the diffusion-weighted gradients).",
:Δ => "Diffusion time in ms (i.e., time between start of the diffusion-weighted gradients). Defaults to the result of [`diffusion_time`](@ref).",
:qvec => "Net dephasing due to gradients in rad/um.",
:area_under_curve => "Net dephasing due to gradients in rad/um (same as [`qvec`](@ref)).",
:bmat => "Full 3x3 diffusion-weighting matrix in ms/um^2.",
:bval => "Size of diffusion-weighting in ms/um^2 (trace of [`bmat`](@ref)).",
:duration_dephase => "Net T2' dephasing experienced over a sequence (in ms).",
:duration_transverse => "Net T2 signal loss experienced over a sequence (in ms). This is the total duration spins spent in the transverse plane.",
:delay => "Delay between readout and spin echo in an asymmetric spin echo in ms.",
],
:pulse => [
:flip_angle => "The flip angle of the RF pulse in degrees",
:amplitude => "The maximum amplitude of an RF pulse in kHz",
:phase => "The angle of the phase of an RF pulse in KHz",
:frequency => "The off-resonance frequency of an RF pulse (relative to the Larmor frequency of water) in KHz",
:bandwidth => "Bandwidth of the RF pulse in kHz. To set constraints it is often better to use [`inverse_bandwidth`](@ref).",
:inverse_bandwidth => "Inverse of bandwidth of the RF pulse in 1/kHz. Also see [`bandwidth`](@ref).",
:N_left => "The number of zero crossings of the RF pulse before the main peak",
:N_right => "The number of zero crossings of the RF pulse after the main peak",
:slice_thickness => "Slice thickness of an RF pulse that is active during a gradient in mm. To set constraints it is often better to use [`inverse_slice_thickness`](@ref).",
:inverse_slice_thickness => "Inverse of slice thickness of an RF pulse that is active during a gradient in 1/mm. Also, see [`slice_thickness`](@ref).",
],
:gradient => [
:qval3 => "The spatial range with orientation on which the displacements can be detected due to this gradient in rad/um (also see [`qval`](@ref)).",
:qval => "The spatial range on which the displacements can be detected due to this gradient in rad/um. This will be a scalar if the orientation is fixed and a scalar otherwise. If you always want a vector, use [`qvec`](@ref).",
:qval_square => "Square of [`qval`](@ref) in rad^2/um^2.",
:δ => "Effective duration of a gradient pulse ([`rise_time`](@ref) + [`flat_time`](@ref)) in ms.",
:rise_time => "Time for gradient pulse to reach its maximum value in ms.",
:flat_time => "Time of gradient pulse at maximum value in ms.",
:gradient_strength => "Vector with maximum strength of a gradient along each dimension (kHz/um)",
:slew_rate => "Vector with maximum slew rate of a gradient along each dimension (kHz/um/ms)",
:spoiler_scale => "Length-scale on which spins will be dephased by exactly 2π in mm.",
],
:readout => [
:dwell_time => "Time between two samples in an `ADC` in ms.",
:nsamples => "Number of samples during a readout. During the optimisation this might produce non-integer values.",
:fov => "Size of the field of view in mm. To set constraints it is often better to use [`inverse_fov`](@ref).",
:inverse_fov => "Inverse of size of the field of view in 1/mm. Also see [`fov`](@ref).",
:voxel_size => "Size of each voxel in mm. To set constraints it is often better to use [`inverse_voxel_size`](@ref).",
:inverse_voxel_size => "Inverse of voxel size in 1/mm. Also see [`voxel_size`](@ref).",
:resolution => "Number of voxels in the final readout. During the optimisation this might produce non-integer values, but this will be fixed after optimsation.",
:oversample => "How much to oversample with ([`nsamples`](@ref) / [`resolution`](@ref))",
:ramp_overlap => "Fraction of overlap between ADC event and underlying gradient pulse ramp (between 0 and 1)."
],
]
"""
A sequence property that can be constrained and/or optimised.
It acts as a function, so you can call it on a sequence or building block to get the actual values (e.g., `v(sequence)`).
It can return one of the following:
- a number
- a vector of number
- a NamedTuple with the values for individual sequence components
"""
mutable struct Variable <: AnyVariable
name :: Symbol
f :: Function
getter :: Union{Nothing, Function}
end
struct AlternateVariable <: AnyVariable
name :: Symbol
other_var :: Symbol
from_other :: Function
to_other :: Union{Nothing, Function}
inverse :: Bool
end
"""
variable_defined_for(var, Val(type))
Check whether variable is defined for a specific sub-type.
"""
variable_defined_for(var::Variable, ::Val{T}) where {T <: AbstractBlock} = hasmethod(var.f, (T, ))
"""
Main module containing all the MRIBuilder sequence variables.
All variables are available as members of this module, e.g.
`variables.echo_time` returns the echo time variable.
New variables can be defined using `@defvar`.
Set constraints on variables by passing them on as keywords during the sequence generation,
e.g., `seq=SpinEcho(echo_time=70)`.
After sequence generation you can get the variable values by calling
`variables.echo_time(seq)`.
For the sequence defined above this would return 70. (or a number very close to that).
"""
Collection of all functions that return variables that can be used to query or constrain their values.
baremodule variables
end
"""
variables = Dict{Symbol, Function}()
@defvar([getter, ], function(s))
Defines new [`variables`](@ref).
Each variable is defined as regular Julia functions embedded within a `@defvar` macro.
For example, to define a `variables.echo_time` variable for a `SpinEcho` sequence, one can use:
```julia
@defvar echo_time(ge::SpinEcho) = 2 * (variables.effective_time(ge, :refocus) - variables.effective_time(ge, :excitation))
```
Multiple variables can be defined in a single `@defvar` by including them in a code block:
```julia
@defvar begin
function var1(seq::SomeSequenceType)
...
end
function var2(seq::SomeSequenceType)
...
end
end
```
Before the variable function definitions one can include a `getter`.
This `getter` defines the type of the sequence component for which the variables will be defined.
If the variable is not defined for the sequence, the variable will be extracted for those type of sequence components instead.
The following sequence component types are provided:
- `pulse`: use [`get_pulse`](@ref)
- `gradient`: use [`get_gradient`](@ref)
- `readout`: use [`get_readout`](@ref)
- `pathway`: use [`get_pathway`](@ref)
e.g. the following defines a `flip_angle` variable, which is marked as a property of an RF pulse.
```julia
@defvar pulse flip_angle(...) = ...
```
If after this definition, `flip_angle` is not explicitly defined for any sequence, it will be extracted for the RF pulses in that sequence instead.
"""
macro defvar(func_def)
return _defvar(func_def, nothing)
end
macro defvar(getter, func_def)
return _defvar(func_def, getter)
end
function _defvar(func_def, getter=nothing)
func_names = []
for (block_symbol, all_functions) in all_variables_symbols
for (func_symbol, description) in all_functions
as_string = " $func_symbol($block_symbol)\n\n$description\n\nThis represents a variable within the sequence. Variables can be set during the construction of a [`AbstractBlock`](@ref) or used to create constraints after the fact."
new_func = @eval begin
function $func_symbol end
@doc $as_string $func_symbol
$func_symbol
if getter isa Symbol
getter_dict = Dict(
:pulse => get_pulse,
:gradient => get_gradient,
:pathway => get_pathway,
:readout => get_readout,
)
if !(getter in keys(getter_dict))
error("label in `@defvar <label> <statement>` should be one of `pulse`/`gradient`/`pathway`/`readout`, not `$getter`")
end
variables[func_symbol] = new_func
getter = getter_dict[getter]
end
function adjust_function(ex)
if ex isa Expr && ex.head == :block
return Expr(:block, adjust_function.(ex.args)...)
end
if ex isa Expr && ex.head == :function && length(ex.args) == 1
push!(func_names, ex.args[1])
return :nothing
end
try
fn_def = MacroTools.splitdef(ex)
push!(func_names, fn_def[:name])
new_def = Dict{Symbol, Any}()
new_def[:name] = Expr(:., Expr(:., :variables, QuoteNode(fn_def[:name])), QuoteNode(:f))
new_def[:args] = esc.(fn_def[:args])
new_def[:kwargs] = esc.(fn_def[:kwargs])
new_def[:body] = esc(fn_def[:body])
new_def[:whereparams] = esc.(fn_def[:whereparams])
return MacroTools.combinedef(new_def)
catch e
if e isa AssertionError
return ex
end
rethrow()
end
end
new_func_def = adjust_function(func_def)
function fix_function_name(ex)
if ex in func_names
return esc(ex)
else
return ex
end
end
new_func_def = MacroTools.postwalk(fix_function_name, new_func_def)
expressions = Expr[]
for func_name in func_names
push!(expressions, quote
if !($(QuoteNode(func_name)) in names(variables; all=true))
function $(func_name) end
variables.$(func_name) = Variable($(QuoteNode(func_name)), $(func_name), $getter)
end
if variables.$(func_name) isa AlternateVariable
error("$($(esc(func_name)).name) is defined through $(variables.$(func_name).other_var). Please define that variable instead.")
end
if !isnothing($getter) && variables.$(func_name).getter != $getter
if isnothing(variables.$(func_name).getter)
variables.$(func_name).getter = $getter
else
name = variables.$(func_name).name
error("$(name) is already defined as a variable for $(variables.$(func_name).getter). Cannot switch to $($getter).")
end
end
end
)
end
args = vcat([e.args for e in expressions]...)
return Expr(
:block,
args...,
new_func_def
)
end
@defvar function duration end
"""
duration(block)
Duration of the sequence or building block in ms.
"""
variables.duration
function def_alternate_variable!(name::Symbol, other_var::Symbol, from_other::Function, to_other::Union{Nothing, Function}, inverse::Bool)
setproperty!(variables, name, AlternateVariable(name, other_var, from_other, to_other, inverse))
end
def_alternate_variable!(:spoiler_scale, :qval, q->1e-3 * 2π/q, l->1e-3 * 2π/l, true)
def_alternate_variable!(:qval, :qval_square, sqrt, q -> q * q, false)
def_alternate_variable!(:qval_square, :qvec, qv -> sum(q -> q * q, qv), nothing, false)
"""
qval(gradient)
The norm of the [`variables.qvec`](@ref).
"""
variables.qval
"""
spoiler_scale(gradient)
Spatial scale in mm over which the spoiler gradient will dephase by 2π.
Automatically computed based on [`variables.qvec`](@ref).
"""
variables.spoiler_scale
for vec_variable in [:gradient_strength, :slew_rate]
vec_square = Symbol(string(vec_variable) * "_square")
vec_norm = Symbol(string(vec_variable) * "_norm")
def_alternate_variable!(vec_norm, vec_square, sqrt, v -> v * v, false)
def_alternate_variable!(vec_square, vec_variable, v -> v[1] * v[1] + v[2] * v[2] + v[3] * v[3], nothing, false)
end
"""
gradient_strength_norm(gradient)
The norm of the [`variables.gradient_strength`](@ref).
"""
variables.gradient_strength_norm
"""
slew_rate_norm(gradient)
The norm of the [`variables.slew_rate`](@ref).
"""
variables.slew_rate_norm
for name in [:slice_thickness, :bandwidth, :fov, :voxel_size]
inv_name = Symbol("inverse_" * string(name))
def_alternate_variable!(name, inv_name, inv, inv, true)
end
for (name, alt_name) in [
(:TE, :echo_time),
(:TR, :repetition_time),
(:Δ, :diffusion_time),
]
def_alternate_variable!(name, alt_name, identity, identity, false)
end
TE(ab::AbstractBlock) = echo_time(ab)
TR(ab::AbstractBlock) = repetition_time(ab)
Δ(ab::AbstractBlock) = diffusion_time(ab)
"""
TE(sequence)
Alternative name to compute the [`variables.echo_time`](@ref) of a sequence in ms.
"""
Dictionary with alternative versions of specific function.
Setting constraints on these alternative functions can be helpful as it avoids some operations, which the optimiser might struggle with.
variables.TE
"""
alternative_variables = Dict(
qval => (qval_square, n->n^2, sqrt, false),
slice_thickness => (inverse_slice_thickness, inv, inv, true),
spoiler_scale => (qval, q->1e-3 * 2π/q, l->1e-3 * 2π/l, true),
bandwidth => (inverse_bandwidth, inv, inv, true),
fov => (inverse_fov, inv, inv, true),
voxel_size => (inverse_voxel_size, inv, inv, true),
)
TR(sequence)
Alternative name to compute the [`variables.repetition_time`](@ref) of a sequence in ms.
"""
variables.TR
"""
Δ(sequence)
Alternative name to compute the [`variables.diffusion_time`](@ref) of a sequence in ms.
"""
variables.Δ
"""
@@ -185,41 +359,48 @@ function get_free_variable(::Val{:max}; kwargs...)
end
"""
get_pulse(building_block)]
get_pulse(sequence)
Get the pulse played out during the building block.
Get the main pulses played out during the sequence.
Any `pulse` variables not explicitly defined for this building block will be passed on to the pulse.
This has to be defined for individual sequences to work.
Any `pulse` variables not explicitly defined for this sequence will be passed on to the pulse.
"""
function get_pulse end
"""
get_gradient(building_block)]
get_gradient(sequence)
Get the main gradients played out during the sequence.
Get the gradient played out during the building block.
This has to be defined for individual sequences to work.
Any `gradient` variables not explicitly defined for this building block will be passed on to the gradient.
Any `gradient` variables not explicitly defined for this sequence will be passed on to the gradient.
"""
function get_gradient end
"""
get_readout(building_block)]
get_readout(sequence)
Get the main readout events played out during the sequence.
Get the readout played out during the building block.
This has to be defined for individual sequences to work.
Any `readout` variables not explicitly defined for this building block will be passed on to the readout.
Any `readout` variables not explicitly defined for this sequence will be passed on to the readout.
"""
function get_readout end
"""
bmat_gradient(gradient::GradientBlock, qstart=(0, 0, 0))
get_pathway(sequence)
Computes the diffusion-weighting matrix due to a single gradient block in rad^2 ms/um^2.
Get the default spin pathway(s) for the sequence.
This should be defined for every `GradientBlock`, but not be called directly.
Instead, the `bmat` and `bval` should be constrained for specific `Pathways`
This has to be defined for individual sequences to work.
Any `pathway` variables not explicitly defined for this sequence will be passed on to the pathway.
"""
function bmat_gradient end
function get_pathway end
"""
@@ -230,77 +411,45 @@ Returns the gradient orientation.
function gradient_orientation end
function effective_time end
"""
VariableNotAvailable(building_block, variable, alt_variable)
Exception raised when a variable function does not support a specific `AbstractBlock`.
"""
mutable struct VariableNotAvailable <: Exception
bb :: Type{<:AbstractBlock}
variable :: Function
alt_variable :: Union{Nothing, Function}
function (var::Variable)(block::AbstractBlock, args...; kwargs...)
if !applicable(var.f, block, args...) && !isnothing(var.getter)
apply_to = var.getter(block)
if apply_to isa AbstractBlock
return var(apply_to, args...; kwargs...)
elseif apply_to isa NamedTuple
return NamedTuple(k => var(v, args...; kwargs...) for (k, v) in pairs(apply_to))
elseif apply_to isa AbstractVector{<:AbstractBlock} || apply_to isa Tuple
return var.(apply_to, args...; kwargs...)
else
error("$(var.getter) returned an unexpected type: $(typeof(apply_to)).")
end
end
return var.f(block, args...; kwargs...)
end
VariableNotAvailable(bb::Type{<:AbstractBlock}, variable::Function) = VariableNotAvailable(bb, variable, nothing)
function Base.showerror(io::IO, e::VariableNotAvailable)
if isnothing(e.alt_variable)
print(io, e.variable, " is not available for block of type ", e.bb, ".")
else
print(io, e.variable, " is not available for block of type ", e.bb, ". Please use ", e.alt_variable, " instead to set any contsraints or objective functions.")
# Special case for BuildingBlock events
function (var::Variable)(event::Tuple{<:VariableType, <:AbstractBlock}, args...; kwargs...)
if applicable(var.f, event, args...; kwargs...)
return var.f(event, args...; kwargs...)
end
# falling back to just processing the `AbstractBlock`
return var(event[2], args...; kwargs...)
end
for (target_name, all_vars) in all_variables_symbols
for (variable_func, _) in all_vars
if variable_func in [:qval3, :TR, :TE, :Δ]
continue
end
get_func = Symbol("get_" * string(target_name))
use_get_func = target_name in (:pulse, :readout, :gradient)
@eval function Variables.$variable_func(bb::AbstractBlock)
try
if Variables.$variable_func in keys(alternative_variables)
alt_var, forward, backward, _ = alternative_variables[Variables.$variable_func]
try
value = alt_var(bb)
if value isa Number
return backward(value)
elseif value isa AbstractArray{<:Number}
return backward.(value)
end
catch e
if e isa VariableNotAvailable
throw(VariableNotAvailable(typeof(bb), Variables.$variable_func))
end
rethrow()
end
throw(VariableNotAvailable(typeof(bb), Variables.$variable_func, alt_var))
end
throw(VariableNotAvailable(typeof(bb), Variables.$variable_func))
catch e
if $use_get_func && e isa VariableNotAvailable && hasmethod($get_func, tuple(typeof(bb)))
apply_to = try
$(get_func)(bb)
catch
throw(VariableNotAvailable(typeof(bb), Variables.$variable_func))
end
if apply_to isa AbstractBlock
return Variables.$variable_func(apply_to)
elseif apply_to isa NamedTuple
return NamedTuple(k => Variables.$variable_func(v) for (k, v) in pairs(apply_to))
elseif apply_to isa AbstractVector{<:AbstractBlock} || apply_to isa Tuple
return Variables.$variable_func.(apply_to)
end
error("$($(use_get_func)) returned an unexpected type for $(bb).")
end
rethrow()
function (var::AlternateVariable)(args...; kwargs...)
other_var = getproperty(variables, var.other_var)
apply_from_other(res::VariableType) = var.from_other(res)
function apply_from_other(res::AbstractVector{<:VariableType})
try
return var.from_other(res)
catch e
if e isa MethodError
return var.from_other.(res)
end
end
end
apply_from_other(res::NamedTuple) = NamedTuple(k => apply_from_other(v) for (k, v) in pairs(res))
return apply_from_other(other_var(args...; kwargs...))
end
@@ -334,25 +483,23 @@ If set to a numeric value, a constraint will be added to fix the function value
If set to `:min` or `:max`, minimising or maximising this function will be added to the cost function.
"""
function set_simple_constraints!(block::AbstractBlock, kwargs)
for (key, value) in kwargs
if variables[key] in keys(alternative_variables)
alt_var, forward, backward, to_invert = alternative_variables[variables[key]]
invert_value(value::VariableType) = forward(value)
real_kwargs = Dict(key => value for (key, value) in kwargs if !isnothing(value))
for (key, value) in real_kwargs
var = getproperty(variables, key)
if var isa AlternateVariable
if var.other_var in keys(real_kwargs)
error("Set constraints on both $key and $(var.other_var), however they are equivalent.")
end
invert_value(value::VariableType) = var.to_other(value)
invert_value(value::Symbol) = invert_value(Val(value))
invert_value(::Val{:min}) = to_invert ? Val(:max) : Val(:min)
invert_value(::Val{:max}) = to_invert ? Val(:min) : Val(:max)
invert_value(::Val{:min}) = var.inverse ? Val(:max) : Val(:min)
invert_value(::Val{:max}) = var.inverse ? Val(:min) : Val(:max)
invert_value(value::AbstractVector) = invert_value.(value)
invert_value(value) = value
try
apply_simple_constraint!(alt_var(block), invert_value(value))
continue
catch e
if !(e isa VariableNotAvailable)
rethrow()
end
end
apply_simple_constraint!(getproperty(variables, var.other_var)(block), invert_value(value))
else
apply_simple_constraint!(var(block), value)
end
apply_simple_constraint!(variables[key](block), value)
end
nothing
end
@@ -369,8 +516,9 @@ Add a single constraint or objective to the `variable`.
- `number`: fix variable to this value
- `equation`: fix variable to the result of this equation
"""
apply_simple_constraint!(variable::VariableType, ::Nothing) = nothing
apply_simple_constraint!(variable::AbstractVector, value::Symbol) = apply_simple_constraint!(sum(variable), Val(value))
apply_simple_constraint!(variable, value::Nothing) = nothing
apply_simple_constraint!(variable::NamedTuple, value::Nothing) = nothing
apply_simple_constraint!(variable::VariableType, value::Symbol) = apply_simple_constraint!(variable, Val(value))
apply_simple_constraint!(variable::VariableType, ::Val{:min}) = @objective global_model() Min objective_function(global_model()) + variable
apply_simple_constraint!(variable::VariableType, ::Val{:max}) = @objective global_model() Min objective_function(global_model()) - variable
@@ -406,33 +554,25 @@ function make_generic end
"""
scanner_constraints!(block)
Constraints [`gradient_strength`](@ref) and [`slew_rate`](@ref) to be less than the [`global_scanner`](@ref) maximum.
Constraints [`variables.gradient_strength`](@ref) and [`variables.slew_rate`](@ref) to be less than the [`global_scanner`](@ref) maximum.
"""
function scanner_constraints!(bb::AbstractBlock)
try
global_scanner()
catch e
return
end
for f in (slew_rate, gradient_strength)
for (var, max_value) in [
(variables.slew_rate, global_scanner().slew_rate),
(variables.gradient_strength, global_scanner().gradient),
]
value = nothing
try
value = f(bb)
catch e
if e isa VariableNotAvailable
continue
else
rethrow()
end
value = var(bb)
catch
continue
end
if value isa AbstractVector
for v in value
@constraint global_model() v <= f(global_scanner())
@constraint global_model() v >= -f(global_scanner())
for v in value
if v isa Number || ((v isa Union{QuadExpr, AffExpr}) && length(v.terms) == 0)
continue
end
else
@constraint global_model() value <= f(global_scanner())
@constraint global_model() value >= -f(global_scanner())
@constraint global_model() v <= max_value
@constraint global_model() v >= -max_value
end
end
end
Loading