Accuracy Testing of Contact Reactions

cellular_raza 0.1.12 introduced the new simulation aspect of ReactionsContact which can couple the intracellular reactions of individual cells. Testing the individual Adams-Bashforth solver which is used in the contact reactions update function reactions_contact_adams_bashforth_2nd is part of a different testing approach. In contrast, these tests take a high-level view and aim to match known results for a complete system onto results generated by cellular_raza.

Two-Component Uncoupled System

To get a general understand of what contact reactions are aiming to solve, we refer to the Reactions section. In this test, we test that a fully uncoupled system is correctly solved and no unspecified couplings are introduced during the process. Furthermore, this also tests for the accuracy of the used solver.

Theoretical Formulation

The first test ensures that a system of two components given by

$$\begin{align} \dot{x}(t) &= f(x) = \alpha\\ \dot{y}(t) &= g(y) = \alpha y \left(1 - \frac{y}{y_\text{max}} \right) \end{align}$$

is correctly solved and no unintended coupling is introduced. These equations correspond to linear growth for $x(t)$ and the well-known logistic curve [1] in $y(t)$. The analytical solutions to these equations are given by

$$\begin{align} x(t) &= \alpha(t-t_0)\\ y(t) &= y_\text{max}\left(1+ \frac{y_\text{max}-y_0}{y_0} e^{-\alpha(t-t_0)} \right)^{-1}. \end{align}$$

Implementation in cellular_raza

To model them with cellular_raza we define a new agent type ContactCell.

contact_reactions.rs
197
198
199
200
201
202
203
204
205
206
207
208
209
210
    use cellular_raza::building_blocks::*;
    use cellular_raza::concepts::*;
    use cellular_raza::core::{backend::chili::*, storage::*, time::*};

    use serde::{Deserialize, Serialize};

    #[derive(CellAgent, Clone, Debug, Deserialize, Serialize)]
    struct ContactCell {
        intracellular: nalgebra::Vector2<f64>,
        alpha0: f64,
        upper_limit: f64,
        #[Position]
        mechanics: NewtonDamped1DF32,
    }

Notice that the mechanics: NewtonDamped1DF32 component only serves as the positional information such that we can use the predefined CartesianCuboid as the simulation domain. Furthermore, we implement the required concepts Intracellular, ReactionsContact as follows.

contact_reactions.rs
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
    impl Intracellular<nalgebra::Vector2<f64>> for ContactCell {
        fn get_intracellular(&self) -> nalgebra::Vector2<f64> {
            self.intracellular
        }
        fn set_intracellular(&mut self, intracellular: nalgebra::Vector2<f64>) {
            self.intracellular = intracellular;
        }
    }

    impl ReactionsContact<nalgebra::Vector2<f64>, nalgebra::Vector1<f32>> for ContactCell {
        fn calculate_contact_increment(
            &self,
            own_intracellular: &nalgebra::Vector2<f64>,
            ext_intracellular: &nalgebra::Vector2<f64>,
            _own_pos: &nalgebra::Vector1<f32>,
            _ext_pos: &nalgebra::Vector1<f32>,
            _rinf: &(),
        ) -> Result<(nalgebra::Vector2<f64>, nalgebra::Vector2<f64>), CalcError> {
            let calculate_incr = |y: f64| -> f64 { self.alpha0 * y * (1.0 - y / self.upper_limit) };
            let own_dr = [self.alpha0, calculate_incr(own_intracellular[1])].into();
            let ext_dr = [self.alpha0, calculate_incr(ext_intracellular[1])].into();
            Ok((own_dr, ext_dr))
        }
        fn get_contact_information(&self) -> () {}
    }

The ReactionsContact trait requires that agents interact with each other, meaning we have to at least insert 2 agents into the simulation to obtain any effect. Since we employ no restraints on the range of interaction by not using the values _own_pos or _ext_pos in the calculate_contact_increment function, every cell will interact with each other. This also results in an increased reaction speed when more than 2 cells are present, meaning our variable $\alpha$ for the exact solution is actually dependent on the number of species.

$$\begin{equation} \alpha = (N_\text{agents}-1)\alpha_0 \end{equation}$$

We have named the variable of the individual agents accordingly.

Solving the System

Now we are ready to solve our system with the run_simulation! macro. To test various configurations, we write a function which takes in all needed parameters to solve the system.

contact_reactions.rs
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
    fn run_cellular_raza(
        alpha0: f64,
        y0: [f64; 2],
        upper_limit: f64,
        n_agents: usize,
        t0: f64,
        dt: f64,
        save_interval: usize,
        t_max: f64,
    ) -> Result<Vec<(f64, Vec<[f64; 2]>)>, SimulationError> {
        // Define initial values
        let y0 = nalgebra::Vector2::from(y0);

        // Agents
        let agents = (0..n_agents).map(|_| ContactCell {
            alpha0,
            intracellular: y0,
            upper_limit,
            mechanics: NewtonDamped1DF32 {
                pos: [0.5].into(),
                vel: [0.0].into(),
                damping_constant: 0.0,
                mass: 0.0,
            },
        });

        // Specify simulation domain, time and only store results intermediately in memory
        let domain = CartesianCuboid::from_boundaries_and_n_voxels([0.0; 1], [1.0; 1], [1; 1])?;
        let time = FixedStepsize::from_partial_save_freq(t0, dt, t_max, save_interval)?;
        let storage = StorageBuilder::new().priority([StorageOption::Memory]);
        let settings = Settings {
            n_threads: 1.try_into().unwrap(),
            show_progressbar: false,
            storage,
            time,
        };

        // Run full simulation and return storager to access results
        let storager = run_simulation!(
            agents: agents,
            settings: settings,
            domain: domain,
            aspects: [ReactionsContact],
        )?;

        // Gather cellular_raza results
        Ok(storager
            .cells
            .load_all_elements()?
            .into_iter()
            .map(|(iteration, elements)| {
                (
                    t0 + iteration as f64 * dt,
                    elements
                        .into_iter()
                        .map(|(_, (cbox, _))| cbox.cell.get_intracellular().into())
                        .collect(),
                )
            })
            .collect())
    }

Comparing Results

In order to meaningfully compare numerical results, we need an estimate for the local and global truncation error [2] which is introduced by our numerical solver. For a given ODE in the form of

$$\begin{equation} \dot{y} = f(t, y) \end{equation}$$

which can be solved by an algorithm $A$ in the form of

$$\begin{equation} y_n = y_{n-1} + \Delta t A(t_{n-1}, y_{n-1}, \Delta t, f) \end{equation}$$

the local truncation error $\tau_n$ is given by

$$\begin{equation} \tau_n = y(t_n) - y(t_{n-1}) - \Delta t A(t_{n-1}, y_{n-1}, \Delta t, f) \end{equation}$$

and is related to the global truncation error $e_n$ via

$$\begin{align} e_n &= y(t_n) - y_n\\ &= y(t_n) - (y_0 + \Delta A(t_0, y_0, \Delta t, f) + \dots + \Delta t A(t_{n-1},y_{n-1},\Delta t, f). \end{align}$$

Under the additional condition that $f$ is Lipschitz with Lipschitz-constant $L$, we can derive a bound for the global error.

$$\begin{equation} |e_n| \leq \frac{\text{max}_j \tau_j}{\Delta t L}\left(e^{L(t-t_0)} + 1 \right) \end{equation}$$

where $\tau_n$ is the local truncation error at time step $n$. By finding an upper bound for it, we can further simplify this formula. The local truncation error $\tau_n$ depends on the type of solver used. In our case, we employ the Adams-Bashforth [3] solver with 3rd order. The executed function in the chili backend is contact_reactions_adams_bashforth_3rd. The local truncation error is bound

$$\begin{equation} |\tau_n| \leq \frac{3\Delta t^4}{8}\sup\limits_{t\in(t_n-2\Delta t,x_n+\Delta t)}|y^{(4)}(t)| \end{equation}$$

where $y^{(4)}(t)$ is the fourth-order derivative of the analytical solution $y$. We can calculate this value for both ODEs by differentiating equations $(3)$ and $(4)$.

$$\begin{align} x^{(n)}(t) &= 0\\ y^{(n)}(t) &= n! y_\text{max} \alpha^n\left(\frac{y_\text{max}-y_0}{y_0}\right)^n \left(1+\frac{y_\text{max}-y_0}{y_0}e^{-\alpha(t-t_0)}\right)^{-(n+1)}\\ \end{align}$$

With this upper bound on the local truncation error we can construct a new function which tests that this upper bound is fulfilled. We begin by writing down a general formula for the nth drivative of the exact solution to the logistic curve ODE problem as given above. Notice that we deliberately use arguments for initial values and formulate the solution for the most general case.

contact_reactions.rs
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
    fn compare_results(
        production: f64,
        y0_first: [f64; 2],
        upper_limit: f64,
        n_agents: usize,
        t0_first: f64,
        dt: f64,
        save_interval: usize,
        t_max: f64,
        #[allow(unused)] save_filename: &str,
    ) -> Result<(), SimulationError> {
        // Define exact solution
        let exact_solution_derivative =
            |t: f64, y0: [f64; 2], t0: f64, n_deriv: i32| -> nalgebra::Vector2<f64> {
                let q = (upper_limit - y0[1]) / y0[1];
                let linear_growth = if n_deriv == 0 {
                    y0[0] + (n_agents - 1) as f64 * production * (t - t0)
                } else {
                    0.0
                };
                let logistic_curve = (1..n_deriv).product::<i32>() as f64
                    * upper_limit
                    * q.powi(n_deriv)
                    * (1.0 + q * (-production * (n_agents - 1) as f64 * (t - t0)).exp())
                        .powi(-(n_deriv + 1));
                nalgebra::Vector2::from([linear_growth, logistic_curve])
            };

        // Estimate upper bound on local and global truncation error
        let lipschitz_constant = nalgebra::vector![

In the next step we calculate the Lipschitz-constants $L_0,L_1$ and use them together with the fourth derivative bound to calculate the local and global truncation errors. The Lipschitz condition is given by

$$\begin{equation} |f(s) - f(t)| \leq L_0 |s-t| \end{equation}$$

In our case, the Lipschitz constant for $f(x)$ could be taken to be indentiaclly $0$. This would introduce a constant global error in the limit $L_0\rightarrow 0$. However, due to numerical uncertainties in even addition operations, we opt to take the more conservative approach and choose $L_0 = \alpha$.

We calculate Lipschitz-constant $L_1$ for $g(y)$ given by equation $(2)$.

$$\begin{align} |g(y)-g(z)| &= \left|\alpha y\left(1-\frac{y}{y_\text{max}}\right) - \alpha z\left(1-\frac{z}{y_\text{max}}\right) \right|\\ &=\alpha |y-z|\left|\left(1 - \frac{y+z}{y_\text{max}}\right) \right|\\ &\leq|y-z|\text{max}\left(1-\frac{2y_0}{y_\text{max}},1-\frac{2y(t_\text{max})}{y_\text{max}}\right)\\ L_1 &= \text{max}\left(1-\frac{2y_0}{y_\text{max}},1-\frac{2y(t_\text{max})}{y_\text{max}}\right) \end{align}$$

contact_reactions.rs
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
    fn compare_results(
        production: f64,
        y0_first: [f64; 2],
        upper_limit: f64,
        n_agents: usize,
        t0_first: f64,
        dt: f64,
        save_interval: usize,
        t_max: f64,
        #[allow(unused)] save_filename: &str,
    ) -> Result<(), SimulationError> {
        // Define exact solution
        let exact_solution_derivative =
            |t: f64, y0: [f64; 2], t0: f64, n_deriv: i32| -> nalgebra::Vector2<f64> {
                let q = (upper_limit - y0[1]) / y0[1];
                let linear_growth = if n_deriv == 0 {
                    y0[0] + (n_agents - 1) as f64 * production * (t - t0)
                } else {
                    0.0
                };
                let logistic_curve = (1..n_deriv).product::<i32>() as f64
                    * upper_limit
                    * q.powi(n_deriv)
                    * (1.0 + q * (-production * (n_agents - 1) as f64 * (t - t0)).exp())
                        .powi(-(n_deriv + 1));
                nalgebra::Vector2::from([linear_growth, logistic_curve])
            };

        // Estimate upper bound on local and global truncation error
        let lipschitz_constant = nalgebra::vector![

In the final step, we use the already defined function `` to generate results from cellular_raza and compare them with the exact known results. Their difference has to be within the margin of the calculated global truncation error $e$. Note that we only start comparing after the first 3 initial steps which are performed by solvers of lower orders (Euler and Adams-Bashforth 2nd) due to insufficient information about increments of previous integration steps. Afterwards, we use the numerically calculated value at $t=t_0 + 2\Delta t$ as the new initial value.

contact_reactions.rs
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
        // Obtain solutions from cellular_raza
        let solutions_cr = run_cellular_raza(
            production,
            y0_first,
            upper_limit,
            n_agents,
            t0_first,
            dt,
            save_interval,
            t_max,
        )?;

        // Compare the results
        let mut results = vec![];
        let mut t0 = t0_first;
        let mut y0 = y0_first;
        for (n_run, (t, res_cr)) in solutions_cr.into_iter().enumerate() {
            if n_run < 3 {
                t0 = t;
                y0 = res_cr[0];
            } else {
                let res_ex = exact_solution_derivative(t, y0, t0, 0);
                let e_global = global_truncation_error(t);
                let e_local = local_truncation_error;
                for r in res_cr.iter() {
                    let d0 = (r[0] - res_ex[0]).abs();
                    let d1 = (r[1] - res_ex[1]).abs();
                    assert!(d0 < e_global[0]);
                    assert!(d1 < e_global[1]);
                }
                results.push((t, res_ex, e_global, e_local, res_cr));
            }
        }

        #[cfg(not(debug_assertions))]
        save_results(results, save_filename);

        Ok(())
    }

In the last step, we also added a function save_results which exports all generated results to a .csv file which.

contact_reactions.rs
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
    #[allow(unused)]
    fn save_results(
        results: Vec<(
            f64,
            nalgebra::Vector2<f64>,
            nalgebra::Vector2<f64>,
            nalgebra::Vector2<f64>,
            Vec<[f64; 2]>,
        )>,
        save_filename: &str,
    ) {
        use std::fs::File;
        use std::io::prelude::*;
        let mut file = File::create(save_filename).unwrap();
        for (t, res_ex, e_global, e_local, res_cr) in results {
            write!(
                file,
                "{},{},{},{},{},{},{}",
                t, e_global[0], e_global[1], e_local[0], e_local[1], res_ex[0], res_ex[1]
            )
            .unwrap();
            for r_cr in res_cr {
                write!(file, ",{},{}", r_cr[0], r_cr[1]).unwrap();
            }
            writeln!(file, "").unwrap();
        }
    }

Equipped with this function compare_results, we can now use it for a collection of configurations to test the solver.

contact_reactions.rs
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
    #[test]
    fn test_config0() {
        // Simulation parameters
        let production = 0.2;
        let y0 = [1.0, 2.0];
        let upper_limit = 12.0;
        let t0 = 3.0;
        let dt = 0.01;
        let save_interval = 50;
        let t_max = 20.0;
        let n_agents = 2;
        compare_results(
            production,
            y0,
            upper_limit,
            n_agents,
            t0,
            dt,
            save_interval,
            t_max,
            "tests/contact_reactions-config0.csv",
        )
        .unwrap();
    }

The generated files can then be used by a small python script to create the plots seen below.

plot.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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
import matplotlib.pyplot as plt
import numpy as np
from glob import glob

if __name__ == "__main__":
    files = glob("tests/*.csv")

    for file in files:
        # One line in such a file has the following entries
        # (
        #   t,
        #   gerror_bound0, gerror_bound1,
        #   lerror_bound0, lerror_bound1,
        #   res_exact0, res_exact_1,
        #   res_cr0_0, res_cr1_0,
        #   res_cr1_0, res_cr1_1,
        #   ...
        # )
        results = np.genfromtxt(file, delimiter=",")

        t = results[:,0]
        gerror = results[:,2]
        lerror = results[:,4]
        res_exact = results[:,6]

        fig, ax = plt.subplots()
        for n in range(7, results.shape[1]):
            if n % 2 == 0:
                ax.plot(t, results[:,n], label="Solution {:1.0f}".format(n), linestyle="--")
        ax.errorbar(
            t,
            res_exact,
            gerror,
            label="Analytical Solution",
            linestyle=":",
            color="k",
            alpha=0.5
        )
        ax.set_title("cellular_raza/" + str(file))
        ax.legend()
        fig.tight_layout()
        fig.savefig(file.replace(".csv", ".png"))

The full code can be found under cellular_raza/tests/contact_reactions.rs. To run it yourself, clone the git repo git clone https://github.com/jonaspleyer/cellular_raza and execute cargo test -- two_component_contact_reaction in the cellular_raza repository directory.

Results

The following plots she the calculated results from cellular_raza and the analytical solution of the second ODE described above in equation $(2)$ for various configurations of parameters. They have been chosen in such a way to capture different dynamics and time-scales. The global truncation error is used as errorbars for the analytical solution.

In the first image, we can clearly see the exponential growth of the global truncation error $e$ over time. The size of the errorbars is mainly determined by the time interval $\Delta t$ chosen to solve the equations. The second studied example config1 shows how a low step-size $\Delta t$ yields results which follow the trajectory of the analytical solution to an even higher margin of error than before.

Discussion

We have shown how to derive useful bounds for the local and global truncation error of a result with a known analytical solution. Furthermore, these mathematical results have been implemented in an automated testing scheme to measure and verify the solvers behind the ReactionsContact simulation aspect of cellular_raza.

Results generated initially are of lower accuracy. This can be explained by the internals of our implementation of the Adams-Bashforth [3] solver. The numerical solver does not have any knowledge about the increments $\Delta y_n$ before the simulation has started (ie. $n<0$). It is thus forced to assume nothing and reverts back to the 1st order case. For the second simulation step, the 2nd order Adams-Bashforth solver can be used and only afterwards do we have enough information about previous time increments such that the full 3rd order solver can take over. This behaviour only occurrs in the very initial steps of our numerical routine.

References

[1] P. F. Verhulst, “Recherches mathématiques sur la loi d’accroissement de la population.” Nouveaux mémoires de l’Académie Royale des Sciences et Belles-Lettres de Bruxelles, vol. 18, pp. 14–54, 1845, Available: http://eudml.org/doc/182533

[2] E. Süli and D. F. Mayers, “An Introduction to Numerical Analysis.” Cambridge University Press, Aug. 28, 2003. doi: 10.1017/cbo9780511801181

[3] Bashforth, Francis and Adams, J. Couch An attempt to test the theories of capillary action : by comparing the theoretical and measured forms of drops of fluid. Cambridge [Eng.]: Cambridge University Press, 1883

[4] G. Fasshauer, “Numerical Methods for Differential Equations/Computational Mathematics II,” 2007, Available: http://www.math.iit.edu/~fass/478578_Chapter_2.pdf