Getting Started

Getting Started

In this introduction we will desribe the basic way of using cellular_raza. We assume that the reader is already somewhat familiar with the Rust programming language and has installed the cargo package manager.

Simulation Code

To create a new project from scratch, initialize an empty project with cargo and change to this directory.

cargo init my-new-project
cd my-new-project

Afterwards add cellular_raza as a dependency.

cargo add cellular_raza serde rand rand_chacha num

Afterwards, we have the following structure of files.

    • main.rs
  • Cargo.toml
  • For now, we only implement physical interactions via the Mechanics and interaction simulation aspects. We can quickly build simulations by combining already existing building_blocks with the CellAgent derive macro.

    Imports

    We begin with some import statements which will be used later on.

    cellular_raza-examples/getting-started/src/main.rs
    1
    2
    3
    4
    5
    
    use cellular_raza::prelude::*;
    
    use rand::Rng;
    use rand_chacha::rand_core::SeedableRng;
    use serde::{Deserialize, Serialize};

    Cellular Agent

    In the next step, we define the cellular agent. This simplistic example only considers two cellular aspects: Mechanics and Interaction. We describe the movement of cells via Langevin dynamics with the Langevin2D struct which assumes that cells can be represented as point-like particles in $d=2$ dimensions. Furthermore, every cell moves stochastically through space. They interact via forces given by the MorsePotential struct.

    cellular_raza-examples/getting-started/src/main.rs
     7
     8
     9
    10
    11
    12
    13
    
    #[derive(CellAgent, Clone, Deserialize, Serialize)]
    struct Agent {
        #[Mechanics]
        mechanics: Langevin2D,
        #[Interaction]
        interaction: MorsePotential,
    }

    We define a struct which stores all necessary parameters of the system. This gives us a unified way to change parameters of the simulation.

    ℹ️
    Note that this step is not strictly required but we highly recommend it.
    cellular_raza-examples/getting-started/src/main.rs
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    
    struct Parameters {
        domain_size: f64,
        cell_number: usize,
        cell_mechanics: Langevin2D,
        cell_interaction: MorsePotential,
        time_dt: f64,
        time_save_interval: f64,
        time_start: f64,
        time_end: f64,
        n_threads: std::num::NonZeroUsize,
    }
    
    impl Default for Parameters {
        fn default() -> Self {
            Self {
                domain_size: 100.0, // µm
                cell_number: 60,
                cell_mechanics: Langevin2D {
                    pos: [0.0; 2].into(), // µm
                    vel: [0.0; 2].into(), // µm / min
                    damping: 2.0,         // 1/min
                    mass: 1.0,            // picogram
                    kb_temperature: 0.3,  // picogram µm^2 / min^2
                },
                cell_interaction: MorsePotential {
                    radius: 2.0,          // µm
                    potential_width: 1.0, // 1/µm
                    cutoff: 6.0,          // µm
                    strength: 0.01,       // picogram * µm / min^2
                },
                time_dt: 0.01,           // min
                time_save_interval: 1.0, // min
                time_start: 0.0,         // min
                time_end: 1_000.0,       // min
                n_threads: 1.try_into().unwrap(),
            }
        }
    }

    In the next step we initialize all components of the simulation. We start by creating the cellular agents by using the values from the Parameters struct. Only the position is overwritten such that cells are placed inside the domain randomly. We chose the CartesianCuboid struct as our domain and initialize it from the domain size and the interaction cutoff of the MorsePotential.

    At last, we define start, end and time-increment together with the folder to store results. This folder will be automatically created. All properties are stored in the Settings struct.

    cellular_raza-examples/getting-started/src/main.rs
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    
    fn main() -> Result<(), SimulationError> {
        let parameters = Parameters::default();
    
        // Define the seed
        let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(1);
    
        let cells = (0..parameters.cell_number).map(|_| {
            let pos = [
                rng.gen_range(0.0..parameters.domain_size),
                rng.gen_range(0.0..parameters.domain_size),
            ];
            Agent {
                mechanics: Langevin2D {
                    pos: pos.into(),
                    ..parameters.cell_mechanics.clone()
                },
                interaction: parameters.cell_interaction.clone(),
            }
        });
    
        let domain = CartesianCuboid2New::from_boundaries_and_interaction_ranges(
            [0.0; 2],
            [parameters.domain_size; 2],
            [parameters.cell_interaction.cutoff * 2.0; 2],
        )?;
    
        let time = FixedStepsize::from_partial_save_interval(
            parameters.time_start,
            parameters.time_dt,
            parameters.time_end,
            parameters.time_save_interval,
        )?;
        let storage_builder = StorageBuilder::new().location("out");
    
        let settings = Settings {
            n_threads: parameters.n_threads,
            time,
            storage: storage_builder,
            show_progressbar: true,
        };

    Finally, we can run the simulation. The chili backend uses the run_simulation macro to set up and run the specified simulation. Also, we need to tell our simulation which aspects to solve for.

    cellular_raza-examples/getting-started/src/main.rs
     95
     96
     97
     98
     99
    100
    101
    102
    
        run_simulation!(
            domain: domain,
            agents: cells,
            settings: settings,
            aspects: [Mechanics, Interaction]
        )?;
        Ok(())
    }

    Executing the Simulation

    Now we can use cargo to compile and execute the project in release mode with all possible optimizations.

    A progress bar will show up that indicates the progress and speed of execution.

    $ cargo run -r
        Finished `release` profile [optimized] target(s) in 0.40s
         Running `my-new-project/target/release/cr_getting_started`
     81%|██████████████████████▊     | 80856/100000 [00:02, 28803.93it/s]

    During the simulation, results will be written to the out folder which contains information about every individual agent and the simulation domain. In the next step, we will see how to visualize these results.

        • Visualization

          Reading Results

          To visualize the results spatially, we write a small python script. We utilize the numpy, matplotlib and tqdm packages. Every other functionality is contained in the standard library of python.

          We again start by importing the required functionalities. Their uses will become clear in just a moment.

          cellular_raza-examples/getting-started/src/plotting.py
          1
          2
          3
          4
          5
          6
          7
          8
          9
          
          from pathlib import Path
          import json
          from glob import glob
          import os
          import numpy as np
          import matplotlib.pyplot as plt
          from matplotlib.patches import Circle
          from matplotlib.collections import PatchCollection
          import tqdm# For nice progress bar in the terminal (optional)

          The results of the simulation have been saved in the popular json format. Cells and subdomains are stored in separate folders.

                • We define a couple of functions to load and store these results. Note that the only information related to the subdomains which is used for plotting is the total domain size which is fixed from the beginning. We can therefore load it only once and need not to change it again.

                  cellular_raza-examples/getting-started/src/plotting.py
                  11
                  12
                  13
                  14
                  15
                  16
                  17
                  18
                  19
                  20
                  21
                  22
                  23
                  24
                  25
                  26
                  27
                  28
                  29
                  30
                  31
                  
                  def get_last_output_path() -> Path:
                      return Path(sorted(glob(str("out/*")))[-1])
                  
                  def get_all_iterations(opath: Path = get_last_output_path()) -> np.ndarray:
                      iteration_dirs = glob(str(opath) + "/cells/json/*")
                      return np.array(sorted([int(os.path.basename(dir)) for dir in iteration_dirs]))
                  
                  def get_cells_at_iteration(iteration: int, opath: Path = get_last_output_path()):
                      cells = []
                      for batch in glob(str(opath) + "/cells/json/{:020}/*".format(iteration)):
                          f = open(batch)
                          cells.extend([c["element"] for c in json.load(f)["data"]])
                      return cells
                  
                  def _get_domain_size(iteration: int, opath: Path = get_last_output_path()):
                      singles = glob(str(opath) + "/subdomains/json/{:020}/*".format(iteration))
                      f = open(singles[0])
                      sdm = json.load(f)
                      domain_min = sdm["element"]["domain_min"]
                      domain_max = sdm["element"]["domain_max"]
                      return domain_min, domain_max

                  Creating Snapshots

                  It is usefull to define a new class for plotting the results. The Plotter class creates a figure and axis when being initialized. It can reuse these variables when plotting multiple iterations in succession. This is being done by the plot_iteration method while save_iteration also stores the created figure in the path where the results are stored in.

                  cellular_raza-examples/getting-started/src/plotting.py
                  33
                  34
                  35
                  36
                  37
                  38
                  39
                  40
                  41
                  42
                  43
                  44
                  45
                  46
                  47
                  48
                  49
                  50
                  51
                  52
                  53
                  54
                  55
                  56
                  57
                  58
                  59
                  60
                  61
                  62
                  63
                  64
                  65
                  66
                  67
                  68
                  69
                  70
                  71
                  72
                  73
                  74
                  
                  class Plotter:
                      def __init__(self, opath: Path = get_last_output_path(), fig = None):
                          iterations = get_all_iterations(opath)
                          self.domain_min, self.domain_max = _get_domain_size(iterations[0], opath)
                          s = (self.domain_max[1] - self.domain_min[1]) / (self.domain_max[0] - self.domain_min[0])
                          self.fig, self.ax = plt.subplots(figsize=(6, s*6))
                          self.ax.set_xlim((self.domain_min[0], self.domain_max[0]))
                          self.ax.set_ylim((self.domain_min[1], self.domain_max[1]))
                          self.fig.tight_layout()
                          self.opath = opath
                  
                      def plot_iteration(
                              self,
                              iteration: int,
                          ):
                      
                          self.ax.set_xlim((self.domain_min[0], self.domain_max[0]))
                          self.ax.set_ylim((self.domain_min[1], self.domain_max[1]))
                      
                          cells = get_cells_at_iteration(iteration, self.opath)
                          positions = np.array([cell[0]["cell"]["mechanics"]["pos"] for cell in cells])
                          radii = np.array([cell[0]["cell"]["interaction"]["radius"] for cell in cells])
                          patches = PatchCollection(
                              [Circle(pos, radius) for (pos, radius) in zip(positions, radii)],
                              facecolor="green",
                              edgecolor="k",
                          )
                          self.ax.add_collection(patches)
                  
                      def save_iteration(
                          self,
                          iteration: int,
                      ):
                          self.plot_iteration(iteration)
                          os.makedirs(str(self.opath) + "/snapshots/", exist_ok=True)
                          self.fig.savefig(str(self.opath) + "/snapshots/{:020}.png".format(iteration))
                          self.ax.cla()
                  
                      def save_all_iterations(self):
                          iterations = get_all_iterations()
                          for iteration in tqdm.tqdm(iterations, total=len(iterations)):
                              self.save_iteration(iteration)

                  Generating Movie (Optional)

                  We can create a movie from the individual snapshots. To do this, we utilize ffmpeg

                  Code
                  cellular_raza-examples/getting-started/src/plotting.py
                  76
                  77
                  78
                  79
                  80
                  81
                  82
                  83
                  84
                  85
                  86
                  87
                  88
                  
                  def generate_movie(opath: Path = get_last_output_path(), and_open: bool = False):
                      cmd = "ffmpeg\
                          -y\
                          -pattern_type glob\
                          -i \'{}/snapshots/*.png\'\
                          -c:v libx264\
                          -r 15\
                          -pix_fmt yuv420p {}/movie.mp4\
                          ".format(opath, opath)
                      os.system(cmd)
                      if and_open:
                          cmd2 = "ffplay {}/movie.mp4".format(opath)
                          os.system(cmd2)

                  Main

                  In our main function, we combine all the functionality defined above.

                  cellular_raza-examples/getting-started/src/plotting.py
                  90
                  91
                  92
                  93
                  
                  if __name__ == "__main__":
                      plotter = Plotter()
                      plotter.save_all_iterations()
                      generate_movie(and_open=True)
                  

                  After we have generated the snapshots and the movie, our folder structure now contains these additional files.

                          • movie.mp4