# _*Quantum chemistry with Qiskit `Terra`, `Aqua` and CQC's `t|ket〉` compiler*_

In this tutorial, we discuss how to use IBM's Qiskit `Terra` and `Aqua` packages, and the `t|ket〉` compiler by Cambridge Quantum Computing (CQC), to calculate the excited state energies of simple molecules using the quantum subspace expansion (QSE) technique. By the end of this tutorial, you will

* Understand why optimizing circuits for quantum chemistry is necessary
* Learn about the quantum subspace expansion technique for computing excited state energies
* See how Qiskit `Terra` enables 3rd-party passes in its transpiler architecture
* Use `pytket` to perform native circuit optimization and circuit routing

NOTE: Throughout this tutorial, we assume the reader has some familiarity with quantum chemistry methods, though an extensive knowledge is not necessary.

**Code setup**

This tutorial makes use of Qiskit `Terra` and `Aqua`, as well as `pytket` (the Python interface to `t|ket〉`). To install `Terra` and `Aqua`, follow instructions available [here](https://qiskit.org/). To install `pytket`, follow the instructions at [this Github repository](https://github.com/CQCL/pytket).

## Quantum chemistry in the NISQ era, and the need for circuit optimization techniques

One of the expected applications of noisy, intemediate-scale quantum (NISQ) devices is simulating quantum chemistry. A simple quantum chemistry question is "Given how the electrons and nuclei of a particular molecule are arranged, what is the _energy_ of that configuration?". By examining how the energy of the configuration changes as the nuclei and electrons are moved relative to one another, we can map out an _energy surface_. The configuration that minimizes the energy is a stable, equilibrium point for the molecule. Knowing this configuration (and its associated energy), we can deduce a variety of molecular properties, such as reaction rates.

Most techniques for computing molecular energies on near term quantum devices rely on the [variational quantum eigensolver (VQE) algorithm](https://doi.org/10.1038/ncomms5213). This algorithm uses a parameterized _ansatz_ $|{\psi}(\boldsymbol{\theta})\rangle$ to describe the ground state energy of the Hamiltonian $H$ for a given configuration. By examining how the expected energy $\langle \psi(\boldsymbol{\theta})|H| \psi(\boldsymbol{\theta})\rangle/\langle \psi(\boldsymbol{\theta})|\psi(\boldsymbol{\theta})\rangle$ changes as $\boldsymbol{\theta}$ is varied, we can optimize the parameters to find an estimate for the ground state and its corresponding energy.

Running the VQE algorithm on actual hardware has two complications:

* Efficiently preparing the trial state $|\psi(\boldsymbol{\theta})\rangle$.

* Efficiently measuring the terms in the Hamiltonian $H$ that describe the configuration.

In this tutorial, we'll focus on the problem of efficiently preparing the trial state. We do so for two reasons:

* An accurate estimate of the ground state is all that's necessary to do the quantum subspace expansion technique to estimate excited state energies

* To demonstrate the capabilies of Qiskit `Terra` and CQC's `t|ket〉`  compiler to reduce the circuit _resources_ (number of gates, depth, etc.) for the state preparation. In principle, these capabilities can be deployed for other problems.

Reducing the resources is crucial in the NISQ era, because the noise present in NISQ devices dominates more for larger circuits (with more operations) and reduces the accuracy of the results. Therefore techniques which reduce circuit requirements can significantly improve the results on real hardware. Note that there are techniques, such as [qubit tapering](https://arxiv.org/abs/1701.08213), or ['gate-efficient' circuits](https://arxiv.org/abs/1809.05057), which are useful for constructing a lower-resource circuit that could be run on _ideal_ hardware. In practice, even these "resource-efficient" circuits can be further optimized, especially when the imperfections and constraints of a real piece of hardware are taken into account.

At a high level, our approach utilizes Qiskit `Aqua` to generate a parameterized ansatz based on the [_Unitary Coupled Cluster, Single-Double_ (UCCSD) ansatz](https://en.wikipedia.org/wiki/Coupled_cluster). We then use Qiskit `Terra` and the `t|ket〉` compiler (as implemented in `pytket`) to optimize the circuit for that ansatz. We will show that the optimized ansatz requires substantially fewer circuit resources than the naive UCCSD ansatz. An application of our approach, we show how to use the quantum subspace expansion (QSE) technique to compute excited state energies for gaseous hydrogen (H$_{2}$) and lithium hydride (LiH).

## Computing excited state energies using the Quantum Subspace Expansion (QSE) technique

The VQE algorithm is often used to compute the _ground state_ energy of a given molecular configuration. However, knowing the energies of _excited states_ of the system is also useful. The energies of electronic excited states prove, in general, challenging to compute. Excited states are usually more _entangled_ than ground states and so require more computational resources to compute. While there exists techniques for efficiently representing certain classes of entangled states, in general, the complexity of the simulation will be non-trivial. This is a problem as it makes classical computation of the energy, to an appropriate accuracy, of many molecules impossible.

This notebook demonstrates calculation of excited states of molecules using the [quantum subspace expanansion (QSE) technique](https://doi.org/10.1103/PhysRevA.95.042308). The QSE technique uses an accurate estimate of the ground state energy of a given molecular configuration to estimate the energies of excited states. Consider a fixed molecule (represented by a given Hamiltonian $H$), and suppose $\left|\Psi_{0}\right\rangle$ is the output of the VQE algorithm for estimating the ground state energy of $H$.

The QSE technique constructs a subspace of state vectors $\left|\Psi_j^k\right\rangle$ formed by one-electron excitations of the ground state wavefunction:

\begin{equation}
\left|\Psi_{j}^{k}\right\rangle = c_k^{\dagger}c_{j}\left|\Psi_0\right\rangle.
\end{equation}

where $c_k^{\dagger}, c_{j}$ are the fermionic creation and annihilation operators over spin orbitals $k$ and $j$, respectively. That is, these vectors are formed by reducing the occupation of spin orbital $j$ by one, and increasing the occupation of spin orbital $k$ by one. The vectors are not in general orthogonal to $\Psi_{0}$ hence we will need to calculate an overlap matrix.

Within this subspace, we solve a generalized eigenvalue problem. Consider the operator $H'$ with matrix elements given by
$$(H')_{jk}^{lm} = \langle\Psi_j^l \left| H \right| \Psi_k^m\rangle,$$

and define an overlap matrix $S$ whose matrix elements are given by

$$S_{jk}^{lm} = \langle \Psi_j^l \left|\Psi_k^m\right\rangle.$$

The generalized eigenvalue equation to be solved is

\begin{equation}
H'C=SCE,
\end{equation}

where $C$ is the matrix of eigenvectors, and $E$ is the vector of eigenvalues. Crucially, _the energy eigenvalues $E$ provide an estimate of the excited state energies of $H$_ as well as a refined value of the ground state energy.

Notice that the solution to the generalized eigenvalue equation can be done on a classical computer, provided $H'$ and $S$ have been calculated. The matrix elements of both of these matrices can be constructed using a quantum computer, in the following way. First, re-write the matrix elements in terms of $\left | \Psi_{0}\right \rangle$:

\begin{align}
(H')_{jk}^{lm} =& \langle\Psi_j^l \left| H \right| \Psi_k^m\rangle = \langle \Psi_{0} | c_{j}^\dagger c_{l} Hc_{m}^{\dagger}c_{k}|\Psi_{0}\rangle\\
S_{jk}^{lm} &= \langle \Psi_j^l \left|\Psi_k^m\right\rangle = \langle \Psi_{0} | c_{j}^\dagger c_{l} c_{m}^{\dagger}c_{k}|\Psi_{0}\rangle.
\end{align}

The matrix elements can be calculated using a quantum computer or simluator. How? By transforming the operators
$c_{j}^\dagger c_{l} c_{k}^{\dagger}c_{m}$ and $c_{l}^\dagger c_{j} H c_{m}^{\dagger}c_{k}$ to a set of Pauli quantum gates according to an appropriate scheme such as Jordan-Wigner or Bravyi-Kitaev, apply this gate set to the ground state wavefunction (constructed with the coefficients obtained from the VQE calculation) and perform a measurement to obtain the expected value. By measuring these expectation values, we obtain estimates of $S_{jk}^{lm}$ and $(H')_{jk}^{lm}$, respectively.

Therefore, to use the QSE technique, we need to use the quantum computer to calculate 2 quantities:

* An estimate of the ground state $\left |\Psi_{0}\right\rangle$ (by running the VQE algorithm)
* The matrix elements of $H'$ and $S$.

For the first quantity, we want to have an efficient representation of the trial state $|\psi(\boldsymbol{\theta})\rangle$. And for the second, we need to be able to take the estimate of the ground state and efficiently compute matrix elements. In both of these cases, being able to optimize the circuit for preparing would be useful. Thankfully, Qiskit `Terra`, in conjunction with `t|ket〉`, provides us tools for doing so. We discuss the problem of _circuit compilation/optimization_ in the next section.

## Circuit compilation with Qiskit `Terra` and `t|ket〉`

In the remainder of this tutorial, we show how to use Qiskit `Terra` and `t|ket〉` to optimize the circuits for preparing VQE trial states, and simulate the QSE technique.

## Application I: Reducing circuit resources for trial state preparation

In this tutorial, we'll focus on two simple molecules: hydrogen (H$_{2}$) and lithium hydride (LiH). NOTE: the code is much slower for LiH, which is why here, we'll demonstrate H$_{2}$.

As a first application of the pipelines provided by `Terra`, `Aqua`, and `t|ket〉`, we first show how to configure a VQE experiment using `Aqua`, and then use `Terra` and `t|ket〉` to compile the trial state preparation circuit.

Let's start by importing the necessary packages.

```python
# numpy, for random number generation
import numpy as np

# Qiskit, for transpiler-related functions, the IBMQ provider, and the Aer simulator
from qiskit import IBMQ, Aer, QuantumRegister
from qiskit.transpiler import transpile, transpile_dag, PassManager
from qiskit.converters import circuit_to_dag, dag_to_circuit

# pytket, for optimization
import pytket
from pytket.qiskit import TketPass

# Qiskit Aqua, for chemistry
from qiskit.chemistry.drivers import PySCFDriver, UnitsType
from qiskit.chemistry import FermionicOperator
from qiskit.chemistry.aqua_extensions.components.initial_states import HartreeFock
from qiskit.chemistry.aqua_extensions.components.variational_forms import UCCSD
```

### Step 0: Enable IBMQ account

```python
provider = IBMQ.load_account()
```

### Step 1: setting up the molecule

We choose the basis set spanning the molecular wavefunction, the molecular geometry, the chemical identity of each atom, the charge and spin quantum number.

To calculate results for LiH (slower), just comment out the H$_{2}$ string and replace it with the LiH one.

NOTE: Here, we focus only on one particular value for the `bond_length`. If you wanted to replicate the final plot for the excited state energies of LiH as a function of bond length, you'd need to sweep `bond_length` over several values.

```python
# Choose a particular bond length
# NOTE: Units are in Angstroms

bond_length = 0.7

# Set up molecule

# base_molecule_str = 'Li .0 .0 .0; H .0 .0 {}'
base_molecule_str = 'H .0 .0 .0; H .0 .0 {}'

# Specify other molecular properties
charge = 0
spin = 0
basis = 'sto3g'
```

Having set up the molecule, we now execute our classical chemistry driver to obtain the integrals that define the terms in the molecule's Hamiltonian. In this case, we choose `PYSCF` as our driver, so make sure you have that installed.

```python
# Using driver to get fermionic Hamiltonian
# PySCF example

driver =  PySCFDriver(atom=base_molecule_str.format(bond_length),
                      unit=UnitsType.ANGSTROM,
                      charge=charge,
                      spin=spin,
                      basis=basis)

molecule = driver.run()

print("Molecular repulsion energy: ", molecule.nuclear_repulsion_energy)
```

    Molecular repulsion energy:  0.7559674441714287

The molecular repulsion energy calculated by the driver corresponds to the coulombic repulsion between the nuclei in the molecule, and we can add it to our electron structure calculation at the end to get the total energy.

### Step 2: Set up a variational form for VQE

To run the VQE algorithm, we need to specify a mapping from the molecular Hamiltonian to qubits, an initial state, and the ansatz we use for the trial state. Here, we use the Jordan-Wigner transform to map the molecular Hamiltonian onto qubits (Pauli operators). We choose the initial state to be a Hartree-Fock state, and we take the variational ansatz to be the unitary coupled cluster with single and double excitations (UCCSD).

```python
n_qubits = molecule.one_body_integrals.shape[0]
n_electrons = molecule.num_alpha + molecule.num_beta - molecule.molecular_charge

# get fermionic operator and mapping to qubit operator
ferOp = FermionicOperator(h1=molecule.one_body_integrals, h2=molecule.two_body_integrals)

qubitOp = ferOp.mapping(map_type='JORDAN_WIGNER', threshold=0.00000001)
qubitOp.chop(10**-10)

# Instantiate the initial state as a Hartree-Fock state
initial_hf = HartreeFock(num_qubits=n_qubits, num_orbitals=n_qubits,
                    qubit_mapping='jordan_wigner', two_qubit_reduction=False, num_particles= n_electrons)

# Create the variational form
var_form = UCCSD(num_qubits=n_qubits, num_orbitals=n_qubits,
                num_particles=n_electrons, depth=1, initial_state=initial_hf, qubit_mapping='jordan_wigner')

# How many qubits do we need?
print('Number of qubits: {0}'.format(n_qubits))
```

    Number of qubits: 4

### Step 3: Transpile circuit and examine circuit properties

At this point, we have instantiated a variational form for the molecule according to the UCCSD formulation. Aqua's `UCCSD` method has returned back to us an abstraction of the VQE variational form. Let's query some of its properties.

```python
# Query the variational form for the number of parameters, and the parameter bounds.
var_form.num_parameters, var_form.parameter_bounds[0]
```

    (3, (-3.141592653589793, 3.141592653589793))

As a VQE circuit, `var_form` has some number of parameters, and each parameter can take values in $[-\pi, \pi]$.
These parameters control the angles of rotation in the quantum circuit that represents the variational anatz.

For a particular set of parameters, there is an assocated quantum circuit. Let's input some fiducial parameter values and query properties of the resulting circuit to introduce some nomenclature for describing circuits.

```python
# Instantiate a concrete instance of the VQE ansatz by setting all the parameters to the
# arbitrarily-chosen value of 0.
var_circ = var_form.construct_circuit(np.zeros(var_form.num_parameters))

# Use Terra to convert the circuit to its directed, acyclic graph (DAG) representation.
var_circ_dag = circuit_to_dag(var_circ)

# The .properties() method of the DAG to get circuit properties.
var_circ_dag.properties()
```

    {'size': 150,
     'depth': 83,
     'width': 4,
     'bits': 0,
     'factors': 1,
     'operations': {'u3': 42, 'u2': 40, 'cx': 56, 'u1': 12}}

These circuit properties are:

* `size`: The total number of gates in the circuit
* `depth`: The total number of _layers_ in the circuit
* `width`: The number of qubits in the circuit
* `bits`: The number of classical bits in the circuit. (NOTE: Because the circuit prepares a VQE trial state, and does not have any measurements, `bits` will be 0.)
* `factors`: The number of tensor factors the circuit could be decomposed into (by looking at the number of weakly connected components of the DAG)

The `.properties()` method of the DAG representation also breaks down the total number of gates (`size`) by the individual gates themselves. These are the $u1,u2,u3$ and CNOT gates described [here](https://qiskit.org/documentation/terra/summary_of_quantum_operations.html). You can verify that the total number of gates is in fact equal to `size`.

### Step 4: Transpile the circuit using Terra and `t|ket〉`

Having instantiated a VQE variational form and examined some of its properties, we'd like to optimize those properties so that the circuit could be run on a near-term device. Terra provides a framework (the [_transpiler_](https://qiskit.org/documentation/terra/overview.html#transpiler)) for manipulating circuits according to certain _passes_, and where the execution of the passes is orchestrated by a _PassManager_, which we can use to do this optimization. Importantly, _the transpiler manipulates the circuit to change circuit properties, without actually changing the input-output relationship the circuit defines_. That is, the transpiler takes a circuit and re-writes it, but doesn't change what the circuit actually does.

CQC has written several passes for manipulating quantum circuits which are available via `pytket`. For an extensive discussion of the framework `t|ket〉` uses to manipulate circuits, see **[TODO: include link]**. Here, we'll demonstrate using Terra and `pytket` to optimize a randomly-chosen realization of the VQE variational form. Currently, passes in the transpiler require a backend in order to run. For simplicity, we start by using a simulator backend.

```python
# Grab an Aer backend
aer_backend = Aer.get_backend('qasm_simulator')
```

```python
# Choose a random set of parameters
seed = 0
np.random.seed(seed)
params = np.random.uniform(low=-3.1, high=3.1, size=var_form.num_parameters)

# Construct a random instance of the variational circuit
var_circuit = var_form.construct_circuit(params)

# Turn the circuit into a DAG
var_dag = circuit_to_dag(var_circuit)
```

This randomly-chosen realization of the variational form has the same circuit properties as the circuit we instantiated in Step 3.

```python
var_dag.properties()
```

    {'size': 150,
     'depth': 83,
     'width': 4,
     'bits': 0,
     'factors': 1,
     'operations': {'u3': 42, 'u2': 40, 'cx': 56, 'u1': 12}}

Now, we set up a transpiler using Terra and the `TketPass` from `pytket`.

```python
# Create a Terra PassManager object
tk_pass_manager = PassManager()

# Set up the TketPass
tk_pass = TketPass(aer_backend)

# Add the TketPass to the PassManager
tk_pass_manager.append(tk_pass)
```

With the transpiler set up and the realization of the variational form put into a DAG, we can now use the `transpile_dag` function to run the PassManger.

```python
var_dag_transpiled = transpile_dag(var_dag, pass_manager=tk_pass_manager)
```

Let's check the properties of this circuit to see how the transpiled circuit differs from the original one.

```python
var_dag_transpiled.properties()
```

    {'size': 95,
     'depth': 57,
     'width': 4,
     'bits': 0,
     'factors': 1,
     'operations': {'u3': 30, 'cx': 52, 'u1': 12, 'u2': 1}}

The `t|ket〉` compiler has optimized the circuit, and as we might hope for, _the transpiled circuit has a lower `size` and `depth` than the original circuit._

The table below gives properties of the transpiled circuit for H$_{2}$ and LiH (with `seed=0`, corresponding to the worst-case and also most common performance of the transpiler).

|      Molecule:  H$_2$         | Total Gates | Depth |  CNOT Count |
|--------------------------------------|-------------|---------------|--------------------|
| Input circuit                        | 150       | 83          | 56              |
| `tket` circuit optimization   (Aer backend)       | 95        | 57          | 52              |

|      Molecule:  LiH           | Total Gates |  Depth |  CNOT Count |
|--------------------------------------|-------------|---------------|--------------------|
| Input circuit                        | 13700       | 9342          | 8064               |
| `tket` circuit optimization (Aer backend)         | 7411        | 4416          | 5096               |

For both H$_2$ and LiH, circuit optimization using `t|ket〉` reduces the number of gates necessary to prepare the trial state. The depth also decreases, which makes running the circuit more feasible on near-term devices.

### Step 5: Route the circuit onto real hardware

Even though we've optimized the circuit, there's no guarantee that it can run, as written, on a real backend. This is because the directionaly of the CNOT gates on the backend may not be respected by the circuit. For this reason, we need to re-write the circuit in such as way that the CNOT gates respect the _coupling map_ of the backend. `t|ket〉` knows how to do this, and handles this problem (called circuit "routing") when a real backend is put into the `TketPass` object. Routing refers to the process of making quantum circuits hardware compliant by the addition of SWAP gates such that all multi-qubit interactions occur on adjacent physical qubits.

Because we know _a priori_ that the H$_{2}$ and LiH molecules requires at most 12 qubits, we make sure to use an IBMQ backend with no less than 12 qubits.

```python
# Grab only backends that have at least 12 qubits
provider.backends(filters=lambda x: x.configuration().n_qubits >= 12)
```

    [<IBMQBackend('ibmq_16_melbourne') from IBMQ()>,
     <IBMQBackend('ibmq_qasm_simulator') from IBMQ()>]

We'll use the `ibmq_16_melbourne` backend.

```python
real_backend = provider.get_backend('ibmq_16_melbourne')
```

To route the variational circuit onto real hardware, we simply need to change the backend.

```python
# Create a Terra PassManager object
tk_pass_manager = PassManager()

# Set up the TketPass
tk_pass = TketPass(real_backend)

# Add the TketPass to the PassManager
tk_pass_manager.append(tk_pass)
```

Again, we can transpile the DAG representation of the variational circuit to a backend-compliant circuit using the `tranpsile_dag` function. Here, because the backend has a non-trivial coupling map, the `TketPass` will perform both circuit optimization and optimal routing calculations. We need to add a register containing the ancilla qubits on the architecture that `TketPass` can use in routing.

```python
blank_qubits = QuantumRegister(len(real_backend.properties().qubits) - var_dag.width())
var_dag.add_qreg(blank_qubits)
var_dag_transpiled = transpile_dag(var_dag, pass_manager=tk_pass_manager)
```

```python
var_dag_transpiled.properties()
```

    {'size': 108,
     'depth': 69,
     'width': 4,
     'bits': 0,
     'factors': 1,
     'operations': {'u3': 30, 'cx': 52, 'u1': 12, 'u2': 14}}

We can compare these results to Qiskit's own default transpilation by passing in the backend's `coupling_map` to the `transpile_dag` function.

```python
transpile_dag(var_dag, coupling_map=real_backend.configuration().coupling_map).properties()
```

    {'size': 119,
     'depth': 89,
     'width': 14,
     'bits': 0,
     'factors': 11,
     'operations': {'u3': 15, 'cx': 56, 'u1': 14, 'u2': 34}}

The table below shows circuit properties for H$_{2}$ (with `seed=0`).

|      Molecule:  H$_2$         | Total Gates | Overall Depth | Overall CNOT Count |
|--------------------------------------|-------------|---------------|--------------------|
| Input circuit                        | 150       | 83          | 56              |
| `tket` circuit optimization (Aer backend)         | 95        | 57          | 52               |
| `Qiskit ` default routing         (real backend)           | 119      | 89         | 56              |
| `tket` circuit optimzation + routing (real backend) | 108       | 69        | 52               |

The table below shows circuit properties for LiH (with `seed=0`).

|      Molecule:  LiH           | Total Gates | Overall Depth | Overall CNOT Count |
|--------------------------------------|-------------|---------------|--------------------|
| Input circuit                        | 13700       | 9342          | 8064               |
| `tket` circuit optimization      (Aer backend)    | 7411        | 4416          | 5096              |
| `Qiskit ` default routing           (real backend)         | 42178       | 23367         | 17977              |
| `tket` circuit optimzation + routing (real backend) | 19256       | 10022         | 8711               |

## Application II: using QSE to compute excited state energies

The previous application showed us how to use `t|ket〉` and Terra to optimally route circuits onto real hardware. In the introduction, we observed that in order to use the quantum subspace expansion to compute excited state energies, we need to first come up with an estimate of the ground state. For this, we use VQE.

In this application, we'll instantiate a VQE circuit, run it, and use the estimated ground state as input to `pytket`'s quantum subspace expansion function(s). NOTE: We'll use a simulator backend, and will not run the VQE algorithm on real hardware.

```python
# Code imports

# From Aqua, we need
from qiskit.aqua import QuantumInstance

from qiskit.aqua.algorithms.adaptive import VQE
from qiskit.aqua.components.optimizers import L_BFGS_B

# From pytket, we need QSE functions

from pytket.chemistry import QseMatrices, QSE
```

```python
backend = Aer.get_backend('statevector_simulator')
```

```python
pass_manager = PassManager()
tk_pass = TketPass(backend)
pass_manager.append(tk_pass)

quantum_instance = QuantumInstance(backend, pass_manager=pass_manager)
```

### Step 1: Use VQE to estimate ground state

First, we'll use VQE to estimate the ground state and its energy.

```python
# Temporary code for Aer on Macbook
import os
os.environ['KMP_DUPLICATE_LIB_OK']='True'
```

```python
# Set initial values of parameters
number_amplitudes = len(var_form._single_excitations)+ len(var_form._double_excitations)

amplitudes_0 = []
for i in range(number_amplitudes):
    amplitudes_0.append(0.00001)

optimizer = L_BFGS_B()
optimizer.set_options(maxfun=1000, factr=10, iprint=10)

# setup VQE with operator, variation form, and optimzer
vqe_algorithm = VQE(operator=qubitOp, operator_mode='matrix',
                    var_form=var_form, optimizer=optimizer, initial_point=amplitudes_0)

results = vqe_algorithm.run(quantum_instance)

eigval = results['eigvals'][0]
gs_energy = eigval.real + molecule.nuclear_repulsion_energy

print("GS Minimum value: {}".format(gs_energy))
print("GS Parameters: {}".format(results['opt_params']))

# store ground state amplitudes for subsequent steps
opti_amplitudes = results['opt_params']
```

    GS Minimum value: -1.1361894540653963
    GS Parameters: [ 5.06008657e-07  5.12457730e-07 -1.04867316e-01]

### Step 2: Use QSE to find excited states from ground state <a id='sectionB'></a>

We now have the main ingredients to perform a QSE calculation: the molecular Hamiltonian and the optimized parameters to reconstruct the ground state wavefunction. We build our excitation hamiltonian and overlap operators, and measure the elements that compose $H$ and $S$. After we have obtained these arrays, we perform a diagonalization to obtain the excited state energies and vectors.

```python
qubitOp = ferOp.mapping(map_type='JORDAN_WIGNER', threshold=0.00000001)
n_qubits = qubitOp.num_qubits
qubitOp.chop(10**-10)

# Use matrix term helper class
matrix_terms = QseMatrices(qubitOp, n_qubits)

# Instantiate an instance of the QSE algorithm
qse_algorithm = QSE(matrix_terms, 'matrix', var_form, opt_init_point=opti_amplitudes)

# Run the algorithm
energies = qse_algorithm.run(quantum_instance)['eigvals']

# The excited state energies are the energies from above,
# plus the nuclear repulsion energy.
print("Excited State Energies: ", energies+molecule.nuclear_repulsion_energy)

```

    Excited State Energies:  [-1.13618945 -0.47845306 -0.47845306 -0.47845306 -0.1204519   0.5833141
      0.75596744  0.75596744  0.75596744  0.75596744  0.75596744  0.75596744
      0.75596744  0.75596744  0.75596744  0.75596744]

The calculation provides a refined value of the ground state and a series of excited state energies, whose number depends on the size of the basis set chosen for the molecule, as well as its nature and symmetry. Some of the energies obtained are repeated several times, signaling that some of the states obtained are degenerate. This result can be improved by either increasing the basis set or considering higher-order excitations in the subspace expansion.

The following graph shows us the excited states of LiH at a range of bond distances calculated via our method, compared to values computed using the classical [EOM-CCSD method](https://aip.scitation.org/doi/10.1063/1.464746). To generate this data yourself, you can  scan the bond length parameter we set at the start of the calculation.

Using the QSE technique, and our simulated VQE ground state, we find the ground state curve has a minimum at a separation of about 1.5 Å, which is in reasonable agreement with experimental data. The calculation also finds a number of excited states. Looking at the first three of these, we find that at the equilibrium distance, these states are 0.11, 0.12 and 0.17 Ha higher in energy than the ground state, which is again in reasonable agreement with experimental data. Note the small kink in one of the excited states energy curve at a distance of approximately 1.2 Å. This indicates that our restriction to single electron excitations is not enough to provide an accurate description at this distance. Overall, the comparison with classically computed EOM-CCSD curves shows that this method reproduces excited state energies with good accuracy at most distances.

```python

```


```python
# Erasmus+ ICCT project (2018-1-SI01-KA203-047081)

# Toggle cell visibility

from IPython.display import HTML
tag = HTML('''
Toggle cell visibility <a href="javascript:code_toggle()">here</a>.''')
display(tag)

# Hide the code completely

# from IPython.display import HTML
# tag = HTML('''<style>
# div.input {
#     display:none;
# }
# </style>''')
# display(tag)
```

Toggle cell visibility here.

```python
%matplotlib notebook
import scipy.signal as signal
import matplotlib.pyplot as plt
from ipywidgets import widgets
from ipywidgets import interact
import numpy as np
import sympy as sym
```

## PID krmilnik - časovni odziv

Proporcionalno-integrirni-diferencirni (PID) krmilni algoritem je najpogosteje uporabljen krmilni algoritem. Njegovo prenosno funkcijo zapišemo kot:

\begin{equation}
    P(s)=K_p \cdot \left( 1 + \frac{1}{T_i s} + T_d s \right).
\end{equation}

Prenosna funkcija je sestavljena iz vsote proporcionalne, integrirne in diferencirne komponente. Ni nujno, da so v izbranem krmilniku prisotne vse tri komponente; če ni diferencirne ali integrirne komponente, govorimo tako o PI oz. PD krmilniku. V tem interaktivnem primeru je prikazan odziv P, Pi, PD in PID krmilnika na enotsko skočno, enotsko impulzno in sinusno funkcijo ter enotsko rampo.

---

### Kako upravljati s tem interaktivnim primerom?
1. Izberi vstopni signal s preklapljanjem med *enotsko skočno funkcijo*, *enotsko impulzno funkcijo*, *enotsko rampo* in *sinusno funkcijo*.
2. Izberi tip krmilnega algoritma s klikom na *P*, *PI*, *PD* ali *PID* gumb.
3. Z uporabo drsnikov spreminjaj vrednosti koeficientov proporcionalnega ($K_p$), integrirnega ($T_i$) in diferencirnega ($T_d$) ojačnja.
4. Z uporabo drsnika $t_{max}$ lahko spreminjaš interval vrednosti prikazanih na x osi.

```python
a = 0.1

# make figure
fig = plt.figure(figsize=(9.8, 5),num='PID krmilnik')
# add axes
ax = fig.add_subplot(111)
ax.grid(which='both', axis='both', color='lightgray')
ax.set_title('Časovni odziv')
# plot step function and responses (initalisation)
input_plot, = ax.plot([],[],'C0', linewidth=1,label='vstopni signal')
response_plot, = ax.plot([],[], 'C1', linewidth=2,label='izstopni signal')
ax.axhline(linewidth=.5, color='k')
ax.axvline(linewidth=.5, color='k')
ax.legend()

ax.set_xlabel('$t$ [s]')
ax.set_ylabel('vhod, izhod')
plt.show()

P, I, D, s = sym.symbols('P, I, D, s')

input_type = 'enotska skočna funkcija' #input function
Time_span = 10 # max time on x-axis plot

#initialize global variables
KP = 1.
TI = 1.
TD = 1.
num = []
den = []

def update_plot():
    global num, den, input_type, Time_span
    num_temp = [float(i.subs(P,KP).subs(I,TI).subs(D,TD)) for i in num]
    den_temp = [float(i.subs(P,KP).subs(I,TI).subs(D,TD)) for i in den]

    system = signal.TransferFunction(num_temp, den_temp)

    #time, response = signal.step(system) #only for setting time borders (for nicer plot. could also calculate dominant frequency)
    #time = np.linspace(0,time[-1],1000)
    time = np.linspace(0, Time_span, 600)

    if input_type == 'enotska skočna funkcija':
        u = np.ones_like(time)
        u = np.concatenate((np.array([0]),u))
        time, response = signal.step(system, T=time)
        time = np.concatenate((np.array([0]), time))
        response = np.concatenate((np.array([0]), response))
    elif input_type == 'enotska impulzna funkcija':
        u = np.zeros_like(time)
        u = np.concatenate((np.array([10]), u))
        time, response = signal.impulse(system, T=time)
        time = np.concatenate((np.array([0]), time))
        response = np.concatenate((np.array([0]), response))
    elif input_type == 'sinusna funkcija':
        u = np.sin(time*2*np.pi)
        time, response, _ = signal.lsim(system, U=u, T=time)
    elif input_type == 'enotska rampa':
        u = time
        time, response, _ = signal.lsim(system, U=u, T=time)
    else:
        raise Exception("Napaka v programu. Prosim znova zaženi primer.")

    response_plot.set_data(time, response)
    input_plot.set_data(time, u)
    ax.set_ylim([min([np.min(u), min(response),-.1]),min(100,max([max(response)*1.05, 1, 1.05*np.max(u[1:])]))])
    ax.set_xlim([-0.1,max(time)])
    plt.show()

def transfer_func(controller_type):
    global num, den
    proportional = P
    integral = P/(I*s)
    differential = P*D*s/(a*D*s+1)
    if controller_type =='P':
        controller_func = proportional
        Kp_widget.disabled=False
        Ti_widget.disabled=True
        Td_widget.disabled=True
    elif controller_type =='PI':
        controller_func = proportional+integral
        Kp_widget.disabled=False
        Ti_widget.disabled=False
        Td_widget.disabled=True
    elif controller_type == 'PD':
        controller_func = proportional+differential
        Kp_widget.disabled=False
        Ti_widget.disabled=True
        Td_widget.disabled=False
    else:
        controller_func = proportional+integral+differential
        Kp_widget.disabled=False
        Ti_widget.disabled=False
        Td_widget.disabled=False
    system_func = controller_func

    num = [sym.fraction(system_func.factor())[0].expand().coeff(s, i) for i in reversed(range(1+sym.degree(sym.fraction(system_func.factor())[0], gen=s)))]
    den = [sym.fraction(system_func.factor())[1].expand().coeff(s, i) for i in reversed(range(1+sym.degree(sym.fraction(system_func.factor())[1], gen=s)))]
    update_plot()

def func(Kp, Ti, Td, time_span):
    global KP, TI, TD, Time_span
    KP = Kp
    TI = Ti
    TD = Td
    Time_span = time_span
    update_plot()

style = {'description_width': 'initial'}

def buttons_controller_clicked(event):
    controller = buttons_controller.options[buttons_controller.index]
    transfer_func(controller)
buttons_controller = widgets.ToggleButtons(
    options=['P', 'PI', 'PD', 'PID'],
    description='Izberi tip krmilnega algoritma:',
    disabled=False,
    style=style)
buttons_controller.observe(buttons_controller_clicked)
style = {'description_width': 'initial','button_width':'180px'}
def buttons_input_clicked(event):
    global input_type
    input_type = buttons_input.options[buttons_input.index]
    update_plot()
buttons_input = widgets.ToggleButtons(
    options=['enotska skočna funkcija','enotska impulzna funkcija', 'enotska rampa', 'sinusna funkcija'],
    description='Izberi vstopni signal:',
    disabled=False,
    style=style)
buttons_input.observe(buttons_input_clicked)

Kp_widget = widgets.IntSlider(value=20,min=1,max=100,step=1,description=r'\(K_p \)',
    disabled=False,continuous_update=True,orientation='horizontal',readout=True,readout_format='.1d')
Ti_widget = widgets.FloatSlider(value=.1,min=0.001,max=3.,step=0.001,description=r'\(T_{i} \)',
    disabled=False,continuous_update=True,orientation='horizontal',readout=True,readout_format='.3f')
Td_widget = widgets.FloatSlider(value=.1,min=0.001,max=3.,step=0.001,description=r'\(T_{d} \)',
    disabled=False,continuous_update=True,orientation='horizontal',readout=True,readout_format='.3f')

time_span_widget = widgets.FloatSlider(value=10.,min=.5,max=50.,step=0.1,description=r'\(t_{max} \)',
    disabled=False,continuous_update=True,orientation='horizontal',readout=True,readout_format='.1f')

transfer_func('P')

display(buttons_input)
display(buttons_controller)

interact(func, Kp=Kp_widget, Ti=Ti_widget, Td=Td_widget, time_span=time_span_widget);
```

    <IPython.core.display.Javascript object>

    ToggleButtons(description='Izberi vstopni signal:', options=('enotska skočna funkcija', 'enotska impulzna funk…

    ToggleButtons(description='Izberi tip krmilnega algoritma:', options=('P', 'PI', 'PD', 'PID'), style=ToggleBut…

    interactive(children=(IntSlider(value=20, description='\\(K_p \\)', min=1, readout_format='.1d'), FloatSlider(…

```python

```


# Self Consistent Field Theory - Lab 1

```python
from IPython.core.display import HTML
css_file = 'https://raw.githubusercontent.com/ngcm/training-public/master/ipython_notebook_styles/ngcmstyle.css'
HTML(url=css_file)
```

<link href='http://fonts.googleapis.com/css?family=Open+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=Nixie+One' rel='stylesheet' type='text/css'>
<style>

@font-face {
    font-family: "Computer Modern";
    src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
}

#notebook_panel { /* main background */
    background: rgb(245,245,245);
}

div.cell { /* set cell width */
    width: 1000px;
}

div #notebook { /* centre the content */
    background: #fff; /* white background for content */
    width: 1200px;
    margin: auto;
    padding-left: 0em;
}

#notebook li { /* More space between bullet points */
margin-top:0.8em;
}

/* draw border around running cells */
div.cell.border-box-sizing.code_cell.running {
    border: 1px solid #111;
}

/* Put a solid color box around each cell and its output, visually linking them*/
div.cell.code_cell {
    background-color: rgb(256,256,256);
    border-radius: 0px;
    padding: 0.5em;
    margin-left:1em;
    margin-top: 1em;
}

div.text_cell_render{
    font-family: 'Open Sans' sans-serif;
    line-height: 140%;
    font-size: 125%;
    font-weight: 400;
    width:900px;
    margin-left:auto;
    margin-right:auto;
}

/* Formatting for header cells */
.text_cell_render h1 {
    font-family: 'Arvo', serif;
    font-style:regular;
    font-weight: 400;
    font-size: 45pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.5em;
    margin-top: 0.5em;
    display: block;
}
.text_cell_render h2 {
    font-family: 'Arvo', serif;
    font-weight: 400;
    font-size: 30pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.1em;
    margin-top: 0.3em;
    display: block;
}

.text_cell_render h3 {
    font-family: 'Arvo', serif;
    margin-top:16px;
	font-size: 22pt;
    font-weight: 600;
    margin-bottom: 3px;
    font-style: regular;
    color: rgb(102,102,0);
}

.text_cell_render h4 {    /*Use this for captions*/
    font-family: 'Arvo', serif;
    font-size: 14pt;
    text-align: center;
    margin-top: 0em;
    margin-bottom: 2em;
    font-style: regular;
}

.text_cell_render h5 {  /*Use this for small titles*/
    font-family: 'Arvo', sans-serif;
    font-weight: 400;
    font-size: 16pt;
    color: rgb(163,0,0);
    font-style: italic;
    margin-bottom: .1em;
    margin-top: 0.8em;
    display: block;
}

.text_cell_render h6 { /*use this for copyright note*/
    font-family: 'PT Mono', sans-serif;
    font-weight: 300;
    font-size: 9pt;
    line-height: 100%;
    color: grey;
    margin-bottom: 1px;
    margin-top: 1px;
}

.CodeMirror{
        font-family: "PT Mono";
        font-size: 90%;
}

</style>

```python
%matplotlib inline
import numpy
from matplotlib import pyplot
from matplotlib import rcParams
rcParams['font.family'] = 'serif'
rcParams['font.size'] = 16
rcParams['figure.figsize'] = (12,6)
from scipy.integrate import quad
```

In Hartree-Fock theory the time-independent Schrödinger equation

\begin{equation}
  H \psi = E \psi
\end{equation}

is solved (approximately), where $H$ is the Hamiltonian operator, $\psi$ the wavefunction, and $E$ the energy. The theory assumes that the wave function depends on the locations of the electrons ${\bf r}_i$, where eg ${\bf r}_1$ is the spatial location of the first electron, and that the wave function can be written as the *Slater determinant*

\begin{equation}
  \psi = \frac{1}{\sqrt{N}} \begin{vmatrix} \chi_1({\bf r}_1) & \chi_2({\bf r}_1) & \dots &\chi_N({\bf r}_1) \\ \chi_1({\bf r}_2) & \chi_2({\bf r}_2) & \dots &\chi_N({\bf r}_2) \\ \vdots & \vdots & \ddots & \vdots \\ \chi_1({\bf r}_N) & \chi_2({\bf r}_N) & \dots &\chi_N({\bf r}_N) \end{vmatrix}
\end{equation}

After some considerable theoretical work, the Hartree-Fock equations are

\begin{equation}
  F({\bf x}_1) \chi_i({\bf x}_1) = \epsilon_i \chi_i({\bf x}_1),
\end{equation}

where $F$ is the *Fock* operator

\begin{equation}
  F({\bf x}_1) = H({\bf x}_1) + \sum_j \left( J_j({\bf x}_1) - K_j({\bf x}_1) \right),
\end{equation}

with $J$ being the Coulomb operator

\begin{equation}
  J_j({\bf x}_1) = \int \text{d} {\bf x}_2 \, \frac{| \chi_j ({\bf x}_2) |^2}{r_{12}}
\end{equation}

and $K$ is the exchange operator

\begin{equation}
  K_j({\bf x}_1) \chi_i({\bf x}_1) = \left[ \int \text{d} {\bf x}_2 \, \chi^*_j ({\bf x}_2) \frac{1}{r_{12}} \chi_i ({\bf x}_2) \right] \chi_j({\bf x}_1).
\end{equation}

In the above $r_{12}$ is the distance between the first and second electrons, $r_{12} = \| {\bf x}_1 - {\bf x}_2 \|$.

As the Hamiltonian operator $H$ contains a second partial derivative ($H = -\frac{1}{2} \nabla^2 + \dots$) this is a set of integro-differential equations, which is painful to solve numerically (see [this review](http://dx.doi.org/10.1016/j.cpc.2012.09.033) for an example). Instead, as with Finite Elements, it's better to write the orbitals $\chi$ in terms of a function basis, as

\begin{equation}
  \chi_i = \sum_{\mu=1}^K C_{i\mu} \tilde{\chi}_{\mu}.
\end{equation}

Here the function basis is *global*: there is one expansion that holds over all of space.

This leads to the Hartree-Fock-Roothaan equations

\begin{equation}
  {\bf F} {\bf C} = {\bf S} {\bf C} \epsilon
\end{equation}

where all of the terms are matrices representing the operators. Written in more detail we have

\begin{equation}
  \sum_{\nu} F_{\mu\nu} C_{\nu i} = \epsilon_i \sum_{\nu} S_{\mu \nu} C_{\nu i}
\end{equation}

where the matrices are

\begin{align}
  S_{\mu \nu} &= \int \text{d} {\bf x}_1 \, \tilde{\chi}^*_{\mu}({\bf x}_1) \tilde{\chi}_{\nu}({\bf x}_1), \\
  F_{\mu \nu} &= \int \text{d} {\bf x}_1 \, \tilde{\chi}^*_{\mu}({\bf x}_1) F({\bf x}_1) \tilde{\chi}_{\nu}({\bf x}_1).
\end{align}

For later purposes we define the *density matrix* ${\bf D}$ as

\begin{equation}
  D_{\mu \nu} = \sum_{j=1}^{N_{\text{electrons}}/2} 2 C_{\mu j} C_{\nu j},
\end{equation}

from which we write the Fock matrix as

\begin{equation}
  F_{\mu \nu} = H_{\mu \nu} + \sum_{\alpha} \sum_{\beta} \left( G_{\mu \nu \alpha \beta} - \frac{1}{2} G_{\mu \beta \alpha \nu} \right) D_{\alpha \beta},
\end{equation}

where $H$ is the one-electron operator in the function basis

\begin{equation}
  H_{\mu \nu} = \int \text{d}{\bf x}_1 \, \chi_{\mu}({\bf x}_1) \left( - \frac{1}{2} \nabla^2 \right) \chi_{\nu}({\bf x}_1) + \sum_a \int \text{d}{\bf x}_1 \, \chi_{\mu}({\bf x}_1) \frac{Z_a}{|{\bf R}_a - {\bf r}_1|} \chi_{\nu}({\bf x}_1)
\end{equation}

and $G$ is the two-electron operator in the function basis

\begin{equation}
  G_{\mu \nu \alpha \beta} = \int \text{d}{\bf x}_1 \, \text{d}{\bf x}_2 \, \chi_{\mu}({\bf x}_1) \chi_{\nu}({\bf x}_2) \frac{1}{r_{12}} \chi_{\alpha}({\bf x}_1) \chi_{\beta}({\bf x}_2).
\end{equation}

Finally, the total energy $E$ is given by

\begin{equation}
  E = \frac{1}{2} \sum_{\mu=1}^N \sum_{\nu=1}^N D_{\mu\nu} \left( H_{\mu\nu} + F_{\mu\nu} \right) + V_{\text{nn}},
\end{equation}

where $V_{\text{nn}}$ is the nucleon-nucleon interaction energy

\begin{equation}
  V_{\text{nn}} = \sum_{a} \sum_{b} \frac{Z_a Z_b}{\| {\bf R}_a - {\bf R}_b \|}.
\end{equation}

## Self-consistent field solution procedure

This is an iterative prcedure, so must start from some initial guess.

1. Calculate all one- and two-electron integrals, $H$ and $G$.
2. Generate starting guess for the $C$ (molecular orbital [MO]) coefficients.
3. Form the density matrix $D$.
4. Form the Fock matrix $F$ from the core (one-electron) integrals $H$ plus the density matrix $D$ times the two-electron integrals $G$.
5. Diagonalize the Fock matrix $F$. The eigenvectors contain the new MO coefficients.
6. Form the new density matrix $D$. If sufficiently close to the old matrix we are done; otherwise, return to step 4.

The first step is difficult, so we will assume here that the elements of the $H$ matrix and $G$ tensor are given.

The crucial point is that all steps must be performed in the right basis, and whilst the basis changes between steps, the transformation matrix stays fixed. Given the overlap matrix $S$ between the basis functions, the transformation matrix $X$ is given by $U \Lambda^{-1/2} U^*$, where $U$ and $\Lambda$ are the eigenvectors and eigenvalues of $S$ respectively.

### Code

Write a function that, given $S$, computes the transformation matrix $X = U \Lambda^{-1/2} U^*$ (using `numpy.linalg.eig`).

```python

```

Write a function that, given $C$ and the number of electrons, computes the density matrix $D$, where

\begin{equation}
  D_{\mu \nu} = \sum_{j=1}^{N_{\text{electrons}}/2} 2 C_{\mu j} C_{\nu j}.
\end{equation}

```python

```

Write a function that, given $H, G$ and $D$, computes the Fock matrix $F$, where

\begin{equation}
  F_{\mu \nu} = H_{\mu \nu} + \sum_{\alpha} \sum_{\beta} \left( G_{\mu \nu \alpha \beta} - \frac{1}{2} G_{\mu \beta \alpha \nu} \right) D_{\alpha \beta}.
\end{equation}

```python

```

Write a function that, given $F$, uses the `numpy.linalg.eigh` function to extract the eigenvalues and eigenvectors. It should return the orbital energies (the eigenvalues in order) and the new orbital coefficients ($X V$, where $V$ is the matrix of eigenvectors). It should compute $F' = X^* F X$ in the transformed basis, compute its eigenvalues $\epsilon$ and eigenvectors $V$, and hence get the new coefficients $X V$.

```python

```

Write a function that, given $X, H, G$, and a guess for $C$ with its associated density matrix $D$, returns the new density matrix, new basis coefficients, and orbital energies.

```python

```

Write a function that, given $D, H, F$ and $V_{\text{nn}}$, returns the total energy of the configuration.

```python

```

Write a function that, given $S, H, G, V_{\text{nn}}$ and a guess for $C$, iterates the Hartree-Fock method until it converges to a certain tolerance. It should print the total energy of the configuration.

```python

```

### Example

A two-electron system would be $\text{He} - \text{H}_+$ - one Helium and one Hydrogen, with an electron missing. The required input data is:

```python
Nelectrons = 2
S = numpy.array([[1.0, 0.434311], [0.434311, 1.0]])
H = numpy.array([[-1.559058, -1.111004], [-1.111004, -2.49499]])
G = numpy.array([[[[ 0.77460594,  0.27894304],[ 0.27894304,  0.52338927]],
                  [[ 0.27894304,  0.14063907],[ 0.14063907,  0.34321967]]],
                 [[[ 0.27894304,  0.14063907],[ 0.14063907,  0.34321967]],
                  [[ 0.52338927,  0.34321967],[ 0.34321967,  1.05571294]]]])
Vnn = 1.3668670357
```

Check that your algorithm works: the total energy should be approximately $-2.626$ (Hartrees), and the initial guess can be pure zeros. It should take around 15 iterations.

```python

```

FEEG6016 Simulation and Modelling/11-Self-Consistent-Fields-Lab-1.ipynb


# Multi-TX/TL

Matthieu Kratz (BE240 Submission)

## Why this model?

Like most genetic circuit models, you typically start with a model that captures the two fundamental processes of transcription and translation. These processes can modelled with varying degrees of complexity, ranging from a basic tx/tl model (__Equation 1__) to complex models that simulate tx/tl at a base pair level. For my project, it was of particular importance that I accurately model the sequestration of transcriptional machinery. Further, I was working without RPU data, making typical models that assume some maximum steady state saturation e.g. positive proportional hill intractable for my system.

\begin{align}
\\ \\
&G \xrightarrow{ktx} T + G \\ \\
&\textbf{Equation 1.} \ \ \text{Simple Transcription}
\\
\end{align}

Hence, I needed a model that gave reasonable steady state transcription and translation dynamics from non-RPU parameters, all while accurately tracking the sequestration of the relevant machinery.

## The Multi-TX Model

The model CRN is below and the mechanism effectively relies on accounting for all possible RNAp occupancy states, including the various transitions between these occupancy states.

\begin{align}
\\ \\
&\textbf{1. Binding} \\
&T7_p + T7_p:G_n  \underset{kT72}{\overset{kT71}\rightleftharpoons} T7_p:G_{n\alpha}  \ \ , \   \text{n $\neq$ $n_{max}$} \\ \\
&\textbf{2. Closed --> Open} \\
&T7_p:G_{n\alpha}  \xrightarrow{k_{iso}} T7_p:G_{n+1}  \ \ , \   \text{n $\neq$ $n_{max}$} \\ \\
&\textbf{3. TX} \\
&T7_p:G_{n\alpha} \xrightarrow{ktx} nT7_m + nT7_p + T7_p:G_{0\alpha} \ \ , \  \text{n $\neq$ 0} \\
&T7_p:G_n \xrightarrow{ktx} nT7_m + nT7_p + T7_p:G_{0} \ \ , \  \text{n $\neq$ 0} \\
\end{align}

kT71 --> Promoter binding rate constant (bimolecular)

kT72 --> Promoter unbinding rate constant (unimolecular)

k_iso --> Closed to open complex transition rate constant (unimolecular)

ktx --> Single polymerase mRNA synthesis rate constant (unimolecular)

A couple of comments:
- We explicitly model the process of transitioning from the closed ($T7_p:G_{n\alpha}$) to open complex ($T7_p:G_{n+1}$). This a pretty slow reaction in vivo, and in the various iterations of this model, including this seemed to be key in accurately reflecting transcription dynamics.

- $n_{max}$ refers to the maximum possible occupancy of the gene. This is the physical limit which is ultimately determined by the footprint of the polymerase and the length of the gene. In relation to the point above, without isomerization, this physical limit is always met. With the explicit isomerization, this physical saturation is not met (with my parameter set at least).

- Polymerase can only be added one at a time to existing genes i.e. cannot have multiple binding events or closed complexes simultaneously.

- We have two TX reactions, one from the closed state and the other from the open state, both with $N$ actively transcribing polymerases. We decided to allow release from the closed state as there is no reason why one polymerase in the closed form should inhibit of other activitely transcribing polymerases. Further, at a modelling level, not allowing release from the closed state results in excessive sequestration of polymerase due to the long time scale of isomerization.

- In both TX reactions, the entirety of the $N$ polymerases (bar the one in the closed state) are simultaneously release, along with $N$ transcripts and the unoccupied gene ($T7_p:G_{0}$)

## Biocrnpyler multi_tx Mechanism subclass

Subclass should be availabe via `from biocrnpyler import mechanism`, code is below for clarity

Couple of comments:
- The subclass has been designed to be used in concert with the Promoter and DNA assemblies subclasses

- Have to define a maximum occupancy (int) and cognate polymerase (species or str) when instantiating

- The various complex species are generated within the subclass from the names of the polymerase and dna objects in the DNAassembly object. They are defined as species with DNA material types.

## Example: T7 Polymerase Transcription of GFP mRNA

```python
from biocrnpyler import *

class multi_tx(Mechanism):
    '''
    Multi-RNAp Transcription w/ Isomerization:
    Detailed transcription mechanism accounting for each individual
    RNAp occupancy states of gene.

    n ={0, max_occ}
    DNA:RNAp_n + RNAp <--> DNA:RNAp_n_c --> DNA:RNAp_n+1
    DNA:RNAp_n --> DNA:RNAp_0 + n RNAp + n mRNA
    DNA:RNAp_n_c --> DNA:RNAp_0_c + n RNAp + n mRNA

    n --> number of open configuration RNAp on DNA
    max_occ --> Physical maximum number of RNAp on DNA (based on RNAp and DNA dimensions)
    DNA:RNAp_n --> DNA with n open configuration RNAp on it
    DNA:RNAp_n_c --> DNA with n open configuration RNAp and 1 closed configuration RNAp on it

    For more details, see examples/MultiTX_Demo.ipynb
    '''

    # initialize mechanism subclass
    def __init__(self, pol, name='multi_tx', mechanism_type='transcription', **keywords):

        if isinstance(pol,str):
            self.pol = Species(name=pol, material_type='protein')

        elif isinstance(pol,Species):
            self.pol = pol

        else:
            raise ValueError("'pol' must be a string or Species")

        Mechanism.__init__(self, name=name, mechanism_type=mechanism_type, **keywords)

    # species update
    def update_species(self, dna, transcript, component, part_id, **keywords):
        max_occ = int(component.get_parameter("max_occ", part_id = part_id, mechanism = self))
        cp_open = []
        cp_closed = []
        for n in range(1,max_occ + 1):
            name_open = self.pol.name + 'x' + dna.name + '_' + str(n)
            cp_open.append(ComplexSpecies([dna]+[self.pol for i in range(n)],name=name_open))
            if n > 1:
                name_closed = self.pol.name + 'x' + dna.name + '_closed' + '_' + str(n-1)
                cp_closed.append(ComplexSpecies([dna]+[self.pol for i in range(n-1)],name=name_closed))
            else:
                name_closed = self.pol.name + 'x' + dna.name + '_closed' + '_' + str(0)
                cp_closed.append(ComplexSpecies([dna]+[self.pol for i in range(1)],name=name_closed))

        cp_misc = [self.pol,dna,transcript]

        return cp_open + cp_closed + cp_misc

    def update_reactions(self, dna, transcript, component, part_id, **keywords):

        '''
    DNA:RNAp_n + RNAp <--> DNA:RNAp_n_c --> DNA:RNAp_n+1
    kf1 = k1, kr1 = k2, kf2 = k_iso
    DNA:RNAp_n --> DNA:RNAp_0 + n RNAp + n mRNA
    kf = ktx_solo
    DNA:RNAp_n_c --> DNA:RNAp_0_c + n RNAp + n mRNA
    kf = ktx_solo

    max_occ =  maximum occupancy of gene (physical limit)
        '''

        # parameter loading
        k1 = component.get_parameter("k1", part_id = part_id, mechanism = self)
        k2 = component.get_parameter("k2", part_id = part_id, mechanism = self)
        k_iso = component.get_parameter("k_iso", part_id = part_id, mechanism = self)
        ktx_solo = component.get_parameter("ktx_solo", part_id = part_id, mechanism = self)
        max_occ = int(component.get_parameter("max_occ", part_id = part_id, mechanism = self))

        # complex species instantiation
        cp_open = []
        cp_closed = []
        for n in range(1,max_occ + 1):
            name_open = self.pol.name + 'x' + dna.name + '_' + str(n)
            cp_open.append(ComplexSpecies([dna]+[self.pol for i in range(n)],name=name_open))
            if n > 1:
                name_closed = self.pol.name + 'x' + dna.name + '_closed' + '_' + str(n-1)
                cp_closed.append(ComplexSpecies([dna]+[self.pol for i in range(n-1)],name=name_closed))
            else:
                name_closed = self.pol.name + 'x' + dna.name + '_closed' + '_' + str(0)
                cp_closed.append(ComplexSpecies([dna]+[self.pol for i in range(1)],name=name_closed))

        # Reactions
        # polymerase + complex(n) --> complex(n_closed)
        rxn_open_pf = [Reaction(inputs=[self.pol, cp_open[n]], outputs=[cp_closed[n+1]], k=k1) for n in range(0,max_occ-1)]
        rxn_open_pr = [Reaction(inputs=[cp_closed[n+1]], outputs=[self.pol, cp_open[n],], k=k2) for n in range(0,max_occ-1)]

        # isomerization
        rxn_iso = [Reaction(inputs=[cp_closed[n]], outputs=[cp_open[n]], k=k_iso) for n in range(0,max_occ)]

        # release/transcription from open and closed states
        rxn_release_open =  []
        rxn_release_closed = []
        for n in range(0,max_occ):
            rxn_temp1 = Reaction(inputs= [cp_open[n]], outputs=[self.pol for i in range(n+1)] +
                                 [transcript for i in range(n+1)] + [dna], k=ktx_solo)
            rxn_release_open.append(rxn_temp1)

        for n in range(1,max_occ):
            rxn_temp2 = Reaction(inputs= [cp_closed[n]], outputs=[self.pol for i in range(n)] +
                                 [transcript for i in range(n)] + [cp_closed[0]], k=ktx_solo)
            rxn_release_closed.append(rxn_temp2)

        # missing reactions (0 --> 0_closed and v.v. 0_closed --> 0)
        rxn_m1 = Reaction(inputs=[dna,self.pol], outputs=[cp_closed[0]], k=k1)
        rxn_m2 = Reaction(inputs=[cp_closed[0]], outputs=[dna,self.pol], k=k2)

        rxn_all = rxn_open_pf + rxn_open_pr + rxn_iso + rxn_release_open + rxn_release_closed + [rxn_m1, rxn_m2]

        return rxn_all
```

    C:\Users\mkratz\Anaconda3\lib\site-packages\biocrnpyler-0.2-py3.7.egg\biocrnpyler\__init__.py:36: UserWarning: No module named 'fa2'
    C:\Users\mkratz\Anaconda3\lib\site-packages\biocrnpyler-0.2-py3.7.egg\biocrnpyler\__init__.py:37: UserWarning: plotting is disabled because you are missing some libraries

First we define a dilution mixture class, a mixture based of ExpressionExtract which adds dilution and degredation interactions to RNA species, respectively. I haven't added this for proteins as it makes it easier to glean the degree of sequestration present with this mechanism. In practical use, you would of course allow your polymerase to be diluted.

```python
class DilutionMixture(Mixture):
    def __init__(self, name="", **keywords):

        simple_transcription = SimpleTranscription() #Transcription will not involve machinery
        simple_translation = SimpleTranslation()

        default_mechanisms = {
            "transcription": simple_transcription, #This will be overwritten by the NegativeHillPromotor
            "translation": simple_translation
        }

        #By Default Species are diluted S-->0 Unless:
        # They are of type 'dna'
        # They have the attribute 'machinery'
        dilution_mechanism = Dilution(filter_dict = {"dna":False,'protein':False,'complex':False}, default_on = True)
        dilution_mrn = Dilution(name = "rna_degredation", filter_dict = {"rna":True}, default_on = False)

        #Add this mechanism to a dictionary which is passed into the Mixture txtl.TxTlExtract
        global_mechanisms = {"dilution":dilution_mechanism, "rna_degredation":dilution_mrn}

        #Always call the superclass __init__ with **keywords
        Mixture.__init__(self, name=name, default_mechanisms=default_mechanisms, global_mechanisms = global_mechanisms, **keywords)
```

Now we define a DNA assembly that use our mechanism in the following steps:
- Create a species for the relevant polymerase
- Create multi_tx object, give a maximum occupancy and polymerase (must be species or str)
- Associate this mechanism with a promoter
- Place this promoter into a DNA assembly

And voila, the cassette regulated by T7p is ready to use!

```python
# Define Polymerase, and max occupancy and instatiate Mechanism Object
T7P = Species('T7p','protein')
MX = multi_tx(pol=T7P,name='MX')

# create promoter object, associated MX and params with it
pT7 = Promoter(name='pT7',mechanisms={'transcription':MX})

# place promoter object into DNA assembly
PFL = DNAassembly('PFL',dna=Species('T7',material_type='dna'),promoter=pT7,transcript='GFP')

# create simple promoter and DNA assembly objects that synthesize polymerase, uncomment if you want
# a constant source of T7p
# pJ = Promoter('J23107',mechanisms={'transcription':OneStepGeneExpression()})
# SC = DNAassembly('SCF',dna='T7_source',promoter=pJ,protein=T7P)
# Test_EX = DilutionMixture(components=[PFL,SC],parameter_file = "parameters.txt")
# CRN = Test_EX.compile_crn()

# make extract with T7p source and GFP and compile CRN
Test_EX = DilutionMixture(components=[PFL],parameter_file = "params_demo.txt")
CRN1 = Test_EX.compile_crn()
```

```python
import numpy as np
import matplotlib.pyplot as plt

ts = np.arange(0,2000,1)
x0_dict = {repr(T7P):10000,repr(PFL.dna):1}

# Do not use simulate_with_bioscrape_via_sbml, not working at time of writing (5/31/2020) because of a bug related to sbml model writing
try:
    R1= CRN1.simulate_with_bioscrape(ts, initial_condition_dict = x0_dict, stochastic = False,)

    fig, ax = plt.subplots(1,2,figsize=(18,8))
    ax[0].set_title('Polymerase Levels',pad=20,fontdict={'fontsize':18})
    ax[0].plot(R1['protein_T7p'],linewidth=3)
    ax[0].set_xlabel('Time (sec)',labelpad=15,fontdict={'fontsize':14})
    ax[0].set_ylabel('Polymerase Count',labelpad=15,fontdict={'fontsize':14})

    ax[1].set_title('Transcript Levels',pad=20,fontdict={'fontsize':18})
    ax[1].plot(R1['rna_GFP'],linewidth=3,c='k')
    ax[1].set_xlabel('Time (sec)',labelpad=15,fontdict={'fontsize':14})
    ax[1].set_ylabel('Transcript Count',labelpad=15,fontdict={'fontsize':14})
except ModuleNotFoundError:
    pass
```

## Comparison of Transcript SS with RPU Data

Here we will do a head to head comparison with a simple transcription model built using RPU data. I consider this to effectively be the ground truth for the SS Transcript Count, although it should be noted this comparison does not necessary validate any of the pre-SS dynamics as the simple transcription model assumes immediate saturation. RPU data is from Qi et al.(2012) and RPU standard is from supplement of Nielsen et al. (2016).

```python
# place promoter object into DNA assembly
PFL = DNAassembly('PFL',dna='T7',promoter='pT7',transcript='GFP')

# make extract with T7p source and GFP and compile CRN
Test_EX = DilutionMixture(components=[PFL],parameter_file = "params_demo.txt")
CRN2 = Test_EX.compile_crn()
```

```python
import numpy as np
import matplotlib.pyplot as plt

ts = np.arange(0,2000,1)
x0_dict = {repr(PFL.dna):1}

# Do not use simulate_with_bioscrape_via_sbml, not working at time of writing (5/31/2020) because of a bug related to sbml model writing
try:
    R2 = CRN2.simulate_with_bioscrape(ts, initial_condition_dict = x0_dict, stochastic = False,)

    fig = plt.figure(figsize=(10,6))
    plt.title('Transcript Levels',pad=20,fontdict={'fontsize':18})
    plt.plot(R1['rna_GFP'],linewidth=3,c='k',label='MTX Model')
    plt.plot(R2['rna_GFP'],linewidth=3,c='b', label='RPU Model')
    plt.xlabel('Time (sec)',labelpad=15,fontdict={'fontsize':14})
    plt.ylabel('Transcript Count',labelpad=15,fontdict={'fontsize':14})
    plt.legend(fontsize=14)

    print('\n \n')
    print(f"MX Model predicts {np.round(R1['rna_GFP'].iloc[-1])} transcripts and RPU Model predicts {np.round(R2['rna_GFP'].iloc[-1])} transcripts at SS \n \n")
except ModuleNotFoundError:
    pass
```

The two models seem to agree pretty well, with some minor difference. This is pretty cool given the fact that MX model uses indirect parameters (promoter affinity, isomerization and tx rates) to predict this SS value. Of course, it's also important to keep in mind that alternative models typically do to not account for the constant sequestration of the polymerase machinery to sustain this SS transcript level. As such, they may not reflect key dynamics resulting from machinery allocation/sharing e.g. retroactivity in tx/tl.

## Putative Multi-TL Model

The general model of binding, isomerization and production can be readily extended to the process of translation. However, there are several caveats for translation.

- First of all, I don't have any direct data like RPU experiments to compare my model with. All I can say is that with biologically reasonable parameter sets, you get something like a few hundred proteins per mRNA (RBZ in excess) and e. coli has a protein to mRNA ratio in that range (100:1 - 1000:1) (Taniguchi et al.(2010) or bionumbers book).

- Second of all, since we're no longer working with DNA complexes, the mRNA-RBZ complexes should be subject to degredation/dilution. However, it seems that at the level of the biology, initiation can have a stabilizing effect and for elongation it is uncertain (Roy et al. (2013)). In my simulations, I've effectively done as if only dilution is applied to these complexes, but there is an argument to include some form of active degredation (at a reduced rate). Depending on how this implemented, one may need to make a new degredation mechanism where the complexes effectively releases the ribosomes i.e. only mRNA degraded. This is also complicated by the fact that using subclasses like ComplexSpecies() results in inheritance of degredation/material properties.

## Quick Example

```python
from biocrnpyler import *
import warnings

class multi_tl(Mechanism):
    '''
    Multi-RBZ Translation w/ Isomerization:
    Detailed translation mechanism accounting for each individual
    RBZ occupancy states of mRNA. Still needs some work, so use with caution,
    read all warnings and consult the example notebook.

    n ={0, max_occ}
    mRNA:RBZ_n + RBZ <--> mRNA:RBZ_n_c --> mRNA:RBZ_n+1
    mRNA:RBZ_n --> mRNA:RBZ_0 + n RBZ + n Protein
    mRNA:RBZ_n_c --> mRNA:RBZ_0_c + n RBZ + n Protein

    n --> number of open configuration RBZ on mRNA
    max_occ --> Physical maximum number of RBZ on mRNA (based on RBZ and mRNA dimensions)
    mRNA:RBZ_n --> mRNA with n open configuration RBZ on it
    mRNA:RBZ_n_c --> mRNA with n open configuration RBZ and 1 closed configuration RBZ on it

    For more details, see examples/MultiTX_Demo.ipynb
    '''

    # initialize mechanism subclass
    def __init__(self, ribosome, name='multi_tl', mechanism_type='translation', **keywords):

        if isinstance(ribosome,str):
            self.ribosome = Species(name=ribosome, material_type='protein')

        elif isinstance(ribosome,Species):
            self.ribosome = ribosome

        else:
            raise ValueError("'ribosome' must be a string or Species")

        warnings.warn('This mechanism still needs some extra validation, use at your own peril and read the warnings!')
        warnings.warn("To properly use this mechanism, set dilution for mRNA-RBZ complexes!")
        warnings.warn("I've set RBZ and mRNA-RBZ complexes as protein Species to apply dilution to them, edit if you want something else!")

        Mechanism.__init__(self, name=name, mechanism_type=mechanism_type, **keywords)

    # species update
    def update_species(self, transcript, protein, component, part_id, **keywords):
        max_occ = int(component.get_parameter("max_occ", part_id = part_id, mechanism = self))
        cp_open = []
        cp_closed = []
        for n in range(1,max_occ + 1):
            name_open = self.ribosome.name + 'x' + transcript.name + '_' + str(n)
            cp_open.append(ComplexSpecies([transcript]+[self.ribosome for i in range(n)],name=name_open))

            if n > 1:
                name_closed = self.ribosome.name + 'x' + transcript.name + '_closed' + '_' + str(n-1)
                cp_closed.append(ComplexSpecies([transcript]+[self.ribosome for i in range(n-1)],name=name_closed))
            else:
                name_closed = self.ribosome.name + 'x' + transcript.name + '_closed' + '_' + str(0)
                cp_closed.append(ComplexSpecies([transcript]+[self.ribosome for i in range(1)],name=name_closed))

        cp_misc = [self.ribosome,transcript,protein]

        return cp_open + cp_closed + cp_misc

    def update_reactions(self, transcript, protein, component, part_id, **keywords):
        '''
    mRNA:RBZ_n + RBZ <--> mRNA:RBZ_n_c --> mRNA:RBZ_n+1
    kf1 = kbr, kr1 = kur, kf2 = k_iso_r
    mRNA:RBZ_n --> mRNA:RBZ_0 + n RBZ + n Protein
    kf = ktl_solo
    mRNA:RBZ_n_c --> mRNA:RBZ_0_c + n RBZ + n Protein
    kf = ktl_solo
        '''

        # parameter loading
        kbr = component.get_parameter("kbr", part_id = part_id, mechanism = self)
        kur = component.get_parameter("kur", part_id = part_id, mechanism = self)
        k_iso_r = component.get_parameter("k_iso_r", part_id = part_id, mechanism = self)
        ktl_solo = component.get_parameter("ktl_solo", part_id = part_id, mechanism = self)
        max_occ = int(component.get_parameter("max_occ", part_id = part_id, mechanism = self))

        # complex species instantiation
        cp_open = []
        cp_closed = []
        for n in range(1,max_occ + 1):
            name_open = self.ribosome.name + 'x' + transcript.name + '_' + str(n)
            cp_open.append(ComplexSpecies([transcript]+[self.ribosome for i in range(n)],name=name_open))

            if n > 1:
                name_closed = self.ribosome.name + 'x' + transcript.name + '_closed' + '_' + str(n-1)
                cp_closed.append(ComplexSpecies([transcript]+[self.ribosome for i in range(n-1)],name=name_closed))
            else:
                name_closed = self.ribosome.name + 'x' + transcript.name + '_closed' + '_' + str(0)
                cp_closed.append(ComplexSpecies([transcript]+[self.ribosome for i in range(1)],name=name_closed))

        # Reactions
        # ribosome + complex(n) --> complex(n_closed)
        rxn_open_pf = [Reaction(inputs=[self.ribosome, cp_open[n]], outputs=[cp_closed[n+1]], k=kbr) for n in range(0,max_occ-1)]
        rxn_open_pr = [Reaction(inputs=[cp_closed[n+1]], outputs=[self.ribosome, cp_open[n],], k=kur) for n in range(0,max_occ-1)]

        # isomerization
        rxn_iso = [Reaction(inputs=[cp_closed[n]], outputs=[cp_open[n]], k=k_iso_r) for n in range(0,max_occ)]

        # release/translation from open and closed states
        rxn_release_open =  []
        rxn_release_closed = []
        for n in range(0,max_occ):
            rxn_temp1 = Reaction(inputs= [cp_open[n]], outputs=[self.ribosome for i in range(n+1)] +
                                 [protein for i in range(n+1)] + [transcript], k=ktl_solo)
            rxn_release_open.append(rxn_temp1)

        for n in range(1,max_occ):
            rxn_temp2 = Reaction(inputs= [cp_closed[n]], outputs=[self.ribosome for i in range(n)] +
                                 [protein for i in range(n)] + [cp_closed[0]], k=ktl_solo)
            rxn_release_closed.append(rxn_temp2)

        # missing reactions (0 --> 0_closed and v.v. 0_closed --> 0)
        rxn_m1 = Reaction(inputs=[transcript,self.ribosome], outputs=[cp_closed[0]], k=kbr)
        rxn_m2 = Reaction(inputs=[cp_closed[0]], outputs=[transcript,self.ribosome], k=kur)

        rxn_all = rxn_open_pf + rxn_open_pr + rxn_iso + rxn_release_open + rxn_release_closed + [rxn_m1, rxn_m2]

        return rxn_all
```

```python
class DilutionMixture(Mixture):
    def __init__(self, name="", **keywords):

        simple_transcription = SimpleTranscription() #Transcription will not involve machinery
        simple_translation = SimpleTranslation()

        default_mechanisms = {
            "transcription": simple_transcription, #This will be overwritten by the NegativeHillPromotor
            "translation": simple_translation
        }

        #By Default Species are diluted S-->0 Unless:
        # They are of type 'dna'
        # They have the attribute 'machinery'
        dilution_mechanism = Dilution(filter_dict = {"dna":False,'protein':True,'complex':True}, default_on = True)
        dilution_mrn = Dilution(name = "rna_degredation", filter_dict = {"rna":True}, default_on = False)

        #Add this mechanism to a dictionary which is passed into the Mixture txtl.TxTlExtract
        global_mechanisms = {"dilution":dilution_mechanism, "rna_degredation":dilution_mrn}

        #Always call the superclass __init__ with **keywords
        Mixture.__init__(self, name=name, default_mechanisms=default_mechanisms, global_mechanisms = global_mechanisms, **keywords)
```

PFL is transcribed through simple transcription and translated through my multi_tl mechanism. We also have a constitutive source of our RBZ being produced to provide a constant saturating concentration of RBZ.

```python
# Instantiate RBZ species
RBZ = Species('RBZ',material_type='protein')

# make RBZ source
JP  = Promoter('J23101')
RBZ_S = DNAassembly('RBZ_source',promoter=JP,transcript=RBZ)

# Instantiate mechanism and mixture
ML = multi_tl(RBZ)
PFL = DNAassembly('PFL',dna='T7',rbs='RBSG',promoter='pT7',transcript='GFP',protein='GFP')
EM = DilutionMixture('EM',components=[PFL,RBZ_S],parameter_file = "params_demo.txt",mechanisms={'translation':ML})
CRN3 = EM.compile_crn()
```

    C:\Users\mkratz\Anaconda3\lib\site-packages\ipykernel_launcher.py:36: UserWarning: This mechanism still needs some extra validation, use at your own peril and read the warnings!
    C:\Users\mkratz\Anaconda3\lib\site-packages\ipykernel_launcher.py:37: UserWarning: To properly use this mechanism, set dilution for mRNA-RBZ complexes!
    C:\Users\mkratz\Anaconda3\lib\site-packages\ipykernel_launcher.py:38: UserWarning: I've set RBZ and mRNA-RBZ complexes as protein Species to apply dilution to them, edit if you want something else!

```python
import numpy as np
import matplotlib.pyplot as plt

ts = np.arange(0,10000,1)
x0_dict = {repr(RBZ_S.dna):100,repr(PFL.dna):1}

try:
    # Do not use simulate_with_bioscrape_via_sbml, not working at time of writing (5/31/2020) because of a bug related to sbml model writing
    R3 = CRN3.simulate_with_bioscrape(ts, initial_condition_dict = x0_dict, stochastic = False,)

    fig, ax = plt.subplots(1,2,figsize=(18,8))
    ax[0].set_title('GFP Protein Levels',pad=20,fontdict={'fontsize':18})
    ax[0].plot(R3['protein_GFP'],linewidth=3)
    ax[0].set_xlabel('Time (sec)',labelpad=15,fontdict={'fontsize':14})
    ax[0].set_ylabel('GFP Protein Count',labelpad=15,fontdict={'fontsize':14})

    ax[1].set_title('GFP Transcript Levels',pad=20,fontdict={'fontsize':18})
    ax[1].plot(R3['rna_GFP'],linewidth=3,c='k')
    ax[1].set_xlabel('Time (sec)',labelpad=15,fontdict={'fontsize':14})
    ax[1].set_ylabel('GFP Transcript Count',labelpad=15,fontdict={'fontsize':14})

    # tiny script to aggregate T7m containing species
    import pandas as pd
    j = pd.DataFrame()
    for col in R3.columns:
        if 'xGFP' in col:
            j[col] = R3[col]
    j['sum'] = j.sum(axis=1)

    # tiny script to calculate total ribosomes bound to mRNA
    rbz_sum = 0
    for col in j.columns[0:-1]:
        if 'closed' in col:
            c = int(col.split('_')[-1])
            c+=1
        else:
            c = int(col.split('_')[-1])
        rbz_sum += c * j[col].iloc[-1]

    print('\n')
    print(f"The ratio of protein to mRNA is {np.round(R3['protein_GFP'].iloc[-1]/j['sum'].iloc[-1])} protein per mRNA")
    print('\n')
    print(f"The average mRNA occupancy is {np.round(rbz_sum/j['sum'].iloc[-1])} ribosomes per mRNA")
    print('\n \n')

except ModuleNotFoundError:
    pass
```

GFP transcript count is very low as all of it is currently occupied in various RBZ-transcript complexes

# Future Work

- Want to do more rigorous validation of multi-tx mechanism. As part of that, I first plan on comparing RPU data of existing consitutive promoters and non-native polymerase expression systems e.g. T5 with what the multi-tx model would predict.
- Is there data out there that would help validate the predicted RNAp occupancy of genes from the multi-tx model (same story for multi-tl model)?
- Eventually want to develop a multi-tx mechanism that can be used for TF-mediated transcription.
- Start looking towards validating multi-tl model, currently plan on seeing what data and parameters I could scrape from existing resource e.g. BCD RBS binding rates from biocrnpyler.
- Do some model comparison using bioscrape inference, where I generate parameters with RPU data model and fit parameters (vary known and unknown parameters) from the MTX model. Definitely very interesting for deriving isomerization rates as that seems hard to come by in literature and seems to be a key part of the model in determining system behaviour (Pre-SS and SS dynamics).

# Mentioned Papers and Parameter Resources

T7 parameters:
- Promoter Binding and Unbinding: Jia, Y., Kumar, A., & Patel, S. S. (1996). Equilibrium and Stopped-flow Kinetic Studies of Interaction between T7 RNA Polymerase and Its Promoters Measured by Protein and 2-Aminopurine Fluorescence Changes. Journal of Biological Chemistry , 271(48), 30451–30458. https://doi.org/10.1074/jbc.271.48.30451
- Isomerization Rate: Skinner, G. M., Baumann, C. G., Quinn, D. M., Molloy, J. E., & Hoggett, J. G. (2004). Promoter Binding, Initiation, and Elongation By Bacteriophage T7 RNA Polymerase: A SINGLE-MOLECULE VIEW OF THE TRANSCRIPTION CYCLE . Journal of Biological Chemistry , 279(5), 3239–3244. https://doi.org/10.1074/jbc.M310471200
- Translation Rate (T7p is VERY fast): Kochetkov, S. N., Rusakova, E. E., & Tunitskaya, V. L. (1998). Recent studies of T7 RNA polymerase mechanism. FEBS Letters, 440(3), 264–267. https://doi.org/https://doi.org/10.1016/S0014-5793(98)01484-7

Translation Parameters:
- RBS Binding and Unbinding: Chandra, F., & Del Vecchio, D. (2016). The Effects of Ribosome Autocatalysis and Negative Feedback in Resource Competition. bioRxiv. https://doi.org/10.1101/042127
- Isomerization Rate: Draper, D. E. (1993). Mechanisms of Translational Initiation and Repression in Prokaryotes BT  - The Translational Apparatus: Structure, Function, Regulation, Evolution. In K. H. Nierhaus, F. Franceschi, A. R. Subramanian, V. A. Erdmann, & B. Wittmann-Liebold (Eds.) (pp. 197–207). Boston, MA: Springer US. https://doi.org/10.1007/978-1-4615-2407-6_19

Misc:
- mRNA Degredation: Roy, B., & Jacobson, A. (2013). The intimate relationships of mRNA decay and translation. Trends in Genetics : TIG, 29(12), 691–699. https://doi.org/10.1016/j.tig.2013.09.002
- RPU Data: Nielsen, A. A. K., Der, B. S., Shin, J., Vaidyanathan, P., Paralanov, V., Strychalski, E. A., … Voigt, C. A. (2016). Genetic circuit design automation. Science, 352(6281), aac7341. https://doi.org/10.1126/science.aac7341 and Qi, L., Haurwitz, R. E., Shao, W., Doudna, J. A., & Arkin, A. P. (2012). RNA processing enables predictable programming of gene expression. Nature Biotechnology, 30(10), 1002–1006. https://doi.org/10.1038/nbt.2355
- Protein:mRNA ratios: Taniguchi, Y., Choi, P. J., Li, G.-W., Chen, H., Babu, M., Hearn, J., … Xie, X. S. (2010). Quantifying E. coli Proteome and Transcriptome with Single-Molecule Sensitivity in Single Cells. Science, 329(5991), 533 LP – 538. https://doi.org/10.1126/science.1188308


# Attributes lookup and sympy-based equations

## ANNarchy side

### Core

```python
import numpy as np

class Value(object):
    "Placeholder for single parameters"
    def __init__(self, value, dtype):
        self._init_value = value
        self._instantiated = False
        self._dtype = dtype

    def _copy(self):
        return Value(self._init_value, self._dtype)

    def _instantiate(self, shape):
        self._instantiated = True
        self.shape = 1
        self._value = self._init_value

    def get_value(self):
        if not self._instantiated:
            return self._init_value
        return self._value

    def set_value(self, val):
        if not self._instantiated:
            self._init_value = val
        else:
            self._value = val

class Array(object):
    "Placeholder for arrays"
    def __init__(self, init, dtype):

        self._init_value = init
        self._dtype = dtype

        self._instantiated = False

    def _copy(self):
        return Array(
            self._init_value,
            self._dtype
        )

    def _instantiate(self, shape):
        self._instantiated = True
        self._value = np.full(shape, self._init_value, dtype=self._dtype)
        self.shape = self._value.shape

    def get_value(self):
        if not self._instantiated:
            return self._init_value
        return self._value

    def set_value(self, val):
        if not self._instantiated:
            self._init_value = val
        else:
            if isinstance(val, np.ndarray):
                if val.shape == self.shape:
                    self._value = val
                elif val.shape == (1,):
                    self._value = np.full(self.shape, val[0], dtype=self._dtype)
                else:
                    print("Array assignment error", val, val.shape)
            elif isinstance(val, (float, int, bool)):
                self._value = np.full(self.shape, val)
            else:
                print("Array assignment error", val)

class Neuron(object):

    def Value(self, value, dtype=np.float32):
        "Creates and returns a single value."
        if not hasattr(self, "_data"):
            self._data = []
        val = Value(value, dtype)
        self._data.append(val)
        return val

    def Array(self, init=0.0, dtype=np.float32):
        "Creates and returns an array."
        if not hasattr(self, "_data"):
            self._data = []
        val = Array(init, dtype)
        self._data.append(val)
        return val

class Synapse(object):

    def Value(self, value, dtype=np.float32):
        "Creates and returns a single value."
        if not hasattr(self, "_data"):
            self._data = []
        val = Value(value, dtype)
        self._data.append(val)
        return val

    def Array(self, init=0.0, dtype=np.float32):
        "Creates and returns an array."
        if not hasattr(self, "_data"):
            self._data = []
        val = Array(init, dtype)
        self._data.append(val)
        return val

class Population(object):

    def __init__(self, shape, neuron):

        # Obligatory arguments
        self.shape = shape
        if isinstance(shape, tuple):
            size = 1
            for n in shape:
                size *= n
            self.size = size
        else: # single value
            self.size = int(shape)

        # Neuron type
        self._neuron_type = neuron
        self._spiking = False

        # Internal stuff
        self._attributes = {}
        self._values_list = []
        self._arrays_list = []

    def _analyse(self):

        # List attributes
        current_attributes = list(self._neuron_type.__dict__.keys())

        for attr in current_attributes:
            if isinstance(getattr(self._neuron_type, attr), (Value, )):
                self._values_list.append(attr)
                self._attributes[attr] = getattr(self._neuron_type, attr)._copy()
                self._attributes[attr]._instantiate(self.shape)
            if isinstance(getattr(self._neuron_type, attr), (Array, )):
                self._arrays_list.append(attr)
                self._attributes[attr] = getattr(self._neuron_type, attr)._copy()
                self._attributes[attr]._instantiate(self.shape)

        # Get lists of values and arrays
        self.attributes = list(self._attributes.keys())

        # Set the attributes to the neuron
        self._neuron_type.attributes = self.attributes
        self._neuron_type._values_list = self._values_list
        self._neuron_type._arrays_list = self._arrays_list

        # Analyse update()
        self._update_equations =  self._neuron_type.update().equations

        # Analyse spike()
        if 'spike' in [f for f in dir(self._neuron_type) if callable(getattr(self._neuron_type, f))]:
            self._spiking = True
            self._spiking_equation = self._neuron_type.spike()
            self._reset_equations =  self._neuron_type.reset().equations

    def __getattribute__(self, name):
        if name in ['attributes']:
            return object.__getattribute__(self, name)
        else:
            if hasattr(self, 'attributes') and name in self.attributes:
                return self._attributes[name].get_value()
        return object.__getattribute__(self, name)

    def __setattr__(self, name, value):

        if hasattr(self, 'attributes') and name in self.attributes:
            self._attributes[name].set_value(value)
        else:
            object.__setattr__(self, name, value)

```

```python
class Network(object):

    def __init__(self):

        self.populations = []

    def add(self, shape, neuron):

        # Create the population
        pop = Population(shape, neuron)

        # Have the population analyse its attributes
        pop._analyse()

        # Store the population
        self.populations.append(pop)

        return pop

```

### Parser

```python
import sympy as sp

class Equations(object):

    def __init__(self, neuron=None, symbols=None):

        self.neuron = neuron
        self.symbols = {'t': sp.Symbol("t")}
        if self.neuron is None:
            self._custom_symbols = symbols
        self._equations_list = []
        self._started = False

    def __enter__(self):

        if self.neuron is not None:

            for attr in self.neuron.attributes:
                if attr in self.neuron._values_list:
                    # Symbol
                    symbol = sp.Symbol("%(pop_prefix)s"+attr+"%(pop_suffix_value)s")
                    self.symbols[attr] = symbol
                    setattr(self, attr, symbol)

                elif attr in self.neuron._arrays_list:
                    # Symbol
                    symbol = sp.Symbol("%(pop_prefix)s"+attr+"%(pop_suffix_array)s")
                    self.symbols[attr] = symbol
                    setattr(self, attr, symbol)

                    # Derivative if needed
                    symbol = sp.Symbol("__grad__" + attr)
                    self.symbols["d"+attr+"_dt"] = symbol
                    setattr(self, "d"+attr+"_dt", symbol)

                else:
                    print("Error")

        else: # Custom set of variables
            for attr in self._custom_symbols:
                # Symbol
                symbol = sp.Symbol(attr)
                self.symbols[attr] = symbol
                setattr(self, attr, symbol)

                # Derivative if needed
                symbol = sp.Symbol("d" + attr + "/dt")
                self.symbols["d"+attr+"_dt"] = symbol
                setattr(self, "d"+attr+"_dt", symbol)

        self._started = True

        return self

    def __exit__(self, exc_type, exc_value, traceback):
        "Would be done later by the generator"

        code = ""
        for var, eq in self._equations_list:
            code += sp.ccode(self.symbols[var]) + " = " + sp.ccode(eq) + ";\n"
            if var.endswith("_dt"):
                orig_var = var[1:-3]
                code += "%(pop_prefix)s"+orig_var+"%(pop_suffix_array)s += dt*(" + sp.ccode(self.symbols[var]) + ");\n"

        self.equations = code

    def __str__(self):
        string = ""
        for var, eq in self._equations_list:
            string += sp.ccode(self.symbols[var]) + " = " + sp.ccode(eq) + "\n"
        return string

    def __getattribute__(self, name):

        return object.__getattribute__(self, name)

    def __setattr__(self, name, value):

        # After __enter__(), track modifications to the variables
        if hasattr(self, '_started') and self._started:
            # Do not assign equations to symbols, just store them
            if name in self.symbols.keys():
                self._equations_list.append((name, value))
            else:
                object.__setattr__(self, name, value)
        else:
            object.__setattr__(self, name, value)

    def ite(self, cond, then, els):

        return sp.Piecewise((then, cond), (els, True))

```

## User side

```python
class RateCoded(Neuron):
    """
    Simple rate-coded neuron.

    $$\tau \, \dfrac{d v(t)}{dt} = ge(t) + ite(ge(t) > 1, ge(t), 0) + v^2(t) - v(t))/\tau$$

    $$r(t) = tanh(v(t))$$
    """

    def __init__(self, tau):

        self.tau = self.Value(tau)

        self.ge = self.Array(init=0.0)
        self.v = self.Array(init=0.0)
        self.r = self.Array(init=0.0)

    def update(self):

        # eq will contain all variables of the model as sympy symbols, plus some operations (ite = if/then/else)
        with Equations(self) as n:

            # One can declare intermediary variables that won't be allocated in memory!
            shunting = n.ite(n.ge > 1, n.ge, 0)

            # ODEs use the dX_dt trick
            n.dv_dt = (n.ge + shunting + sp.exp(n.v**2) - n.v) / n.tau

            # Sympy function can be used directly
            n.r = sp.tanh(n.v)

        return n

```

```python
net = Network()
pop = net.add(10, RateCoded(tau=20.))

print("Attributes:", pop.attributes)

print("Tau:", pop.tau)
print("v = 0:", pop.v)

pop.v = 1.

print("v = 1:", pop.v)

pop.v *= 5.

print("v = 5:", pop.v)

pop.v[:3] = 1.

print("v[:3] = 1:", pop.v)

print()
print("Generated C code:")
print(pop._update_equations  %{'pop_prefix': "", "pop_suffix_value": "", "pop_suffix_array": "[i]"})
```

    Attributes: ['tau', 'ge', 'v', 'r']
    Tau: 20.0
    v = 0: [0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]
    v = 1: [1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]
    v = 5: [5. 5. 5. 5. 5. 5. 5. 5. 5. 5.]
    v[:3] = 1: [1. 1. 1. 5. 5. 5. 5. 5. 5. 5.]

    Generated C code:
    __grad__v = (ge[i] - v[i] + ((ge[i] > 1) ? (
       ge[i]
    )
    : (
       0
    )) + exp(pow(v[i], 2)))/tau;
    v[i] += dt*(__grad__v);
    r[i] = tanh(v[i]);

```python
class LIF(Neuron):

    def __init__(self, params):

        self.tau = self.Value(params['tau'])
        self.V_th = self.Value(params['V_th'])

        self.ge = self.Array(init=0.0)
        self.v = self.Array(init=0.0)

    def update(self):

        with Equations(self) as n:

            n.dv_dt = (n.ge - n.v) / n.tau

        return n

    def spike(self):

        with Equations(self) as n:

            return n.v >= n.V_th

    def reset(self):

        with Equations(self) as n:

            n.v = 0

        return n

```

```python
net = Network()
pop = net.add(100, LIF({'tau': 20., 'V_th': 1.0}))

print("Neural update:")
print(pop._update_equations  %{'pop_prefix': "", "pop_suffix_value": "", "pop_suffix_array": "[i]"})

print()
print("Spiking condition:")
print(sp.ccode(pop._spiking_equation)  %{'pop_prefix': "", "pop_suffix_value": "", "pop_suffix_array": "[i]"})

print()
print("Reset:")
print(pop._reset_equations  %{'pop_prefix': "", "pop_suffix_value": "", "pop_suffix_array": "[i]"})
```

    Neural update:
    __grad__v = (ge[i] - v[i])/tau;
    v[i] += dt*(__grad__v);

    Spiking condition:
    v[i] >= V_th

    Reset:
    v[i] = 0;

One can use the Equations() in a standalone mode to check correctness:

```python
with Equations(symbols=['v', 'r', 'tau']) as n:
    n.dv_dt = (1 - n.v)/n.tau
    n.r = n.ite(n.v > 0, n.v, 0)

print(n)
```

    dv/dt = (1 - v)/tau
    r = ((v > 0) ? (
       v
    )
    : (
       0
    ))

Sanity check whether it works in multiprocessing:

```python
import multiprocessing

def worker(val):

    with Equations(symbols=['v', 'r', 'tau']) as n:
        n.dv_dt = (val - n.v)/n.tau

    return n

jobs = []
vals = np.linspace(0.0, 1.0, 5)

with multiprocessing.Pool() as pool:
    res = pool.map(worker, vals)

    for w in res:
        print(w)
        print("----")
```

    dv/dt = -v/tau

    ----
    dv/dt = (0.25 - v)/tau

    ----
    dv/dt = (0.5 - v)/tau

    ----
    dv/dt = (0.75 - v)/tau

    ----
    dv/dt = (1.0 - v)/tau

    ----

```python
class RateCodedSynapse(Synapse):

    def __init__(self):

        self.w = self.Weight()

    def transmit(self):

        with Equations(self) as s:

            s.post.target += s.w * s.pre.r

        return s

class SpikingSynapse(Synapse):

    def __init__(self):

        self.w = self.Weight()

    def transmit(self):

        self.transmit_on_spike = True # default when pre is a spiking population
        # self.transmit_on_spike = False # continuous integration for NMDA synapse

        with Equations(self) as s:

            s.post.target += s.w

        return s
```

```python
class TraceSynapse(RateCodedSynapse):

    def __init__(self, tau, eta):

        self.tau = self.Value(tau)

        self.eta = self.Value(eta)

        self.w = self.Weight()

        self.trace = self.Array(init=0.0)

    def update(self):

        with Equations(self, method="euler") as s:

            s.dtrace_dt = (s.pre.r - s.trace) / s.tau

        return s

    def apply(self):

        with Equations(self) as s:

            s.w += s.eta * s.trace * s.post.r * s.post.R

        return s

proj = net.connect(pop, pop, 'ge', TraceSynapse)
#...
net.simulate(100.)
if success:
    pop.R = 1.0
else:
    pop.R = 0.0
proj.apply()

```

```python
class STDP(SpikingSynapse):

    def __init__(self, tau):

        self.tau = self.Value(tau)

        self.w = self.Weight()

        self.x = self.Array(init=0.0)
        self.y = self.Array(init=0.0)

    def update(self):

        with Equations(self, method=Euler) as s:

            s.dx_dt = (- s.x) / s.tau
            s.dy_dt = (- s.y) / s.tau

        return s

    def transmit(self):

        with Equations(self) as s:

            s.post.target += s.w

        return s

    def on_pre(self):

        with Equations(self) as s:

            s.x += 1.0
            s.w -= s.y

        return s

    def on_post(self):

        with Equations(self) as s:

            s.y += 1.0
            s.w += s.x

        return s

```