Created: Tuesday 31 January 2017
github.com/rhyswhitley/fire_limitation

Quantifying the uncertainity of a global fire limitation model using Bayesian inference

Part 2: Bayesian inference

1,* Douglas Kelley,
2 Ioannis Bistinas,
3, 4 Chantelle Burton,
1 Tobias Marthews,
5 Rhys Whitley

1 Centre for Ecology and Hydrology, Maclean Building, Crowmarsh Gifford, Wallingford, Oxfordshire, United Kingdom

2 Vrije Universiteit Amsterdam, Faculty of Earth and Life Sciences, Amsterdam, Netherlands

3 Met Office United Kingdom, Exeter, United Kingdom

4 Geography, University of Exeter, Exeter, United Kingdom

5 Natural Perils Pricing, Commercial & Consumer Portfolio & Pricing, Suncorp Group, Sydney, Australia

Summary

This notebook aims to quantify the model parameters of a global fire model (defined below). The model is driven by a number of covariates (Xi=1, 2, ... M) that describe: cropland, pasture and urban area footprints; frequency of lightening ignitions, population density, net primary productivity (NPP) and Alpha, a proxy measure of available soil moisture in the root zone. The model attempts to predict the impact of fire through burnt area and is thus the model target (Y).

Python code and calculations below

### Model description
The model considers percentage of burnt area to be the joint product of a set of conditions that modulate fire through fuel load, ignitions, moisture and supression. Each function assumes some equilibrium point that desribes the optimal conditions for fire, that may be proportionally modified through some empirical relationship. These are briefly outlined below for the sake of comprehension in this notebook, but can be referred to in more detail in the model protocol located in the docs/ folder (model protocol).

\begin{eqnarray}
    F_{burn} &=& \prod_{i}S(x_{i}) \\[1em]
\end{eqnarray}

Where $S(x_{i})$ representes some measure of fire conditions by $i =$ fuel, moisture, ignitions and anthropagenic supression, and is describe by a sigmoid:

\begin{equation}
    S(x_{i=fuel, moist, ignite, suppr}) = \frac{1}{1 + \exp\{-b\cdot(x_i-a)\}}
\end{equation}

The $fuel$ and $moist$ sigmoid considers only NPP and fuel moisture  and therefore have no hyper-parameters. Sigmoids $ignite$ and $suppr$ describe an aggregation of other climate and land-use covariates. Because these sigmoids are influenced by an aggregation of different drivers, they are influenced in turn by different sets of hyper-parameters; these are now described below.

#### Fuel load covariate (no hyper-parameters)
\begin{equation}
    x_{fuel} = NPP
\end{equation}

#### Moisture covariate
\begin{equation}
    x_{moist} = \alpha
\end{equation}

#### Ignition covariate
\begin{equation}
    x_{ignite} = Lightn + k_p\cdot A_{pasture} + k_{d1}\cdot\rho_{population}
\end{equation}

Where $Lightn$ is the number of cloud-to-ground lightning strikes, modified  as per Kelley et al. 2014.

#### Supression covariate
\begin{equation}
    x_{supress} = A_{urban} + k_C\cdot A_{Crop} + k_{d2}\cdot\rho_{population}
\end{equation}

This leaves 12 free parameters that need to be optimised against observations of burnt area.

### Load libraries

```python
import os
import numpy as np
import pandas as pd

import pymc3 as pm3
from pymc3.backends import SQLite
from scipy import optimize
from theano import tensor as tt

import matplotlib.pyplot as plt

# setup nice plotting
plt.style.use('ggplot')
%matplotlib inline

# paths and parameters
outPath = "../data/globfire.csv.gz"
```

## 2.1 Fire limitation model definition

Could possibly contain this in a class object, but I'm not sure theano can instantiate the object to be used by the GPU. If I've made absolutely no sense just then, then I would leave the following as is.

```python
def fuel_load(NPP):
    """
    Definition to describe fuel load: while return the input; capability to be modified later.
    """
    return NPP

def moisture(alpha):
    """
    Definition to describe moisture: currently equals alpha; capability to be modified later.
    """
    return (alpha)

def ignition(lightning, pasture_area, pop_density, kp, kd1):
    """
    Definition for the measure of ignition
    """
    return lightning + kp*pasture_area + kd1*pop_density

def supression(crop_area, pop_density, kd2):
    """
    Definition for the measure of fire supression
    """
    return crop_area + kd2*pop_density

def tt_sigmoid(x, a, b):
    """
    Sigmoid function to describe limitation using tensor
    """
    return 1.0/(1.0 + tt.exp(a*x + b))
```

## 2.2 Import data

Load data and do any necessary transformation needed for the Bayesian modelling framework. For testing purposes I've limited the number of rows I'm importing (here about 100K). If you plan to do the full dataset, then delete the `nrows` variabe in the cell below.

```python
DATAPATH = os.path.expanduser(outPath)

fd = pd.read_csv(DATAPATH, nrows=1e5)
```

Do a sanity check to make sure our data has imported correctly.

```python
fd.info()
```

    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 100000 entries, 0 to 99999
    Data columns (total 8 columns):
    alpha                  100000 non-null float64
    cropland               100000 non-null float64
    fire                   100000 non-null float64
    lightning_ignitions    100000 non-null float64
    NPP                    100000 non-null float64
    pasture                100000 non-null float64
    population_density     100000 non-null float64
    urban_area             100000 non-null float64
    dtypes: float64(8)
    memory usage: 6.1 MB

## 2.3 Baysian framework

A simple explanation of Baye's law is:

\begin{equation}
    P(\beta|X) \propto P(\beta)\cdot P(X|\beta)
\end{equation}

where $X$ is our data (observations of some arbitrary system), and $\beta$ our set of unexplained parameters that describe the reponse of our _proposed understanding_ of this system as it varies with $X$.

### 2.3.1 Prior definitions
Because I have no idea what the uncertainty on the hyper parameters should look like (beyond $\beta> 0$), I've set them all as uniform. Some of them can possibly be describe as exponential or half-normal, due to the physical nature of $\beta$, but we can play around with that later.

\begin{eqnarray}
    P(\beta) &=& \prod_{i=1}^{4}P(a_i)\prod_{i=1}^{4}P(b_i)\cdot P(\sigma)\cdot P(k_c)P(k_p)P(k_{d,1})P(k_{d,2}) \\[1.5em]
    P(a) = P(b) = P(\sigma) &=& \mathcal{N}(0, 1) \\[1em]
    P(k_c) = P(k_p) = P(k_{d,1}) = P(k_{d,2}) &=& \mathcal{U}(\beta_{\min}, \beta_{\max}) \\[1.5em]
\end{eqnarray}

I'm not totally sure about the maths above being right, but it's just to show that _full_ prior is normal. Important, because we'll also describe the error (likelihood) as normal, such that the posterior is therefore normal (conjugate); i.e. $\mathcal{N}\times\mathcal{N}=\mathcal{N}$ (expansion happens in the mean of the exponent).

Back to the code.., `pymc3` is quite funky in that it allows me to create an empty `Model()` object and just add things to it as I need them using a `with` statement. I've called our Bayesian model `fire_error` as that is what we are trying to Quantify.

```python
with pm3.Model() as fire_error:

# first for the sigmoids (the 4's at the end state that I want 4 of them)
    a_s4 = pm3.Normal('sig_a', mu=0, sd=100, shape=4)
    b_s4 = pm3.Normal('sig_b', mu=0, sd=100, shape=4)

# now for the hyper-parameters that describe the independent fire condition covariates

    kp = pm3.Normal('kp', mu=500, sd=100)
    kd1 = pm3.Normal('kd1', mu=500, sd=100)
    kd2 = pm3.Normal('kd2', mu=500, sd=100)

#    kp = pm3.Uniform('kp', 0, 1e3)
#    kd1 = pm3.Uniform('kd1', 0, 1e3)
#    kd2 = pm3.Uniform('kd2', 0, 1e3)

# describe the standard deviation in the error term
    sigma = pm3.HalfNormal('sigma', sd=1)
```

### 2.3.2 Likelihood definition

For the sake of simplicity (and because I don't really know any better), we define the model error as normally distributed (i.i.d.) although it most likely isn't. We could make this more complicated later by defining the error as heteroscedastic, but I wouldn't bother with that until we have some idea of the convergence. We're describing the error (observations minus model predictions) as follows:

\begin{eqnarray}
    P(X|\beta) &=& \mathcal{N}(F_{burn}, \sigma) \\[1em]
    \mathcal{N}(F_{burn}, \sigma) &=& \frac{N}{\sigma\sqrt{2\pi}}\exp\left\{\sum_{i=1}^{N}\left(\frac{y_i - F_{burn, i}}{\sigma_i}\right)^2\right\}
\end{eqnarray}

where $y_i$ is a set of observations we're attempting to optimise on. Below is the code that describes the above:

```python
with fire_error:

    # transform hyper-covariates
    x_1 = fd["NPP"].values

    x_2 = fd["alpha"].values

    x_3 = ignition(fd["lightning_ignitions"].values, \
                   fd["pasture"].values, \
                   fd["population_density"].values, \
                   kp, kd1)

    x_4 = supression(fd["cropland"].values, \
                     fd["population_density"].values, \
                     kd2)

    # list of primary covariates
    xs = [x_1, x_2, x_3, x_4]
    Nx = len(xs)

    # burnt area is assumed to be the product of the 4 sigmoids
    prediction = np.product([tt_sigmoid(xs[i], a_s4[i], b_s4[i]) for i in range(Nx)])

    # calculate the error between observed and predicted burnt area
    error = pm3.Normal('error', mu=prediction, sd=sigma, observed=fd['fire'].values)
```

### 2.3.3 Posterior sampling

Because it is nigh impossible to determine the posterior solution analytically we will instead sample the information space to **infer** the posterior solutions for each of the model parameters. In this case we are using a Metropolis-Hasting step MCMC.

I've tried using No-U-Turn (NUTS) sampling (which is the new kid on the block), but there are issues with it's current implementation in pymc3 (see github repo issues). Can use it once problems are ironed out - but TBH it doesn't matter if we're getting a reasonable convergence.

```python
with fire_error:

    # help the sampling out by quickly finding an optimal start position
    start = pm3.find_MAP(model=fire_error.model, fmin=optimize.fmin_powell)

    # set the step-method (criteria algorithm for moving around information space)
    step = pm3.Metropolis()

    # save our sampling to disk so we can access it later
    db_save = SQLite('../data/firemodel_trace.db')

    # do the sampling
    mcmc_traces = pm3.sample(5e3, step=step, start=start, njobs=-1, trace=db_save)
```

    Optimization terminated successfully.
             Current function value: -293612.186103
             Iterations: 8
             Function evaluations: 1426

    100%|████████████████████████████████████████████████████████████████████████████| 5000/5000.0 [13:37<00:00,  6.00it/s]

```python
pm3.traceplot(mcmc_traces);
```

```python
type(mcmc_traces)
```

    pymc3.backends.base.MultiTrace

```python

```


# Aim of this notebook

* To construct the singular curve of universal type to finalize the solution of the optimal control problem

# Preamble

```python
from sympy import *
init_printing(use_latex='mathjax')

# Plotting
%matplotlib inline
## Make inline plots raster graphics
from IPython.display import set_matplotlib_formats
## Import modules for plotting and data analysis
import matplotlib.pyplot as plt
from matplotlib import gridspec,rc,colors
import matplotlib.ticker as plticker
# Parameters for seaborn plots
import seaborn as sns
clrs = sns.color_palette("Spectral", 6)
def set_plot_style(usetex=False):
    sns.set_style('white', {'axes.linewidth': 0.5})
    sns.set(style='white', font_scale=1.1,#context='paper',
            rc={'xtick.major.size': 6, 'ytick.major.size': 6, 'legend.fontsize': 14,
                'text.usetex': usetex, 'font.family': 'serif', 'font.serif': ['Verdana'],
                'text.latex.preamble': r"\usepackage{type1cm}"})
    plt.rcParams['xtick.major.size'] = 6
    plt.rcParams['xtick.major.width'] = 1
    plt.rcParams['ytick.major.size'] = 6
    plt.rcParams['ytick.major.width'] = 1
    plt.rcParams['xtick.bottom'] = True
    plt.rcParams['ytick.left'] = True

set_plot_style(True)

import pandas as pd
pd.set_option('mode.chained_assignment',None)

import numpy as np
from scipy.optimize import fsolve, root
from scipy.integrate import ode
backend = 'dopri5'
import warnings

# Timer
import time

from copy import deepcopy

from itertools import cycle
palette_size = 10;
clrs = sns.color_palette("Reds",palette_size)
iclrs = cycle(clrs) # iterated colors

clrs0 = sns.color_palette("Set1",palette_size)

# Suppress warnings
import warnings
warnings.filterwarnings("ignore")
```

# Parameter values

* Birth rate and const of downregulation are defined below in order to fit some experim. data

```python
d = .13 # death rate
α = .3 # low equilibrium point at expression of the main pathway (high equilibrium is at one)
θ = .45 # threshold value for the expression of the main pathway
κ = 40 # robustness parameter
```

* Symbolic variables - the list insludes μ & μbar, because they will be varied later

```python
σ, φ0, φ, x, μ, μbar = symbols('sigma, phi0, phi, x, mu, mubar')
```

* Main functions

```python
A = 1-σ*(1-θ)
Eminus = (α*A-θ)**2/2
ΔE = A*(1-α)*((1+α)*A/2-θ)
ΔEf = lambdify(σ,ΔE)
```

* Birth rate and cost of downregulation

```python
b = (0.1*(exp(κ*(ΔEf(1)))+1)-0.14*(exp(κ*ΔEf(0))+1))/(exp(κ*ΔEf(1))-exp(κ*ΔEf(0))) # birth rate
χ = 1-(0.14*(exp(κ*ΔEf(0))+1)-b*exp(κ*ΔEf(0)))/b
b, χ
```

$$\left ( 0.140168330860362, \quad 0.325961223954473\right )$$

```python
c_relative = 0.1
c = c_relative*(b-d)/b+(1-c_relative)*χ/(exp(κ*ΔEf(0))+1) # cost of resistance
c
```

$$0.00833519849448376$$

* Hamiltonian *H* and a part of it ρ that includes the control variable σ

```python
h = b*(χ/(exp(κ*ΔE)+1)*(1-x)+c*x)
H = -φ0 + φ*(b*(χ/(exp(κ*ΔE)+1)-c)*x*(1-x)+μ*(1-x)/(exp(κ*ΔE)+1)-μbar*exp(-κ*Eminus)*x) + h
ρ = (φ*(b*χ*x+μ)+b*χ)/(exp(κ*ΔE)+1)*(1-x)-φ*μbar*exp(-κ*Eminus)*x
ρ1 = (φ*(b*χ*x+μ)+b*χ)/(exp(κ*ΔE)+1)*(1-x)
ρ2 = φ*μbar*exp(-κ*Eminus)*x
n = b*(1-χ*(1-x)/(exp(κ*ΔE)+1)-c*x)-d
H, ρ, n
```

$$\left ( \phi \left(\frac{\mu \left(- x + 1\right)}{e^{40 \left(- 0.385 \sigma + 0.7\right) \left(- 0.3575 \sigma + 0.2\right)} + 1} - \bar{\mu} x e^{- 20 \left(- 0.165 \sigma - 0.15\right)^{2}} + x \left(-0.00116833086036159 + \frac{0.045689440686899}{e^{40 \left(- 0.385 \sigma + 0.7\right) \left(- 0.3575 \sigma + 0.2\right)} + 1}\right) \left(- x + 1\right)\right) - \phi_{0} + 0.00116833086036159 x + \frac{0.045689440686899 \left(- x + 1\right)}{e^{40 \left(- 0.385 \sigma + 0.7\right) \left(- 0.3575 \sigma + 0.2\right)} + 1}, \quad - \bar{\mu} \phi x e^{- 20 \left(- 0.165 \sigma - 0.15\right)^{2}} + \frac{\left(- x + 1\right) \left(\phi \left(\mu + 0.045689440686899 x\right) + 0.045689440686899\right)}{e^{40 \left(- 0.385 \sigma + 0.7\right) \left(- 0.3575 \sigma + 0.2\right)} + 1}, \quad - 0.00116833086036159 x - \frac{0.140168330860362 \left(- 0.325961223954473 x + 0.325961223954473\right)}{e^{40 \left(- 0.385 \sigma + 0.7\right) \left(- 0.3575 \sigma + 0.2\right)} + 1} + 0.0101683308603616\right )$$

* Same but for no treatment (σ = 0)

```python
h0 = h.subs(σ,0)
H0 = H.subs(σ,0)
ρ0 = ρ.subs(σ,0)
H0, ρ0
```

$$\left ( \phi \left(0.00368423989943599 \mu \left(- x + 1\right) - 0.637628151621773 \bar{\mu} x - 0.001 x \left(- x + 1\right)\right) - \phi_{0} + 0.001 x + 0.000168330860361587, \quad - 0.637628151621773 \bar{\mu} \phi x + 0.00368423989943599 \left(- x + 1\right) \left(\phi \left(\mu + 0.045689440686899 x\right) + 0.045689440686899\right)\right )$$

* Machinery: definition of the Poisson brackets

```python
PoissonBrackets = lambda H1, H2: diff(H1,x)*diff(H2,φ)-diff(H1,φ)*diff(H2,x)
```

* Necessary functions and defining the right hand side of dynamical equations

```python
ρf = lambdify((x,φ,σ,μ,μbar),ρ)
ρ1f = lambdify((x,φ,σ,μ,μbar),ρ1)
ρ2f = lambdify((x,φ,σ,μ,μbar),ρ2)
ρ0f = lambdify((x,φ,μ,μbar),ρ0)
dxdτ = lambdify((x,φ,σ,μ,μbar),-diff(H,φ))
dφdτ = lambdify((x,φ,σ,μ,μbar),diff(H,x))
dVdτ = lambdify((x,σ),h)
dρdσ = lambdify((σ,x,φ,μ,μbar),diff(ρ,σ))
dδρdτ = lambdify((x,φ,σ,μ,μbar),-PoissonBrackets(ρ0-ρ,H))
def ode_rhs(t,state,μ,μbar):
    x, φ, V, δρ = state
    σs = [0,1]
    if (dρdσ(1.,x,φ,μ,μbar)<0) and (dρdσ(θ,x,φ,μ,μbar)>0):
        σstar = fsolve(dρdσ,.8,args=(x,φ,μ,μbar,))[0]
    else:
        σstar = 1.;
    if ρf(x,φ,σstar,μ,μbar) < ρ0f(x,φ,μ,μbar):
        sgm = 0
    else:
        sgm = σstar
    return [dxdτ(x,φ,sgm,μ,μbar),dφdτ(x,φ,sgm,μ,μbar),dVdτ(x,sgm),dδρdτ(x,φ,σstar,μ,μbar)]
def σstarf(x,φ,μ,μbar):
    if (dρdσ(1.,x,φ,μ,μbar)<0) and (dρdσ(θ,x,φ,μ,μbar)>0):
        σstar = fsolve(dρdσ,.8,args=(x,φ,μ,μbar,))[0]
    else:
        σstar = 1.;
    if ρf(x,φ,σstar,μ,μbar) < ρ0f(x,φ,μ,μbar):
        sgm = 0
    else:
        sgm = σstar
    return sgm
```

```python
def get_primary_field(name, experiment,μ,μbar):
    solutions = {}
    solver = ode(ode_rhs).set_integrator(backend)
    τ0 = experiment['τ0']
    tms = np.linspace(τ0,experiment['T_end'],1e3+1)
    for x0 in experiment['x0']:
        δρ0 = ρ0.subs(x,x0).subs(φ,0)-ρ.subs(x,x0).subs(φ,0).subs(σ,1.)
        solver.set_initial_value([x0,0,0,δρ0],0.).set_f_params(μ,μbar)
        sol = []; k = 0;
        while (solver.t < experiment['T_end']) and (solver.y[0]<=1.) and (solver.y[0]>=0.):
            solver.integrate(tms[k])
            sol.append([solver.t]+list(solver.y))
            k += 1
        solutions[x0] = {'solution': sol}
    for x0, entry in solutions.items():
        entry['τ'] = [entry['solution'][j][0] for j in range(len(entry['solution']))]
        entry['x'] = [entry['solution'][j][1] for j in range(len(entry['solution']))]
        entry['φ'] = [entry['solution'][j][2] for j in range(len(entry['solution']))]
        entry['V'] = [entry['solution'][j][3] for j in range(len(entry['solution']))]
        entry['δρ'] = [entry['solution'][j][4] for j in range(len(entry['solution']))]
    return solutions
def get_δρ_value(tme,x0,μ,μbar):
    solver = ode(ode_rhs).set_integrator(backend)
    δρ0 = ρ0.subs(x,x0).subs(φ,0)-ρ.subs(x,x0).subs(φ,0).subs(σ,1.)
    solver.set_initial_value([x0,0,0,δρ0],0.).set_f_params(μ,μbar)
    while (solver.t < tme) and (solver.y[0]<=1.) and (solver.y[0]>=0.):
        solver.integrate(tme)
        sol = [solver.t]+list(solver.y)
    return solver.y[3]
def get_δρ_ending(params,μ,μbar):
    tme, x0 = params
    solver = ode(ode_rhs).set_integrator(backend)
    δρ0 = ρ0.subs(x,x0).subs(φ,0)-ρ.subs(x,x0).subs(φ,0).subs(σ,1.)
    solver.set_initial_value([x0,0,0,δρ0],0.).set_f_params(μ,μbar)
    δτ = 1.0e-8; tms = [tme,tme+δτ]
    _k = 0; sol = []
    while (_k<len(tms)):# and (solver.y[0]<=1.) and (solver.y[0]>=0.):
        solver.integrate(tms[_k])
        sol.append(solver.y)
        _k += 1
    #print(sol)
    return(sol[0][3],(sol[1][3]-sol[0][3])/δτ)
def get_state(tme,x0,μ,μbar):
    solver = ode(ode_rhs).set_integrator(backend)
    δρ0 = ρ0.subs(x,x0).subs(φ,0)-ρ.subs(x,x0).subs(φ,0).subs(σ,1.)
    solver.set_initial_value([x0,0,0,δρ0],0.).set_f_params(μ,μbar)
    δτ = 1.0e-8; tms = [tme,tme+δτ]
    _k = 0; sol = []
    while (solver.t < tms[-1]) and (solver.y[0]<=1.) and (solver.y[0]>=0.):
        solver.integrate(tms[_k])
        sol.append(solver.y)
        _k += 1
    return(list(sol[0])+[(sol[1][3]-sol[0][3])/δτ])
```

# Machinery for the universal line

* To find the universal singular curve we need to define two parameters

```python
γ0 = PoissonBrackets(PoissonBrackets(H,H0),H)
γ1 = PoissonBrackets(PoissonBrackets(H0,H),H0)
```

* The dynamics

```python
dxdτSingExpr = -(γ0*diff(H0,φ)+γ1*diff(H,φ))/(γ0+γ1)
dφdτSingExpr = (γ0*diff(H0,x)+γ1*diff(H,x))/(γ0+γ1)
dVdτSingExpr = (γ0*h0+γ1*h)/(γ0+γ1)
σSingExpr = γ1*σ/(γ0+γ1)
```

* Machinery for Python: lambdify the functions above

```python
dxdτSing = lambdify((x,φ,σ,μ,μbar),dxdτSingExpr)
dφdτSing = lambdify((x,φ,σ,μ,μbar),dφdτSingExpr)
dVdτSing = lambdify((x,φ,σ,μ,μbar),dVdτSingExpr)
σSing = lambdify((x,φ,σ,μ,μbar),σSingExpr)
```

```python
def ode_rhs_Sing(t,state,μ,μbar):
    x, φ, V = state
    if (dρdσ(1.,x,φ,μ,μbar)<0) and (dρdσ(θ,x,φ,μ,μbar)>0):
        σstar = fsolve(dρdσ,.8,args=(x,φ,μ,μbar,))[0]
    else:
        σstar = 1.;
    return [dxdτSing(x,φ,σstar,μ,μbar),dφdτSing(x,φ,σstar,μ,μbar),dVdτSing(x,φ,σstar,μ,μbar)]
def get_universal_curve(end_point,tmax,Nsteps,μ,μbar):
    tms =  np.linspace(end_point[0],tmax,Nsteps);
    solver = ode(ode_rhs_Sing).set_integrator(backend)
    solver.set_initial_value(end_point[1:4],end_point[0]).set_f_params(μ,μbar)
    _k = 0; sol = []
    while (solver.t < tms[-1]):
        solver.integrate(tms[_k])
        sol.append([solver.t]+list(solver.y))
        _k += 1
    return sol
def get_σ_universal(tme,end_point,μ,μbar):
    δτ = 1.0e-8; tms = [tme,tme+δτ]
    solver = ode(ode_rhs_Sing).set_integrator(backend)
    solver.set_initial_value(end_point[1:4],end_point[0]).set_f_params(μ,μbar)
    _k = 0; sol = []
    while (solver.t < tme+δτ):
        solver.integrate(tms[_k])
        sol.append([solver.t]+list(solver.y))
        _k += 1
    x, φ = sol[0][:2]
    sgm = fsolve(lambda σ: dxdτ(x,φ,σ,μ,μbar)-(sol[1][0]-sol[0][0])/δτ,θ/2)[0]
    return sgm
def get_state_universal(tme,end_point,μ,μbar):
    solver = ode(ode_rhs_Sing).set_integrator(backend)
    solver.set_initial_value(end_point[1:4],end_point[0]).set_f_params(μ,μbar)
    solver.integrate(tme)
    return [solver.t]+list(solver.y)
```

```python
def ode_rhs_with_σstar(t,state,μ,μbar):
    x, φ, V = state
    if (dρdσ(1.,x,φ,μ,μbar)<0) and (dρdσ(θ,x,φ,μ,μbar)>0):
        σ = fsolve(dρdσ,.8,args=(x,φ,μ,μbar,))[0]
    else:
        σ = 1.;
    return [dxdτ(x,φ,σ,μ,μbar),dφdτ(x,φ,σ,μ,μbar),dVdτ(x,σ)]
def ode_rhs_with_given_σ(t,state,σ,μ,μbar):
    x, φ, V = state
    return [dxdτ(x,φ,σ,μ,μbar),dφdτ(x,φ,σ,μ,μbar),dVdτ(x,σ)]
def get_trajectory_with_σstar(starting_point,tmax,Nsteps,μ,μbar):
    tms = np.linspace(starting_point[0],tmax,Nsteps)
    solver = ode(ode_rhs_with_σstar).set_integrator(backend)
    solver.set_initial_value(starting_point[1:],starting_point[0]).set_f_params(μ,μbar)
    sol = []; _k = 0;
    while solver.t < max(tms) and (solver.y[0]<=1.) and (solver.y[0]>=0.):
        solver.integrate(tms[_k])
        sol.append([solver.t]+list(solver.y))
        _k += 1
    return sol
def get_trajectory_with_given_σ(starting_point,tmax,Nsteps,σ,μ,μbar):
    tms = np.linspace(starting_point[0],tmax,100)
    solver = ode(ode_rhs_with_given_σ).set_integrator(backend)
    solver.set_initial_value(starting_point[1:],starting_point[0]).set_f_params(σ,μ,μbar)
    sol = []; _k = 0;
    while solver.t < max(tms) and (solver.y[0]<=1.) and (solver.y[0]>=0.):
        solver.integrate(tms[_k])
        sol.append([solver.t]+list(solver.y))
        _k += 1
    return sol
def get_state_with_σstar(tme,starting_point,μ,μbar):
    solver = ode(ode_rhs_with_σstar).set_integrator(backend)
    solver.set_initial_value(starting_point[1:4],starting_point[0]).set_f_params(μ,μbar)
    solver.integrate(tme)
    return [solver.t]+list(solver.y)
def get_finalizing_point_from_universal_curve(tme,tmx,end_point,μ,μbar):
    unv_point = get_state_universal(tme,end_point,μ,μbar)
    return get_state_with_σstar(tmx,unv_point,μ,μbar)[1]
```

# Field of optimal trajectories as the solution of the Bellman equation

* μ & μbar are varied by *T* and *T*bar ($\mu=1/T$ and $\bar\mu=1/\bar{T}$)

```python
tmx = 180.
end_switching_curve = {'t': 19., 'x': .7}
# for Τ, Τbar in zip([28]*5,[14,21,28,35,60]):
Τ = 10.5; Τbar = 14.
μ = 1./Τ; μbar = 1./Τbar
print("Parameters: μ = %.5f, μbar = %.5f"%(μ,μbar))
end_switching_curve['t'], end_switching_curve['x'] = fsolve(get_δρ_ending,(end_switching_curve['t'],end_switching_curve['x']),args=(μ,μbar),xtol=1.0e-12)
end_point = [end_switching_curve['t']]+get_state(end_switching_curve['t'],end_switching_curve['x'],μ,μbar)
print("Ending point for the switching line: τ = %.1f days, x = %.1f%%" % (end_point[0], end_point[1]*100))
print("Checking the solution - should give zero values: ")
print(get_δρ_ending([end_switching_curve['t'],end_switching_curve['x']],μ,μbar))
print("* Constructing the primary field")
primary_field1 = []
experiments = {
    'sol1': { 'T_end': tmx, 'τ0': 0., 'x0': list(np.linspace(0,end_switching_curve['x']-(1e-3),7)) } }
for name, values in experiments.items():
    primary_field1.append(get_primary_field(name,values,μ,μbar))
primary_field2 = []
experiments = {
    'sol1': { 'T_end': tmx, 'τ0': 0., 'x0': list(np.linspace(end_switching_curve['x']+(3e-6),1.,7)) } }
for name, values in experiments.items():
    primary_field2.append(get_primary_field(name,values,μ,μbar))
print("* Constructing the switching curve")
switching_curve = []
x0s = np.linspace(end_switching_curve['x'],1,21); _y = end_switching_curve['t']

for x0 in x0s:
    tme = fsolve(get_δρ_value,_y,args=(x0,μ,μbar))[0]
    if (tme>0):
        switching_curve = switching_curve+[[tme,get_state(tme,x0,μ,μbar)[0]]]
        _y = tme
print("* Constructing the universal curve")
universal_curve = get_universal_curve(end_point,tmx,25,μ,μbar)
print("* Finding the last characteristic")
#time0 = time.time()
tuniv = fsolve(get_finalizing_point_from_universal_curve,tmx-40.,args=(tmx,end_point,μ,μbar,))[0]
#print("The proccess to find the last characteristic took %0.1f minutes" % ((time.time()-time0)/60.))
univ_point = get_state_universal(tuniv,end_point,μ,μbar)
print("The last point on the universal line:")
print(univ_point)
last_trajectory = get_trajectory_with_σstar(univ_point,tmx,50,μ,μbar)
print("Final state:")
final_state = get_state_with_σstar(tmx,univ_point,μ,μbar)
print(final_state)
```

    Parameters: μ = 0.09524, μbar = 0.07143
    Ending point for the switching line: τ = 11.1 days, x = 62.9%
    Checking the solution - should give zero values:
    (-1.8766899605925048e-10, 9.321241741773813e-10)
    * Constructing the primary field
    * Constructing the switching curve
    * Constructing the universal curve
    * Finding the last characteristic
    The last point on the universal line:
    [168.21633500053056, 0.6285637714632423, -0.24007763436787447, 1.3270312549851486]
    Final state:
    [180.0, -8.373857163235243e-14, -0.3878381029647683, 1.6191478880823515]

```python
# Plotting
plt.rcParams['figure.figsize'] = (4.5, 3.2)
_k = 0
for solutions in primary_field1:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
_k = 0
for solutions in primary_field2:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
plt.plot([x[0] for x in switching_curve],[x[1] for x in switching_curve],linewidth=3,color="k",zorder=4,linestyle="dashed")
plt.plot([end_point[0]],[end_point[1]],marker='o',color="black",zorder=4)

plt.xlim([0,120]); plt.ylim([0,1]);
ytks = np.arange(0,1.2,.2)
plt.yticks(ytks,[int(y*100) for y in ytks])
plt.xlabel("Backward time (days)"); plt.ylabel("Fraction of resistant cells (\%)")
# plt.show()
plt.savefig("../figures/draft/Fig2-0.pdf",format='pdf',bbox_inches='tight')
```

```python
# Plotting
plt.rcParams['figure.figsize'] = (4.5, 3.2)
_k = 0
for solutions in primary_field1:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
_k = 0
for solutions in primary_field2:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
plt.plot([x[0] for x in switching_curve],[x[1] for x in switching_curve],linewidth=3,color="k",zorder=4,linestyle="dashed")
# plt.plot([end_point[0]],[end_point[1]],marker='o',color="black",zorder=4)
plt.plot([x[0] for x in universal_curve],[x[1] for x in universal_curve],linewidth=3,color="k",zorder=3)
for tend in [42,64,86,110]:
    tuniv = fsolve(get_finalizing_point_from_universal_curve,tend-20.,args=(tend,end_point,μ,μbar,))[0]
    univ_point = get_state_universal(tuniv,end_point,μ,μbar)
    trajectory = get_trajectory_with_σstar(univ_point,tend,50,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
    trajectory = get_trajectory_with_given_σ(univ_point,tend+20,100,0,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
plt.xlim([0,120]); plt.ylim([0,1]);
ytks = np.arange(0,1.2,.2)
plt.yticks(ytks,[int(y*100) for y in ytks])
plt.xlabel("Backward time (days)"); plt.ylabel("Fraction of resistant cells (\%)")
# plt.show()
plt.savefig("../figures/draft/Fig2-1.pdf",format='pdf',bbox_inches='tight')
```

```python
# Plotting
plt.rcParams['figure.figsize'] = (3.5, 2.5)

σs = np.linspace(0,1,1001)
plt.plot(σs,[ρf(.9,0,σ,μ,μbar) for σ in σs],linewidth=2,color="k")
σimx = np.argmax([ρf(.9,0,σ,μ,μbar) for σ in σs])
print(σimx)
plt.plot(σs[σimx],[ρf(.9,0,σs[σimx],μ,μbar)],'ro')
plt.plot(σs,[ρ1f(.9,0,σ,μ,μbar) for σ in σs],'g--',linewidth=1,zorder=-5)
plt.plot(σs,[ρ2f(.9,0,σ,μ,μbar) for σ in σs],'b--',linewidth=1,zorder=-5)
plt.ylim([-.0002,.0082]);
plt.savefig("../figures/draft/Fig3-A.pdf",format='pdf',bbox_inches='tight')
```

```python
fig, ax = plt.subplots()
for solution in primary_field2:
    k = 0
    for x0, entry in solution.items():
        if (k==0):
            print("Terminal point: ",entry['τ'][0],entry['x'][0],entry['φ'][0])
            kk = 16
            print(entry['τ'][kk],entry['x'][kk],entry['φ'][kk])
            ρyy = [ρf(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs]
            plt.plot(σs,ρyy,linewidth=2,color="k")
            σimx = np.argmax(ρyy)
            print(σimx)
            plt.plot(σs[σimx],ρyy[σimx],'ro')
            plt.plot(σs,[ρ1f(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs],'g--',linewidth=1,zorder=-5)
            plt.plot(σs,[-ρ2f(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs],'b--',linewidth=1,zorder=-5)
            plt.ylim([-.0002,.0082]);
            break
        k = k + 1
plt.savefig("../figures/draft/Fig3-B.pdf",format='pdf',bbox_inches='tight')
```

```python
fig, ax = plt.subplots()
for solution in primary_field2:
    k = 0
    for x0, entry in solution.items():
        if (k==0):
            print("Terminal point: ",entry['τ'][0],entry['x'][0],entry['φ'][0])
            kk = 70
            print(entry['τ'][kk],entry['x'][kk],entry['φ'][kk])
            ρyy = [ρf(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs]
            plt.plot(σs,ρyy,linewidth=2,color="k")
            σimx = np.argmax(ρyy)
            print(σimx)
            plt.plot(σs[σimx],ρyy[σimx],'ro')
            plt.plot(σs,[ρ1f(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs],'g--',linewidth=1,zorder=-5)
            plt.plot(σs,[-ρ2f(entry['x'][kk],entry['φ'][kk],σ,μ,μbar) for σ in σs],'b--',linewidth=1,zorder=-5)
            plt.ylim([-.0002,.0082]);
            break
        k = k + 1
plt.savefig("../figures/draft/Fig3-C.pdf",format='pdf',bbox_inches='tight')
```

```python
fig, ax = plt.subplots()
kk = 10
xu = universal_curve[kk][1]
φu = universal_curve[kk][2]
print("Point on the universal curve: ",universal_curve[kk][0],xu,φu)

ρyy = [ρf(xu,φu,σ,μ,μbar) for σ in σs]
plt.plot(σs,ρyy,linewidth=2,color="k")
σimx = np.argmax(ρyy[1:]) #except zero
print(σimx)
plt.plot(σs[σimx],ρyy[σimx],'ro')
plt.plot([0],ρyy[σimx],'ro')
plt.plot(σs,[ρ1f(xu,φu,σ,μ,μbar) for σ in σs],'g--',linewidth=1,zorder=-5)
plt.plot(σs,[-ρ2f(xu,φu,σ,μ,μbar) for σ in σs],'b--',linewidth=1,zorder=-5)
plt.ylim([-.0002,.0082]);
# ax.yaxis.set_major_formatter(plt.NullFormatter())
plt.savefig("../figures/draft/Fig3-D.pdf",format='pdf',bbox_inches='tight')
```

# Preparation for second figure

```python
# Plotting
plt.rcParams['figure.figsize'] = (6.75, 4.5)
_k = 0
for solutions in primary_field1:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
_k = 0
for solutions in primary_field2:
    for x0, entry in solutions.items():
        plt.plot(entry['τ'], entry['x'], '-', linewidth=1, color=clrs0[1])
        _k += 1
plt.plot([x[0] for x in switching_curve],[x[1] for x in switching_curve],linewidth=3,color=clrs0[0],zorder=4,linestyle="dashed")
plt.plot([end_point[0]],[end_point[1]],marker='o',color="black",zorder=4)
plt.plot([x[0] for x in universal_curve],[x[1] for x in universal_curve],linewidth=3,color=clrs0[0],zorder=3)
for tend in [80,110,140]:
    tuniv = fsolve(get_finalizing_point_from_universal_curve,tend-20.,args=(tend,end_point,μ,μbar,))[0]
    univ_point = get_state_universal(tuniv,end_point,μ,μbar)
    trajectory = get_trajectory_with_σstar(univ_point,tend,50,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
    trajectory = get_trajectory_with_given_σ(univ_point,tend+20,100,0,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
plt.xlim([0,120]); plt.ylim([0,1]);
plt.xlabel("Backward time (days)"); plt.ylabel("Fraction of resistant cells (\%)")
plt.show()
```

```python
plt.rcParams['figure.figsize'] = (6.75, 4.5)

_k = 0
for solutions in primary_field1:
    for x0, entry in solutions.items():
        if _k==5:
            sol = [[1,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in zip(entry['τ'],entry['x'],entry['φ'],entry['V'])]
        if _k==6:
            trajectory_thr = [[τ,x,φ,V] for τ,x,φ,V in zip(entry['τ'],entry['x'],entry['φ'],entry['V'])]
            sol += [[0,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in trajectory_thr]
            T0 = max([x[0] for x in trajectory_thr])
        _k += 1
#plt.plot(τ1, x1, '-', linewidth=1, color=clrs0[1])
#plt.plot(τthr, xthr, '--', linewidth=1, color=clrs0[1])
print(T0/30.)

plt.plot([end_point[0]],[end_point[1]],marker='o',color="black",zorder=4)
for tend in [180]:
    tuniv = fsolve(get_finalizing_point_from_universal_curve,tend-20.,args=(tend,end_point,μ,μbar,))[0]
    univ_point = get_state_universal(tuniv,end_point,μ,μbar)
    trajectory = get_trajectory_with_σstar(univ_point,tend,50,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
    sol += [[3,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in trajectory]
    universal_curve = get_universal_curve(end_point,univ_point[0],50,μ,μbar)
    plt.plot([x[0] for x in universal_curve],[x[1] for x in universal_curve],linewidth=3,color=clrs0[0],zorder=3)
    sol = [[3,τ,get_σ_universal(τ,end_point,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in universal_curve] + sol
    trajectory = get_trajectory_with_σstar([0,end_switching_curve['x'],0,0],end_point[0],50,μ,μbar)
    sol = [[3,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in trajectory] + sol
for tend in [124]:
    tuniv = fsolve(get_finalizing_point_from_universal_curve,tend-20.,args=(tend,end_point,μ,μbar,))[0]
    univ_point = get_state_universal(tuniv,end_point,μ,μbar)
#     trajectory = get_trajectory_with_σstar(univ_point,tend,50,μ,μbar)
    trajectory = get_trajectory_with_given_σ(univ_point,tend+20,200,0,μ,μbar)
    plt.plot([x[0] for x in trajectory],[x[1] for x in trajectory],linewidth=1,color=clrs0[4])
    sol += [[2,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in trajectory]
    universal_curve = get_universal_curve(end_point,univ_point[0],50,μ,μbar)
    plt.plot([x[0] for x in universal_curve],[x[1] for x in universal_curve],linewidth=3,color=clrs0[0],zorder=3)
    sol = [[2,τ,get_σ_universal(τ,end_point,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in universal_curve] + sol
    trajectory = get_trajectory_with_σstar([0,end_switching_curve['x'],0,0],end_point[0],150,μ,μbar)
    sol = [[2,τ,σstarf(x,φ,μ,μbar),x,exp((b-d)*τ-V)] for τ,x,φ,V in trajectory] + sol
plt.xlim([0,180]); plt.ylim([0,1]);
plt.xlabel("Backward time (days)"); plt.ylabel("Fraction of resistant cells (\%)")
plt.show()
```

```python
df_res = pd.DataFrame(sol,columns=['part','time','sigma','resistance','fold_change'])
df_res[:10]
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>part</th>
      <th>time</th>
      <th>sigma</th>
      <th>resistance</th>
      <th>fold_change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>2</td>
      <td>0.000000</td>
      <td>1.0</td>
      <td>0.832663</td>
      <td>1</td>
    </tr>
    <tr>
      <th>1</th>
      <td>2</td>
      <td>0.074777</td>
      <td>1.0</td>
      <td>0.831816</td>
      <td>1.00018377186716</td>
    </tr>
    <tr>
      <th>2</th>
      <td>2</td>
      <td>0.149555</td>
      <td>1.0</td>
      <td>0.830962</td>
      <td>1.00036509677104</td>
    </tr>
    <tr>
      <th>3</th>
      <td>2</td>
      <td>0.224332</td>
      <td>1.0</td>
      <td>0.830100</td>
      <td>1.00054395111730</td>
    </tr>
    <tr>
      <th>4</th>
      <td>2</td>
      <td>0.299110</td>
      <td>1.0</td>
      <td>0.829231</td>
      <td>1.00072031112691</td>
    </tr>
    <tr>
      <th>5</th>
      <td>2</td>
      <td>0.373887</td>
      <td>1.0</td>
      <td>0.828353</td>
      <td>1.00089415283525</td>
    </tr>
    <tr>
      <th>6</th>
      <td>2</td>
      <td>0.448665</td>
      <td>1.0</td>
      <td>0.827468</td>
      <td>1.00106545209120</td>
    </tr>
    <tr>
      <th>7</th>
      <td>2</td>
      <td>0.523442</td>
      <td>1.0</td>
      <td>0.826575</td>
      <td>1.00123418455620</td>
    </tr>
    <tr>
      <th>8</th>
      <td>2</td>
      <td>0.598219</td>
      <td>1.0</td>
      <td>0.825674</td>
      <td>1.00140032570334</td>
    </tr>
    <tr>
      <th>9</th>
      <td>2</td>
      <td>0.672997</td>
      <td>1.0</td>
      <td>0.824765</td>
      <td>1.00156385081649</td>
    </tr>
  </tbody>
</table>
</div>

```python
pd.DataFrame(sol).to_csv('../figures/draft/Fig6-trjs_optimal.csv',index=False,header=False)
```

```python
df_res_ = df_res.loc[lambda df: df.part==3].sort_values('time').drop('part',axis=1)
df_res_[:10]
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>time</th>
      <th>sigma</th>
      <th>resistance</th>
      <th>fold_change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>200</th>
      <td>0.000000</td>
      <td>1.0</td>
      <td>0.832663</td>
      <td>1</td>
    </tr>
    <tr>
      <th>201</th>
      <td>0.227384</td>
      <td>1.0</td>
      <td>0.830065</td>
      <td>1.00055119847425</td>
    </tr>
    <tr>
      <th>202</th>
      <td>0.454769</td>
      <td>1.0</td>
      <td>0.827396</td>
      <td>1.00107932271479</td>
    </tr>
    <tr>
      <th>203</th>
      <td>0.682153</td>
      <td>1.0</td>
      <td>0.824653</td>
      <td>1.00158369331340</td>
    </tr>
    <tr>
      <th>204</th>
      <td>0.909538</td>
      <td>1.0</td>
      <td>0.821835</td>
      <td>1.00206361475324</td>
    </tr>
    <tr>
      <th>205</th>
      <td>1.136922</td>
      <td>1.0</td>
      <td>0.818941</td>
      <td>1.00251837517607</td>
    </tr>
    <tr>
      <th>206</th>
      <td>1.364307</td>
      <td>1.0</td>
      <td>0.815967</td>
      <td>1.00294724615568</td>
    </tr>
    <tr>
      <th>207</th>
      <td>1.591691</td>
      <td>1.0</td>
      <td>0.812912</td>
      <td>1.00334948247831</td>
    </tr>
    <tr>
      <th>208</th>
      <td>1.819075</td>
      <td>1.0</td>
      <td>0.809775</td>
      <td>1.00372432193087</td>
    </tr>
    <tr>
      <th>209</th>
      <td>2.046460</td>
      <td>1.0</td>
      <td>0.806552</td>
      <td>1.00407098509788</td>
    </tr>
  </tbody>
</table>
</div>

```python
df_res_.to_csv('../figures/draft/Fig7-trj_optimal.csv',index=False,header=False)
```

```python

```

scripts/D. Field of optimal trajectories [Python].ipynb


+ This notebook is part of *Quiz review 3* in the OCW MIT course 18.06 by Prof Gilbert Strang [1]
+ Created by me, Dr Juan H Klopper
    + Head of Acute Care Surgery
    + Groote Schuur Hospital
    + University Cape Town
    + Email me with your thoughts, comments, suggestions and corrections

Linear Algebra OCW MIT18.06 IPython notebook [2] study notes by Dr Juan H Klopper is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.

+ [1] OCW MIT 18.06
+ [2] Fernando Pérez, Brian E. Granger, IPython: A System for Interactive Scientific Computing, Computing in Science and Engineering, vol. 9, no. 3, pp. 21-29, May/June 2007, doi:10.1109/MCSE.2007.53. URL: http://ipython.org

```python
from IPython.core.display import HTML, Image
css_file = 'style.css'
HTML(open(css_file, 'r').read())
```

<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Arvo:400,700,400italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=PT+Mono' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Shadows+Into+Light' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Philosopher:400,700,400italic,700italic' rel='stylesheet' type='text/css'>

```python
from sympy import init_printing, Matrix, symbols, sqrt, Rational
from numpy import matrix, transpose, sqrt, eye
from numpy.linalg import pinv, inv, det, svd, norm, eig
from scipy.linalg import pinv2
from warnings import filterwarnings
```

```python
init_printing(use_latex = 'mathjax')
filterwarnings('ignore')
```

# Review of select topics

## Quick facts

+ Eigenvalues
    + There are shortcuts that we can sometimes employ to calculate them
+ In symmetric matrices A = AT
    + Their eigenvalues are always real
    + There are always enough eigenvectors and we can choose them to be orthogonal
    + They van be diagonalized and factorized as Q&Lambda'QT
+ Similar matrices are any square matrices that are related by A = M-1BM
    + They have the same eigenvalues (not eigenvectors)
    + As one grows / decays so does the other Ak = M-1BkM

## Exercise problems

### Differential equation matrix

+ Consider the following and solve for a general solution and solve for *e*At
$$ \frac{du}{dt}={A}{u}=\begin{bmatrix}0&-1&0\\1&0&-1\\0&1&0\end{bmatrix}{u} $$

+ There are no initial condition, so we need the general solutions
    + They will be in the form
    $$ u\left(t\right)={c}_{1}{e}^{{\lambda}_{1}{t}}{x}_{1}+{c}_{2}{e}^{{\lambda}_{2}{t}}{x}_{2}+{c}_{3}{e}^{{\lambda}_{3}{t}}{x}_{3} $$

```python
A = Matrix([[0, -1, 0], [1, 0, -1], [0, 1, 0]])
A, A.det()
```

$$\begin{pmatrix}\left[\begin{matrix}0 & -1 & 0\\1 & 0 & -1\\0 & 1 & 0\end{matrix}\right], & 0\end{pmatrix}$$

+ It is clearly singular (dependent rows and columns)

```python
A.transpose()
```

$$\left[\begin{matrix}0 & 1 & 0\\-1 & 0 & 1\\0 & -1 & 0\end{matrix}\right]$$

+ It is skew-symmetric and therefor eigenvalues are purely complex numbers (including 0*i*)

```python
A.eigenvals()
```

$$\begin{Bmatrix}0 : 1, & - \sqrt{2} i : 1, & \sqrt{2} i : 1\end{Bmatrix}$$

+ The solution is thus as follows
$$ u\left(t\right)={c}_{1}{x}_{1}+{c}_{2}{e}^{{\sqrt{2}i}{t}}{x}_{2}+{c}_{3}{e}^{{-\sqrt{2}i}{t}}{x}_{3} $$
+ The solution moves around the unit circle (doesn't grow / decay)
+ It returns to the same value (it's periodic) after a certain time *t*
$$ \sqrt{2}{i}{T}=2{\pi}{i};\quad{e}^{0}=1 \\ \sqrt{2}{T}=2{\pi} \\ {T}={\pi}\sqrt{2} $$

+ Finding *u*(*t*) allows the following
$$ u\left(t\right)={e}^{At}u\left(0\right) $$

+ If A is diagonalizable (A = SΛS-1) then we have the following
$$ {e}^{At}=S{e}^{\Lambda{t}}{S}^{-1} \\ { e }^{ \Lambda t }=\begin{bmatrix} { e }^{ { \lambda  }_{ 1 }t } & 0 & 0 \\ 0 & \ddots  & 0 \\ 0 & 0 & { e }^{ { \lambda  }_{ n }t } \end{bmatrix} $$

### Orthogonal eigenvalues

+ Which matrices have orthogonal eigenvectors?

+ The following
    + Symmetric matrices
    + When AT = -A (skew-symmetric)
    + Orthogonal matrices
    + In general these are all when AAT=ATA

### Definitions

+ Given the following
$$ { \lambda  }_{ 1 }=0;\quad { \lambda  }_{ 2 }=c;\quad { \lambda  }_{ 3 }=2\\ { x }_{ 1 }=\begin{bmatrix} 1 \\ 1 \\ 1 \end{bmatrix},\quad { x }_{ 2 }=\begin{bmatrix} 1 \\ -1 \\ 0 \end{bmatrix},\quad { x }_{ 3 }=\begin{bmatrix} 1 \\ 1 \\ 2 \end{bmatrix} $$

+ Is the matrix A diagonalizable and of so for which value(s) of *c*?
    + So we need enough enough eigenvectors and they should be independent
    + They are indeed
    + More so they are orthogonal
    + So, for all *c* the matrix is diagonalizable

+ Is A symmetric and if so, for which value(s) of *s*?
    + The eigenvalues all have to be real values
    + Thus all real values for *c*

+ Is A positive definite and if so, for which values of *c*?
    + This is a sub-case of symmetric matrices
    + There are a lot of tests for positive definite matrices
    + One of the eigenvalues are zero, so it can, at best, be semi-definite, for *c* ≥ 0

+ Is this a Markov matrix and if so, for which values of *c*?
    + One of the eigenvalues must be 1 and the others must be smaller
    + So, no

+ Could ½A be a projection matrix?
    + They are symmetric and eigenvalues must be real
    + Any projection matrix eigenvalues must be 0 and 1
    $$ P=\frac{A}{2} \\ {P}^{2}=P \\ \therefore {\lambda}^{2}=\lambda \\ \therefore \lambda = 0; \quad \lambda = 1 $$
    + Thus *c* = 0 or *c* = 2 will work (for ½A we will have ½λ)

### Singular value decomposition

+ In SVD we have the following
$$ A=U\Sigma {V}^{T} $$
+ Where U and V are orthogonal matrices and Σ a diagonal matrix

+ Every matrix has a SVD
$$ {A}^{T}A=\left( V {\Sigma}^{T} {U}^{T} \right) \left( U {\Sigma} {V}^{T} \right) \\ {A}^{T} A = V \left( {\Sigma}^{T} {\Sigma} \right) {V}^{T} $$
+ V is the eigenvector matrix for AT
+ &Sigma: has along its main diagonal the square roots of the eigenvalues
+ U is similarly calculated as the eigenvector matrix of AAT
+ There is always, though, as sign issue when choosing V and U
    + For whichever signs are chosen for V, this forces the signs for U which can be checked against the following
    $$ A{v}_{i} = {\sigma}_{i} {u}_{i} \\ AV = \Sigma U $$
+ Σ can tell us a lot about A
    + All values must be ≥ 0
    + If it contains a 0 along the main diagonal, A is singular

### Symmetric AND orthogonal matrices (matrices that are both)
+ A = AT = A-1

+ What can be said about the eigenvalues of these?
    + Symmetric matrices have real eigenvalues and the orthogonal matrix eigenvalues must have length 1; ||λ|| = 1

+ Is A sure to be positive definite?
    + No, as λ can be -1

+ Does it have repeated eigenvalues?
    + Yes (if *n* ≥ 2, some eigenvalues must be repeated)

+ Is it diagonalizable?
    + Most definitely

+ Is it non-singular
    + Yes (no zero eigenvalues)

+ Prove that the following is a projection matrix
$$ \frac{1}{2} \left( A+I \right) $$
    + Squaring it should result in the same
    $$ \frac{1}{4} \left( {A}^{2} + 2A + I \right) $$
    + This begs the question, what is A2?
    + Well, if A equals its inverse, A2 = I
    + As an aside the eigenvalues of A + I will be twice the eigenvalues of A

## Example problems

### Example problem 1

+ Find the eigenvalues and eigenvectors of the following
    + 1: The projection matrix P
    $$ {P} = \frac{{a}{a}^{T}}{{a}^{T}{a}}; \quad a= \begin{bmatrix}3\\4\end{bmatrix} $$
    + 2: The matrix Q
    $$ Q = \begin{bmatrix}0.6&-0.8\\0.8&0.6\end{bmatrix} $$
    + 3: The matrix R
    $$ R = 2P - I $$

#### Solution

+ 1:

```python
a = matrix([[3], [4]]) # Not using sympy
P = (a * transpose(a)) / (transpose(a) * a)
P
```

    matrix([[ 0.36,  0.48],
            [ 0.48,  0.64]])

+ The eigenvalues of a projection matrix are either 0 or 1

```python
eig(P) # eig() gives the eigenvalues and eigenvector matrix
```

    (array([ 0.,  1.]), matrix([[-0.8, -0.6],
             [ 0.6, -0.8]]))

+ 2:

```python
Q = matrix([[0.6, -0.8], [0.8, 0.6]])
Q
```

    matrix([[ 0.6, -0.8],
            [ 0.8,  0.6]])

+ Note that Q is a projection matrix

```python
eig(Q) # eigenvalues come in complex conjugate pairs
```

    (array([ 0.6+0.8j,  0.6-0.8j]),
     matrix([[ 0.70710678+0.j        ,  0.70710678-0.j        ],
             [ 0.00000000-0.70710678j,  0.00000000+0.70710678j]]))

+ 3:

+ R will have the same eigenvectors, but (shifted) eigenvalues

```python
R = 2 * P - eye(2)
R
```

    matrix([[-0.28,  0.96],
            [ 0.96,  0.28]])

```python
eig(R)
```

    (array([-1.,  1.]), matrix([[-0.8, -0.6],
             [ 0.6, -0.8]]))

+ The eigenvalues of P was 0 and 1
    + 2 × 0 - 1 = -1
    + 2 × 1 - 1 = 1

```python

```


# Enzyme Kinetics
We now study common reaction mechanisms that describe enzyme catalysis. Enzymes can dramatically accelerate the rate of biochemical reactions inside and outside living cells. The absolute rates of biochemical reactions are key biological design variables because they can evolve from a very low rate as determined by the mass action kinetics based on collision frequencies, to a very high and specific reaction rate as determined by appropriately-evolved enzyme properties. We first describe the procedure used to derive enzymatic rate laws, which we then apply to the Michaelis-Menten reaction mechanism, then to the Hill model, and finally to the symmetry model. The first is used to describe plain chemical transformations, while the latter two are used to describe regulatory effects.

**MASSpy** will be used to demonstrate some of the topics in this chapter.

```python
from mass import (
    MassModel, MassMetabolite, MassReaction, Simulation, MassSolution)
from mass.visualization import plot_time_profile, plot_phase_portrait
```

Other useful packages are also imported at this time.

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
```

## Enzyme Catalysis
Enzymes are catalysts that accelerate biochemical transformations in cells. Almost all enzymes are proteins. There are also catalytically active ribonucleic acids, called "ribozymes." The fundamental properties of enzyme catalysis are described in this section.

### Enzymatic activity
The activity of an enzyme is measured by determining the increase in the reaction rate relative to the absence of the enzyme. In other words we compare the reaction rate of the un-catalyzed reaction to the catalyzed rate. The ratio can be thought of as an acceleration factor and this number can be quite high, sometimes by many million-fold.

### Reaction and substrate specificity
Enzymes are usually very specific both with respect to the type of reaction being catalyzed (reaction specificity) and with respect to the reactants (the "substrates") that they act on. Highly specific enzymes catalyze the cleavage of only one type of a chemical bond, and only in one substrate. Other enzymes may have a narrow reaction specificity, but broad substrate specificity, i.e., they act on a number of chemically similar substrates. Rare enzymes exist that have both low reaction specificity and low substrate specificity.

**Figure 5.1:** Basic principles of enzyme catalysis. From (Koolman, 2005).

### Catalysis
As discussed in Chapter 2 (Figure 2.4), two molecules can only react with each other if they collide in a favorable orientation. Such collisions may be rare, and thus the reaction rate is slow. An un-catalyzed reaction starts with a favorable collision as shown in Figure 5.1a. Before the products are formed, the collision complex A-B has to pass through what is called a _transition state_. Its formation requires _activation energy_. Since activation energies can be quite high, only a few A-B complexes have this amount of energy, and thus a productive transition state arises only for a fraction of favorable collisions. As a result, conversion only happens occasionally even when the reaction is thermodynamically feasible; i.e., when the net change in Gibbs free energy is negative ($\Delta G < 0$).

Enzymes can facilitate the probability of a favorable collision and lower the activation energy barrier, see Figure 5.1b,c. Enzymes are able to bind their substrates in the catalytic site. As a result, the substrates are favorably oriented relative to one another, greatly enhancing the probability that productive A-B complexes form. The transition state is stabilized leading to a lowered activation energy barrier.

### Information on enzymes
Detailed information is available on a large number of enzymes. This include structural information, the organism source, and other characteristics. An example is shown in Figure 5.2. Many online sources of such information exist.

**Figure 5.2:** Detailed information on enzymes is available. From PDB.

## Deriving Enzymatic Rate Laws
The chemical events underlying the catalytic activities of enzymes are described by a reaction mechanism.  A reaction mechanism is comprised of the underlying elementary reactions that are believed to take place. A rate law is then formulated to describe the rate of reaction.

A rate law describes the conversion of a substrate $(x_1)$ by an enzyme into a product $(x_2)$:

$$\begin{equation} x_1 \stackrel{v}{\rightarrow} x_2 \tag{5.1} \end{equation}$$

where $v$ is a function of the concentrations of the chemical species involved in the reaction. The steps involved in the development and analysis of enzymatic rate laws are illustrated in Figure 5.3 and they are as follows:

**Figure 5.3:** The process of formulating enzymatic rate laws. QSSA represents the quasi-steady state assumption and QEA represents the quasi-equilibrium assumption.

* Formulate the dynamic mass balances based on the elementary reactions in the postulated reaction mechanism,

* Identify time invariants, or conservation relationships,

* Reduce the dynamic dimension of the reaction mechanism by eliminating dynamically dependent variables using the conservation relationships,

* Apply commonly used simplifying kinetic assumptions to formulate a rate law, representing a reduction in the dynamic dimension of the kinetic model,

* Apply mathematical and numerical analysis to determine when the simplifying assumptions are valid and the reaction rate law can be used; and

* Identify key dimensionless parameter ratios. This last step is optional and used by those interested in deeper mathematical analysis of the properties of the rate laws.

The use of enzymatic rate laws in dynamic network models is hampered by their applicability in vivo based on in vitro measurements. From a practical standpoint, with the numerical simulation capacity that is now routinely available, applying simplifying assumptions may no longer be needed for computational simplification and convenience. However, it is useful to help understand the historical origin of enzymatic rate laws, the simplifications on which they are based, and when it may be desirable to use them.

## Michaelis-Menten Kinetics
The simplest enzymatic reaction mechanism, first proposed by Henri (Henri, 1903) but named after Michaelis and Menten (Michaelis, 1913) is;

$$\begin{equation} S + E \underset{k_{-1}}{\stackrel{k_1}{\rightleftharpoons}} X \stackrel{k_2}{\rightarrow} E + P \tag{5.2} \end{equation}$$

where a substrate, $S$, binds reversibly to the enzyme, $E$, to form the intermediate, $X$, which can break down to give the product, $P$, and regenerate the enzyme. Note that it is similar to the reaction mechanism of two connected reversible bi-linear reactions (Eq. (4.38)) with $x_5 = x_2$, as one of the original reactants $(E)$ is regained in the second step. Historically speaking, the Michaelis-Menten scheme is the most important enzymatic reaction mechanism. A detailed account of the early history of Michaelis-Menten kinetics is found in (Segal, 1959).

### Step 1: Dynamic mass balances for Michaelis-Menten kinetics
Applying the law of mass action to the Michaelis-Menten reaction mechanism, one obtains four differential equations that describe the dynamics of the concentrations of the four chemical species involved in the reaction mechanism:

$$\begin{align} \frac{ds}{dt} &= -k_1es + k_{-1}x, & s(t = 0) = s_0 \ \ &\tag{5.3a} \\ \frac{dx}{dt} &= k_1es - (k_{-1} + k_2)x, & x(t = 0) = 0 \ \ &\tag{5.3b} \\ \frac{de}{dt} &= -k_1es + (k_{-1} + k_2)x, & e(t = 0) = e_0 \ \ &\tag{5.3c} \\ \frac{dp}{dt} &= k_2x, & p(t = 0) = 0 \ \ &\tag{5.3d}\\ \end{align}$$

where the lower case letters denote the concentrations of the corresponding chemical species. The initial conditions shown are for typical initial rate experiments where substrate and free enzyme are mixed together at time $t=0$. $e_0$ and $s_0$ denote the initial concentration of enzyme and substrate, respectively. No mass exchange occurs with the environment.

### Step 2: Finding the time invariants for Michaelis-Menten kinetics
Using $\textbf{x} = (s, e, x, p)$ and $\textbf{v} = (k_1es, \ k_{-1}x, \  k_2x)$ the stoichiometrix matrix is

$$\begin{equation} \textbf{S} = \begin{pmatrix} {-1} & {1} & {0} \\ {-1} & {1} & {1} \\ {1} & {-1} & {-1} \\ {0} & {0} & {1} \\ \end{pmatrix} \end{equation}$$
$$\tag{5.4}$$

It has a rank of 2 and thus there are two conservation quantities. They are the total concentration of the enzyme and total concentration of the substrate:

$$\begin{align} e_0 & = e + x \tag{5.5} \\ s_0 &= s + x  + p \tag{5.6} \end{align}$$

### Step 3: Reducing the dynamic description for Michaelis-Menten kinetics
As a consequence of the two conservation relationships, only two of equations 5.3 are dynamically independent. Choosing the substrate, $s$, and the intermediate complex, $x$, concentrations as the two independent variables, the reaction dynamics are described by:

$$\begin{align} \frac{ds}{dt} &= -k_1e_0s + (k_1s + k_{-1})x, \ &s(t = 0)=s_0 \ \ &\tag{5.7} \\ \frac{dx}{dt} &= k_1e_0s - (k_1s + k_{-1} + k_2)x, \ &x(t = 0) = 0 \ \ &\tag{5.8} \\ \end{align}$$

The major problem with this mass action kinetic model is that it is mathematically intractable (Hommes, 1962). Equations 5.7 and 5.8 can be reduced to an Abel type differential equation whose solution cannot be obtained in a closed form.

### Step 4: Applying kinetic assumptions for Michaelis-Menten kinetics
A closed form analytical solution to the mass action kinetic equations, 5.7 and 5.8, is only attainable by using simplifying kinetic assumptions. Two assumptions are used: the _quasi-steady state assumption_ (QSSA) and the _quasi-equilibrium assumption_ (QEA).

#### The quasi-steady state assumption:
The rationale behind the quasi-steady state assumption (Briggs, 1925) is that, after a rapid transient phase, the intermediate, $X$, reaches a quasi-stationary state in which its concentration does not change appreciably with time. Applying this assumption to Eq. (5.8) (i.e., $dx/dt=0$) gives the concentration of the intermediate complex as:

$$\begin{equation} x_{qss} = \frac{e_0s}{K_m + s} \tag{5.9} \end{equation}$$

where $K_m = (k_{-1} + k_2)/k_1$ is the well-known Michaelis constant. Substituting $x_{qss}$ into the differential equation for the substrate (Eq. (5.7)) gives the rate law

$$\begin{equation} \frac{ds}{dt} = \frac{-k_2e_0s}{K_m + s} \tag{5.10} \end{equation}$$

which is the well-known Michaelis-Menten equation, where $v_m$ is the maximum reaction rate (or reaction velocity).

Initially, the quasi-steady state assumption was justified based on physical intuition, but justification for its applicability is actually found within the theory of singular perturbations (Bowen, 1963) Eq. (5.10) can be shown to be the first term in an asymptotic series solution derived from singular perturbation theory (Heineken,1967), (Meiske, 1978); see review in (Palsson, 1984).

#### The quasi-equilibrium assumption:
Here, one assumes that the binding step quickly reaches a quasi-equilibrium state (Henri, 1903), (Michaelis, 1913) where

$$\begin{equation} \frac{se}{x} = \frac{s(e_0 - x)}{x} = \frac{k_{-1}}{k_1} = K_d, \ \text{or} \ x_{qe} = \frac{e_0s}{K_d + s} \tag{5.11} \end{equation}$$

holds. $K_d$ is the disassociation equilibrium constant. Note the similarity to Eq. (5.9). Hence, one obtains the rate law

$$\begin{equation} \frac{dp}{dt} = \frac{k_2e_0s}{K_d + s} \tag{5.12} \end{equation}$$

by using Eq. (5.11) in the differential equation for the product $P$.

### Step 5: Numerical solutions for Michaelis-Menten kinetics
The full dynamic description of the kinetics of the reaction (Eq. (5.7) and (5.8)) can be obtained by direct numerical integration. The results are most conveniently shown on a phase portrait along with the transient response of the concentrations on both the fast and slow time scales, see Figure 5.4.

#### QSSA Solution for Michaelis-Menten kinetics

```python
t0 = 0
tf = 1e3
# QSSA Assumption
# Define function to integrate
def qssa(t, s, *params):
    k2, e0, Km = params
    dsdt = (-k2*e0*s)/(Km + s)
    return dsdt

# Define initial conditions and parameters for integration
s0 = 1

e0 = (1/100)
k2 = 1
Km = 1
params = [k2, e0, Km]

# Obtain numerical solutions
sol_obj = solve_ivp(fun=lambda t, s: qssa(t, s, *params),
                    t_span=(t0, tf), y0=[s0])
t, s_sol = (sol_obj.t, sol_obj.y)
x_sol = np.array([(e0 * val)/(Km + val) for val in s_sol])
# Store solutions into Solution Objects
qssa_sol = MassSolution(
    "QSSA", solution_type="Conc",
    data_dict={"s": s_sol[0], "x": x_sol[0]},
    time=t, interpolate=False)
```

#### Numerical Solution for Michaelis-Menten kinetics

```python
model = MassModel('Michaeli_Menten')
## Define metabolites
s = MassMetabolite("s")
e = MassMetabolite("e")
x = MassMetabolite("x")
p = MassMetabolite("p")
# Define reactions
v1 = MassReaction("v1")
v2 = MassReaction("v2", reversible=False)
v1.add_metabolites({s: -1, e: -1, x: 1})
v2.add_metabolites({x: -1, e: 1, p: 1})
model.add_reactions([v1, v2])
## Define parameters
v1.kf = 2
v1.Keq = 2
v2.kf = 1
# Define initial conditions
model.update_initial_conditions({s: s0, e: e0, x: 0, p: 0})

# Solve
MM_simulation = Simulation(model, verbose=True)
conc_sol, flux_sol = MM_simulation.simulate(model, (t0, tf))
```

    Set parameter Username

    WARNING: No compartments found in model. Therefore creating compartment 'compartment' for entire model.

    Successfully loaded MassModel 'Michaeli_Menten' into RoadRunner.

```python
fig_5_4 = plt.figure(figsize=(9, 7))
gs = fig_5_4.add_gridspec(nrows=2, ncols=2, width_ratios=[1, 1.5],
                          height_ratios=[1, 1])

ax1 = fig_5_4.add_subplot(gs[0, 0])
ax2 = fig_5_4.add_subplot(gs[0, 1])
ax3 = fig_5_4.add_subplot(gs[1, 1])
# Phase portrait of both solutions' substrate vs. intermediate
plot_phase_portrait(
    conc_sol, x=s, y=x, ax=ax1, legend=["Numerical Solution", "lower outside"],
    annotate_time_points="endpoints",
    annotate_time_points_color=["r", "b"],
    annotate_time_points_labels=True);
plot_phase_portrait(
    qssa_sol, x=s, y=x, ax=ax1, legend=["QSSA", "lower outside"],
    xlabel=s.id, ylabel=x.id, linestyle=["--"],
    title=("(a) Phase Portrait", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

# Time profile of solutions' substrate concentration
plot_time_profile(conc_sol, observable=s, ax=ax2);
plot_time_profile(
    qssa_sol, observable=s, ax=ax2,
    xlabel="Time", ylabel="Concentration",
    title=("(b) Substrate Concentration", {"size": "x-large"}),
    linestyle=["--"]);
# Time profile of solutions' intermediate concentration
plot_time_profile(conc_sol, observable=x, ax=ax3);
plot_time_profile(
    qssa_sol, observable=x, ax=ax3,
    xlabel="Time", ylabel="Concentration",
    title=("(c) Intermediate Concentration", {"size": "x-large"}),
    linestyle=["--"]);
fig_5_4.tight_layout()
```

**Figure 5.4:** The transient response of the Michaelis-Menten reaction mechanism, for $k_2 = k_{-1}, \ 100e_0 = K_m \ \text{and} \ s_0 = K_m$. (a) The phase portrait . (b) The substrate concentrations. (c) The intermediate concentrations. The solid and dashed line represent the quasi-steady state and the full numerical solution respectively.

* _The phase portrait_. The phase portrait is shown in of Figure 5.4a and it shows how the reaction rapidly approaches the quasi-steady state line and then moves along that line towards the equilibrium in the origin where the reaction has gone to completion.

* _The fast motion_. With the slider on moved to the left, Figure 5.4 shows the changes in the concentrations during the faster time scale. The intermediate concentration exhibits a significant fast motion, while the substrate does not move far from its initial value.

* _The slow motion_. The changes in the concentrations during the slower time scale are shown when the slider is on the right of Figure 5.4. Both the substrate and the intermediate complex decay towards zero. During the decay process, the complex is in a quasi-stationary state and the motion of the substrate drives the reaction dynamics. The quasi-steady state solution gives a good description of the motion on the slower time scale.

### Step 6: Identification of dimensionless parameters for Michaelis-Menten kinetics
Simulation studies suggests that there are three dimensionless parameters of interest:

$$\begin{equation} a = k_2/k_{-1}, \ b = e_0/K_m, \ c = s_0/K_m \tag{5.13} \end{equation}$$

This result is also found by rigorous mathematical analysis (Palsson, 1984). The dynamic behavior of the reaction is determined by three dimensionless groups: a ratio of kinetic constants and the two initial conditions scaled to $K_m$.

1. The first dimensionless group, a, is a ratio consisting only of kinetic constants, $k_2/k_{-1}$. This ratio has been called the 'stickiness number'  (Palsson, 1984), (Palsson, 1984a), since a substrate is said to stick well to an enzyme if $k_2 > k_{-1}$. Once $X$ is formed it is more likely to break down to yield the product than to revert back to substrate.

2. The second dimensionless number, $e_0/K_m$, is a dimensionless concentration parameter - the total enzyme concentration relative to the Michaelis constant. This quantity varies from one situation to another and takes particularly different values under _in vitro_ and _in vivo_ conditions. The enzyme concentrations used _in vitro_ are several orders of magnitude lower than the $K_m$ values (Masters, 1977), (Srere, 1967), (Srere, 1970). In vivo enzyme concentrations can approach the same order of magnitude as $K_m$.

3. The third dimensionless ratio, $s_0/K_m$, is the initial condition for the substrate concentration. Typical values for this ratio _in vivo_ is on the order of unity.

```python
# Define new initial conditions and parameters for integration
s0 = (1/100)

e0 = (1/100)
k2 = 1
Km = 1
params = [k2, e0, Km]

# Obtain numerical solutions
sol_obj = solve_ivp(fun=lambda t, s: qssa(t, s, *params),
                    t_span=(t0, tf), y0=[s0])
s_sol = sol_obj.y
x_sol = np.array([(e0 * val)/(Km + val) for val in s_sol])
# Store solutions into MassSolution Objects
qssa_sol = MassSolution(
    "QSSA", solution_type="Conc",
    data_dict={"s": s_sol[0], "x": x_sol[0]},
    time=sol_obj.t, interpolate=False)

# Update initial conditions for MassModel
model.update_initial_conditions({s: s0})

# Solve
MM_simulation = Simulation(model, verbose=True)
conc_sol, flux_sol = MM_simulation.simulate(model, (t0, tf))
```

    WARNING: No compartments found in model. Therefore creating compartment 'compartment' for entire model.

    Successfully loaded MassModel 'Michaeli_Menten' into RoadRunner.

```python
fig_5_5 = plt.figure(figsize=(9, 7))
gs = fig_5_5.add_gridspec(nrows=2, ncols=2, width_ratios=[1, 1.5],
                          height_ratios=[1, 1])

ax1 = fig_5_5.add_subplot(gs[0, 0])
ax2 = fig_5_5.add_subplot(gs[0, 1])
ax3 = fig_5_5.add_subplot(gs[1, 1])
# Phase portrait of both solutions' substrate vs. intermediate
plot_phase_portrait(
    conc_sol, x=s, y=x, ax=ax1, legend=["Numerical Solution", "lower outside"],
    annotate_time_points="endpoints",
    annotate_time_points_color=["r", "b"],
    annotate_time_points_labels=True);
plot_phase_portrait(
    qssa_sol, x=s, y=x, ax=ax1, legend=["QSSA", "lower outside"],
    xlabel=s.id, ylabel=x.id, linestyle=["--"],
    title=("(a) Phase Portrait", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

# Time profile of solutions' substrate concentration
plot_time_profile(conc_sol, observable=s, ax=ax2);
plot_time_profile(qssa_sol, observable=s, ax=ax2,
                xlabel="Time", ylabel="Concentration",
                title=("(b) Substrate Concentration", {"size": "x-large"}),
                linestyle=["--"]);
# Time profile of solutions' intermediate concentration
plot_time_profile(conc_sol, observable=x, ax=ax3);
plot_time_profile(qssa_sol, observable=x, ax=ax3,
                xlabel="Time", ylabel="Concentration",
                title=("(c) Intermediate Concentration", {"size": "x-large"}),
                linestyle=["--"]);
fig_5_5.tight_layout()
```

**Figure 5.5:** The transient response of the Michaelis-Menten reaction mechanism, for $k_2 = k_{-1},\ 100e_0 = K_m \ \text{and} \ s_0 = K_m$. (a) The phase portrait . (b) The substrate concentrations. (c) The slow transients. The solid and dashed line represent the quasi-steady state and the full numerical solution respectively.

### Comment on the criterion $e_0 << s_0$
Historically, the commonly accepted criterion for the applicability of the quasi-steady state assumption is that the initial concentration of the enzyme must be much smaller than that of the substrate. The actual criterion is $e_0 << K_m, \ \text{or} \ b << 1$ (Palsson, 1984). Figure 5.5 shows the reaction dynamics for $e_0 = K_m, \ e_0 = 100s_0, \ 100k_2 = k_{-1}$, which is analogous to Figure 5.4, except the initial substrate concentration is now a hundred times smaller than $K_m$. In other words, we have $e_0 = s_0 << K_m$ and, as demonstrated in Figure 5.5, the quasi-steady state assumption is applicable.

## Hill-kinetics for Enzyme Regulation
### Regulated enzymes
Enzyme activity is regulated by the binding of small molecules to the enzyme resulting in an altered enzymatic activity. Such binding can inhibit or activate the catalytic activities of the enzyme. The regulation of enzymes such regulators represents a 'tug of war' between the functional states of the enzyme, see Figure 5.6. A simple extension of the oldest reaction mechanisms for ligand binding to oligomeric protein, i.e., oxygen binding to hemoglobin, is commonly used to obtain simple rate laws for regulated enzymes (Hill, 1910).

**Figure 5.6:** An example of a regulated multimeric enzyme. The T form of the enzyme created by inhibitor binding is inactive, where as the R form, where no inhibitor is bound, is catalytically active. From (Koolman, 2005) (reprinted with permission).

### The reaction mechanism for Hill-kinetics
The Hill reaction mechanism is based on two reactions: a catalytic conversion and the sequestration of the enzyme in an inactive form. It assumes that the catalyzed reaction is an irreversible bi-molecular reaction between the substrate, $S$, and the enzyme, $E$, to form the product,$P$, and the free enzyme in a single elementary reaction:

$$\begin{equation} S + E \stackrel{k}{\rightarrow} E + P \tag{5.14} \end{equation}$$

The enzyme in turn can be put into a catalytically inactive state, $X$, through binding simultaneously and reversibly to $\nu$ molecules of an inhibitor, $I$:

$$\begin{equation} E + {\nu}I \underset{k_{-i}^-}{\stackrel{k_{i}^+}{\rightleftharpoons}} X \tag{5.15} \end{equation}$$

Numerical values for $\nu$ often exceed unity. Thus, the regulatory action of $I$ is said to be lumped in the simple $E$ to $X$ transformation, as values of $\nu$ greater than 1 are chemically unrealistic. Numerical values estimated from data show that the best fit values for $\nu$ are not integers; for instance $\nu$ is found to be around 2.3 to 2.6 for $O_2$ binding to hemoglobin. Section 5.5 describes more realistic reaction mechanisms of serial binding of an inhibitor to a regulated enzyme to sequester it in an inactive form.

### Step 1: Dynamic mass balances for Hill-kinetics
The mass action kinetic equations are

$$\begin{equation} \frac{ds}{dt} = -v_1, \ \frac{de}{dt} = -v_2 + v_3, \ \frac{dp}{dt} = v_1, \ \frac{di}{dt} = -\nu (v_2 - v_3), \ \frac{ds}{dt} = v_2 - v_3 \end{equation}$$

where the reaction rates are

$$\begin{equation} v_1 = kse, \ v_2 = k_i^+i^{\nu}e, \ v_3 = k_i^-x \tag{5.16} \end{equation}$$

### Step 2: Finding the time invariants for Hill-kinetics
We define $\textbf{x} = (s, e, p, i, x) \ \text{and} \ \textbf{v} = (ks, k_i^+i^{\nu}e, k_i^-x)$. The stoichiometric matrix is then

$$\begin{equation} \textbf{S} = \begin{pmatrix} {-1} & {0} & {0} \\ {0} & {-1} & {1} \\ {1} & {0} & {0} \\ {0} & {-\nu} & {\nu} \\ {0} & {1} & {-1} \\ \end{pmatrix} \end{equation}$$
$$\tag{5.17}$$

and has a rank of two. The conservation quantities are a balance on the substrate, the enzyme, and the inhibitor:

$$\begin{equation} s_0 = s + p, \ e_0 = e + x, \ i_0 = i + \nu x \tag{5.18} \end{equation}$$

### Step 3: Reducing the dynamic description for Hill-kinetics
We need two differential equations to simulate the dynamic response and then the remaining three variables can be computed from the conservation relationships. We can choose the substrate, $s$, and the concentration of the enzyme, $e$:

$$\begin{equation} \frac{ds}{dt} = kse, \ \frac{de}{dt} = -k_i^+i^{\nu}e + k_i^-x \tag{5.19} \end{equation}$$

then $p$, $x$ and $i$ are computed from Eq. (5.18).

### Step 4: Applying simplifying kinetic assumptions for Hill-kinetics

If we assume that the binding of the inhibitor is fast, so that a quasi-equilibrium  forms for the reaction of Eq. (5.15), we have

$$\begin{equation} v_2 = v_3, \ \text{thus} \ x = (k_i^+/k_i^-)i^{\nu}e = (i/K_i)^{\nu}e, \ \text{and} \ \frac{de}{dt} = \frac{dx}{dt} = \frac{di}{dt} = 0 \tag{5.20} \end{equation}$$

where $K_i$ is a "per-site" dissociation constant for Eq. (5.15). The enzyme is in one of two states, so that we have the mass balance

$$\begin{equation} e_0 = e + x = (1 + (i/K_i)^{\nu})e \ \text{or} \ e(i) = \frac{e_0}{1 + (i/K_i)^{\nu}} \tag{5.21} \end{equation}$$

where $e_0$ is the total concentration of the enzyme. Using the mass balance and the quasi-equilibrium assumption gives the flux through the regulated reaction as

$$\begin{equation} v(i) = ke(i)s = \frac{ke_0s}{1 + (i/K_i)^{\nu}} = \frac{v_m}{1 + (i/K_i)^{\nu}} \tag{5.22} \end{equation}$$

with $v_m = ke_0s$. The Hill model has three parameters: 1) $\nu$, the degree of cooperativity,  2) $K_i$, the dissociation constant for the inhibitor and, 3) $v_m$, the maximum reaction rate or the capacity of the enzyme. We note that

$$\begin{equation} f_e = \frac{e(i)}{e_0} = \frac{1}{1 + (i/K_i)^{\nu}} \tag{5.23} \end{equation}$$

represents the fraction of the enzyme that is in the active state. Note that $f_e \lt 1$ for any finite concentration of the inhibitor.

```python
t0 = 0
tf = 10

def hill(t, state_vars, *params):
    s, p, e, i, x = state_vars
    k1, k_plus,k_minus, nu = params
    # Reaction Rates
    v1 = k1 * s * e
    v2 = k_plus * i**nu * e
    v3 = k_minus * x
    # Differential equations
    diffeqs =[-v1,          # ds/dt
              v1,           # dp/dt
              -v2 + v3,     # de/dt
              -nu*(v2 - v3), # di/dt
              v2 - v3]      # dx/dt
    return diffeqs

# Define initial conditions
s0, p0, e0, i0, x0 = (1, 0, 1, 1, 0)

# Define paramters
k1 = 1
k_plus, k_minus = (100, 100)
nu = 2
params = [k1, k_plus, k_minus, nu]

# Obtain numerical solutions
sol_obj = solve_ivp(
    fun=lambda t, state_vars: hill(t, state_vars, *params),
    t_span=(t0, tf), y0=[s0, p0, e0, i0, x0])
# Store solutions into Solution Objects
sol_dict = dict(zip(["s", "p", "e", "i", "x"], sol_obj.y))
hill_sol = MassSolution(
    "Hill", solution_type="Conc", data_dict=sol_dict,
    time=sol_obj.t, interpolate=False)
```

```python
fig_5_7 = plt.figure(figsize=(9, 8))
gs = fig_5_7.add_gridspec(nrows=2, ncols=2, width_ratios=[1, 1],
                          height_ratios=[1, 1])

ax1 = fig_5_7.add_subplot(gs[0, 0])
ax2 = fig_5_7.add_subplot(gs[0, 1])
ax3 = fig_5_7.add_subplot(gs[1, 0])
ax4 = fig_5_7.add_subplot(gs[1, 1])

plot_phase_portrait(
    hill_sol, x="s", y="e", ax=ax1, xlabel="s", ylabel="e",
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(a) Phase Portrait of s vs. e", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);
plot_phase_portrait(
    hill_sol, x="e", y="x", ax=ax2, xlabel="e", ylabel="x",
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(b) Phase Portrait of e vs. x", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

plot_phase_portrait(
    hill_sol, x="i", y="x", ax=ax3, xlabel="i", ylabel="x",
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(a) Phase Portrait of i vs. x", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);
plot_time_profile(
    hill_sol, ax=ax4, legend="right outside",
    title=("(d) Concentration Profiles", {"size": "x-large"}));
fig_5_7.tight_layout()
```

**Figure 5.7:** The transient response of the Hill reaction mechanism, for $k_i^+ = k_i^- = 100$, $k = 1$, $\nu = 2$, $x_0 = 0$ and $e_0 = s_0 = i_0 = 1$. (a) The phase portraits of $s$ and $e$. (b) The phase portraits of $e$ and $x$. (c) The phase portraits of $i$ and $x$. (d) The concentration profiles.

### Step 5: Numerical solutions for Hill-kinetics
The dynamic response of the Hill reaction mechanism is shown in Figure 5.7. The trace in the $s$ vs. $e$ phase portrait is L-shaped, showing a rapid initial equilibration of the enzyme to the inhibitor (the vertical line), followed by the slower conversion of the product (the horizontal line). These two reactions are naturally (stoichiometrically) decoupled and separated in time for the numerical values of the kinetic constants used.

The phase portraits for $e$ vs. $x$ and $i$ vs. $x$ are straight lines as given by the conservation Eq. (5.18), see Figure 5.7b,c. The two phase transient responses in Figure 5.7d shows the rapid equilibration of the enzyme and the slow conversion of substrate. Under these parameter conditions, the QEA should give good results.

### Step 6: Estimating key parameters
There are two features of the Hill rate law that are of interest:

#### Applicability of the quasi-equilibrium assumption.
Given the fact that the two reactions have characteristic times scales, their relative magnitude is of key concern when it comes to the justification of the QEA:

$$\begin{equation} a = (\frac{\text{characteristic binding time of the inhibitor}}{\text{characteristic turnover time of the substrate}}) = \frac{k}{k_i^+} \tag{5.24} \end{equation}$$

If $a$ is much smaller than unity, we would expect the QEA to be valid. In Figure 5.7, $a$ is 0.01.

#### Regulatory characteristics
The Hill rate law has a sigmoidal shape with sensitivity of the reaction rate to the end product concentration as

$$\begin{equation} v_i = \frac{\partial v}{\partial i} = \frac{-\nu v_m}{i} \frac{(i/K_i)^{\nu}}{[1 + (i/K_i)^{\nu}]^2} \tag{5.25} \end{equation}$$

which has a maximum

$$\begin{equation} v_i^* = -\frac{v_m}{K_i}N(\nu) \ \text{where} \ N(\nu) = \frac{1}{4\nu}(\nu - 1)^{1 - 1/\nu}(\nu + 1)^{1 + 1/\nu} \tag{5.26} \end{equation}$$

at the inflection point

$$\begin{equation} i^* = K_i(\frac{\nu - 1}{\nu + 1})^{1/\nu} \tag{5.27} \end{equation}$$

For plausible values of $\nu$, the function $N(\nu)$ is on the order of unity (Table 5.1), and hence the maximum sensitivity $v_i^*$ is on the order of $(-v_m/K_i)$. The ratio $(K_i/v_m)$ can be interpreted as a time constant characterizing the inhibition process;

$$\begin{equation} t_i = \frac{K_i}{v_m} = [\frac{\text{concentration}}{\text{concentration/time}}]\tag{5.28} \end{equation}$$

This estimate represents an upper bound since the steady state concentration of $i$ can be different from $i^*$. The turnover of the substrate happens on a time scale defined by the rate constant $t_s = 1/k$. Thus, a key dimensionless property is

$$\begin{equation} b = \frac{t_s}{t_i} = \frac{1/k}{K_i/v_m} = \frac{v_m}{kK_i} = \frac{e_t}{K_i} \tag{5.29} \end{equation}$$

Therefore, the dimensionless parameter $b$ can be interpreted as a ratio of time constants or as a ratio of concentration ranges.

**Table 5.1:** The values of the function $N(\nu)$ and $i^*/K_i$ at the inflection point.

```python
def N(nu): # N(v)
    return (1/(4*nu))*((nu-1)**(1-1/nu))*((nu+1)**(1+1/nu))

def i_Ki(nu): # i*/Ki
    return ((nu-1)/(nu+1))**(1/nu)

cols = [nu for nu in np.linspace(2, 5, 4)]
tab_5_1 = pd.DataFrame([[round(N(nu), 2) for nu in cols],
                        [round(i_Ki(nu), 2) for nu in cols]],
                      index=['N($\\nu$)', '$i^{*}$$/K_{i}$'], columns=cols)
tab_5_1.index.rename('$\\nu$', inplace=True)
tab_5_1
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>2.0</th>
      <th>3.0</th>
      <th>4.0</th>
      <th>5.0</th>
    </tr>
    <tr>
      <th>$\nu$</th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>N($\nu$)</th>
      <td>0.65</td>
      <td>0.84</td>
      <td>1.07</td>
      <td>1.30</td>
    </tr>
    <tr>
      <th>$i^{*}$$/K_{i}$</th>
      <td>0.58</td>
      <td>0.79</td>
      <td>0.88</td>
      <td>0.92</td>
    </tr>
  </tbody>
</table>
</div>

## The Symmetry Model
The regulatory molecules are often chemically quite different than the substrate molecule. They thus often have a different binding site on the protein molecule than the catalytic site. It called an _allosteric site_. One of the earliest enzyme kinetic models that accounted for allosterism was the symmetry model (Monod, 1965), named after certain assumed symmetry properties of the subunits of the enzyme. It is a mechanistically realistic description of regulatory enzymes. An example of a multimeric regulatory enzyme is given in Figure 5.6.

### The reaction mechanism for the symmetry model
The main chemical conversion in the symmetry model is as before and is described by Equation (5.14). The symmetry model postulates that the regulated enzyme lies naturally in two forms, $E$ and $X$, and is converted between the two states simply as

$$\begin{equation} E \underset{k_{-}}{\stackrel{k_+}{\rightleftharpoons}} X  \tag{5.30} \end{equation}$$

The equilibrium constant for this reaction,

$$\begin{equation} L = k_+/k_- = x/e \tag{5.31} \end{equation}$$

has a special name, the allosteric constant. Then $\nu$ molecules of an inhibitor, $I$, can bind sequentially to $X$ as

$$\begin{equation} \begin{matrix} {X} & {+} & {I} & {\underset{k_i^-}{\stackrel{\nu k_i^+}{\rightleftharpoons}}} & {X_1} \\ {X_1} & {+} & {I} & {\underset{2 k_i^-}{\stackrel{(\nu-1) k_i^+}{\rightleftharpoons}}} & {X_2} \\ {\vdots} & {} & {} & {} & {\vdots} \\ {X_{\nu - 1}} & {+} & {I} & {\underset{\nu k_i^-}{\stackrel{k_i^+}{\rightleftharpoons}}} & {X_{\nu}} \\ \end{matrix}\end{equation}$$
$$\tag{5.32}$$

where the binding steps have the same dissociation constant, $K_i = k_i^- / k_i^+$. We will discuss the most common case of a tetramer here, i.e., $\nu = 4$, see Figure 5.8.

**Figure 5.8:** The reaction mechanisms for the symmetry model. The enzyme has four binding sites for the inhibitor.

### Step 1: Dynamic mass balances for the symmetry model
The conversion rate of the substrate is

$$\begin{equation} v = kse \tag{5.33} \end{equation}$$

whereas the enzyme sequestration is characterized by the reaction rates

$$\begin{equation} \begin{matrix} {v_1 = k^+e,} & {v_2 = k^-x,} & {v_3 = 4k_i^+xi,} \\ {v_4 = k_i^-x_1,} & {v_5 = 3 k_i^+x_1i,} & {v_6 = 2k_i^-x_2,} \\ {v_7 = k_i^+x_2i,} & {v_8 = k_i^-x_3,} & {v_9 = k_i^+x_3i,} \\ {} & {v_{10} = 4k_i^-x_4} & {} \\ \end{matrix} \end{equation}$$
$$\tag{5.34}$$

The dynamic mass balances on the various states of the enzyme are:

$$\begin{align} \frac{de}{dt} &= -v_1 + v_2\ \ &\tag{5.35a} \\ \frac{dx}{dt} &= v_1 - v_2 - v_3 + v_4\ \ &\tag{5.35b} \\ \frac{di}{dt} &= -v_3 + v_4 - v_5 + v_6 - v_7 + v_8 - v_9 + v_{10}\ \ &\tag{5.35c} \\ \frac{dx_1}{dt} &= v_3 - v_4 - v_5 + v_6\ \ &\tag{5.35d} \\ \frac{dx_2}{dt} &= v_5 - v_6 - v_7 + v_8\ \ &\tag{5.35e} \\ \frac{dx_3}{dt} &= v_7 - v_8 - v_9 + v_{10}\ \ &\tag{5.35f} \\ \frac{dx_4}{dt} &= v_9 - v_{10}\ \ &\tag{5.35g}\\ \end{align}$$

### Step 2: Finding the time invariants for the symmetry model
The stoichiometric matrix for $\textbf{x} = (e, x, i, x_1, x_2, x_3, x_4)$ is a 7x10 matrix:

$$\begin{equation} \textbf{S} = \begin{pmatrix} {-1} & {1} & {0} & {0} & {0} & {0} & {0} & {0} & {0} & {0} \\ {1} & {-1} & {-1} & {1} & {0} & {0} & {0} & {0} & {0} & {0} \\ {0} & {0} & {-1} & {1} & {-1} & {1} & {-1} & {1} & {-1} & {1} \\ {0} & {0} & {1} & {-1} & {-1} & {1} & {0} & {0} & {0} & {0} \\ {0} & {0} & {0} & {0} & {1} & {-1} & {-1} & {1} & {0} & {0} \\ {0} & {0} & {0} & {0} & {0} & {0} & {1} & {-1} & {-1} & {1} \\ {0} & {0} & {0} & {0} & {0} & {0} & {0} & {0} & {1} & {-1} \\ \end{pmatrix} \end{equation}$$
$$\tag{5.36}$$

that has a rank of 5. Thus, there are two conservation relationships, for the enzyme: $e_0 = e + x + x_1 + x_2 + x_3 + x_4$;, and, for the inhibitor: $i_0 = i + x_1 + 2x_2 + 3x_3 + 4x_4$. If the dynamic mass balances on the substrate and product are taken into account, a third conservation $s_0 = s + p$ appears.

### Step 3: Reducing the dynamic description for the symmetry model
We leave it to the reader to pick two dynamic variables from the full kinetic model as the dependent variables and then eliminate them from the dynamic description using the conservation relationships. The impetus for doing so algebraically becomes smaller as the number of differential equations grows. Most standard software packages will integrate a dynamically redundant set of differential equations and such substitution is not necessary to obtain the numerical solutions.

### Step 4: Using simplifying kinetic assumptions to derive a rate law  for the symmetry model
The serial binding of an inhibitor to X that has four binding sites is shown in Figure 5.8. The derivation of the rate law is comprised of four basic steps:

1. Mass balance on enzyme:

$$\begin{equation} e_0 = e + x + x_1 + x_2 + x_3 + x_4 \tag{5.37} \end{equation}$$

2. QEA for binding steps:

$$\begin{align} 4k_i^+ix   =  k_i^-x_1 \ &\Rightarrow \ x_1 = \frac{4}{1}x  (i/K_i) = 4x(i/K_i)\ \ &\tag{5.38a} \\ 3k_i^+ix_1 = 2k_i^-x_2 \ &\Rightarrow \ x_2 = \frac{3}{2}x_1(i/K_i) = 6x(i/K_i)^2 \ \ &\tag{5.38b} \\ 2k_i^+ix_2 = 3k_i^-x_3 \ &\Rightarrow \ x_3 = \frac{2}{3}x_2(i/K_i) = 4x(i/K_i)^3 \ \ &\tag{5.38ac} \\ k_i^+ix_3 = 4k_i^-x_4 \ &\Rightarrow \ x_4 = \frac{1}{4}x_3(i/K_i) =  x(i/K_i)^4 \ \ &\tag{5.38d} \\ \end{align}$$

3. Combine 1 and 2:

$$\begin{align} e_0 &= e + x + 4x(i/K_i) + 6x(i/K_i)^2 + 4x(i/K_i)^3 + x(i/K_i)^4 \\ &= e + x(1 + (i/K_i))^4 \ \text{where} \ x=Le \\ &= e(1 + L(1 + (i/K_i)))^4  \end{align}$$
$$\tag{5.39}$$

4. Form the rate law: The reaction rate is given by: $v = kse$. We can rewrite the last part of Eq. (5.39) as:

$$\begin{equation} e = \frac{e_0}{1 + L(1 + i/K_i)^4} \tag{5.40} \end{equation}$$

leading to the rate law:

$$\begin{equation} v(s, i)  = \frac{ke_0s}{1 + L(1 + i/K_i)^4} \tag{5.41} \end{equation}$$

This rate law generalizes to:

$$\begin{equation} v(s, i)  = \frac{ke_0s}{1 + L(1 + i/K_i)^{\nu}} = \frac{v_m}{1 + L(1 + i/K_i)^{\nu}} \tag{5.42} \end{equation}$$

for any $\nu$. The reader can find the same key dimensionless groups as for the Hill rate law. Note again the fraction

$$\begin{equation} f_e = \frac{e}{e_0} = \frac{1}{1 + L(1 + i/K_i)^{\nu}} \tag{5.43} \end{equation}$$

that describes the what fraction of the enzyme is in the catalytically active state.

```python
t0 = 0
tf = 15

def symmetry(t, state_vars, *params):
    s, p, e, i, x, x1, x2, x3, x4 = state_vars
    k1, k_plus, k_minus, ki_plus, ki_minus = params
    # Enzyme Reaction Rates
    v1 = k_plus * e;           v2 = k_minus * x;
    v3 = 4 * ki_plus * i * x;  v4 = ki_minus * x1;
    v5 = 3 * ki_plus * i * x1; v6 = 2 * ki_minus * x2;
    v7 = 2 * ki_plus * i * x2; v8 = 3 * ki_minus * x3;
    v9 = ki_plus * i * x3;    v10 = 4 * ki_minus * x4;
    # Differential equations to integrate
    diffeqs = [-k1 * s * e,                             #  ds/dt
               k1 * s * e,                              #  dp/dt
               -v1 + v2,                                #  de/dt
               -v3 + v4 - v5 + v6 - v7 + v8 - v9 + v10, #  di/dt
               v1 - v2 - v3 + v4,                       #  dx/dt
               v3 - v4 - v5 + v6,                       # dx1/dt
               v5 - v6 - v7 + v8,                       # dx2/dt
               v7 - v8 - v9 + v10,                      # dx3/dt
               v9 - v10]                                # dx4/dt
    return diffeqs

# Define initial conditions
s0, p0, e0, i0, x0 = (1, 0, 1, 1, 0)
x1_0, x2_0, x3_0, x4_0 = (0, 0, 0, 0)

# Define paramters
k1 = 1;
k_plus, k_minus = (100, 100)
ki_plus, ki_minus = (2, 2)
params = [k1, k_plus,k_minus, ki_plus, ki_minus]

# Obtain numerical solutions
sol_obj = solve_ivp(fun=lambda t, state_vars: symmetry(t, state_vars, *params),
                    t_span=(t0, tf), y0=[s0, p0, e0, i0, x0, x1_0, x2_0, x3_0, x4_0])
# Store solutions into Solution Objects
sol_dict = dict(zip(["s", "p", "e", "i", "x", "x1", "x2", "x3", "x4"], sol_obj.y))

x_total = sum(sol_dict[k] for k in ["x", "x1", "x2", "x3", "x4"])
i_bound = sum(i*sol_dict[k] for i, k in zip([1, 2, 3, 4], ["x1", "x2", "x3", "x4"]))

sol_dict.update({"x_total": x_total, "i_bound": i_bound})

symmetry_sol = MassSolution(
    "Symmetry", solution_type="Conc", data_dict=sol_dict,
    time=sol_obj.t, interpolate=False)
```

```python
fig_5_9 = plt.figure(figsize=(10, 8))
gs = fig_5_9.add_gridspec(nrows=2, ncols=2, width_ratios=[1, 1],
                          height_ratios=[1, 1])

ax1 = fig_5_9.add_subplot(gs[0, 0])
ax2 = fig_5_9.add_subplot(gs[0, 1])
ax3 = fig_5_9.add_subplot(gs[1, 0])
ax4 = fig_5_9.add_subplot(gs[1, 1])

plot_phase_portrait(
    symmetry_sol, x="s", y="e", ax=ax1, xlabel="s", ylabel="e",
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(a) Phase Portrait of s vs. e", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

plot_phase_portrait(
    symmetry_sol, x="e", y="x_total", ax=ax2,
    xlabel="e", ylabel='x + x1 + x2 + x3 + x4',
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(b) Phase Portrait of e vs. x_total", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

plot_phase_portrait(
    symmetry_sol, x="i", y="i_bound", ax=ax3,
    xlabel="i", ylabel='1*x1 + 2*x2 + 3*x3 + 4*x4',
    xlim=(-0.05, 1.05), ylim=(-0.05, 1.05),
    title=("(a) Phase Portrait of i vs. x", {"size": "x-large"}),
    annotate_time_points="endpoints",
    annotate_time_points_labels=True);

plot_time_profile(
    symmetry_sol, observable=list(
        k for k in symmetry_sol.keys() if k not in [
            "x", "x1", "x2", "x3", "x4"]),
    ax=ax4, legend="right outside",
    title=("(d) Concentration Profiles", {"size": "x-large"}));
fig_5_9.tight_layout()
```

**Figure 5.9:**  The transient response of the symmetry model, for $k^+ = k^- = 100$, $k_i^+ = k_i^- = 2$, $k = 1$, $\nu = 4$, $x_0 = x_{1, 0} = x_{2, 0} = x_{3, 0} = x_{4, 0} = 0$ and $e_0 = s_0 = i_0 = 1$. (a) The phase portraits of $s$ and $e$. (b) The phase portraits of $e$ and $(x + x_1 + x_2 + x_3 + x_4)$. (c) The phase portraits of $i$ and $(x_1 + 2x_2 + 3x_3 + 4x_4)$. (d) Concentration and pool profiles.

### Step 5: Numerical solutions for the symmetry model
These equations can be simulated. Typically the conformational changes between $E$ and $X$ are fast as are the inhibitor binding steps relative to the catalysis rate. Numerical simulations were carried out for this situation and the results are plotted in Figure 5.9.

* Figure 5.9a shows how the substrate-enzyme phase portrait is L-shaped showing that the sequestration of the enzyme in the inhibited form (the vertical line) is faster than the conversion of the substrate (the horizontal line).

* Figure 5.9b shows the redistribution of the total enzyme among the active and inactive forms, that is, $e$ vs. $(x + x_1 + x_2 + x_3 + x_4)$. The fraction of the enzyme in the inactive form is about 0.29.

* Figure 5.9c shows the redistribution of the inhibitor between the free and bound form; $i$ vs. $(x_1 + 2x_2 + 3x_3 + 4x_4)$. This panel shows that the fraction the inhibitor that is bound is high, 0.70.

* Finally, Figure 5.9d show the transient changes in the concentrations and pools on the fast and slow time scales. Note that two natural aggregation variables appear: the total enzyme in the inactive form, and the total number of inhibitor molecules bound to the enzyme.

## Scaling Dynamic Descriptions
The analysis of simple equations requires the "proper frame of mind." In step 6 of the process of formulating rate laws, this notion is translated into quantitative measures. We need to scale the variables with respect to intrinsic reference scales and thereby cast our mathematical descriptions into appropriate coordinate systems. All parameters then aggregate into dimensionless property ratios that, if properly interpreted, have a clear physical significance.

### The scaling process:
The examples above illustrate the decisive role of time constants and their use to analyze simple situations and to elucidate intrinsic reference scales. Identification of unimportant terms is sometimes more difficult and familiarity with a formal scaling procedure is useful. This procedure basically consists of four steps:

1. Identify logical reference scales. This step is perhaps the most difficult. It relies partly on physical intuition, and the use of time constants is surprisingly powerful even when analyzing steady situations.

2. Introduce reference scales into the equations and make the variables dimensionless.

3. Collect the parameters into dimensionless property ratios. The number of dimensionless parameters is always the same and it is given by the well-known Buckingham Pi theorem.

4. Interpret the results. The dimensionless groups that appear can normally be interpreted as ratios of the time constants, such as those discussed above.

Scaling of equations is typically only practiced for small models and for analysis purposes only. Numerical simulations of complex models are essentially always performed with absolute values of the variables.

### The importance of intrinsic reference scales
The process by which the equations are made dimensionless is not unique. The 'correct' way of putting the equations into a dimensionless form, where judgments of relative orders of magnitude can be made, is called _scaling_. The scaling process is defined by Lin and Segel (Segel, 1974) as:

"...select intrinsic reference quantities so that each term in the dimensional equations transforms into a product of a constant dimensional factor which closely estimates the term's order of magnitude and a dimensionless factor of unit order of magnitude."

In other words, if one has an equation which is a sum of terms $T_i$ as:

$$\begin{equation} T_1 + T_2 + \dots = 0 \tag{5.44} \end{equation}$$

one tries to scale the _variables_ involved so that they are of unit order of magnitude or

$$\begin{equation} t_i = \frac{\text{variable}_i}{\text{intrinistic reference scale}_i} \approx \text{unit order of magnitude} \tag{5.45} \end{equation}$$

Introducing these dimensionless variables into equation (5.44)  results in the dimensionless form:

$$\begin{equation} \pi_1 t_1 + \pi_2 t_2 + \dots = 0 \tag{5.44} \end{equation}$$

where the dimensionless multipliers, $\pi_i$ are the dimensionless groups and they will indicate the order of magnitude of the product, $\pi_it_i$. Once the equations are in this form, order of magnitude judgements can be made based on the dimensionless groups.

## Summary

* Enzymes are highly specialized catalysts that can dramatically accelerate the rates of biochemical reactions.

* Reaction mechanisms are formulated for the chemical conversions carried out by enzymes in terms of elementary reactions.

* Rate laws for enzyme reaction mechanisms are derived based on simplifying assumptions.

* Two simplifying assumptions are commonly used: the quasi-steady state (QSSA) and the quasi-equilibrium assumptions (QEA).

* The validity of the simplifying assumptions can be determined using scaling of the equations followed by mathematical and numerical analysis.

* A number of rate laws have been developed for enzyme catalysis and for the regulation of enzymes. Only three reaction mechanisms were described in this chapter.

$\tiny{\text{© B. Ø. Palsson 2011;}\ \text{This publication is in copyright.}\\ \text{Subject to statutory exception and to the provisions of relevant collective licensing agreements,}\\ \text{no reproduction of any part may take place without the written permission of Cambridge University Press.}}$