Welcome to CBMOS’ documentation!

CBMOS is a Python framework for the numerical analysis of center-based models. It focuses on flexibility and ease of use and is capable of simulating up to a few thousand cells within a few seconds, or even up to 10,000 cells if GPU support is available. CBMOS shines best for exploratory tasks and prototyping, for instance when one wants to compare different sets of parameters or solvers. At the moment, it implements most popular force functions, a few first and second-order explicit solvers, and even one implicit solver. The following sections describe how to run a simple simulation and illustrate what kind of convergence studies can be performed with this package.

Getting Started

Initial condition

Setting up the initial condition of a simulation is very simple, all you need is create a list of cell objects. In this example we set up a Cartesian grid of 25 cells. Each cell will immediately divides after the simulation starts. This behavior is defined by the list of CellDivisionEvent objects. We then plot the current cell configuration using the basic plotting function provided in the utils module.

import numpy as np
import matplotlib.pyplot as plt

import cbmos
import cbmos.force_functions as ff
import cbmos.solvers.euler_forward as ef
import cbmos.cell as cl
import cbmos.utils as utils
import cbmos.events as events
n_x = 5
n_y = 5
coordinates = utils.generate_cartesian_coordinates(n_x, n_y)

sheet = [
    cl.ProliferatingCell(
        i, # Cell ID, must be unique to each cell
        [x,y], # Initial coordinates
        -6.0, # Birthtime, in this case 6 hours before the simulation starts
        True, # Whether or not the cell is proliferating
        lambda t: 6 + t # Function generating the next division time
    )
    for i, (x, y) in enumerate(coordinates)
]

event_list = [
    events.CellDivisionEvent(cell)
    for cell in sheet
]
utils.plot_2d_population(sheet)
_images/basic_example_4_0.png

Simulation

In this simulation, we use the Gls force and the Euler forward solver. The force function’s parameters are given to the simulate function as a dictionary. Parameters can also be passed to the solver in the same way. This function returns a tuple containing the time points and a list of cells for each of these time points. If needed a detailed log of the division events can be displayed by setting the log level to debug.

# Initialize model
model = cbmos.CBModel(ff.Gls(), ef.solve_ivp, dimension=2)
dt = 0.01
t_data = np.arange(0, 4, dt)

t_data, history = model.simulate(
    sheet, # Initial cell configuration
    t_data, # Times at which the history is saved
    {"mu": 5.70, "s": 1.0, "rA": 1.5}, # Force parameters
    {'dt': dt}, # Solver parameters
    event_list=event_list
)
utils.plot_2d_population(history[-1])
_images/basic_example_8_0.png

Exploring Convergence

CBMOS is aimed at performing numerical studies of Center-Based overlapping Spheres Models. In this section, we present a minimal example to study the convergence of the Euler forward and Adams-Bashforth methods for a few non-proliferating cells and for three of the most popular force functions (namely GLS, Cubic and Piecewise Quadratic) can be studied.

Setup

After importing relevant modules, we set up the simulation parameters as well as all three models (one per force function). Each model takes three parameters: the force function to be used, the solver, and the dimension of the model (usually 2 or 3). For this simple example, we will use Euler Forward for all models.

import cbmos
import cbmos.force_functions as ff
import cbmos.solvers.euler_forward as ef
import cbmos.solvers.adams_bashforth as ab
import cbmos.cell as cl

import numpy as np
import scipy.interpolate as sci

# Simulation parameters
s = 1.0    # rest length
tf = 4.0  # final time
rA = 1.5   # maximum interaction distance
dim = 2

forces = {
    'cubic':ff.Cubic(),
    'pw. quad.':ff.PiecewisePolynomial(),
    'GLS': ff.Gls()
}

solvers = {
    'EF': ef.solve_ivp,
    'AB': ab.solve_ivp,
}

models = {
        solver_name: {
            force_name: cbmos.CBModel(
                             force,
                             solver,
                             dim,
                         )
            for force_name, force in forces.items()}
    for solver_name, solver in solvers.items()
}

Initial Conditions

Then, we create a list a cells using a list of (x, y) coordinates. Each cell takes at least 2 parameters: a (unique) id, and some coordinates. Optional parameters can be used to define if and when each cell should proliferate. In this example, and for the sake of simplicity, we will only not use proliferation. Cells are set to be at 30% of their rest lenght. Once the simulation is launched, the cells will push each other and relax to a new configuration.

import numpy.random as npr

n_x = 5
n_y = 5
compactness = 0.3

initial_sheet = [
    cl.Cell(i, [compactness*x, compactness*y])
    for i, (x, y) in enumerate(
        [(x, y) for x in range(n_x) for y in range(n_y)])
]

Model Parameters

The parameters of each force function can be set with a dictionnary containing all keywords argument to be passed to the force functions. In this case, the parameters are taken from [1], where they were set so that the relaxation time between the cells is one hour.

params_cubic = {
    "mu": 5.70,
    "s": s,
    "rA": rA,
}

muR = 9.1
ratio = 0.21
params_poly = {
    'muA': ratio*muR,
    'muR': muR,
    'rA': rA,
    'rR': 1.0/(1.0-np.sqrt(ratio)/3.0),
    'n': 1.0,
    'p': 1.0,
}

mu_gls=1.95
params_gls = {
    'mu': mu_gls,
    'a':-2*np.log(0.002/mu_gls),
}

params = {
    'cubic': params_cubic,
    'pw. quad.': params_poly,
    'GLS': params_gls,
}

Reference Solutions

Here we compute the reference solutions for each force functions, using fine timesteps. The simulate function takes the inital configuration of the cell sheet, the time span over which to perform the simulation, the parameters to be used for the force functions, and the parameters to pass to the numerical solver.

In this case, since we are interested in measuring the error at the timesteps used by the solver, we set raw_t to True. Otherwise, the simulator would interpolate the position of the cells at the times specified in t_data_ref. The exact timesteps used by the solver are then returned along the history, containing the configurations of the cells at each time point. In this example we convert this history into a numpy array for efficiency.

dt_ref = 0.0005

N_ref = int(1/dt_ref*tf)+1
t_data_ref = np.arange(0, tf, dt_ref)

ref_traj = {}
for solver in solvers:
    for force in forces:
        t_data_ref, history = models[solver][force].simulate(
            initial_sheet,
            t_data_ref,
            params[force],
            {'dt': dt_ref},
            raw_t=True,
        )

        ref_traj.setdefault(solver, {})[force] = np.array(
            [
                [cell.position for cell in cell_list]
                for cell_list in history
            ]
        ) # (N_ref, n_cells, dim)

Error computation

We now compute the error between the reference solution and solutions computed with coarser timesteps.

dt_values = np.array([0.001*1.25**n for n in range(0, 22)])

sol = {}
for dt in dt_values:
    N = int(1/dt*tf) + 1
    t_data = np.arange(0, tf, dt)

    for solver in solvers:
        for force in forces:
            t_data_sol, history = models[solver][force].simulate(
                initial_sheet,
                t_data,
                params[force],
                {'dt': dt},
                raw_t=True,
            )

            traj = np.array([
                [cell.position for cell in cell_list]
                for cell_list in history
            ]) # (N, n_cells, dim)
            interp = sci.interp1d(
                t_data_sol, traj,
                axis=0,
                bounds_error=False,
                fill_value=tuple(traj[[0, -1], :, :])
            )(t_data_ref[:])
            error = (
                np.linalg.norm(
                    interp - ref_traj[solver][force],
                    axis=0)
                /np.linalg.norm(
                    ref_traj[solver][force],
                    axis=0)
            ).mean(axis=0)

            sol.setdefault(solver, {})\
                .setdefault(force, [])\
                .append(error)

Convergence Plot

import matplotlib.pyplot as plt

plt.style.use('seaborn-whitegrid')
plt.style.use('tableau-colorblind10')
params = {'legend.fontsize': 'xx-large',
          'figure.figsize': (6.75, 5),
          'lines.linewidth': 3.0,
         'axes.labelsize': 'xx-large',
         'axes.titlesize':'xx-large',
         'xtick.labelsize':'xx-large',
         'ytick.labelsize':'xx-large',
          'legend.fontsize': 'xx-large',
         'font.size': 11,
          'font.family': 'serif',
          "mathtext.fontset": "dejavuserif",
         'axes.titlepad': 12,
        'axes.labelpad': 12}
plt.rcParams.update(params)

defcolors = plt.rcParams['axes.prop_cycle'].by_key()['color']
colors = {
    'cubic': defcolors[0],
    'pw. quad.': defcolors[5],
    'GLS': defcolors[6],
}
linestyles = {
    'cubic': '-',
    'pw. quad.': '--',
    'GLS': '-.',
}

fig, ax = plt.subplots(
    1, 2,
    figsize=(14.5, 5),
    sharey=True,
    gridspec_kw={'wspace': 0.1},
)

for i, solver in enumerate(solvers):
    for force in forces:
        ax[i].loglog(
            dt_values,
            np.sum(np.array(sol[solver][force]), axis=1),
            label=force,
            color=colors[force],
            linestyle=linestyles[force]
        )

    ax[i].set_xlabel('$\Delta t$')

ax[0].set_title('Euler Forward')
ax[1].set_title('Adams-Bashforth')

ax[0].loglog(dt_values[1:-1], dt_values[1:-1]*0.5, ':',
           label='$f(\Delta t)= \Delta t$', color='grey')
ax[1].loglog(dt_values[1:-1], dt_values[1:-1]**2*4, ':',
           label='$f(\Delta t)= \Delta t^2$', color='grey')


ax[0].set_ylabel('$\epsilon_{rel}$')
ax[0].legend(loc=0)
ax[1].legend(loc=0)

plt.show()
_images/convergence_example_11_0.png

References

  1. Mathias, S., Coulier, A., Bouchnita, A., & Hellander, A. (2020). Impact of force function formulations on the numerical simulation of centre-based models. Bulletin of Mathematical Biology, 82(10), 1-43.

Implementing your own events

Although the events we provide in this package are relatively limited, CBMOS is designed to be flexible enough to use any event you wish to implement.

New event can easily be implemented by deriving the abstract Event class and overriding the __init__ and apply methods. As mentionned in Event class’ documentation, the only requirement is that the new subclass possesses an attribute tau, which represents the time at which the event is triggered. The apply method takes an object of type CBModel as unique parameter. This object contains all the information about the current simulation and can be used to add, modify and remove cells.

In this example, we implement two simple events:

  • GlobalDeathEvent selects one cell at random and removes it from the simulation.

  • GlobalDivisionEvent selects one cell at random and duplicates it.

Here, we set the simulation so that these events are triggered at regular intervals. This mimics other cell-based software, where the simulation is stopped at regular intervals to check if events have taken place.

Although cell division and cell death can be implemented in many different ways, depending of the complexity of the biological model. We hope these examples illustrate how CBMOS can handle such a variety of possibilities.

import numpy as np
import numpy.random as npr

npr.seed(0)

from cbmos.events import Event
from cbmos.cell import Cell

class GlobalDeathEvent(Event):
    def __init__(self, tau):
        self.tau = tau

    def apply(self, cbmodel):
        target_cell = npr.choice(cbmodel.cell_list)
        del cbmodel.cell_list[npr.randint(len(cbmodel.cell_list))]

class GlobalDivisionEvent(Event):
    def __init__(self, tau):
        self.tau = tau

    def apply(self, cbmodel):
        target_cell = npr.choice(cbmodel.cell_list)

        division_direction = self.get_division_direction(cbmodel)
        updated_position_parent = target_cell.position \
            - 0.5 * cbmodel.separation * division_direction
        position_daughter = target_cell.position \
            + 0.5 * cbmodel.separation * division_direction

        daughter_cell = Cell(cbmodel.next_cell_index, position_daughter)
        cbmodel.next_cell_index += 1
        cbmodel.cell_list.append(daughter_cell)

        target_cell.position = updated_position_parent

    def get_division_direction(self, cbmodel):
        if cbmodel.dim == 1:
            division_direction = np.array([-1.0 + 2.0 * npr.randint(2)])

        elif cbmodel.dim == 2:
            random_angle = 2.0 * np.pi * npr.rand()
            division_direction = np.array([
                np.cos(random_angle),
                np.sin(random_angle)])

        elif cbmodel.dim == 3:
            u = npr.rand()
            v = npr.rand()
            random_azimuth_angle = 2 * np.pi * u
            random_zenith_angle = np.arccos(2 * v - 1)
            division_direction = np.array([
                np.cos(random_azimuth_angle) * np.sin(random_zenith_angle),
                np.sin(random_azimuth_angle) * np.sin(random_zenith_angle),
                np.cos(random_zenith_angle)])

        return division_direction
import numpy as np

import cbmos.cell as cl
import cbmos.utils as utils

T = 4
t_data = np.linspace(0, T, num=6)

n_x = 5
n_y = 5
coordinates = utils.generate_cartesian_coordinates(n_x, n_y)

sheet = [
    cl.Cell(
        i, # Cell ID, must be unique to each cell
        [x,y], # Initial coordinates
    )
    for i, (x, y) in enumerate(coordinates)
]

event_list = [
    GlobalDivisionEvent(tau)
    for tau in np.linspace(0, T, num=50)
] + [
    GlobalDeathEvent(tau)
    for tau in np.linspace(0, T, num=25)
]
import cbmos
import cbmos.force_functions as ff
import cbmos.solvers.euler_forward as ef

# Initialize model
model = cbmos.CBModel(ff.Gls(), ef.solve_ivp, dimension=2)
t_data, history = model.simulate(
    sheet, # Initial cell configuration
    t_data, # Times at which the history is saved
    {"mu": 5.70, "s": 1.0, "rA": 1.5}, # Force parameters
    {'dt': 0.01}, # Solver parameters
    event_list=event_list,
    raw_t=False,
)
import matplotlib.pyplot as plt

color = 'blue'

fig, ax = plt.subplots(2, 3)
for i, cells in enumerate(history):
    for cell in cells:
        ax[i//3, i%3].add_patch(plt.Circle(cell.position, 0.5, color=color, alpha=0.4))
        ax[i//3, i%3].plot(cell.position[0], cell.position[1], '.', color=color)
        ax[i//3, i%3].set_aspect('equal')
        ax[i//3, i%3].set_xlim(-2, 6.5)
        ax[i//3, i%3].set_ylim(-2.5, 7)
        ax[i//3, i%3].set_title(f"t = {t_data[i]:.1f}")

fig.tight_layout()
_images/generalized_events_6_0.png

cbmos

Indices and tables