Nested models

Most simulations in computational science involve running a single model code, usually either a widely used one like OpenFOAM or GROMACS, or a custom code written by the researcher. To simulate more complex systems, sometimes two codes are connected together using a coupling framework like MUSCLE3.

For really complex systems however, coupled simulations may be needed comprising many parts. For example, tokamak fusion reactors contain a plasma that is kept in place and heated by magnetic fields. The plasma is then heated futher by one or more heating systems, until it ignites and fusion takes place. This generates heat in the form of fast-moving particles, which dissipate their kinetic energy into the plasma.

All this heat is transported from the core to the edge of the plasma, where eventually it is extracted and used to generate electricity. New deuterium fuel needs to be added, tritium needs to be bred and injected, neutrons are produced and collide with the reactor wall producing impurities that enter the plasma, meanwhile turbulence occurs near the edge as the plasma tries to escape its magnetic confinement, and then we haven’t even mentioned the many sensors, actuators and controllers that keep the whole thing stable and running as intended.

An all-up digital twin of a fusion reactor may comprise many codes and software components, making its creation not just an advanced physics modelling exercise, but also an advanced software engineering project. Without a good way of dealing with the inherent complexity of such a simulation, we won’t be able to get it to work.

Nested graphs

In MUSCLE3, coupled simulations are described by graphs. Graphs are visual and intuitive, but their main drawback is that they scale poorly: graphs with many components tend to be difficult to understand, because there are just too many things in them to take in at once.

The most efficient way of dealing with complexity is by introducing a hierarchy. This is common in software, where a script may use some libraries, which in turn are built on top of other libraries, and so on. It’s also how large organisation can function, by having subsidiaries which have branches which have offices, and by having directors who direct one or more layers of managers who manage the workers. In MUSCLE3, this takes the form of nested graphs.

The fusion reactor from our example can be described by a graph with four components: the tokamak with the plasma inside, the sensors, the controllers, and the actuators. Of course this misses a lot of details, but it’s a good high-level overview. We could then zoom in on the tokamak, and create another graph for it containing components for the magnetic equilibrium, transport, sources, and heating. These in turn can each be described by graphs describing e.g. different heating or transport mechanisms, and so on. As a result, we can understand at each level what is going on, without being overwhelmed by the rest of the system.

MUSCLE3 is capable of combining multiple coupling graphs in this hierarchical way, thus enabling complex simulations. We’ll leave the fusion reactor to the experts, and instead use the example from the Uncertainty Quantification section to demonstrate it.

A hierarchical UQ model

_images/reaction_diffusion_qmc.png

gMMSL diagram of the qMC reaction-diffusion simulation set-up.

The above diagram shows the model from the Uncertainty Quantification section. This is an ensemble simulation, in which we’re doing a large number of runs of a reaction-diffusion model, each with slightly different parameter values. The qmc component draws samples from a distribution to generate a set of parameter values, one for each ensemble member. To speed up the simulation, we run multiple copies of the macro and micro components in parallel, with the rr component in between to send each set of parameters to an available model instance.

In the UQ section, this is implemented as a single model with four components, and as you can tell from the description above, it’s a bit complicated. There are really two different things being combined here: a quasi-Monte Carlo uncertainty quantification (top two components), and a reaction-diffusion simulation (bottom two components). If we model each of them separately and then combine them together, then we can do one thing at a time and make things a bit easier to think about.

Reaction-Diffusion

If you’ve read the other sections, then you’ve already seen the multiscale reaction-diffusion simulation a couple of times. We’re not going to change the model itself, but we will add a new feature: model ports.

docs/source/examples/nested_rdmc.ymmsl, lines 1-30
ymmsl_version: v0.2

models:
  reaction_diffusion:
    ports:
      f_init: settings_in     # Settings
      o_f: final_state_out    # 1D array

    components:
      macro:
        ports:
          o_i: state_out
          s: state_in
          o_f: final_state_out
        description: |
          The macro model implements the diffusion process.
        implementation: diffusion_cpp
      micro:
        ports:
          f_init: initial_state
          o_f: final_state
        description: |
          The micro model implements the reaction process.
        implementation: reaction_cpp

    conduits:
      settings_in: macro.muscle_settings_in
      macro.final_state_out: final_state_out
      macro.state_out: micro.initial_state
      micro.final_state: macro.state_in

If we look at the definition above, then we can see the familiar macro and micro components that we’ve seen before, with the additional final_state_out port from the UQ example. On lines 5-7 however, there’s something new: the model itself has ports.

We’ve seen port definitions like this before for components and for programs, but not for models. Ports on models allow messages to be received from and sent to the world outside of the model, which makes it possible to connect multiple, nested models together. The syntax for these ports is exactly the same as for components and programs.

In this case, our reaction-diffusion model can receive some settings at the start of its run, and it can send the final state at the end.

To make these ports work, they need to be connected to something. This is done using conduits, just like when connecting ports between components. In this case, we route the settings_in port to the macro component’s special muscle_settings_in port (see Uncertainty Quantification for an explanation of muscle_settings_in), and we send macro’s final output to the final_state_out port.

quasi-Monte Carlo UQ

Next, we need to describe the uncertainty quantification process. The listing above is from examples/nested_rdmc.ymmsl, which contains a second model:

docs/source/examples/nested_rdmc.ymmsl, lines 32-62
  qmc:
    ports:
      o_i: settings_out     # vector port, Settings
      s: final_state_in     # vector port, 1D array

    components:
      mc:
        ports:
          o_i: parameters_out
          s: states_in
        description: |
          The Monte Carlo driver component samples parameters and collects results for
          statistical analysis.
        implementation: mc_driver_cpp

      rr:
        ports:
          f_init: front_in
          o_i: back_out
          s: back_in
          o_f: front_out
        description: |
          The round-robin load balancer distributes runs across the implementations of
          the model.
        implementation: load_balancer_cpp

    conduits:
      mc.parameters_out: rr.front_in
      rr.front_out: mc.states_in
      rr.back_out: settings_out
      final_state_in: rr.back_in

Like the reaction-diffusion model, this qmc model has two ports: one for sending sampled parameters, and one for receiving final states for analysis. We’re using the O_I and S operators here, because we’re sending and receiving on these ports multiple times while we’re running. As before, there are conduits to connect the model ports to the components, in this case to rr.

Combining the models

Finally, we need to apply the uncertainty quantification to the reaction-diffusion model. The nested_rmdc_model.ymmsl file contains a third model that does just that:

docs/source/examples/nested_rdmc.ymmsl, lines 64-85
  rduq:
    components:
      uq:
        ports:
          o_i: settings_out   # vector port, Settings
          s: final_state_in   # vecotr port, 1D array
        description: |
          Implements a quasi-Monte Carlo uncertainty quantification method.
        implementation: qmc

      uncertain_model:
        ports:
          f_init: settings_in
          o_f: final_state_out
        description: |
          A model with uncertain parameters to apply UQ to.
        multiplicity: 2
        implementation: reaction_diffusion

    conduits:
      uq.settings_out: uncertain_model.settings_in
      uncertain_model.final_state_out: uq.final_state_in

This model only has two components, one for the uncertainty quantification process, and one for the model to be quantified. They are connected together using conduits as usual. Note that there are no model ports here, all messages flow within this model, not to or from the outside world.

The rduq model is connected to the others by specifying the other models as implementations of its components. You can freely mix components implemented by programs with components implemented by models within a model. To avoid ambiguity, it’s not allowed to have a model and a program with the same name in a single configuration.

Note that the multiplicity of the uncertain_model component is set here, and will apply transparently to all components inside of it, including components that have a multiplicity of their own.

Running nested simulations

Besides the model(s), we’ll also need some settings, programs, and resources to actually be able to run the simulation. We can reuse the program definitions we’ve used with the other models, but settings and resources need to be adjusted slightly to work with the nested models.

The reason for this is that settings may and resources must refer to a specific component that they apply to. In a nested simulation, this is done by specifying a complete component path all the way from the top (outermost) model, rather than only the local name. For example, here are the resource declarations for the nested model:

docs/source/examples/nested_rdmc.ymmsl, lines 105-116
resources:
  rduq.uq.mc:
    threads: 1

  rduq.uq.rr:
    threads: 1

  rduq.uncertain_model.macro:
    threads: 1

  rduq.uncertain_model.micro:
    threads: 1

Resources need to be specified for all components that are implemented by a program. In this case there are four of these. To find the full name, we start at the top model and keep zooming in on components until we get to the program-implemented component. The full name consists of all the components we’ve traversed, separated by periods.

So, for example, to find the reaction model, we start at the top rduq model, pick the uncertain_model component, look up its implementation which is the reaction_diffusion model, and inside that find the micro component, which has the reaction model as its implementation. We’ve passed two components below rduq, uncertain_model and micro, so the full name is rduq.uncertain_model.micro.

Note that components that are implemented using models, as opposed to programs, don’t exist at runtime. Any program-implemented components anywhere inside them are instantiated, and then connected together directly. In this particular model, that means that there is no instance named uq or uncertain_model, only the four shown above.

If uq.rr sends a message to uncertain_model.macro via the conduits from uq.rr.back_out, to uq.settings_out, to uncertain_model.settings_in, to uncertain_model.macro.muscle_settings_in, then at runtime there’s only a direct transfer from uq.rr.back_out to uncertain_model.macro.muscle_settings_in just like in the non-nested version of the model. In fact, other than the component names, at run time there is no difference at all between the nested and non-nested versions of this model.

Settings can be specified as before, except that any settings specific to a particular component need to use the full path (without the top model name this time, although this may change in the future for consistency and to enable new features):

docs/source/examples/nested_rdmc.ymmsl, lines 88-102
settings:
  muscle_local_log_level: INFO
  muscle_remote_log_level: WARNING

  uncertain_model.micro.t_max: 2.469136e-06
  uncertain_model.micro.dt: 2.469136e-08
  uncertain_model.macro.t_max: 0.0001234568
  uncertain_model.macro.dt: 2.469136e-06
  x_max: 1.01
  dx: 0.01
  k_min: -4.455e4
  k_max: -3.645e4
  d_min: 0.03645
  d_max: 0.04455
  n_samples: 10

With all that taken care of, we can now run the model in the usual way:

. python/build/venv/bin/activate
muscle_manager --start-all nested_rdmc.ymmsl rd_programs.ymmsl

Note that the model(s), settings and resources are all in one file here, so that there are only two yMMSL files to pass.

Discussion

Splitting up monolithic models (or computer programs in general!) into a separate pieces is an excellent way to reduce complexity. In this example, the most complex model in the original version has four components and six conduits. In the nested version, the maximum complexity is two components and four conduits. This makes it easier to understand each piece.

This improvement does not come for free however, because the overall number of items has increased. Across the three models, we now have six components and ten conduits. If you cut your software into smaller and smaller pieces, then the overall amount of code will increase, and the additional code will become less and less meaningful.

So when designing models, the trick is to find a good middle ground. Can you understand your model? Can a person new to it easily grasp it? Then you should not split it up. If you frequently find yourself having to look up how some part worked again, and you have trouble explaining it to newcomers, then perhaps it’s time to see if you can find a good submodel to split off.

Importing implementations

In the above example, we have put all three models in a single file, with the programs in a separate one. For smaller models built by a single person this is fine, but there comes a point where it’s easier to split the model across different files. Similarly to how this works in most programming languages, we can import models and programs from a different yMMSL file and use them as implementations.

As an example, here is the reaction-diffusion submodel of our nested UQ model in a separate file:

docs/source/examples/ymmsl/reaction_diffusion.ymmsl
ymmsl_version: v0.2

imports:
- from rd_programs import implementation diffusion_cpp
- from rd_programs import implementation reaction_cpp

models:
  reaction_diffusion:
    ports:
      f_init: settings_in     # Settings
      o_f: final_state_out    # 1D array

    components:
      macro:
        ports:
          o_i: state_out
          s: state_in
          o_f: final_state_out
        description: |
          The macro model implements the diffusion process.
        implementation: diffusion_cpp
      micro:
        ports:
          f_init: initial_state
          o_f: final_state
        description: |
          The micro model implements the reaction process.
        implementation: reaction_cpp

    conduits:
      settings_in: macro.muscle_settings_in
      macro.final_state_out: final_state_out
      macro.state_out: micro.initial_state
      micro.final_state: macro.state_in

Without imports, we’re allowed to pass to the manager a yMMSL file with the model and one or more files with the programs, and MUSCLE3 will combine all that information to run the simulation.

With imports, the rules are a bit stricter, because we’re now making a large piece of software and it will become too difficult to keep track of what to pass when. Instead, in imported yMMSL files, all implementations that are used need to be available in that file, either because they’re specified there, or because they are imported.

Our reaction-diffusion model above specifies two implementations, which are imported at the top:

docs/source/examples/ymmsl/reaction_diffusion.ymmsl, lines 3-5
imports:
- from rd_programs import implementation diffusion_cpp
- from rd_programs import implementation reaction_cpp

The imports section contains a list of import statements. Vertical lists in YAML start with a - for each item, and after that is the actual import statement. Import statements have a form similar to Python:

yMMSL import statement

from <somewhere> import <kind> <name>

The <somewhere> part specifies where to import from. This works the same as in Python: you write names separated by periods, and those refer to subdirectories and finally a file. So, a.b.c refers to a file a/b/c.ymmsl that we’ll be importing something from. In the reaction-diffusion model, we’re importing from rd_programs.ymmsl, which contains the program definitions we want to use.

The <kind> part specifies what it is that we want to import. As of MUSCLE3 0.9.0, only one kind is supported, implementation. This can be either a model or a program, and we can’t really tell from the import statement which of those the thing we’re importing is, but that’s okay because we don’t need to know: both programs and models can be used as implementations and that’s what matters.

Finally, the <name> part is the name of the program or model that we want to import, in this case diffusion_cpp and reaction_cpp.

In Python, which has a very similar syntax, there are a few other ways in which you can write an import statement, like importing multiple names from a single file in a single line, and importing something under a different name. MUSCLE3 does not currently support these, so you’ll have to write your import statements exactly as shown above.

Running a simulation with imports

Besides the above reaction_diffusion submodel, the nested UQ model consists of two more models, the qmc one and the top-level rduq model. You’ll find qmc in examples/ymmsl/uncertainty.ymmsl, and a version of the top-level model that uses imports in examples/nested_rdmc_imports.ymmsl. Have a look at those files and see if the imports make sense.

With the import statements in place, we can now run the model by starting muscle_manager with only nested_rdmc_imports.ymmsl on the command line. However, we do need to point out to MUSCLE3 where it should look for files to import from, because it can’t know how we have set up our folder structure. This is done by setting an environment variable called YMMSL_PATH.

YMMSL_PATH contains a list of directories separated by colons, just like PATH, PYTHONPATH, and LD_LIBRARY_PATH if you’re familiar with those. When an import is needed from a.b.c, then MUSCLE3 will look at each path in YMMSL_PATH in turn from left to right, add /a/b/c.ymmsl, and try to import from that file.

The files we’re importing from nested_rdmc_imports.ymmsl are all in examples/ymmsl/, so all we need in our YMMSL_PATH is that directory. If you’re using bash and are in the examples folder, then this will set YMMSL_PATH correctly:

export YMMSL_PATH="${PWD}/ymmsl"

${PWD} is the present working directory, and then we add the subdirectory to that.

Next, we can run the simulation using

muscle_manager --start-all nested_rdmc_imports.ymmsl

The result will be exactly the same as for the integrated model, but now we have separate files that can be maintained separately, possibly by separate people.