4a. Numerical solution of FFLs


[1]:
# Colab setup ------------------
import os, sys, subprocess
if "google.colab" in sys.modules:
    cmd = "pip install --upgrade colorcet biocircuits watermark"
    process = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
# ------------------------------

import numpy as np
import scipy.integrate

import biocircuits

import colorcet
colors = colorcet.b_glasbey_category10

import bokeh.io
import bokeh.layouts
import bokeh.models
import bokeh.plotting

bokeh.io.output_notebook()
Loading BokehJS ...

It is useful to write functions to solve for the dynamics of FFLs in response to a step up and a step down to quickly explore the dynamics of the various sub-architectures of the FFL.

As a reminder, the architectures for the various FFLs are shown below.

FFL classes

Each architecture may feature AND or OR logic for the regulation of Z by X and Y. As we worked out in the chapter, the dimensionless dynamical equations for an FFL are

\begin{align} \frac{\mathrm{d}y}{\mathrm{d}t} &= \beta\,f_y\left(\kappa x; n_{xy}\right) - y,\\[1em] \gamma^{-1}\,\frac{\mathrm{d}z}{\mathrm{d}t} &= f_z(x, y; n_{xz}, n_{yz}) - z, \end{align}

where the function \(f_y\) specifies how the expression of Y is regulated by the concentration of X and the function \(f_z\) specifies how the expression of Z is regulated by the concentrations of X and Y. We can use the regulatory functions included in the biocircuits when we code up the ODEs for FFLs.

It is convenient to define a function that will give back a function that we can use as the right-hand side we need to specify to scipy.integrate.odeint(). Remember that odeint() requires a function of the form func(yz, t, *args), where yz is an array of length two containing the values of \(y\) and \(z\). For convenience, our function will return a function with call signature rhs(yz, t, x), where x is the value of \(x\) at a given time point.

[2]:
def ffl_rhs(beta, gamma, kappa, n_xy, n_xz, n_yz, ffl, logic):
    """Return a function with call signature fun(yz, x) that computes
    the right-hand side of the dynamical system for an FFL. Here,
    `yz` is a length two array containing concentrations of Y and Z.
    """
    if ffl[:2].lower() in ("c1", "c3", "i1", "i3"):
        fy = lambda x: biocircuits.act_hill(x, n_xy)
    else:
        fy = lambda x: biocircuits.rep_hill(x, n_xy)

    if ffl[:2].lower() in ("c1", "i4"):
        if logic.lower() == "and":
            fz = lambda x, y: biocircuits.aa_and(x, y, n_xz, n_yz)
        else:
            fz = lambda x, y: biocircuits.aa_or(x, y, n_xz, n_yz)
    elif ffl[:2].lower() in ("c4", "i1"):
        if logic.lower() == "and":
            fz = lambda x, y: biocircuits.ar_and(x, y, n_xz, n_yz)
        else:
            fz = lambda x, y: biocircuits.ar_or(x, y, n_xz, n_yz)
    elif ffl[:2].lower() in ("c2", "i3"):
        if logic.lower() == "and":
            fz = lambda x, y: biocircuits.ar_and(y, x, n_yz, n_xz)
        else:
            fz = lambda x, y: biocircuits.ar_or(y, x, n_yz, n_xz)
    else:
        if logic.lower() == "and":
            fz = lambda x, y: biocircuits.rr_and(x, y, n_xz, n_yz)
        else:
            fz = lambda x, y: biocircuits.rr_or(x, y, n_xz, n_yz)

    def rhs(yz, t, x):
        y, z = yz
        dy_dt = beta * fy(kappa * x) - y
        dz_dt = gamma * (fz(x, y) - z)

        return np.array([dy_dt, dz_dt])

    return rhs

To study the dynamics, we will investigate how the circuit responds to a step up in concentration of X, assuming all concentrations are initially zero, and how a circuit at steady state with nonzero concentration of X responds to a step down in X to zero. This case is particularly relevant for a C1-FFL and an I1-FFL, since in the absence of X (and leakage), the steady state levels of both Y and Z are zero. For other FFLs, the steady state concentrations of Y or Z absent X can be nonzero. In this case, you can think of the sudden rise in X being associated also with a sudden rise of effectors that allow Y and Z to turn on.

Now we can write a function to solve the ODEs. Because the steps are discontinuous, we need to solve the ODEs in a piecewise manner. We specify that the step up starts at \(t = 0\), and we will allow the time of the step down to be specified. The magnitude of the step up, \(x_0\) will also be specified.

[3]:
def solve_ffl(beta, gamma, kappa, n_xy, n_xz, n_yz, ffl, logic, t, t_step_down, x_0):
    """Solve an FFL. The dynamics are given by
    `rhs`, the output of `ffl_rhs()`.
    """
    if t[0] != 0:
        raise RuntimeError("time must start at zero.")

    rhs = ffl_rhs(beta, gamma, kappa, n_xy, n_xz, n_yz, ffl, logic)

    # Integrate if we do not step down
    if t[-1] < t_step_down:
        return scipy.integrate.odeint(rhs, np.zeros(2), t, args=(x_0,))

    # Integrate up to step down
    t_during_step = np.concatenate((t[t < t_step_down], (t_step_down,)))
    yz_during_step = scipy.integrate.odeint(
        rhs, np.zeros(2), t_during_step, args=(x_0,)
    )

    # Integrate after step
    t_after_step = np.concatenate(((t_step_down,), t[t > t_step_down]))
    yz_after_step = scipy.integrate.odeint(
        rhs, yz_during_step[-1, :], t_after_step, args=(0,)
    )

    # Concatenate solutions
    if t_step_down in t:
        return np.vstack((yz_during_step[:-1, :], yz_after_step))
    else:
        return np.vstack((yz_during_step[:-1, :], yz_after_step[1:, :]))

Finally, we can write a function to solve and plot the dynamics of an FFL for a unit step.

[4]:
def plot_ffl(
    beta=1.0,
    gamma=1.0,
    kappa=1.0,
    n_xy=1.0,
    n_xz=1.0,
    n_yz=1.0,
    ffl="c1",
    logic="and",
    t=np.linspace(0, 20, 200),
    t_step_down=10.0,
    x_0=1.0,
    normalized=False,
):
    yz = solve_ffl(
        beta, gamma, kappa, n_xy, n_xz, n_yz, ffl, logic, t, t_step_down, x_0
    )
    y, z = yz.transpose()

    # Generate x-values
    if t[-1] > t_step_down:
        t_x = np.array([-t_step_down / 10, 0, 0, t_step_down, t_step_down, t[-1]])
        x = np.array([0, 0, x_0, x_0, 0, 0], dtype=float)
    else:
        t_x = np.array([-t[-1] / 10, 0, 0, t[-1]])
        x = np.array([0, 0, x_0, x_0], dtype=float)

    # Add left part of y and z-values
    t = np.concatenate(((t_x[0],), t))
    y = np.concatenate(((0,), y))
    z = np.concatenate(((0,), z))

    # Normalize if necessary
    if normalized:
        x /= x.max()
        y /= y.max()
        z /= z.max()

    # Set up figure
    p = bokeh.plotting.figure(
        frame_height=175,
        frame_width=550,
        x_axis_label="dimensionless time",
        y_axis_label=f"{'norm. ' if normalized else ''}dimensionless conc.",
        x_range=[t.min(), t.max()],
    )

    # Column data sources
    cds = bokeh.models.ColumnDataSource(dict(t=t, y=y, z=z))
    cds_x = bokeh.models.ColumnDataSource(dict(t=t_x, x=x))

    # Populate glyphs
    p.line(source=cds_x, x="t", y="x", line_width=2, color=colors[0], legend_label="x")
    p.line(source=cds, x="t", y="y", line_width=2, color=colors[1], legend_label="y")
    p.line(source=cds, x="t", y="z", line_width=2, color=colors[2], legend_label="z")

    # Allow vanishing lines by clicking legend
    p.legend.click_policy = "hide"

    return p

We can take this for a spin to see the response of an I1-FFL.

[5]:
# Parameter values
beta = 5
gamma = 1
kappa = 1
n_xy, n_yz = 3, 3
n_xz = 5

# Plot
p = plot_ffl(beta, gamma, kappa, n_xy, n_xz, n_yz, ffl="i1", logic="and")
bokeh.io.show(p)

The functionality of the solve_ffl() and plot_ffl() functions are available in the biocircuits packages as biocircuits.apps.solve_ffl() and biocircuits.apps.plot_ffl(). The biocircuits.apps.plot_ffl() also outputs the ColumnDataSources cds and cds_x that are used in constructing the FFL explorer app, so its API does differ from the function defined above.

Computing environment

[6]:
%load_ext watermark
%watermark -v -p numpy,scipy,bokeh,biocircuits,jupyterlab
Python implementation: CPython
Python version       : 3.10.10
IPython version      : 8.10.0

numpy      : 1.23.5
scipy      : 1.10.0
bokeh      : 3.1.0
biocircuits: 0.1.9
jupyterlab : 3.5.3