Software Tutorial: Traveling Up an Attractant Gradient

In the previous tutorial, we modeled how bacteria react and adapt to a one-time addition of attractants. In real life, bacteria don’t suddenly drop into an environment with more attractants; instead, they explore a variable environment. In this tutorial, we will adapt our model to simulate a bacterium as it travels up an exponentially increasing concentration gradient.

We will also explore defining and using functions, a feature of BioNetGen that will allow us to specify reaction rules in which the reaction rates are dependent on the current state of the system.

To get started, open Visual Studio Code, and click File > Open Folder.... Open the EColiSimulations folder from the first tutorial. Create a copy of your adaptation.bngl file from the adaptation tutorial and save it as addition.bngl. If you would rather not follow along below, you can download a completed BioNetGen file here: addition.bngl

We also will build a Jupyter notebook in this tutorial for plotting the concentrations of molecules over time. You should create a file called plotter_up.ipynb; if you would rather not follow along, we provide a completed notebook here: plotter_up.ipynb

Before running this notebook, make sure the following dependencies are installed.

Installation Link Version Check install/version
Python3 3.6+ python --version
Jupyter Notebook 4.4.0+ jupyter --version
Numpy 1.14.5+ pip list \| grep numpy
Matplotlib 3.0+ pip list \| grep matplotlib
Colorspace or with pip any pip list \| grep colorspace

Modeling an increasing ligand gradient with a BioNetGen function

Our BioNetGen model will largely stay the same, except for the fact that we are changing the concentration of ligand over time. To model an increasing concentration of ligand corresponding to a bacterium moving up an attractant gradient, we will increase the background ligand concentration at an exponential rate.

We will simulate an increase in attractant concentration by using a “dummy reaction” L → 2L in which one ligand molecule becomes two. To do so, we will add the following reaction to the reaction rules section.

As we have observed earlier in this module, when the ligand concentration is very high, receptors are saturated, and the cell can no longer detect a change in ligand concentration. If you explored the adaptation simulation, then you saw that this occurs after l0 passes 1e8; we will therefore cap the allowable ligand concentration at this value.

We can cap our ligand concentration by defining the rate of the dummy reaction using a function add_Rate(). This function requires another observable, AllLigand. By adding the line Molecules AllLigand L in the observables section, AllLigand will record the total concentration of ligand in the system at each time step (both bound and unbound). As for our reaction, if AllLigand is less than 1e8, then the dummy reaction should take place at some given rate k_add. Otherwise, AllLigand exceeds1e8, and we will set the rate of the dummy reaction to zero. This can be achieved with a functions section in BioNetGen using the following if statement to branch based on the value of AllLigand.

Note: Please ensure that the functions section occurs before the reaction rules section in your BioNetGen file.

begin functions
	addRate() = if(AllLigand>1e8,0,k_add)
end functions

Now we are ready to add our dummy reaction to the reaction rules section with a reaction rate of addRate().

#Simulate an exponentially increasing gradient using a dummy reaction
LAdd: L(t) -> L(t) + L(t) addRate()

Now that we have defined our dummy reaction, we should specify the default rate of this reaction k_add in the parameters section. We first will try a value of k_add of 0.1/s with an initial ligand concentration L0 of 1e4. This means that the model is simulating a gradient of d[L]/dt = 0.1[L]. If L0 is 1e4, then the solution to this differential equation is [L] = 1000e0.1t molecules per second.

k_add 0.1
L0 1e4

Running our updated BioNetGen model

Because we have largely kept the same model from the adaptation tutorial, we are ready to simulate. Please make sure that the following lines appear after end model so that we can run our simulation for 1000 seconds.

simulate({method=>"ssa", t_end=>1000, n_steps=>500})

The following code contains our complete simulation, which you can also download here: addition.bngl

begin model

begin molecule types
end molecule types

begin observables
	Molecules bound_ligand L(t!1).T(l!1)
	Molecules phosphorylated_CheY CheY(Phos~P)
	Molecules low_methyl_receptor T(Meth~A)
	Molecules medium_methyl_receptor T(Meth~B)
	Molecules high_methyl_receptor T(Meth~C)
	Molecules phosphorylated_CheB CheB(Phos~P)
	Molecules AllLigand L
end observables

begin parameters
	NaV2 6.02e8   #Unit conversion to cellular concentration M/L -> #/um^3
	miu 1e-6

	L0 1e4
	T0 7000
	CheY0 20000
	CheZ0 6000
	CheR0 120
	CheB0 250

	k_lr_bind 8.8e6/NaV2   #ligand-receptor binding
	k_lr_dis 35            #ligand-receptor dissociation

	k_TaUnbound_phos 7.5   #receptor complex autophosphorylation

	k_Y_phos 3.8e6/NaV2    #receptor complex phosphorylates Y
	k_Y_dephos 8.6e5/NaV2  #Z dephosphorylates Y

	k_TR_bind 2e7/NaV2          #Receptor-CheR binding
	k_TR_dis  1            #Receptor-CheR dissociaton
	k_TaR_meth 0.08        #CheR methylates receptor complex

	k_B_phos 1e5/NaV2      #CheB phosphorylation by receptor complex
	k_B_dephos 0.17        #CheB autodephosphorylation

	k_Tb_demeth 5e4/NaV2   #CheB demethylates receptor complex
	k_Tc_demeth 2e4/NaV2   #CheB demethylates receptor complex

	k_add 0.1              #Ligand increase

end parameters

begin functions
	addRate() = if(AllLigand>1e8,0,k_add)
end functions

begin reaction rules
	LigandReceptor: L(t) + T(l) <-> L(t!1).T(l!1) k_lr_bind, k_lr_dis

	#Receptor complex (specifically CheA) autophosphorylation
	#Rate dependent on methylation and binding states
	#Also on free vs. bound with ligand
	TaUnboundP: T(l,Meth~A,Phos~U) -> T(l,Meth~A,Phos~P) k_TaUnbound_phos
	TbUnboundP: T(l,Meth~B,Phos~U) -> T(l,Meth~B,Phos~P) k_TaUnbound_phos*1.1
	TcUnboundP: T(l,Meth~C,Phos~U) -> T(l,Meth~C,Phos~P) k_TaUnbound_phos*2.8
	TaLigandP: L(t!1).T(l!1,Meth~A,Phos~U) -> L(t!1).T(l!1,Meth~A,Phos~P) 0
	TbLigandP: L(t!1).T(l!1,Meth~B,Phos~U) -> L(t!1).T(l!1,Meth~B,Phos~P) k_TaUnbound_phos*0.8
	TcLigandP: L(t!1).T(l!1,Meth~C,Phos~U) -> L(t!1).T(l!1,Meth~C,Phos~P) k_TaUnbound_phos*1.6

	#CheY phosphorylation by T and dephosphorylation by CheZ
	YPhos: T(Phos~P) + CheY(Phos~U) -> T(Phos~U) + CheY(Phos~P) k_Y_phos
	YDephos: CheZ() + CheY(Phos~P) -> CheZ() + CheY(Phos~U) k_Y_dephos

	#CheR binds to and methylates receptor complex
	#Rate dependent on methylation states and ligand binding
	TRBind: T(r) + CheR(t) <-> T(r!2).CheR(t!2) k_TR_bind, k_TR_dis
	TaRUnboundMeth: T(r!2,l,Meth~A).CheR(t!2) -> T(r,l,Meth~B) + CheR(t) k_TaR_meth
	TbRUnboundMeth: T(r!2,l,Meth~B).CheR(t!2) -> T(r,l,Meth~C) + CheR(t) k_TaR_meth*0.1
	TaRLigandMeth: T(r!2,l!1,Meth~A).L(t!1).CheR(t!2) -> T(r,l!1,Meth~B).L(t!1) + CheR(t) k_TaR_meth*30
	TbRLigandMeth: T(r!2,l!1,Meth~B).L(t!1).CheR(t!2) -> T(r,l!1,Meth~C).L(t!1) + CheR(t) k_TaR_meth*3

	#CheB is phosphorylated by receptor complex, and autodephosphorylates
	CheBphos: T(Phos~P) + CheB(Phos~U) -> T(Phos~U) + CheB(Phos~P) k_B_phos
	CheBdephos: CheB(Phos~P) -> CheB(Phos~U) k_B_dephos

	#CheB demethylates receptor complex
	#Rate dependent on methyaltion states
	TbDemeth: T(Meth~B) + CheB(Phos~P) -> T(Meth~A) + CheB(Phos~P) k_Tb_demeth
	TcDemeth: T(Meth~C) + CheB(Phos~P) -> T(Meth~B) + CheB(Phos~P) k_Tc_demeth

	#Simulate exponentially increasing gradient
	LAdd: L(t) -> L(t) + L(t) addRate()

end reaction rules

begin compartments
  EC  3  100       #um^3
  PM  2  1   EC    #um^2
  CP  3  1   PM    #um^3
end compartments

begin species
	@EC:L(t) L0
	@PM:T(l,r,Meth~A,Phos~U) T0*0.84*0.9
	@PM:T(l,r,Meth~B,Phos~U) T0*0.15*0.9
	@PM:T(l,r,Meth~C,Phos~U) T0*0.01*0.9
	@PM:T(l,r,Meth~A,Phos~P) T0*0.84*0.1
	@PM:T(l,r,Meth~B,Phos~P) T0*0.15*0.1
	@PM:T(l,r,Meth~C,Phos~P) T0*0.01*0.1
	@CP:CheY(Phos~U) CheY0*0.71
	@CP:CheY(Phos~P) CheY0*0.29
	@CP:CheZ() CheZ0
	@CP:CheB(Phos~U) CheB0*0.62
	@CP:CheB(Phos~P) CheB0*0.38
	@CP:CheR(t) CheR0
end species

end model

simulate({method=>"ssa", t_end=>1000, n_steps=>500})

Now save your file and run the simulation by clicking on the Run BNG button. The results will be saved in a new folder called addition/TIME contained in the current directory. Rename the folder from the timestamp to the value of k_add, 0.1.


Open the newly created addition.gdat file and create a plot by clicking the Built-in plotting button.


What happens to the concentration of phosphorylated CheY?

Note: You can deselect AllLigand to make the plot of the concentration of phosphorylated CheY easier to see.

Next, try the following few different values for k_add: 0.01, 0.03, 0.05, 0.1, 0.3, 0.5. What do these changing k_add values represent in the simulation? How does the system respond to the different values?

Note: All of your simulation results are stored in the addition/TIME/ directory within your working directory. As you change the value of k_add, rename the directory with the k_add values instead of the timestamp for simplicity.

You will observe that CheY phosphorylation drops gradually first, instead of the instantaneous sharp drop as we add lots of ligand at once. That means, with the ligand concentration increases, the cell is able to continuously lower the tumbling frequency.

Visualizing the results of our simulation

We are now ready to fill in plotter_up.ipynb, a Jupyter notebook that we will use to visualize the outcome of our simulations.

First specify the directories, model name, species of interest, and rates. Put the plotter_up.ipynb file inside the same folder as addition.bngl, or change the model_path below to point at this folder.

#Specify the data to plot here.
model_path = "addition"  #The folder containing the model
model_name = "addition"  #Name of the model
target = "phosphorylated_CheY"    #Target molecule
vals = [0.01, 0.03, 0.05, 0.1, 0.3, 0.5]  #Gradients of interest

We next provide some import statements for needed dependencies.

import numpy as np
import sys
import os
import matplotlib.pyplot as plt
import colorspace

To compare the responses for different gradients, we color-code each gradient. Colorspace is one of the straight-forward ways to set up a color palette. Here we use a qualitative palette with hues (h) equally spaced between [0, 300], and constant chroma (c) and luminance (l) values.

#Define the colors to use
colors = colorspace.qualitative_hcl(h=[0, 300.], c = 60, l = 70, pallete = "dynamic")(len(vals))

The following function loads and parses the data. Once the file containing your data is loaded, we use the first row to investigate which column stores the concentration of the “target” observable species of interest. When we find that target, we will then access the time points and concentrations of this target molecule.

def load_data(val):
    file_path = os.path.join(model_path, str(val), model_name + ".gdat")
    with open(file_path) as f:
        first_line = f.readline() #Read the first line
    cols = first_line.split()[1:] #Get the col names (species names)

    ind = 0
    while cols[ind] != target:
        ind += 1                  #Get col number of target molecule

    data = np.loadtxt(file_path)  #Load the file
    time = data[:, 0]             #Time points
    concentration = data[:, ind]  #Concentrations

    return time, concentration

Now we will write a function to plot the time coordinates on the x-axis and the concentrations of the molecule at these time points on the y-axis. To do so, we will use the Matplotlib plot function to plot concentrations through time for each gradient value. Time-series data will be colored by the color palette we mentioned earlier.

def plot(val, time, concentration, ax, i):
    legend = "k = " + str(val)
    ax.plot(time, concentration, label = legend, color = colors[i])


The plotting function above needs to be initialized with a figure defined by the subplot function. We loop through every gradient concentration to perform the plotting. Afterward, we define labels for the x-axis and y-axis, figure title, and tick lines. The call to displays the plot.

fig, ax = plt.subplots(1, 1, figsize = (10, 8))
for i in range(len(vals)):
    val = vals[i]
    time, concentration = load_data(val)
    plot(val, time, concentration, ax, i)

plt.xlabel("time (s)")
plt.ylabel("concentration (#molecules)")
plt.title("Phosphorylated CheY vs time")
ax.grid(b = True, which = 'minor', axis = 'both', color = 'lightgrey', linewidth = 0.5, linestyle = ':')
ax.grid(b = True, which = 'major', axis = 'both', color = 'grey', linewidth = 0.8 , linestyle = ':')

Now run the notebook. How do changing values of k_add impact the CheY-P concentrations? Why do you think this is?

In the main text, we will examine the results of our plots and discuss how they can be used to infer the cell’s behavior in the presence of increasing attractant.

Return to main text