msl 1.3.0
Loading...
Searching...
No Matches
Controlling the Execution

Graph

The objects from the previous section provide the building blocks of the sequence kernel, i.e. the part which is run at each repetition. An usual MRI sequence is however made of several nested loops, including e.g the sampling of the k-space and the repetitions, and of options, which are chosen by the user at run-time, e.g. whether a gradient noise is played at the start to warn the subject that the sequence is about to start.

Historically, nested for-loops and if statements were used, which forced a static structure on the execution flow. As has been done in computer graphics with scene graphs (e.g. Open Inventor), Siemens has introduced the SeqLoop to provide a dynamic way to nest the loops and the other control structures. The documentation of the SeqLoop and its successor, the CompositeSeqLoop, is however lacking, regarding both the default structure and ways to modify the execution. msl takes a different approach in which the user defines a sequence graph from scratch: the execution flow is then transparent, and can be easily fine-tuned to the specific sequence.

The sequence graph (more precisely a directed acyclic graph) is a collection of nodes, some of them having children, and some of them being terminals, or leaves.

A simple example, shown below, would be a sequence where an optional gradient noise is played at the beginning if the user selected it in the interface, followed by the averages of the sequence. Inside each average, all lines are sampled, by running the kernel.

dot_inline_dotgraph_1.png

msl has various classes for the different kind of nodes, e.g. msl::graph::Node for the root node, msl::graph::If for checking whether the gradient noise should be run, msl::graph::Loop for the two nested loops, etc.

The recursive nature of the graph (a node contains other nodes) implies that nodes are stored as pointers, specifically shared pointers so that the user needs not perform explicit allocations or deallocations. Nodes are not created using their constructor, but using the New function:

auto root = msl::graph::Node::New();
static Pointer New(Dictionary::Pointer registry={})
Create a node with no child.

The nodes have two main functions, as with the sequence and real-time objects present in IDEA: prepare, in which the user-defined parameters are checked and the static information is updated, and run, in which the effective action of the node is performed.

Shared Data

The nodes of a sequence graph require shared data: this data can come from an external source, e.g. the choices of the user in the interface, or can be an information required by multiple nodes, e.g. the k-space coordinate of the current line. The msl::Dictionary class assigns a generic value to a name, similar to Python dictionaries, and an instance of this is stored in each node, and by default shared across all children of a node.

The following code sample shows how to create an empty dictionary, a boolean value, and retrieve it.

msl::Dictionary registry;
registry.set("runIntro", false);
registry.get<bool>("runIntro");
// throws an exception due to wrong type: registry.get<int>("runIntro");
// throws an exception due to missing data : registry.get<int>("repetitions");
Generic key-value associative container.
Definition Dictionary.h:26
container::mapped_type get(std::string const &key) const
Return a generic value.
Dictionary & set(std::string const &key, T &&value)
Set the value at given key, create it if required.
Definition Dictionary.h:123

Note that since C++ is a statically typed language, it is necessary to specify the correct type when retrieving an object from a dictionary.

As said above, each node contains a dictionary, known as the registry of this node. Since this registry may be shared across nodes, it is stored as a pointer. It can be accessed through the msl::graph::AbstractNode::registry function, or directly through the msl::graph::AbstractNode::get function:

root->registry()->get<bool>("runIntro");
// or equivalently
root->get<bool>("runIntro");

Relationship with SeqBuildBlock Objects

In addition to the SeqLoop, Siemens also provides Sequence Building Blocks, or SBB: these objects, inheriting from SeqBuildBlock, encapsulate some code, e.g. gradient noise, noise scan, or an excitation RF pulse. msl allows using those objects in a graph, using the msl::graph::Block node, which is templated by the SBB it contains. The constructor of msl::graph::Block simply passes parameters to the underlying SBB, and the SBB can be accessed by the msl::graph::Block::block variable. For example, adding a TokTokTok SBB to the root node is done as follows:

root->appendChild(
Node encapsulating a block (anything respecting the API of SeqBuildBlock).
Definition Block.h:48

Decision Nodes

The previous examples plays the TokTokTok SBB unconditionally: since there is a checkbox in the interface to enable or disable this feature, there must be a way to conditionally run a part of the graph. While this can be achieved in the prepare function by fully re-creating the sequence graph, it is easier to use an msl::graph::If node.

The constuctor of the msl::graph::If node takes two parameters: the name of registry key which, and a child or set of children. The registry value associated with the key decides whether or not the child must be run: it can be a boolean, or a function, which is called each time the graph is run.

For the boolean form, there will be a part in the initialize function:

root->set("intro", true);
root->appendChild(msl::graph::If::New("intro",
static Pointer New(std::string const &key, Dictionary::Pointer registry={})
Create an If node without children.

This form will also require a part in the prepare function, in order to update the registry:

root->get<bool>("intro") = protocol.intro();

Using a boolean requires splitting the condition from its node: this can be solved using the function form of the msl::graph::If node, in which all the code will be in the initialize function:

root->set(
"intro", msl::graph::If::Function([](MrProt & p){ return p.intro(); }));
root->appendChild(msl::graph::If::New("intro",
FlexibleFunction< bool > Function
Test function.
Definition If.h:34

For more complex functions, the lambda expression can be replaced by a function object or function pointer:

bool runIntro(MrProt & p)
{
return p.intro();
}
void initialize()
{
root->set("intro", msl::graph::If::Function(runIntro));
root->appendChild(msl::graph::If::New("intro",
}

The function may have any of the following prototypes:

  • bool()
  • bool(msl::Dictionary::Pointer)
  • bool(MrProt &)
  • bool(MrProt &, msl::Dictionary::Pointer)
  • bool(MrProt &, SeqLim &, SeqExpo &)
  • bool(MrProt &, SeqLim &, SeqExpo &, msl::Dictionary::Pointer)

This variety of prototypes makes it easier to write condition-function in most circumstances, but creates a rare case in which the type of the object stored in the registry must be explicitely specified (msl::graph::If::Function(runIntro)).

For cases more complex than a simple true/false decision, the msl::graph::Case node may be used in a similar way: instead of a single child, it will have as many children as the case-function may return. Refer to the tests of msl for usage samples.

Loop-like Nodes

The main part of an MRI sequence is the nested loops (repetitions, averages, partitions, lines, etc.). msl provides two types of nodes to encapsulate loops

As for the decision nodes, the looping information is stored in a key of the registry. For the msl::graph::Loop node, a msl::Counter object is used: this object contains information about its current value (msl::Counter::index), and about whether the iteration at the first element, at the last element or finished, i.e. past the last element (msl::Counter::first, msl::Counter::last, msl::Counter::done).

These nodes will also hold children, as for the decision nodes: the child nodes will be prepared once, but executed as many times as there are steps in the counter: in the following sample, the kernel node will be prepared once, but run 256 times.

root->set("line", msl::Counter(256));
auto kernel = msl::graph::Node::New(); // dummy kernel node
root->appendChild(msl::graph::Loop("line", kernel));
Counter from 0 (included) to end (excluded).
Definition Counter.h:14
Node encapsulating a loop structure, using a Counter from the registry.
Definition Loop.h:31

Loops can be nested within other loops, as follows:

root->set("line", msl::Counter(256));
root->set("partitions", msl::Counter(16));
auto kernel = msl::graph::Node::New(); // dummy kernel node
root->appendChild(
msl::graph::Loop("partitions",
msl::graph::Loop("line", kernel)));

Action Nodes

A run-time function can be added to the sequence graph using the msl::graph::Action node: its parameters must match one of the list item described above, and its return type must be void. Other than these two constraints, it may modify the registry and perform any kind of action; one common example is to update the MDH (data header) of the readout object at each repetition to correctly fill-in the line and partition indices.

Assuming a read-out object is stored in the registry, the start of the updateMDH function could read:

void updateMDH(MrProt & protocol, msl::Dictionary::Pointer registry)
{
auto & adc = *registry->get<sREADOUT*>("adc");
auto & mdh = adc.getMDH();
auto & kSpace = protocol.kSpace();
mdh.setKSpaceCentreLineNo(uint16_t(kSpace.echoLine()));
mdh.setKSpaceCentrePartitionNo(
kSpace.dimension() == SEQ::DIM_3 ? uint16_t(kSpace.echoPartition()) : 0);
// ...
}
std::shared_ptr< Dictionary > Pointer
Reference-counted pointer to Dictionary
Definition Dictionary.h:39

The action node can then be added to the graph:

root->appendChild(msl::graph::Action(updateMDH));
Node calling a function when run is called.
Definition Action.h:29

Building a Graph

The main structure of our tutorial FLASH sequence consists of a loop on all lines (either 2D or 3D) at each iteration of which:

  1. the MDH is updated
  2. a kernel is run, wihc performs the acquisition per se
  3. the phase of the RF pulse is updated to perform RF spoiling, based on msl::PhaseCycling

The associated graph is then

dot_inline_dotgraph_2.png

The code to create this subset of the graph first creates the loop node, then adds each of the three children.

auto linesLoop = msl::graph::Loop::New("lines");
linesLoop.appendChild(msl::graph::Action::New(Sequence::updateMDH));
linesLoop.appendChild(Kernel::New("slices", "indices", "phaseCycling", "adc"));
linesLoop.appendChild(
++d->get<msl::PhaseCycling>("phaseCycling"); }));
Compute phase cycling with linear and quadratic increments.
Definition PhaseCycling.h:12
static Pointer New(Function const &function, Dictionary::Pointer registry={})
Create an action from a function and registry.
static Pointer New(std::string const &counter, Dictionary::Pointer registry={})
Create a loop with no child.

By nesting calls to the node constructor, it can also be written as a single statement:

auto linesLoop = msl::graph::Loop::New("lines", {
msl::graph::Action::New(Sequence::updateMDH),
Kernel::New("slices", "indices", "phaseCycling", "adc"),
++d->get<msl::PhaseCycling>("phaseCycling"); })
});

Both forms are equivalent, and the choice of one of them only depends on the developer.