# MASSpy Tutorial

In this notebook example, a step-by-step approach of building a simple model$^1$ of trafficking of high-energy phosphate bonds is demonstrated. Illustrated below is a graphical view of the full system along with the reaction rate equations and numerical values:

## Creating a Model

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

from mass import (
    MassConfiguration, MassMetabolite, MassModel, MassReaction)
from mass.util.matrix import left_nullspace, nullspace

mass_config = MassConfiguration()
```

The first step to creating the model is to define the `MassModel` object. A `MassModel` only requires an identifier to be initialized. For best practice, it is recommended to utilize SBML compliant identifiers for all objects.

```python
model = MassModel("Phosphate_Trafficking")
```

    Set parameter Username
    Academic license - for non-commercial use only - expires 2022-01-21

The model is initially empty.

```python
print("Number of metabolites: {0}".format(len(model.metabolites)))
print("Number of initial conditions: {0}".format(len(model.initial_conditions)))
print("Number of reactions: {0}".format(len(model.reactions)))
```

    Number of metabolites: 0
    Number of initial conditions: 0
    Number of reactions: 0

The next step is to create `MassMetabolite` and `MassReaction` objects to represent the metabolites and reactions that should exist in the model.

### Defining metabolites

To create a `MassMetabolite`, a unique identifier is required. The `formula` and `charge` attributes are set to ensure mass and charge balancing of reactions in which the metabolite is a participant. The `compartment` attribute indicates where the metabolite is located. In this model, all metabolites exist in a single compartment, abbreviated as "c".

```python
atp_c = MassMetabolite(
    "atp_c",
    name="ATP",
    formula="C10H12N5O13P3",
    charge=-4,
    compartment="c")

adp_c = MassMetabolite(
    "adp_c",
    name="ADP",
    formula="C10H12N5O10P2",
    charge=-3,
    compartment="c")

amp_c = MassMetabolite(
    "amp_c",
    name="AMP",
    formula="C10H12N5O7P",
    charge=-2,
    compartment="c")
```

The metabolite concentrations can be defined as the initial conditions for the metabolites using the `initial_condition` attribute. As previously stated, the concentrations are $\text{[ATP]}=1.6$, $\text{[ADP]}=0.4$, and $\text{[AMP]}=0.1$.

```python
atp_c.initial_condition = 1.6
adp_c.ic = 0.4  # Alias for initial_condition
amp_c.ic = 0.1

for metabolite in [atp_c, adp_c, amp_c]:
    print("{0}: {1}".format(metabolite.id, metabolite.initial_condition))
```

    atp_c: 1.6
    adp_c: 0.4
    amp_c: 0.1

The metabolites are currently not a part of any reaction. Consequently, the `ordinary_differential_equation` attribute is `None`.

```python
print(atp_c.ordinary_differential_equation)
print(adp_c.ode)  # Alias for ordinary_differential_equation
print(amp_c.ode)
```

    None
    None
    None

The next step is to create the reactions in which the metabolites participate.

### Defining reactions

Just like `MassMetabolite` objects, a unique identifier is also required to create a `MassReaction`. The `reversible` attribute determines whether the reaction can proceed in both the forward and reverse directions, or only in the forward direction.

```python
distr = MassReaction("distr", name="Distribution", reversible=True)
use = MassReaction("use", name="ATP Utilization", reversible=False)
form = MassReaction("form", name="ATP Formation", reversible=False)
```

Metabolites are added to reactions using a dictionary of metabolite objects and their stoichiometric coefficients. A group of metabolites can be added either all at once or one at a time. A negative coefficient indicates the metabolite is a reactant, while a positive coefficient indicates the metabolite is a product.

```python
distr.add_metabolites({
    adp_c: -2,
    atp_c: 1,
    amp_c: 1})

use.add_metabolites({
    atp_c: -1,
    adp_c: 1})

form.add_metabolites({
    adp_c: -1,
    atp_c: 1})

for reaction in [distr, use, form]:
    print(reaction)
```

    distr: 2 adp_c <=> amp_c + atp_c
    use: atp_c --> adp_c
    form: adp_c --> atp_c

Once the reactions are created, their parameters can be defined. As stated earlier, the distribution reaction is considerably faster when compared to other reactions in the model. The forward rate constant $k^{\rightarrow}$, represented as `kf`, can be set as $k^{\rightarrow}_{distr}=1000\ \text{min}^{-1}$. The equilibrium constant $K_{eq}$, represented as `Keq`, is approximately $K_{distr}=1$.

```python
distr.forward_rate_constant = 1000
distr.equilibrium_constant = 1
distr.parameters  # Return defined mass action kinetic parameters
```

    {'kf_distr': 1000, 'Keq_distr': 1}

As shown earlier, the forward rate constants are set as $k^{\rightarrow}_{use}=6.25\ \text{min}^{-1}$ and $k^{\rightarrow}_{form}=25\ \text{min}^{-1}$.
The `kf_str` attribute can be used to get the identifier of the forward rate constant as a string.

```python
use.forward_rate_constant = 6.25
form.kf = 25  # Alias for forward_rate_constant

print("{0}: {1}".format(use.kf_str, use.kf))
print("{0}: {1}".format(form.kf_str, form.kf))
```

    kf_use: 6.25
    kf_form: 25

Reactions can be added to the model using the `add_reactions()` method. Adding the reactions to the model also adds the associated metabolites and genes.

```python
model.add_reactions([distr, use, form])

print("Number of metabolites: {0}".format(len(model.metabolites)))
print("Number of initial conditions: {0}".format(len(model.initial_conditions)))
print("Number of reactions: {0}".format(len(model.reactions)))
```

    Number of metabolites: 3
    Number of initial conditions: 3
    Number of reactions: 3

The stoichiometric matrix of the model is automatically constructed with the addition of the reactions and metabolites to the model. It can be accessed through the `stoichiometric_matrix` property (alias `S`).

```python
print(model.S)
```

    [[-2.  1. -1.]
     [ 1. -1.  1.]
     [ 1.  0.  0.]]

The stoichiometric matrix attribute can be updated and stored in various formats using the `update_S()` method. For example, the stoichiometric matrix can be converted and stored as a `pandas.DataFrame`.

```python
model.update_S(array_type="DataFrame", dtype=np.int_, update_model=True)
model.S
```

      distr
      use
      form

      adp_c
      -2
      1
      -1

      atp_c
      1
      -1
      1

      amp_c
      1
      0
      0

Associating the metabolites with reactions allows for the mass action reaction rate expressions to be generated based on the stoichiometry.

```python
print(distr.rate)
```

    kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr)

Generation of the reaction rates also allows for the metabolite ODEs to be generated.

```python
print(atp_c.ode)
```

    kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr) + kf_form*adp_c(t) - kf_use*atp_c(t)

The `nullspace()` method can be used to obtain the null space of the stoichiometric matrix. The nullspace reflects the pathways through the system.

```python
ns = nullspace(model.S)  # Get the null space
# Divide by the minimum and round to nearest integer
ns = np.rint(ns / np.min(ns[np.nonzero(ns)]))
pd.DataFrame(
    ns, index=model.reactions.list_attr("id"),  # Rows represent reactions
    columns=["Pathway 1"], dtype=np.int_)
```

      Pathway 1

      distr
      0

      use
      1

      form
      1

In a similar fashion the left nullspace can be obtained using the `left_nullspace` function. The left nullspace represents the conserved moieties in the model.

```python
lns = left_nullspace(model.S)
# Divide by the minimum and round to nearest integer
lns = np.rint(lns / np.min(lns[np.nonzero(lns)]))
pd.DataFrame(
    lns, index=["Total AxP"],
    columns=model.metabolites.list_attr("id"),  # Columns represent metabolites
    dtype=np.int_)
```

      adp_c
      atp_c
      amp_c

      Total AxP
      1
      1
      1

## Expanding an Existing Model

Now, the existing model is expanded to include a buffer reaction, where a phosphagen is utilized to store a high-energy phosphate in order to buffer the ATP/ADP ratio as needed. Because the buffer molecule represents a generic phosphagen, there is no chemical formula for the molecule. Therefore, the buffer molecule can be represented as a moiety in the `formula` attribute using square brackets.

```python
b = MassMetabolite(
    "B",
    name="Phosphagen buffer (Free)",
    formula="[B]",
    charge=0,
    compartment="c")

bp = MassMetabolite(
    "BP",
    name="Phosphagen buffer (Loaded)",
    formula="[B]-PO3",
    charge=-1,
    compartment="c")

buffer = MassReaction("buffer", name="ATP Buffering")
```

When adding metabolites to the reaction, the `get_by_id()` method is used to add already existing metabolites in the model to the reaction.

```python
buffer.add_metabolites({
    b: -1,
    model.metabolites.get_by_id("atp_c"): -1,
    model.metabolites.get_by_id("adp_c"): 1,
    bp: 1})

# Add reaction to model
model.add_reactions([buffer])
```

For this reaction, $k^{\rightarrow}_{buffer}=1000\ \text{min}^{-1}$ and $K_{buffer}=1$. Because the reaction has already been added to the model, the `MassModel.update_parameters()` method can be used to update the reaction parameters using a dictionary:

```python
model.update_parameters({
    buffer.kf_str: 1000,
    buffer.Keq_str: 1})

buffer.parameters
```

    {'kf_buffer': 1000, 'Keq_buffer': 1}

By adding the reaction to the model, the left nullspace expanded to include a conservation pool for the total buffer in the system.

```python
lns = left_nullspace(model.S)
for i, row in enumerate(lns):
    # Divide by the minimum and round to nearest integer
    lns[i] = np.rint(row / np.min(row[np.nonzero(row)]))
pd.DataFrame(lns, index=["Total AxP", "Total Buffer"],
             columns=model.metabolites.list_attr("id"),
             dtype=np.int_)
```

      adp_c
      atp_c
      amp_c
      B
      BP

      Total AxP
      1
      1
      1
      0
      0

      Total Buffer
      0
      0
      0
      1
      1

### Performing symbolic calculations

Although the concentrations for the free and loaded buffer molecules are currently unknown, the total amount of buffer is known and set as $B_{total} = 10$. Because the buffer reaction is assumed to be at equilibrium, it becomes possible to solve for the concentrations of the free and loaded buffer molecules.

Below, the symbolic capabilities of **SymPy** are used to solve for the steady state concentrations of the buffer molecules.

```python
from sympy import Eq, Symbol, pprint, solve

from mass.util import strip_time
```

The first step is to define the equation for the total buffer pool symbolically:

```python
buffer_total_equation = Eq(Symbol("B") + Symbol("BP"), 10)
pprint(buffer_total_equation)
```

    B + BP = 10

The equation for the reaction rate at equilibrium is also defined. The `strip_time()` function is used to strip time dependency from the equation.

```python
buffer_rate_equation = Eq(0, strip_time(buffer.rate))
# Substitute defined concentration values into equation
buffer_rate_equation = buffer_rate_equation.subs({
    "atp_c":  atp_c.initial_condition,
    "adp_c":  adp_c.initial_condition,
    "kf_buffer": buffer.kf,
    "Keq_buffer": buffer.Keq})
pprint(buffer_rate_equation)
```

    0 = 1600.0⋅B - 400.0⋅BP

These two equations can be solved to get the buffer concentrations:

```python
buffer_sol = solve([buffer_total_equation, buffer_rate_equation],
                   [Symbol("B"), Symbol("BP")])
buffer_sol
```

    {B: 2.00000000000000, BP: 8.00000000000000}

Because the metabolites already exist in the model, their initial conditions can be updated to the calculated concentrations using the `MassModel.update_initial_conditions()` method.

```python
# Replace the symbols in the dict
for met_symbol, concentration in buffer_sol.copy().items():
    metabolite = model.metabolites.get_by_id(str(met_symbol))
    # Make value as a float
    buffer_sol[metabolite] = float(buffer_sol.pop(met_symbol))

model.update_initial_conditions(buffer_sol)
model.initial_conditions
```

    {<MassMetabolite adp_c at 0x7fc278a0bb20>: 0.4,
     <MassMetabolite atp_c at 0x7fc278a0bb80>: 1.6,
     <MassMetabolite amp_c at 0x7fc278a0bbb0>: 0.1,
     <MassMetabolite B at 0x7fc2dad5ed00>: 2.0,
     <MassMetabolite BP at 0x7fc2dad5efa0>: 8.0}

### Adding boundary reactions

After adding the buffer reactions, the next step is to define the AMP source and demand reactions. The `add_boundary()` method is employed to create and add a boundary reaction to a model.

```python
amp_drain = model.add_boundary(
    model.metabolites.amp_c,
    boundary_type="demand",
    reaction_id="amp_drain")

amp_in = model.add_boundary(
    model.metabolites.amp_c,
    boundary_type="demand",
    reaction_id="amp_in")

print(amp_drain)
print(amp_in)
```

    amp_drain: amp_c -->
    amp_in: amp_c -->

The automatic generation of the boundary reaction can be useful. However, sometimes the reaction stoichiometry needs to be switched in order to be intuitive. In this case, the stoichiometry of the AMP source reaction should be reversed to show that AMP enters the system, which is accomplished by using the `MassReaction.reverse_stoichiometry()` method.

```python
amp_in.reverse_stoichiometry(inplace=True)
print(amp_in)
```

    amp_in:  --> amp_c

When a boundary reaction is created, a 'boundary metabolite' is also created as a proxy metabolite. The proxy metabolite is the external metabolite concentration (i.e., boundary condition) without instantiating a new `MassMetabolite` object to represent the external metabolite.

```python
amp_in.boundary_metabolite
```

    'amp_b'

The value of the 'boundary metabolite' can be set using the `MassModel.add_boundary_conditions()` method. Boundary conditions are accessed through the `MassModel.boundary_conditions` attribute.

```python
model.add_boundary_conditions({amp_in.boundary_metabolite: 0})
model.boundary_conditions
```

    {'amp_b': 0.0}

Note that the addition of these two reactions adds an another pathway to the null space:

```python
ns = nullspace(model.S).T
for i, row in enumerate(ns):
    # Divide by the minimum to get all integers
    ns[i] = np.rint(row / np.min(row[np.nonzero(row)]))
ns = ns.T
pd.DataFrame(
    ns, index=model.reactions.list_attr("id"),  # Rows represent reactions
    columns=["Path 1", "Path 2"], dtype=np.int_)
```

      Path 1
      Path 2

      distr
      0
      0

      use
      1
      0

      form
      1
      0

      buffer
      0
      0

      amp_drain
      0
      1

      amp_in
      0
      1

### Defining custom rates

In this model, the rate for the AMP source reaction should remain at a fixed input value. However, the current rate expression for the AMP source reaction is dependent on an external AMP metabolite that exists as a boundary condition:

```python
print(amp_in.rate)
```

    amp_b*kf_amp_in

Therefore, the rate can be set as a fixed input by using a custom rate expression. Custom rate expressions can be set for reactions in a model using the `MassModel.add_custom_rate()` method as follows: by passing the reaction object, a string representation of the custom rate expression, and a dictionary containing any custom parameter associated with the rate.

```python
model.add_custom_rate(amp_in, custom_rate="b1",
                      custom_parameters={"b1": 0.03})
print(model.rates[amp_in])
```

    b1

## Ensuring Model Completeness
### Inspecting rates and ODEs

According to the [network schema](#Constructing-Models) at the start of the notebook, the network has been fully reconstructed. The reaction rates and metabolite ODEs can be inspected to ensure that the model was built without any issues.

The `MassModel.rates` property is used to return a dictionary containing reactions and symbolic expressions of their rates. The model always prioritizes custom rate expressions over automatically generated mass action rates.

```python
for reaction, rate in model.rates.items():
    print("{0}: {1}".format(reaction.id, rate))
```

    distr: kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr)
    use: kf_use*atp_c(t)
    form: kf_form*adp_c(t)
    buffer: kf_buffer*(B(t)*atp_c(t) - BP(t)*adp_c(t)/Keq_buffer)
    amp_drain: kf_amp_drain*amp_c(t)
    amp_in: b1

Similarly, the model can access the ODEs for metabolites using the `ordinary_differential_equations` property (alias `odes`) to return a dictionary of metabolites and symbolic expressions of their ODEs.

```python
for metabolite, ode in model.odes.items():
    print("{0}: {1}".format(metabolite.id, ode))
```

    adp_c: kf_buffer*(B(t)*atp_c(t) - BP(t)*adp_c(t)/Keq_buffer) - 2*kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr) - kf_form*adp_c(t) + kf_use*atp_c(t)
    atp_c: -kf_buffer*(B(t)*atp_c(t) - BP(t)*adp_c(t)/Keq_buffer) + kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr) + kf_form*adp_c(t) - kf_use*atp_c(t)
    amp_c: b1 - kf_amp_drain*amp_c(t) + kf_distr*(adp_c(t)**2 - amp_c(t)*atp_c(t)/Keq_distr)
    B: -kf_buffer*(B(t)*atp_c(t) - BP(t)*adp_c(t)/Keq_buffer)
    BP: kf_buffer*(B(t)*atp_c(t) - BP(t)*adp_c(t)/Keq_buffer)

### QCQA model

```python
from mass import qcqa_model
```

The `qcqa_model()` function can be used to print a report about the model's completeness based on the set kwargs. The `qcqa_model()` function is used to ensure that all numerical values necessary for simulating the model are defined by setting the `parameters` and `concentrations` kwargs as `True`.

```python
qcqa_model(model, parameters=True, concentrations=True)
```

    ╒══════════════════════════════════════════════╕
    │ MODEL ID: Phosphate_Trafficking              │
    │ SIMULATABLE: False                           │
    │ PARAMETERS NUMERICALY CONSISTENT: True       │
    ╞══════════════════════════════════════════════╡
    │ ============================================ │
    │             MISSING PARAMETERS               │
    │ ============================================ │
    │ Reaction Parameters                          │
    │ ---------------------                        │
    │ amp_drain: kf                                │
    │ ============================================ │
    ╘══════════════════════════════════════════════╛

As shown in the report above, the forward rate constant for the AMP drain reaction was never defined. Therefore, the forward rate constant is defined, and the model is checked again.

```python
amp_drain.kf = 0.3

qcqa_model(model, parameters=True, concentrations=True)
```

    ╒══════════════════════════════════════════╕
    │ MODEL ID: Phosphate_Trafficking          │
    │ SIMULATABLE: True                        │
    │ PARAMETERS NUMERICALY CONSISTENT: True   │
    ╞══════════════════════════════════════════╡
    ╘══════════════════════════════════════════╛

## Creating a Simulation

```python
# The model that was built. Load this to go straight to simulation.
# import mass.example_data
# model = mass.example_data.create_example_model("Phosphate_Trafficking")

from mass import Simulation
```

```python
# Let the model start at a non-steady state for demo
model.update_initial_conditions({
    "amp_c": 0.2,
    "adp_c": 0.2,
    "atp_c": 1.7,
})
```

The `Simulation` object manages all aspects related to simulating one or more model. This includes interfacing with the `RoadRunner` object from the [libRoadRunner](https://libroadrunner.readthedocs.io/en/latest/index.html) package, which is utilized for JIT compilation of the model, integration of model ODEs, and returning simulation output.

### Loading a model

A `Simulation` object is initialized by providing a `MassModel` to be loaded into the underlying `RoadRunner` object. The provided `MassModel` is treated as the `reference_model` of the `Simulation`.

`RoadRunner` is designed to simulate models in [SBML](http://sbml.org/Main_Page) format. Therefore, models must be SBML compliant to be loaded into the `RoadRunner` instance.

```python
sim = Simulation(reference_model=model, verbose=True)
```

    Successfully loaded MassModel 'Phosphate_Trafficking' into RoadRunner.

The reference `MassModel` is accessed using the `reference_model` attribute.

```python
sim.reference_model
```

        Name	Phosphate_Trafficking

        Memory address	0x07fc2c853e730

        Stoichiometric Matrix
        5x6

        Matrix Rank
        4

        Number of metabolites
        5

        Initial conditions defined
        5/5

        Number of reactions
        6

        Number of genes
        0

        Number of enzyme modules
        0

        Number of groups
        0

        Objective expression
        0

        Compartments
        c

The underlying `RoadRunner` instance can be accessed using the `roadrunner` attribute:

```python
sim.roadrunner
```

    <roadrunner.RoadRunner() { this = 0x6000013e1120 }>

Upon loading a model into a `Simulation`, the numerical values for species' initial conditions and reaction parameters are extracted. Dictionaries containing values are retrieved using the `get_model_simulation_values()` method.

```python
initial_conditions, parameters = sim.get_model_simulation_values(model)
for metabolite, initial_condition in initial_conditions.items():
    print("{0}: {1}".format(metabolite, initial_condition))
```

    adp_c: 0.2
    atp_c: 1.7
    amp_c: 0.2
    B: 2.0
    BP: 8.0

## Running Dynamic Simulations
### Simulating a model

Once a model has been loaded, it can be simulated using the `Simulation.simulate()` method. The `simulate()` method requires a model identifier or `MassModel` object of a loaded model and a tuple that contains the initial and final time points.

```python
# Simulate the model from 0 to 10000 time units
tfinal = 10000

solutions = sim.simulate(model, time=(0, tfinal))
solutions
```

    (<MassSolution Phosphate_Trafficking_ConcSols at 0x7fc2b9247630>,
     <MassSolution Phosphate_Trafficking_FluxSols at 0x7fc2b9240040>)

After a model has been simulated, the concentration and flux solutions are returned in two specialized dictionaries known as `MassSolution` objects.

```python
conc_sol, flux_sol = solutions

# List the first 5 points of concentration solutions
for metabolite, solution in conc_sol.items():
    print("{0}: {1}".format(metabolite, solution[:5]))
```

    adp_c: [0.2        0.20020374 0.20040726 0.20095514 0.2015016 ]
    atp_c: [1.7        1.69982168 1.69964357 1.69916416 1.69868608]
    amp_c: [0.2        0.19997458 0.19994916 0.19988069 0.1998123 ]
    B: [2.         1.99984758 1.99969535 1.99928569 1.99887727]
    BP: [8.         8.00015242 8.00030465 8.00071431 8.00112273]

By default, solutions are returned as `numpy.ndarrays`. To return interpolating functions instead, the `interpolate` argument is set as `True`.

```python
conc_sol, flux_sol = sim.simulate(model, time=(0, 100), interpolate=True)

for metabolite, solution in conc_sol.items():
    print("{0}: {1}".format(metabolite, solution))
```

    adp_c: <scipy.interpolate.interpolate.interp1d object at 0x7fc2b924d130>
    atp_c: <scipy.interpolate.interpolate.interp1d object at 0x7fc2da67e310>
    amp_c: <scipy.interpolate.interpolate.interp1d object at 0x7fc2b91e6810>
    B: <scipy.interpolate.interpolate.interp1d object at 0x7fc2b924d310>
    BP: <scipy.interpolate.interpolate.interp1d object at 0x7fc2b924d360>

### Setting integration options

Although the integrator options are set to accomadate a variety of models, there are circumestances in which the integrator options need to be changed. The underlying integrator can be accessed using the `integrator` property:

```python
print(sim.integrator)
```

    < roadrunner.Integrator() >
      name: cvode
      settings:
          relative_tolerance: 1e-06
          absolute_tolerance: 1e-12
                       stiff: true
           maximum_bdf_order: 5
         maximum_adams_order: 12
           maximum_num_steps: 20000
           maximum_time_step: 0
           minimum_time_step: 0
           initial_time_step: 0
              multiple_steps: false
          variable_step_size: true
             max_output_rows: 100000

Each setting comes with a brief description that can be viewed:

```python
print(sim.integrator.getDescription("variable_step_size"))
```

    (bool) Enabling this setting will allow the integrator to adapt the size of each time step. This will result in a non-uniform time column.  The number of steps or points will be ignored, and the max number of output rows will be used instead.

For example, to change the integration options so a uniform time vector is returned instead of one with a variable step size:

```python
sim.integrator.variable_step_size = False

# Simulate the model from 0 to 100 time units
# with output returned at evenly spaced time points
conc_sol, flux_sol = sim.simulate(model, time=(0, 100))

# Print the time vector
print(conc_sol.time[:10])
```

    [ 0.  2.  4.  6.  8. 10. 12. 14. 16. 18.]

When encountering exceptions from the integrator, [libRoadRunner recommends](https://libroadrunner.readthedocs.io/en/latest/simulation_and_integration.html) specifying an initial time step and tighter absolute and relative tolerances.

See the [libRoadRunner documentation](https://libroadrunner.readthedocs.io/en/latest/PythonAPIReference/cls_Integrator.html) about the `roadrunner.Integrator` class for more information on the integrator.

### Simulation results and the MassSolution object
For every model simulated, two `MassSolution` objects are returned per model. `MassSolution` objects are always outputted as pairs, with one `MassSolution` object containing the solutions for metabolite concentrations, and the other containing the solutions for reaction fluxes.

```python
# Simulate the model from 0 to 100 time units
sim = Simulation(model)
conc_sol, flux_sol = sim.simulate(model, time=(0, tfinal))
```

A `MassSolution` for a successful simulation contains string identifiers of objects and their corresponding solutions. Because `MassSolution` objects are specialized dictionaries, solutions can be retrieved using the object identifier as `dict` keys. For example, to access the solution for "atp_c":

```python
# Print first 10 solution values for ATP
conc_sol["atp_c"][:10]
```

    array([1.7       , 1.69982168, 1.69964357, 1.69916416, 1.69868608,
           1.69820944, 1.69773427, 1.69673394, 1.69460788, 1.6925119 ])

If care is taken when assigning object identifiers (e.g., does not start with a number, does not contain certain characters such as “-”), it is possible to access solutions inside of a `MassSolution` as if the corresponding keys were attributes.

```python
# Print first 10 solution values for ATP
conc_sol.atp_c[:10]
```

    array([1.7       , 1.69982168, 1.69964357, 1.69916416, 1.69868608,
           1.69820944, 1.69773427, 1.69673394, 1.69460788, 1.6925119 ])

The time points returned by the integrator are accessible using the `MassSolution.time` attribute:

```python
# Print the first 10 time points
print(conc_sol.time[0:10])
```

    [0.00000000e+00 8.47847116e-08 1.69569423e-07 3.98257355e-07
     6.26945287e-07 8.55633219e-07 1.08432115e-06 1.56811392e-06
     2.60709564e-06 3.64607736e-06]

The solutions contained within the `MassSolution` can be obtained as a `pandas.DataFrame` using the `to_frame()` method.

```python
conc_sol.to_frame()
```

      adp_c
      atp_c
      amp_c
      B
      BP

      Time

      0.000000e+00
      0.200000
      1.700000
      0.200000
      2.000000
      8.000000

      8.478471e-08
      0.200204
      1.699822
      0.199975
      1.999848
      8.000152

      1.695694e-07
      0.200407
      1.699644
      0.199949
      1.999695
      8.000305

      3.982574e-07
      0.200955
      1.699164
      0.199881
      1.999286
      8.000714

      6.269453e-07
      0.201502
      1.698686
      0.199812
      1.998877
      8.001123

      ...
      ...
      ...
      ...
      ...
      ...

      1.803913e+03
      0.400000
      1.600000
      0.100000
      2.000000
      8.000000

      2.547755e+03
      0.400000
      1.600000
      0.100000
      2.000000
      8.000000

      4.147364e+03
      0.400000
      1.600000
      0.100000
      2.000000
      8.000000

      7.416054e+03
      0.400000
      1.600000
      0.100000
      2.000000
      8.000000

      1.000000e+04
      0.400000
      1.600000
      0.100000
      2.000000
      8.000000

184 rows × 5 columns

Solutions also can be viewed visually using the `view_time_profile()` method. Note that this requires `matplotlib` to be installed in the environment. See [Plotting and Visualization](./plot_visualization.ipynb) for more information.

```python
conc_sol.view_time_profile()
```

```python
for k, v in conc_sol.items():
    print(k, v[-1])
```

    adp_c 0.40000000000149216
    atp_c 1.600000000005999
    amp_c 0.10000000000037108
    B 1.9999999999999587
    BP 8.000000000000046

### Aggregate variables and solutions

Often, it is desirable to look at mathematical combinations of metabolites concentrations or reaction fluxes. To create an aggregate variable, the `MassSolution.make_aggregate_solution()` method is used. To use the method, three inputs are required:

1. A unique ID for the aggregate variable.
2. The mathematical equation for the aggregate variable given as a `str`.
3. A list of the `MassSolution` keys representing variables used in the equation.

For example, to make the Adenylate Energy Charge (Atkinson, 1968), the occupancy and capacity pools are first defined:

```python
occupancy = conc_sol.make_aggregate_solution(
    aggregate_id="occupancy",
    equation="(atp_c + 0.5 * adp_c)",
    variables=["atp_c", "adp_c"])
conc_sol.update(occupancy)
print(list(conc_sol.keys()))
```

    ['adp_c', 'atp_c', 'amp_c', 'B', 'BP', 'occupancy']

The aggregate variables are returned as a `dict`, which can be added to the `MassSolution` object. Alternatively, the `update` flag can be set as `True` to automatically add an aggregate variable to the solution after creation.

```python
capacity = conc_sol.make_aggregate_solution(
    aggregate_id="capacity",
    equation="(atp_c + adp_c + amp_c)",
    variables=["atp_c", "adp_c", "amp_c"],
    update=True)
print(list(conc_sol.keys()))
```

    ['adp_c', 'atp_c', 'amp_c', 'B', 'BP', 'occupancy', 'capacity']

Aggregate variables formed from other aggregate variables also can be created using the `make_aggregate_solution()` method as long as the aggregate variables have been added to the `MassSolution`. To make the energy charge from the occupancy and capacity aggregate variables:

```python
ec = conc_sol.make_aggregate_solution(
    aggregate_id="energy_charge",
    equation="occupancy / capacity",
    variables=["occupancy", "capacity"],
    update=True)
```

If care is taken when assigning aggregate variable identifiers, it is possible to access aggregate variable solutions inside of a `MassSolution`, as if aggregate variable keys were attributes.

```python
# Print first 10 solution points for the energy charge
conc_sol.energy_charge[:10]
```

    array([0.85714286, 0.85710645, 0.8570701 , 0.85697226, 0.85687471,
           0.85677749, 0.85668059, 0.85647669, 0.85604374, 0.85561747])

## Determining Steady State
The steady state for models can be found using the `Simulation.find_steady_state()` method. This method requires a model identifier or a `MassModel` object and a string thats indicates a strategy for finding the steady state. For example, to find the steady state by simulating the model for a long time:

```python
sim = Simulation(reference_model=model)

conc_sol, flux_sol = sim.find_steady_state(model, strategy="simulate")
for metabolite, solution in conc_sol.items():
    print("{0}: {1}".format(metabolite, solution))
```

    adp_c: 0.3999999999999983
    atp_c: 1.5999999999999932
    amp_c: 0.09999999999999959
    B: 1.999999999999997
    BP: 7.999999999999988

Setting `update_values=True` updates the model initial conditions and fluxes with the steady state solution:

```python
conc_sol, flux_sol = sim.find_steady_state(model, strategy="simulate",
                                           update_values=True)
model.initial_conditions  # Same object as reference model in Simulation
```

    {<MassMetabolite adp_c at 0x7fc278a0bb20>: 0.3999999999999983,
     <MassMetabolite atp_c at 0x7fc278a0bb80>: 1.5999999999999932,
     <MassMetabolite amp_c at 0x7fc278a0bbb0>: 0.09999999999999959,
     <MassMetabolite B at 0x7fc2dad5ed00>: 1.999999999999997,
     <MassMetabolite BP at 0x7fc2dad5efa0>: 7.999999999999988}

The `find_steady_state()` method also allows for perturbations to be made before determining a steady state solution:

```python
conc_sol, flux_sol = sim.find_steady_state(
    model, strategy="simulate", perturbations={"kf_use": "kf_use * 1.5"})

for metabolite, solution in conc_sol.items():
    print("{0}: {1}".format(metabolite, solution))
```

    adp_c: 0.2666666666666716
    atp_c: 0.7111111111111246
    amp_c: 0.10000000000000185
    B: 2.7272727272727235
    BP: 7.272727272727263

## Perturbing a Model

To simulate various disturbances in the system, the `perturbations` argument of the `simulate()` method can be used. There are several types of perturbations that can be implemented for a given simulation as long as they adhere to the following guidelines:

1. Perturbations are provided to the method as a `dict` with dictionary keys that correspond to variables to be changed. Dictionary values are the new numerical values or mathematical expressions as strings that indicate how the value is to be changed.

2. A formula for the perturbation can be provided as a `str` as long as the formula string can be sympified via the `sympy.sympify()` function. The formula can have one variable that is identical to the corresponding `dict` key.

3. Boundary conditions can be set as a function of time. The above rules still apply, but allow for the time "t", as a second variable.

Some examples are demonstrated below.

A simulation without perturbations:

```python
conc_sol, flux_sol = sim.simulate(model, time=(0, tfinal))
conc_sol.view_time_profile()
```

Perturbing the initial concentration of ATP from 1.6 to 2.5:

```python
conc_sol, flux_sol = sim.simulate(
    model, time=(0, tfinal), perturbations={"atp_c": 2.5})
conc_sol.view_time_profile()
```

Increasing the rate constant of ATP use by 50%:

```python
conc_sol, flux_sol = sim.simulate(
    model, time=(0, tfinal), perturbations={"kf_use": "kf_use * 1.5"})
conc_sol.view_time_profile()
```


# Orbitales moleculares y curvas de energía potencial
# Parte 1: Moléculas diatómicas

Javier Cerezo, UAM (Madrid)

Marzo 2021

## Instrucciones

Al abrir el notebook, debes, en primer lugar, ejecutar todas las celdas para iniciar los elementos interactivos. Para ello, utiliza el icono con dos flechas `▶▶` en la barra de herramientas de la parte superior o marca en la barra de menú desplegable `Cell > Run All`.

**ATENCIÓN**:
**NO** vuelvas a ejecutar las celdas una vez iniciada la práctica.

Tras esto, navega por el documento en order, siguiendo las instrucciones que se proporcionan En principio, no es necesario volver a ejecutar ninguna celda, aunque en algunos casos puede ser útil para reiniciar los datos si las cosas no marchan correctamente.

```python
# Import modules
import nglview as nv
import ase.io
# Load psi4 to compute orbitals
import psi4
psi4_vers = psi4.__version__.split('.')[1][0]
if psi4_vers != '3':
    # Avoid all future warnings when using 1.4
    import warnings
    warnings.simplefilter(action='ignore', category=FutureWarning)
import qcelemental as qcel
# Interactive stuff (ipywidgets)
import ipywidgets as widgets
from ipywidgets import interactive, VBox, HBox, Output
from IPython.display import display, Image
# Matplotlib to make plots
%matplotlib widget
import matplotlib.pyplot as plt
# Numpy for matrix manipulation and scipy for interpolation
import numpy as np
from scipy.interpolate import interp1d
# datetime to get unique job id:
from datetime import datetime
from uuid import uuid4
# Manage files/folders
import os, shutil, glob
```

```python
# Get unique job_id
job_id = datetime.now().strftime('%Y%m%d%H%M%S') + '_' + str(uuid4())[:5]

# Create a folder with job_id name and change cwd to it
user = os.getenv("USER")
base_path = os.getcwd() + '/'
#os.chdir(base_path)
job_path = base_path + job_id + '/'
os.mkdir(job_path)
#os.chdir(job_path)

# NOTE:
# There is a conflict between file loading by ase/nv and os.chdir. So, chdir is not used
# along the note book. Instead, files generated in-place (e.g. cubefiles) are copied to
# created folders. This implies that simultaneous run of this notebook may lead to
# unexpected results in some cases (but rather unlikely)

# Initial values
at1_ = 'F'
at2_ = 'H'
dist_ = qcel.covalentradii.get(at1_,units='angstrom') + \
        qcel.covalentradii.get(at2_,units='angstrom')
carga_ = 0
mult_ = 1
```

## Datos de entrada

Los datos de entrada para un cálculo de estructura electrónica son:

* Posición de los núcleos atómicos
* Carga de cada núcleo (número atómico)
* Número total de electrones en el sistema
* Número de electrones desapareados (multiplicidad de espín)

Todos estos datos quedan definidos indicando la estructura molecular junto con la carga y multiplicidad.

Existen una gran cantidad de formatos estandarizados para escribir la geometría molecular. Entre ellos, uno de los más sencillos, es el formato `xyz`, en el que la primera línea indica el número de átomo, la segunda puede usarse para incluir una descripción y las siguientes contienen el elemento junto con las posiciones X,Y,Z del núcleo (en Angstroms). Para moléculas complejas, podemos usar editores moleculares que permiten construir las moléculas gráficamente. Para moléculas sencillas, como las diatómicas, pueden escribirse estos ficheros "a mano". En el caso de una molécula diatómica, basta con especificar los elementos que corresponden a cada átomo y la distancia de enlace para construir la estructura. Indica esta información en el formulario siguiente. Se el contenido del fichero en formato `xyz` correspondiente a la estructura.

```python
def set_structure(at1,at2,dist):
    global at1_, at2_, dist_, mol

    if len(at1) == 0 or len(at2) == 0:
        return None

    if at1 not in qcel.periodictable.E:
        raise BaseException('Átomo desconocido: {}'.format(at1))
    if at2 not in qcel.periodictable.E:
        raise BaseException('Átomo desconocido: {}'.format(at2))

    # Molecula name
    if at1 == at2:
        mol_name = at1+'2'
    else:
        mol_name = at1 + at2

    # Estimated initial distance for new atoms
    if at1 != at1_ or at2 != at2_:
        dist = qcel.covalentradii.get(at1,units='angstrom') + \
               qcel.covalentradii.get(at2,units='angstrom')

    comment='Molécula de {}'.format(mol_name)

    # Estructura molecular
    ## Formato: XYZ
    geomxyz = '''2
    {}
    {} 0.0 0.0 0.0
    {} 0.0 0.0 {:<8.3f}
    '''.format(comment,at1,at2,dist)
    print('------------------------------\n'+geomxyz)
    fxyz = job_path+'test.xyz'
    null = open(fxyz,'w').write(geomxyz)

    # Set ASE molecule object
    mol = ase.io.read(fxyz)

    # Store at1 and at2 to keep them after rerunning the cell
    at1_ = at1
    at2_ = at2
    dist_ = dist
    # Update value on the box
    DistBox.value = dist_

DistBox = widgets.FloatText(value = dist_, step=0.01, description = 'Dist (Å)')
interactive(set_structure,
            at1 = widgets.Text(value = at1_, description = 'Átomo 1'),
            at2 = widgets.Text(value = at2_, description = 'Átomo 2'),
            dist = DistBox)
```

Podemos visualizar la estructura resultante con una gran variedad de visores. En este documento interactivo se hace uso de uno de ellos. Activa el botón de `Mostrar estructura` y usa el ratón para acercar/alejar y rotar las molécula. Puedes mostrar la distancia de enlace presionando el sobre él con el botón derecho del ratón.

```python
out_msg = Output()
view1 = nv.NGLWidget()
view1.parameters = {"clipNear": 0, "clipFar": 100, "clipDist": 1}
camera = view1._camera_orientation
n_clicks1 = 0

def show_structure(clicked):
    global mol, view1, camera

    if not clicked:
        if hasattr(view1,'component_0'):
            camera = view1._camera_orientation
            view1.remove_component(view1.component_0)
        return None
    else:
        s = nv.ASEStructure(mol)
        view1.add_structure(s)
        view1._set_camera_orientation(camera)

controls = interactive(show_structure,
                       clicked = widgets.ToggleButton(description='Mostrar estructura'))
VBox([controls,view1])
```

Como hemos dicho anteriormente, para poder realizar un cálculo de estructura electrónica, necesitamos indicar, además de la estructura molecular, su carga total y la multiplicidad de espín. El fichero de entrada de un programa de cálculo electrónica debe contener estos datos, junto con la estructura.

Para esta práctica, solo vamos a realizar cálculos con estructuras singlete (`mult=1`). Ten en cuenta que no todas las cambinaciones de carga y multiplicidad son físicamente válidos. En el caso de

En el caso de `Psi4`, el programa que vamos a usar en esta práctica, el fichero de entrada en el que se especifica carga, multiplicidad y geometría se genera con el siguiente cuestionario.

```python
def set_mol(carga,mult,clicked):
    global geomxyz, psi4_mol, carga_, mult_

    if clicked:
        geninput_button.value = False

    # Generate input
    fxyz = job_path+'test.xyz'
    mol.write(fxyz)
    geomxyz = open(fxyz).read()
    psi4_inp = geomxyz.split('\n')
    psi4_inp = '\n'.join(psi4_inp[2:])
    psi4_inp = '{} {}\n'.format(carga,mult) + psi4_inp
    # Show on screen
    out_gen.clear_output()
    with out_gen:
        print('----------------------------------\n'+psi4_inp)
    # Set psi4 Molecule
    with out_gen:
        try:
            psi4_mol = psi4.geometry(psi4_inp)
        except:
            out_gen.clear_output()
            print('ERROR: la combinación de carga {} y multiplicidad {} es imposible'.format(carga,mult))
            #raise BaseException('La combinación de carga {} y multiplicidad {} es imposible'.format(carga,mult))

    # Store data to keep them after rerunning the cell
    carga_ = carga
    mult_ = mult

cargas = [ str(i) for i in range(-2,3) ]

geninput_button = compute_button = widgets.ToggleButton(description='Actualizar input Psi4')
out_gen = Output()
controls = interactive(set_mol,
            clicked = geninput_button,
            carga = widgets.Combobox(value = str(carga_), options = cargas, description = 'Carga',ensure_option=False),
            mult  = widgets.Dropdown(value = str(mult_), options = ['1'], description = 'Mult', ensure_option=True))

VBox([controls,out_gen])
```

## Cálculo electrónico

Un cálculo electrónico consiste en resolver la ecuación de Schrödinger electrónica:

\begin{equation}
\hat{H}_{el}\psi_{el}(\mathbf{r};\mathbf{R}) = E_{el}(\mathbf{R})\psi_{el}(\mathbf{r};\mathbf{R})
\label{Eq:Schr}
\end{equation}

Y la energía total del sistema debe incluir, además de $E_{el}$ la repulsión entre núcleos:

\begin{equation}
V(\mathbf{R}) = E_{el}(\mathbf{R}) + \sum_{i=1}^{i=n_{at}}\sum_{j=1}^{j<i}\frac{Z_iZ_j}{|R_i-R_j|}
\end{equation}

Sin embargo, la resolución de la ecuación (\ref{Eq:Schr}) solo es posible, de forma exacta, para la molécula de dihidrógeno (o moléculas/iones análogos, con un solo electrón). Para el resto, debemos emplear aproximaciones. Por esta razón, debemos de indicar al programa cuál es la aproximación que vamos a emplear.

En esta práctica, vamos a emplear la teoría del funcional de la densidad (DFT, por sus siglas en inglés) como método para resolver el problema electrónico.

### Ajustes del cálculo

Aunque el método DFT es, en principio, exacto, en la práctica debemos de emplear un funcional aproximado, existiendo una gran cantidad de funcionales desarrollados hasta la fecha. Elije un funcional de los propuestos en el menú desplegable siguiente:

```python
avail_functionals = ['B3LYP','PBE0','WB97X']

def get_var(option):
    global funcional
    funcional = option
    return None

interactive(get_var,option = widgets.Dropdown(options = avail_functionals, description="Funcional: "))
```

Asimismo, además de emplear un funcional aproximado, la resolución de las ecuaciones se realizar numéricamente, para lo que es necesario introducir un conjunto de funciones de base. Elije un conjunto de funciones de base de los propuestos en el menú desplegable siguiente:

```python
avail_basis = ['6-31G(d)','6-31+G(d)','aug-cc-pVDZ']

def get_var(option):
    global base
    base = option
    return None

interactive(get_var,option = widgets.Dropdown(options = avail_basis, description="Funciones de base: "))
```

#### Inspección de las funciones de base

Las funciones de base, $\{\phi_i\}_{i=1}^{N_b}$, se emplean para expesar los orbitales moleculares, a través de una combinación lineal:

$$
\psi_a = \sum_{i=1}^{N_b} c_i^a \phi_i
$$

Las funciones de base son funciones monoelectrónicas centradas en cada uno de los átomos de la molécula. En principio, estas funciones se podrían seleccionarse como los resultantes del cálculo en los átomos aislados (orbitales atómicos), sin embargo, por razones de eficiencia y precisión en el cálculo suelen realizarse algunas modificaciones:

1. Las expresiones de las funciones no se corresponden exactamente con las de los orbitales atómicos, empleándose combinaciones de funciones Gaussianas. Esto facilita su tratamiento matemático

2. Para la capa de valencia, se emplean más orbitales de los que naturalmente pertenecen a la capa:
* Se emplean varios conjuntos con el mismo momento angular (2s, 2s', 2px, 2px'...)
* Se emplean funciones con momento angular mayor del que corresponde a la capa. Por ejemplo, para n=2, se emplean funciones tipo d

La finalidad es conseguir una mejor representación de los orbitales moleculares de la capa de valencia.

Puedes usar los siguientes menús para inspeccionar la base que has elegido.

```python
out_AOcubegen = Output()
def compute_AOcubes(r_grid,clicked):
    global cube_path, wfn

    if not clicked:
        return None
    cubegenAO_button.value = False

    # Generate wfn object
    E,wfn = psi4.energy(funcional+'/'+base,return_wfn=True)

    out_AOcubegen.clear_output()
    with out_AOcubegen:
        print('Generando...')

    # Create a new folder ('SinglePoint') to generate the cube files on
    cube_folder = 'SinglePoint/'
    cube_path = job_path + cube_folder
    try:
        os.mkdir(cube_path)
    except:
        shutil.rmtree(cube_path, ignore_errors=True)
        os.mkdir(cube_path)
    #os.chdir(cube_path)
    # Generate cubes
    psi4.set_options({'CUBEPROP_TASKS':['BASIS_FUNCTIONS'], # DENSITY, ESP, ORBITALS, BASIS_FUNCTIONS, LOL, ELF, FRONTIER_ORBITALS, DUAL_DESCRIPTOR
                      'CUBEPROP_FILEPATH':cube_path,
                      'CUBIC_GRID_OVERAGE':[r_grid,r_grid,r_grid],
                      })
    psi4.driver.p4util.cubeprop(wfn)

    out_AOcubegen.clear_output()
    with out_AOcubegen:
        print('Orbitales generados')

cubegenAO_button = widgets.ToggleButton(description = 'Generar Orbitales Atómicos',icon = 'bolt')
gridsize_box = widgets.FloatText(value = 4.0, step=0.1, description = 'Grid (Å)')
controlsAO = interactive(compute_AOcubes,
                       r_grid = gridsize_box,
                       clicked = cubegenAO_button)

VBox([controlsAO,out_AOcubegen])
```

```python
# Initial defaults
mo_ = ""
iso_ = 0.02
repr_type_ = 'superficie'
highlighted = None

def load_AOdata(clicked):
    global ao_dropdown, viewAO, orbsAO, ao_

    out_AOlabel.clear_output()
    if not clicked:
        if hasattr(viewAO,'component_0'):
            for orb in orbsAO:
                viewAO.remove_component(orb)
            viewAO.remove_component(viewAO.component_0)
        with out_AOlabel:
            print('Pulsa para (re)cargar los datos de la base')
        ao_dropdown.options = [""]
        ao_dropdown.value = ""
        ao_ = ""
        return None

    # ** AO info **
    # (Adapted from: http://forum.psicode.org/t/printing-of-molecular-orbitals/335/6)
    #
    # Cartesian AO labels
    #
    ao_labels = []
    k = 0
    for s in range(wfn.basisset().nshell()):
        shell = wfn.basisset().shell(s)
        center = str(shell.ncenter+1)
        # center name
        center = psi4_mol.to_dict()['elem'][shell.ncenter] + center
        am = shell.am
        amchar = shell.amchar
        basename = '_{'+center+'}'+amchar
        for j in range(0,am+1):
            lx = am - j
            for lz in range(0, j + 1):
                k += 1
                ly  = j - lz
                ao_labels.append(str(k)+basename+'x'*lx+'y'*ly+'z'*lz)

    # Update dropdown
    ao_dropdown.options = ao_labels

    # Initialize view
    geomxyz = cube_path + 'geom.xyz'
    mol = ase.io.read(geomxyz)
    s = nv.ASEStructure(mol)
    viewAO.add_structure(s)
    viewAO.parameters = {"clipNear": 0, "clipFar": 100, "clipDist": 1}

def update_AOrepr(ao,iso,repr_type):
    global viewAO, orbsAO, ao_, iso_, repr_type_, highlighted, clicked

    if ao == "":
        orbsAO = []
        return None

    # Only one AO is loaded in memory
    # So, if it changes, reload the right one
    if (ao != ao_ or iso != iso_ or repr_type != repr_type_):
        #Update mo (load new set)
        # Set is_wire
        if repr_type == 'superficie':
            is_wire = False
        else:
            is_wire = True
        # remove current
        for orb in orbsAO:
            viewAO.remove_component(orb)
        # load new mo
        orbsAO=[]
        ao_ind = ao.split('_')[0]
        cubefile = cube_path + 'Phi_'+ao_ind+'.cube'
        # component_1 (store component address in orbs list)
        orbsAO.append(viewAO.add_component(cubefile))
        orbsAO[-1].clear()
        orbsAO[-1].add_surface(opacity=0.5, wireframe=is_wire, color='blue', isolevelType="value", isolevel=abs(iso))
        orbsAO[-1].add_surface(opacity=0.5, wireframe=is_wire, color='red',  isolevelType="value", isolevel=-abs(iso), depthWrite=False)
        orbsAO[-1].hide()

    # Display selected
    orbsAO[-1].show()

    # Update ids
    ao_ = ao
    repr_type_ = repr_type
    iso_ = iso

# CONTAINERS
# Build an output container to print info about orbital
out_AOlabel=Output()
# Molecule
viewAO = nv.NGLWidget()

# CONTROLS
ao_dropdown = widgets.Dropdown(options=[""],
                               value="",
                               description='AO:')
load_button = widgets.ToggleButton(description='Cargar datos')
load_control = interactive(load_AOdata,
                           clicked = load_button)
controls = interactive(update_AOrepr,
                       ao=ao_dropdown,
                       iso=widgets.FloatText(value = 0.02, step=0.01, description = 'Isovalor'),
                       repr_type=widgets.Dropdown(options=['superficie','malla'],
                                                   value='superficie',
                                                   description='Representación'))
surfbox = VBox([out_AOlabel,viewAO, controls],layout={'width': '700px'})
VBox([load_control,surfbox])
```

### Energía en un punto y optimización

Ya tenemos todo lo neceario para realizar el cálculo. Nuestro método nos va a proporcionar una aproximación a la energía total del sistema y la función de onda. Realiza el cálculo pulsando sobre el siguiente botón.

```python
out_msg_SP = Output()
def compute_energy(clicked):
    global funcional, base, dipole, wfn, n_clicks1

    if not clicked:
        return None
    compute_button.value = False

    out_msg_SP.clear_output()
    with out_msg_SP:
        print('Calculando...')

    # Cálculo de la energía
    # Restart log file
    psi4.core.set_output_file(job_path+'psi4.log')
    # and ask for MO printing
    psi4.set_module_options('scf',{'print_mos':True})
    # calculate!
    E, wfn = psi4.energy(funcional+'/'+base,return_wfn=True, molecule=psi4_mol)

    out_msg_SP.clear_output()
    with out_msg_SP:
        print('Método de cálculo: {}/{}'.format(funcional,base))
        print('Energía                         : {:8.3f} hartrees'.format(E))

    dist = mol.get_distance(0,1)
    with out_msg_SP:
        print('Distancia de enlace (inicial)   : {:8.3f} Å'.format(dist))

    # Dipole
    # Additional dipoloes can be computed with
    # psi4.core.oeprop(wfn,'DIPOLE')
    # See all accesible variables with psi4.core.variables()
    dipole = np.array([psi4.core.variable('SCF DIPOLE X'),
                       psi4.core.variable('SCF DIPOLE Y'),
                       psi4.core.variable('SCF DIPOLE Z')])
    with out_msg_SP:
        print('Momento dipolar (a.u.)          : {:8.3f}'.format(np.linalg.norm(dipole)))

    # Lowdin/Mayer bond order?
    psi4.oeprop(wfn,'WIBERG_LOWDIN_INDICES','MAYER_INDICES')
    lowdin_bond_order = wfn.variable('WIBERG_LOWDIN_INDICES')
    mayer_bond_order  = wfn.variable('MAYER_INDICES')
    with out_msg_SP:
        print('Orden de enlace (Wiberg/Lowding): {:8.3f}'.format(lowdin_bond_order.nph[0][0,1]))
        print('Orden de enlace (Mayer)         : {:8.3f}'.format(mayer_bond_order.nph[0][0,1]))

    # Reset nclicks to allow a new representation of the structure with dipole
    n_clicks1 = 0

# CONTROLS
psi4_icon = Output()
with psi4_icon:
    display(Image(url='https://psicode.org/psi4manual/master/_static/psi4square.png',width=50))
compute_button = widgets.ToggleButton(description='Calcuar Energía')
controls = interactive(compute_energy,
                       clicked = compute_button)
HBox([psi4_icon,VBox([controls,out_msg_SP])])
```

```python
E, wfn = psi4.energy(funcional+'/'+base,return_wnf=True,return_wfn=True, molecule=psi4_mol)
```

```python
psi4.core.variable('CURRENT ENERGY')
```

```python
[psi4.core.variable('CURRENT DIPOLE X'),
 psi4.core.variable('CURRENT DIPOLE Y'),
 psi4.core.variable('CURRENT DIPOLE Z')]
```

----------------

Como vemos, al terminar el cálculo, tenemos acceso a la energía del sistema. Además, obtenemos también una función de onda, que consiste en el producto de funciones monoelectrónicas: los **orbitales moleculares**, que vamos a analizar en la siguiente sección.

Antes, vamos a obtener la estructura de equilibrio, es decir, aquella para la que la energía del sistema se minimiza. Como hemos indicado anteriormente, la energía del sistema es una función de la posición de los núcleos atómicos, $V(R)$. En este caso, será una función de la distancia del único enlace.

Para obtener la energía del mínimo temos que minimizar la energía (optimizar la función energía). Los programas de cálculo electrónico proporcionan métodos numéricos para realizar esta optimización.

```python
out_msg_Opt = Output()
def optimize_energy(clicked):
    global funcional, base, wfn, dipole, mol, n_clicks1
    if not clicked:
        return None
    optimize_button.value = False

    out_msg_Opt.clear_output()
    with out_msg_Opt:
        print('Calculando...')

    # Optimización de la energía (estructura de equilibrio)
    # Restart log file
    psi4.core.set_output_file(job_path+'psi4.log')
    # and ask for MO printing
    psi4.set_module_options('scf',{'print_mos':True})
    # calculate!
    E, wfn = psi4.optimize(funcional+'/'+base,return_wfn=True, molecule=psi4_mol)

    out_msg_Opt.clear_output()
    with out_msg_Opt:
        print('Energía                         : {:8.3f} hartrees'.format(E))

    # New structure to ase object
    geomxyz = psi4_mol.to_string('xyz')
    fxyz = job_path+'test.xyz'
    null = open(fxyz,'w').write(geomxyz)
    mol = ase.io.read(fxyz)

    dist = mol.get_distance(0,1)
    with out_msg_Opt:
        print('Distancia de enlace (equilibrio): {:8.3f} Å'.format(dist))

    # Dipole
    # Additional dipoloes can be computed with
    # psi4.core.oeprop(wfn,'DIPOLE')
    # See all accesible variables with psi4.core.variables()
    dipole = np.array([psi4.core.variable('SCF DIPOLE X'),
                       psi4.core.variable('SCF DIPOLE Y'),
                       psi4.core.variable('SCF DIPOLE Z')])
    with out_msg_Opt:
        print('Momento dipolar (a.u.)          : {:8.3f}'.format(np.linalg.norm(dipole)))

    # Lowdin/Mayer bond order?
    psi4.oeprop(wfn,'WIBERG_LOWDIN_INDICES','MAYER_INDICES')
    lowdin_bond_order = wfn.variable('WIBERG_LOWDIN_INDICES')
    mayer_bond_order  = wfn.variable('MAYER_INDICES')
    with out_msg_Opt:
        print('Orden de enlace (Wiberg/Lowding): {:8.3f}'.format(lowdin_bond_order.nph[0][0,1]))
        print('Orden de enlace (Mayer)         : {:8.3f}'.format(mayer_bond_order.nph[0][0,1]))

    # Reset nclicks to allow a new representation of the structure with dipole
    n_clicks1 = 0

psi4_icon = Output()
with psi4_icon:
    display(Image(url='https://psicode.org/psi4manual/master/_static/psi4square.png',width=50))
optimize_button = widgets.ToggleButton(description='Optimizar geometría')
controls = interactive(optimize_energy,
                       clicked = optimize_button)
HBox([psi4_icon,VBox([controls,out_msg_Opt])])
```

A continuación, podemos mostrar la estructura optimizada usando el botón siguiente. Puedes mostrar el dipolo marcando la casilla correspondiente (NOTA: usamos el creterio IUPAC para la dirección del dipolo).

```python
view2 = nv.NGLWidget()
view2.parameters = {"clipNear": 0, "clipFar": 100, "clipDist": 1}
camera = view2._camera_orientation
n_clicks1 = -1

def show_structure_with_dipole(clicked,show_dipole):
    global funcional, base, mol, dipole, view2, n_clicks1, dipole_checkbox, camera

    if not clicked:
        camera = view2._camera_orientation
        out_showdip.clear_output()
        dipole_checkbox.disabled = True
        dipole_checkbox.value = False
        if hasattr(view2,'component_0'):
            view2.remove_component(view2.component_0)
        if hasattr(view2,'component_1'):
            view2.remove_component(view2.component_1)
        return None
    elif clicked:
        if n_clicks1 == -1:
            showstr_button.value = False
            out_showdip.clear_output()
            with out_showdip:
                print('No hay cálculos que mostrar')
            return None
        out_showdip.clear_output()
        dipole_checkbox.disabled = False
        n_clicks1 += 1

    if not hasattr(view2,'component_0'):
        s = nv.ASEStructure(mol)
        view2.add_structure(s)
        view2._set_camera_orientation(camera)

    view2.component_0.clear_representations()
    if show_dipole:
        view2.component_0.add_representation('ball+stick',opacity=0.5)
        if not hasattr(view2,'component_1'):
            # Place dipole starting at the center
            center = (mol.positions[1] + mol.positions[0])/2.0
            p1 = center
            p2 = p1 + dipole
            view2.shape.add_arrow(list(p1), list(p2), [0,0,1], 0.1, 'Dipole')

    else:
        view2.component_0.add_representation('ball+stick',opacity=1)
        if hasattr(view2,'component_1'):
            view2.remove_component(view2.component_1)

# Containers/controls
out_showdip = Output()

showstr_button = widgets.ToggleButton(description='Mostrar estructura')
dipole_checkbox = widgets.Checkbox(description='Mostrar dipolo',disabled = True)
controls = interactive(show_structure_with_dipole,
                       clicked = showstr_button,
                       show_dipole = dipole_checkbox)
VBox([HBox([showstr_button,dipole_checkbox]),out_showdip,view2])
```

--------------

### Orbitales moleculares

En primer lugar debemos generar los ficheros que contienen el valor de cada función (orbital molecular) sobre una maya de puntos tridimensional. Usamos el formato `cube`, bastante extendido para guardar datos volumétricos. Los orbitales más relevantes son los de valencia, por lo que en principio, pordemos ignorar los orbitales internos. Por otro lado, de los orbitales de valencia, los más relevantes para nuestros análisis son los que se corresponden con los orbitales atómicos "verdaderos" de la capa de valencia. Nuestra base puede contener más orbitales con el fin de mejorar la descripción de las funciones de onda, que conducen a orbitlaes moleculares no ocupados cuyo significado físico puede ser cuestionable. Por esta razón, por defecto solo generaremos los orbitales que corresponderían a una base mínima. Puedes mostrar todos los orbitales generados desmarcando las casillas correspondientes.

```python
out_cubegen = Output()
def compute_cubes(skip_core,only_vale,r_grid,clicked):
    global cube_path, i0, ilast, n_core, n_vale, wfn

    if not clicked:
        return None
    cubegen_button1.value = False

    # Get core orbitals
    n_per_shell = [1,4,9,16,]
    n_core = 0
    n_vale = 0
    n_occ = wfn.nalpha() # Assume close-shell
    for Z in mol.numbers:
        p = qcel.periodictable.to_period(Z)
        n_core += np.array([ n_per_shell[i] for i in range(p-1) ],dtype=int).sum()
        n_vale += n_per_shell[p-1]

    if skip_core:
        i0  = n_core
    else:
        i0 = 0
    if only_vale:
        ilast = n_core + n_vale
    else:
        ilast = wfn.nmo()

    out_cubegen.clear_output()
    with out_cubegen:
        print('Generando...')

    # Create a new folder ('SinglePoint') to generate the cube files on
    cube_folder = 'SinglePoint/'
    cube_path = job_path + cube_folder
    try:
        os.mkdir(cube_path)
    except:
        shutil.rmtree(cube_path, ignore_errors=True)
        os.mkdir(cube_path)
    #os.chdir(cube_path)
    # Generate cubes
    psi4.set_options({'CUBEPROP_TASKS':['ORBITALS'], # DENSITY, ESP, ORBITALS, BASIS_FUNCTIONS, LOL, ELF, FRONTIER_ORBITALS, DUAL_DESCRIPTOR
                      'CUBEPROP_ORBITALS':list(range(i0+1,ilast+1)), # beta orbitals are requested with negative indices
                      'CUBIC_GRID_OVERAGE':[r_grid,r_grid,r_grid],
                      'CUBEPROP_FILEPATH':cube_path,
                      })
    psi4.driver.p4util.cubeprop(wfn)

    out_cubegen.clear_output()
    with out_cubegen:
        print('Orbitales generados')

cubegen_button1 = widgets.ToggleButton(description = 'Generar Orbitales',icon = 'bolt')
# Reuse the same for the grid size as for AOs: gridsize_box
# gridsize_box = widgets.FloatText(value = 4.0, step=0.1, description = 'Grid (Å)')
controls = interactive(compute_cubes,
                       skip_core = widgets.Checkbox(value=True,description='Ignorar OM internos'),
                       only_vale = widgets.Checkbox(value=True,description='Capa de valencia mínima'),
                       r_grid = gridsize_box,
                       clicked = cubegen_button1)

VBox([controls,out_cubegen])
```

A continuación podemos cargar los datos para representar los orbitales junto con el diagrama. Utiliza el menu desplegable que se activa al cargar los datos para inspeccionar los distintos orbitales. Se muestran en el diagrama solo los orbitales que se hayan generado anteriormente.

```python
# Initial defaults
mo_ = ""
iso_ = 0.02
repr_type_ = 'superficie'
highlighted = None

def load_data(clicked):
    global mo_dropdown, view3, all_to_diagram_map, mo_diagram, orbs1, mo_

    out_label.clear_output()
    if not clicked:
        if hasattr(view3,'component_0'):
            for orb in orbs1:
                view3.remove_component(orb)
            view3.remove_component(view3.component_0)
        with out_label:
            print('Pulsa para (re)cargar los datos del último cálculo')
        mo_dropdown.options = [""]
        mo_dropdown.value = ""
        mo_ = ""
        ax1.clear()
        return None

    # ** ENERGIES **
    # Get symms
    pg = psi4_mol.point_group()
    ct = pg.char_table()
    irep_symbols = [ ct.gamma(i).symbol() for i in range(pg.order()) ]

    # Get MO energies (per symm)
    mo_symm_eners = wfn.epsilon_a().nph

    # Get all in one array and sort
    mo_all_eners = np.concatenate(mo_symm_eners)
    inds = np.argsort(mo_all_eners)

    # Get symm labels
    mo_all_ireps = []
    for i,mo_symm_ener in enumerate(mo_symm_eners):
        for j,ii in enumerate([i]*len(mo_symm_ener)):
            mo_all_ireps.append(str(j+1)+'-'+irep_symbols[ii])
    mo_all_ireps = np.array(mo_all_ireps)

    # Sort mo_ireps and mo_eners together
    mo_all_ireps = mo_all_ireps[inds]
    mo_all_eners = mo_all_eners[inds]
    # Get last core index
    i_core = n_core-1
    mo_all_shell = ['C']*(i_core+1) + ['V']*(len(mo_all_eners)-i_core-1)

    # Manage degenerancies
    mo_diagram=[]
    ener_last=99999.
    for ener,irep,shell in zip(mo_all_eners,mo_all_ireps,mo_all_shell):
        if np.isclose(ener,ener_last,atol=0.001):
            for i in range(len(mo_diagram[-1])):
                mo_diagram[-1][-1][3] = 'D'+str(i+1)
            mo_diagram[-1].append([ener,irep,shell,'D'+str(i+2)])
        else:
            mo_diagram.append([[ener,irep,shell,'ND']])
        ener_last=ener

    # Make a mapping from serial to with-degenerancies
    all_to_diagram_map = []
    for i,levels in enumerate(mo_diagram):
        for j,level in enumerate(levels):
            all_to_diagram_map.append([i,j])

    # ** DIAGRAM (FIGURE) **
    # WARNING: figures must be closed
    with out_diagram1:
        ax1.clear()
        k = -1
        for level in mo_diagram:
            # Skip core orbitals
            if len(level) == 1:
                k += 1
                if k >= i0 and k <= ilast-1:
                    ax1.hlines(level[0][0],xmin=1.6,xmax=2.4,color='k')
            else:
                k += 1
                if k >= i0 and k <= ilast-1:
                    ax1.hlines(level[0][0],xmin=1.5,xmax=1.9,color='k')
                k += 1
                if k >= i0 and k <= ilast-1:
                    ax1.hlines(level[1][0],xmin=2.1,xmax=2.5,color='k')

    # Get list of orbitals
    orbital_list = []
    k = 0
    for i,j in all_to_diagram_map:
        orbital_list.append(str(k+1)+'_'+mo_diagram[i][j][1])
        k += 1
    mo_dropdown.options = orbital_list[i0:ilast]

    # Initialize view
    geomxyz = cube_path + 'geom.xyz'
    mol = ase.io.read(geomxyz)
    s = nv.ASEStructure(mol)
    view3.add_structure(s)
    view3.parameters = {"clipNear": 0, "clipFar": 100, "clipDist": 1}

def update_repr(mo,iso,repr_type):
    global view3, orbs1, mo_, iso_, repr_type_, highlighted, clicked

    if mo == "":
        orbs1 = []
        return None

    # Only one MO is loaded in memory (for all geoms)
    # So, if it changes, reload the right one
    if (mo != mo_ or iso != iso_ or repr_type != repr_type_):
        #Update mo (load new set)
        # Set is_wire
        if repr_type == 'superficie':
            is_wire = False
        else:
            is_wire = True
        # remove current
        for orb in orbs1:
            view3.remove_component(orb)
        # load new mo
        orbs1=[]
        cubefile = cube_path + 'Psi_a_'+mo+'.cube'
        # component_1 (store component address in orbs list)
        orbs1.append(view3.add_component(cubefile))
        orbs1[-1].clear()
        orbs1[-1].add_surface(opacity=0.5, wireframe=is_wire, color='blue', isolevelType="value", isolevel=abs(iso))
        orbs1[-1].add_surface(opacity=0.5, wireframe=is_wire, color='red',  isolevelType="value", isolevel=-abs(iso), depthWrite=False)
        orbs1[-1].hide()

    # Display selected
    orbs1[-1].show()

    # Update diagram
    k = int(mo.split('_')[0])-1
    i,j = all_to_diagram_map[k]
    ener = mo_diagram[i][j][0]
    if mo_diagram[i][j][3] == 'ND':
        x0, xf = 1.6, 2.4
    elif mo_diagram[i][j][3] == 'D1':
        x0, xf = 1.5, 1.9
    elif mo_diagram[i][j][3] == 'D2':
        x0, xf = 2.1, 2.5
    else:
        x0, xf = 2.6, 2.9
    if highlighted:
        highlighted.remove()
    highlighted = ax1.hlines(ener,xmin=x0,xmax=xf,linewidths=3,color='yellow', visible=True, alpha=0.7)

    # Update atom info
    out_label.clear_output()
    with out_label:
        print('MO: {}  |  E(hartree) = {:8.3f}'.format(mo,ener))

    # Update ids
    mo_ = mo
    repr_type_ = repr_type
    iso_ = iso

# CONTAINERS
# Figure (diagram)
out_diagram1 = Output(layout={'border': '1px solid black'})
with out_diagram1:
    OMdiagram1, ax1 = plt.subplots(1,figsize=(1.8,6))
    OMdiagram1.tight_layout()
    OMdiagram1.canvas.header_visible = False
    ax1.set_ylabel('Energy, a.u.')
    ax1.set_xticklabels([])
# Build an output container to print info about orbital
out_label=Output()
# Molecule
view3 = nv.NGLWidget()

# CONTROLS
mo_dropdown = widgets.Dropdown(options=[""],
                               value="",
                               description='MO:')
load_button = widgets.ToggleButton(description='Cargar datos')
load_control = interactive(load_data,
                           clicked = load_button)
controls = interactive(update_repr,
                       mo=mo_dropdown,
                       iso=widgets.FloatText(value = 0.02, step=0.01, description = 'Isovalor'),
                       repr_type=widgets.Dropdown(options=['superficie','malla'],
                                                   value='superficie',
                                                   description='Representación'))
surfbox = VBox([out_label,view3, controls],layout={'width': '700px'})
VBox([load_control,HBox([surfbox,out_diagram1])])
```

#### Análisis en términos de los Orbitales Atómicos (base)

--------------------------

## Barrido de la distancia de enlace

### Cálculos electrónicos

Como hemos comentado al inicio, a cada estrucutra molecular le corresponde un valor de energía potencial total. Es decir, el potencial es una función de la geometría de la molécula, definida por la posición relativa de los núcleos atómicos: $V(\mathbf{R})$. En general, llamaremos a esta función \textbf{superficie de energía potencial}, que dependerá del número de coordenadas internucleares. En el caso de moléculas diatómicas, donde la geometría queda definida con solo un parámetro (distancia de enlace), tendremos una curva de potencial monodimensional.

En la sección anterior nos hemos fijado en la estrucutra para la que esa función es un mínimo (estructura de equilibrio). Este es un punto muy relevante de la superficie de energía potencial, pero conocer esta función más allá del mínimo nos permite entender cómo se mueven las moléculas (vibraciones, flexibilidad...) y analizar su reactividad (rotura y formación de enlaces). En esta práctica, vamos a analizar la curva de energía potencial para la molécula diatómica que estamos tratando. Para ello, calcularemos la energía (y orbitales moleculares) para distintos valores de la distancia de equilibrio.

Usa el siguiente formulario para seleccionar el rango para el que vamos a realizar el barrido de la distancia de enlace. Si desear continuar el barrido anterior añadiendo distancias mayores, marca la casilla correspondiente.

```python
def get_var(option1,option2,option3,option4):
    global dmin, dmax, nstep, continue_scan
    dmin = option1
    dmax = option2
    nstep = option3
    continue_scan = option4

    print('\n')
    print('Parámetros para el barrido:\n----------------------------')
    print('Rmin  = {:5.1f}'.format(dmin))
    print('Rmax  = {:5.1f}'.format(dmax))
    print('Pasos = {:5}'.format(nstep))

    return None

continue_checkbox = widgets.Checkbox(value=False,description='Continuar barrido anterior',disabled=True)

interactive(get_var,
            option1 = widgets.FloatText(value = 0.80, description = 'Rmin (Å)'),
            option2 = widgets.FloatText(value = 4.50, description = 'Rmax (Å)'),
            option3 = widgets.BoundedIntText(value = 10, min=2, step=1, description = 'Pasos'),
            option4 = continue_checkbox)
```

Una vez definido el rango a lo largo del que se hará el barrido, pulsa sobre el botón para realizar el barrido.

```python
out_msg_SCAN = Output()
def compute_scan(clicked):
    global funcional, base, ener_scan, continue_scan, success_scan, \
           dist_scan, wfn_scan, geom_scan, charges_scan, mo_symm_eners_scan

    if not clicked:
        return None
    scan_button.value = False

    # First check funcional and base (not present if section 3 is skiped)
    if 'funcional' not in globals():
        print('Selecciona un funcional y una base primero')
    elif 'base' not in globals():
        print('Selecciona un funcional y una base primero')
    else:
        if 'ener_scan' not in globals():
            continue_scan = False
        if not continue_scan:
            # Initialize arrays to store data
            ener_scan = []
            dist_scan = []
            success_scan = []
            mo_symm_eners_scan = []
            charges_scan = []
            wfn_scan = []
            geom_scan = []
        iscan = len(ener_scan)

        # Scan range
        distances = np.linspace(dmin,dmax,nstep)

        # Define interatomic unitary vector
        geom = psi4_mol.geometry().np
        v = geom[1]-geom[0]
        u = v/np.linalg.norm(v)

        # Prints to screen
        out_msg_SCAN.clear_output()
        with out_msg_SCAN:
            print("Energía total a lo largo del barrido\n\n")
            print("          R [Ang]                 E [hartree]       ")
            print("---------------------------------------------------------")

        # Use looser threshold for E and D (default is 1e-6)
        psi4.set_module_options('scf',{'D_CONVERGENCE':1e-5,
                                       'E_CONVERGENCE':1e-5,
                                       'GUESS':'AUTO'})
        # Restart log file
        psi4.core.set_output_file(job_path+'psi4.log')
        # and ask for MO printing
        psi4.set_module_options('scf',{'print_mos':True})
        for dist in distances:
            # Read guess from second step to improve convergence
            # Using looser thresholds for E and D
            if iscan>0:
                if success_scan[-1]:
                    psi4.set_module_options('scf',{'D_CONVERGENCE':1e-5,
                                                   'E_CONVERGENCE':1e-5,
                                                   'GUESS':'READ'})
                else:
                    psi4.set_module_options('scf',{'D_CONVERGENCE':1e-5,
                                                   'E_CONVERGENCE':1e-5,
                                                   'GUESS':'AUTO'})

            # Generate new geom (better from xyz string with updated R)
            psi4.core.print_out('\n\n*************\nSTEP {}\n*************\n'.format(iscan))
            dist = np.round(dist,3)
            geom = psi4_mol.geometry().np
            geom[1] = geom[0] + u*dist/psi4.constants.bohr2angstroms
            geom_scan.append(geom.copy())
            geom=psi4.core.Matrix.from_array(geom)
            psi4_mol.set_geometry(geom)
            try:
                E, wfn = psi4.energy(funcional+'/'+base,return_wfn=True, molecule=psi4_mol)
                ener_scan.append(E)
                dist_scan.append(dist)
                success_scan.append(True)
                # Population charges
                psi4.oeprop(wfn,'LOWDIN_CHARGES','MULLIKEN_CHARGES')
                lowdin_charges = wfn.variable('LOWDIN_CHARGES')
                mulliken_charges = wfn.variable('MULLIKEN_CHARGES')
                if psi4_vers == '3': # e.g. v1.3.2
                    charges_scan.append([lowdin_charges.nph[0][0][0],mulliken_charges.nph[0][0][0]])
                elif psi4_vers == '4': #e.g. v1.4rc2
                    charges_scan.append([lowdin_charges[0],mulliken_charges[0]])

                # Update table
                with out_msg_SCAN:
                    print("           {:5.3f}                  {:<1.6f}".format(dist, E))
                # Store wfn files to generate cubes in a subsequent step
                wfn_scan.append(wfn)
                # MO energies
                mo_symm_eners_scan.append(wfn.epsilon_a().nph)

            except:
                dist_scan.append(dist)
                ener_scan.append(None)
                charges_scan.append(None)
                success_scan.append(False)
                wfn_scan.append(None)
                # Update table
                with out_msg_SCAN:
                    print("           {:5.3f}                  {:}".format(dist, 'ERROR'))
            if iscan > 1:
                if not success_scan[-1] and not success_scan[-2]:
                    # Abort scan
                    break
            # Update iscan counter
            iscan += 1

        with out_msg_SCAN:
            print("---------------------------------------------------------\n")
        # Enable continue scan
        continue_checkbox.disabled = False

# CONTROLS
psi4_icon = Output()
with psi4_icon:
    display(Image(url='https://psicode.org/psi4manual/master/_static/psi4square.png',width=50))
scan_button = widgets.ToggleButton(description='Barrido de energía')
controls = interactive(compute_scan,
                       clicked = scan_button)
HBox([psi4_icon,VBox([controls,out_msg_SCAN])])
```

### Curva de energía

Una vez realizados los cálculos, podemos representar la curva de energía. Para ello, en lugar de usar directamente los datos de energía absoluta total en Hartrees, es más conveniente realizar la representación de la energía respecto a una geometría de referencia en unidades con más "sentido químico", como kcal/mol.

Carga los datos y selecciona los parámetros de la representación.

```python
def update_plot(cliked,is_relative,i_ref,units):
    global dist_scan, ener_scan, success_scan

    if not cliked:
        pes_plot.clear()
        geoplot_dropdown.disabled = True
        geoplot_dropdown.options = [0]
        units_dropdown.disabled = True
        rel_checkbox.disabled = True
        return None

    # Enable buttons
    geoplot_dropdown.disabled = False
    geoplot_dropdown.options = list(range(len(dist_scan)))
    units_dropdown.disabled = False
    rel_checkbox.disabled = False

    out_msg1.clear_output()

    # Relative geom
    if is_relative:
        if not success_scan[i_ref]:
            with out_msg1:
                print('ERROR: el cálculo falló a la geometría seleccionada. Dist = {:5.3f}'
                      .format(dist_scan[i_ref]))
            pes_plot.clear()
            return None
        Eref = ener_scan[i_ref]
    else:
        Eref = 0

    # Filter data (to discard failed jobs)
    ener_sucess = np.array(ener_scan)[np.array(success_scan)==True]
    dist_sucess = np.array(dist_scan)[np.array(success_scan)==True]
    dist_failed = np.array(dist_scan)[np.array(success_scan)==False]

    # Compute relative energy
    if units == 'kcal/mol':
        factor = qcel.constants.hartree2kcalmol
    elif units == 'kJ/mol':
        factor = qcel.constants.hartree2kJmol
    elif units == 'eV':
        factor = qcel.constants.hartree2ev
    elif units == 'cm-1':
        factor = qcel.constants.hartree2wavenumbers
    elif units == 'Hartree':
        factor = 1

    Eplot = (ener_sucess - Eref) * factor

    # Make interpolation (only possible if we have 4 data points at least)
    if len(Eplot)>3:
        interpo = interp1d(dist_sucess, Eplot, kind='cubic',fill_value='extrapolate')
    else:
        interpo = interp1d(dist_sucess, Eplot, kind='linear',fill_value='extrapolate')

    # Update plot
    # Sucessful points
    pes_plot.clear()
    pes_plot.set_ylabel('Energía ({})'.format(units))
    pes_plot.set_xlabel('R (Å)')
    pes_plot.plot(dist_sucess,Eplot,'ob')
    # Interpolation
    xdata = np.linspace(dist_sucess.min(),dist_sucess.max(),100)
    pes_plot.plot(xdata,interpo(xdata),'--k')
    # Failed point (interpolated/extrapolated)
    pes_plot.plot(dist_failed,interpo(dist_failed),'xr')
    # Ref point if requested
    if is_relative:
        pes_plot.plot(dist_scan[i_ref],[0],color='yellow',marker='o',alpha=0.7)

    return None

# CONTAINERS
out_curve = Output()
with out_curve:
    pes_fig, pes_plot = plt.subplots(1)
    pes_fig.tight_layout()
    pes_fig.canvas.header_visible = False
    pos1 = pes_plot.get_position() # get the original position
    pos2 = [pos1.x0 + 0.1, pos1.y0 + 0.05,  pos1.width * 0.9, pos1.height * 0.95]
    pes_plot.set_position(pos2) # set a new position
# Output info
out_msg1 = Output()

# CONTROLS
units_list = ['kcal/mol','kJ/mol','Hartree','eV','cm-1']
load_plot_button = widgets.ToggleButton(description='Representar')
geoplot_dropdown = widgets.Dropdown(options = [0], disabled=True, description = 'Ref. Geom.')
units_dropdown = widgets.Dropdown(options = units_list, description = 'Unidades',disabled=True)
rel_checkbox = widgets.Checkbox(value = True, description='Energía relativa',disabled=True)
controls1 = interactive(update_plot,
            cliked = load_plot_button,
            is_relative = rel_checkbox,
            i_ref = geoplot_dropdown,
            units = units_dropdown)
VBox([controls1,out_curve,out_msg1])
```

### Orbitales moleculares

Por último, vamos a echar un vistazo a cómo cambian los orbitales a lo largo del barrido. Carga los datos para activar el menu interactivo con el que visualizar los orbitales. Ten en cuenta que, al seleccionar una orbital, se cargan los datos de este orbital para todas las geometrías, por lo que puede tarda unos segundos. La actualización al cambial la geometría es más fluída.

```python
# Separation of cube generation from scan introduces some additional-memory cost
# but renders the steps a bit simpler for the final user (need to check the additional
# memory cost, though)

out_cubegen2 = Output()
def compute_cubes(skip_core,only_vale,clicked):
    global i0, ilast, n_core, n_vale, n_occ

    if not clicked:
        return None
    cubegen_button2.value = False

    # Get core orbitals
    n_per_shell = [1,4,9,16,]
    n_core = 0
    n_vale = 0
    n_occ = wfn_scan[0].nalpha() # Assume close-shell
    for Z in mol.numbers:
        p = qcel.periodictable.to_period(Z)
        n_core += np.array([ n_per_shell[i] for i in range(p-1) ],dtype=int).sum()
        n_vale += n_per_shell[p-1]

    if skip_core:
        i0  = n_core
    else:
        i0 = 0
    if only_vale:
        ilast = n_core + n_vale
    else:
        ilast = wfn_scan[0].nmo()

    out_cubegen2.clear_output()
    with out_cubegen2:
        print('Generando...')

    # Clean old cube directories
    geom_folders = glob.glob(job_path+'GEOM*')
    for folder in geom_folders:
        shutil.rmtree(folder, ignore_errors=True)

    # Set cubeprop options
    psi4.set_options({'CUBEPROP_TASKS':['ORBITALS'], # DENSITY, ESP, ORBITALS, BASIS_FUNCTIONS, LOL, ELF, FRONTIER_ORBITALS, DUAL_DESCRIPTOR
                      'CUBEPROP_ORBITALS':list(range(i0+1,ilast+1)), # beta orbitals are requested with negative indices
                      })

    # Run over all scan steps
    for iscan,sucess in enumerate(success_scan):
        # Only if the calculation finised properly
        if not sucess:
            continue

        geom_folder = job_path + 'GEOM_{:03g}'.format(iscan)+'/'
        os.mkdir(geom_folder)
        # Each cubeprop call populates memory! (~50MB with 6-31G(d) basis)
        psi4.set_options({'CUBEPROP_FILEPATH':geom_folder,})
        # Generate cubes. WARNING: geometry (in geom.xyz and .cube) is obtained from
        # psi4 active mol not from wfn, so it is not right
        geom=psi4.core.Matrix.from_array(geom_scan[iscan])
        psi4_mol.set_geometry(geom)
        psi4.driver.p4util.cubeprop(wfn_scan[iscan])

    # Update info
    out_cubegen2.clear_output()
    with out_cubegen2:
        print('Orbitales generados')

# CONTROLS
cubegen_button2 = widgets.ToggleButton(description = 'Generar Orbitales',icon = 'bolt')
ignrore_checkbox2 = widgets.Checkbox(value=True,description='Ignorar OM internos')
onlyvale_checkbox2 = widgets.Checkbox(value=True,description='Capa de valencia mínima')
# Reuse the same for the grid size as for single point: gridsize_box
controls = interactive(compute_cubes,
                       skip_core = ignrore_checkbox2,
                       only_vale = onlyvale_checkbox2,
                       r_grid = gridsize_box, # Already defined for single orbs (share the same value)
                       clicked = cubegen_button2)

VBox([ignrore_checkbox2,onlyvale_checkbox2,gridsize_box,cubegen_button2,out_cubegen2])
```

```python
# Initial defaults
mo_ = ""
iso_ = 0.02
repr_type_ = 'superficie'
highlighted = None
geo_ = -1

# FUNCTIONS
def load_data2(clicked):
    global mo_dropdown2, view4, orbs2, mo_, geo_, mo_all_eners, mo_all_ireps, irep_symbols

    out_load.clear_output()
    out_label2.clear_output()
    if not clicked:
        if hasattr(view4,'component_0'):
            for orb in orbs2:
                view4.remove_component(orb)
            view4.remove_component(view4.component_0)
        with out_load:
            print('Pulsa para (re)cargar los datos del último cálculo')
        mo_dropdown2.options = [""]
        mo_dropdown2.value = ""
        geo_dropdown2.min=0
        geo_dropdown2.max=0
        geo_dropdown2.disabled=True
        mo_ = ""
        ax2.clear()
        return None

    ###############################
    ## ** TRAYECTORIA **
    ###############################
    # Set traj view
    ## Name of intermediate file
    trfile=job_path + 'scan.traj'
    # From single sctructures to trajectory loaded on nglview
    ## Get structures into ase object
    mols = []

    mol_files = glob.glob(job_path + 'GEOM_*/geom.xyz')
    mol_files.sort()
    for file in mol_files:
        mols.append(ase.io.read(file))
    ## Write into traj file
    ase.io.write(trfile,mols)
    ## Read trajectory intp ase and convert to nglview object
    asetraj = ase.io.trajectory.TrajectoryReader(trfile)
    ## Generate view with trajectory
    traj = nv.ASETrajectory(asetraj)
    view4.add_trajectory(traj)

    ###############################
    ## ** ORBITALES **
    ###############################
    #
    # ** ENERGIES **
    #
    # Get symmetry (irep) symbols (assume PG is not changing with geom)
    pg = psi4_mol.point_group()
    ct = pg.char_table()
    irep_symbols = [ ct.gamma(i).symbol() for i in range(pg.order()) ]

    # Get MO energies (per symm) at the selected geom
    mo_symm_eners = mo_symm_eners_scan[geo_]
    # Paste (concatenate) data for all symms
    mo_all_eners = np.concatenate(mo_symm_eners)
    mo_sym_ireps = [ np.array([i]*len(l)) for i,l in enumerate(mo_symm_eners) ]
    mo_sym_ireps = [ np.array([ str(i+1)+'-'+irep_symbols[j] \
                               for i,j in enumerate(irep_symm)]) for irep_symm in mo_sym_ireps]
    mo_all_ireps = np.concatenate(mo_sym_ireps)
    inds = np.argsort(mo_all_eners)

    # Sort mo_ireps and mo_eners together
    mo_all_ireps = mo_all_ireps[inds]
    mo_all_eners = mo_all_eners[inds]

    # Update dropdowns
    mo_dropdown2.options=['No mostrar']+list(mo_all_ireps[i0:ilast])
    mo_dropdown2.value = mo_all_ireps[i0]

    # Get cubefiles
    geom_files = glob.glob(job_path + 'GEOM_*/geom.xyz') #only used to get number of geoms
    geo_dropdown2.value=0
    geo_dropdown2.min=0
    geo_dropdown2.max=len(geom_files)-1
    geo_dropdown2.disabled=False

def update_repr(geo,mo,iso,repr_type):
    global view4, orbs2, geo_, mo_, iso_, repr_type_, highlighted, mo_ener

    if mo == "":
        orbs2 = []
        return None

    # Initialize to catch when data are not loaded
    have_cube_data = True

    # Only one MO is loaded in memory (for all geoms)
    # So, if it changes, reload the right one
    if ((mo != mo_ or iso != iso_ or repr_type != repr_type_) and mo != 'No mostrar'):
        out_label2.clear_output()
        with out_label2:
            print('Espera mientras se cargan los orbitales')
        #Update mo (load new set)
        # Set is_wire
        if repr_type == 'superficie':
            is_wire = False
        else:
            is_wire = True
        # remove current
        for orb in orbs2:
            view4.remove_component(orb)
        # load new mo
        orbs2=[]
        cube_files = glob.glob(job_path + 'GEOM_*/Psi_a_*_'+mo+'.cube')
        cube_files.sort()
        for cubefile in cube_files:
            orbs2.append(view4.add_component(cubefile))
            orbs2[-1].clear()
            orbs2[-1].add_surface(opacity=0.5, wireframe=is_wire,
                                 color='blue', isolevelType="value", isolevel=abs(iso))
            orbs2[-1].add_surface(opacity=0.5, wireframe=is_wire,
                                 color='red',  isolevelType="value", isolevel=-abs(iso), depthWrite=False)
            orbs2[-1].hide()

    if (mo != mo_ or geo != geo_ or highlighted == None):
        # Get MO energies (per symm) at the selected geom
        mo_symm_eners = mo_symm_eners_scan[geo]
        # Paste (concatenate) data for all symms
        mo_all_eners = np.concatenate(mo_symm_eners)
        mo_sym_ireps = [ np.array([i]*len(l)) for i,l in enumerate(mo_symm_eners) ]
        mo_sym_ireps = [ np.array([ str(i+1)+'-'+irep_symbols[j] \
                                   for i,j in enumerate(irep_symm)]) for irep_symm in mo_sym_ireps]
        mo_all_ireps = np.concatenate(mo_sym_ireps)
        inds = np.argsort(mo_all_eners)

        # Sort mo_ireps and mo_eners together
        mo_all_ireps = mo_all_ireps[inds]
        mo_all_eners = mo_all_eners[inds]

        # ** DIAGRAM (FIGURE) **
        # WARNING: figures must be closed
        ax2.clear()
        ax2.set_ylabel('Energy, a.u.')
        ax2.set_xticklabels([])

        # Manage degenerancies
        levels = []
        ener_ = 99999.
        for ener,symm in zip(mo_all_eners[i0:ilast],mo_all_ireps[i0:ilast]):
            if np.isclose(ener,ener_,atol=0.001):
                levels[-1].append([ener,symm])
            else:
                levels.append([[ener,symm]])
            ener_=ener

        have_cube_data = False
        hl0 = None
        i_mo = 0
        mo_color = 'blue'
        mo_lstyle = 'solid'
        for level in levels:
            if len(level) == 1:
                i_mo += 1
                if i_mo+i0 > n_occ:
                    mo_color = 'gray'
                    #mo_lstyle = 'dashed'
                ax2.hlines(level[0][0],xmin=-0.5,xmax=0.5,color=mo_color,linestyle=mo_lstyle)
                if level[0][1] == mo:
                    ener, hl0, hlf = level[0][0], -0.5, 0.5

            elif len(level) == 2:
                i_mo += 1
                if i_mo+i0 > n_occ:
                    mo_color = 'gray'
                    #mo_lstyle = 'dashed'
                ax2.hlines(level[0][0],xmin=-0.65,xmax=-0.15,color=mo_color,linestyle=mo_lstyle)
                if level[0][1] == mo:
                    ener, hl0, hlf = level[0][0], -0.65, -0.15
                #
                i_mo += 1
                if i_mo+i0 > n_occ:
                    mo_color = 'gray'
                    #mo_lstyle = 'dashed'
                ax2.hlines(level[1][0],xmin=0.15,xmax=0.65,color=mo_color,linestyle=mo_lstyle)
                if level[1][1] == mo:
                    ener, hl0, hlf = level[1][0], 0.15, 0.65
            else:
                # Fix ini-fin, and fit levels within
                # Settings
                sep_space = 0.02
                lev_ini = -0.7
                lev_fin = 0.7
                #
                sep_spaces = sep_space*(len(level)-1)
                lev_space = (lev_fin-lev_ini-sep_spaces)/len(level)
                l0_ = lev_ini - sep_space
                for i in range(len(level)):
                    i_mo += 1
                    if i_mo+i0 > n_occ:
                        mo_color = 'gray'
                        #mo_lstyle = 'dashed'
                    l0 = l0_ + sep_space
                    lf = l0  + lev_space
                    ax2.hlines(level[i][0],xmin=l0,xmax=lf,color=mo_color,linestyle=mo_lstyle)
                    if level[i][1] == mo:
                        ener, hl0, hlf = level[i][0], l0, lf
                    l0_ = lf

        if hl0 is not None:
            have_cube_data = True
            # Highlight selected
            if highlighted and mo_ != 'No mostrar':
                highlighted.remove()
            highlighted = ax2.hlines(ener,xmin=hl0,xmax=hlf,
                                     linewidths=3,color='yellow', visible=True, alpha=0.7)
        mo_ener = ener

    #Update geo
    # Geom
    view4.frame = geo
    # Orbs
    if len(orbs2):
        orbs2[geo_].hide()

    # Update atom info
    out_label2.clear_output()
    if mo == 'No mostrar':
        pass
    elif have_cube_data:
        # Display selected
        orbs2[geo].show()
        with out_label2:
            print('MO: {}  |  E(hartree) = {:8.3f}'.format(mo,mo_ener))
    else:
        with out_label:
            print('MO: {}  |  No hay datos'.format(mo))

    # Update ids
    mo_=mo
    geo_=geo
    repr_type_=repr_type
    iso_=iso

# CONTAINERS
# CONTAINERS
# Figure (diagram)
out_diagram2 = Output(layout={'border': '1px solid black'})
with out_diagram2:
    OMdiagram2, ax2 = plt.subplots(1,figsize=(1.8,6))
    OMdiagram2.tight_layout()
    OMdiagram2.canvas.header_visible = False
    ax2.set_ylabel('Energy, a.u.')
    ax2.set_xticklabels([])
# Build an output container to print info about orbital
out_label2=Output()
# Molecule
out_load=Output()
view4 = nv.NGLWidget()
view4.parameters = {"clipNear": 0, "clipFar": 100, "clipDist": 1}
# Disable embeded player slider
view4.player.widget_player_slider.close()

# CÇNTROSL
mo_dropdown2 = widgets.Dropdown(options=[""],
                               value="",
                               description='MO:')
geo_dropdown2 = widgets.IntSlider(value=0,min=0,max=0,disabled=True,description='Geom')
load_button2 = widgets.ToggleButton(description='Cargar datos')
load_control2 = interactive(load_data2,
                            clicked = load_button2)
controls2 = interactive(update_repr,
                       geo=geo_dropdown2,
                       mo=mo_dropdown2,
                       iso=widgets.FloatText(value = 0.02, step=0.01, description = 'Isovalor'),
                       repr_type=widgets.Dropdown(options=['superficie','malla'],
                                                   value='superficie',
                                                   description='Representación'))
surfbox2 = VBox([view4, out_label2, controls2],layout={'width': '700px'})
VBox([HBox([load_control2,out_load]),HBox([surfbox2,out_diagram2])])

```

## Borrar datos

Al finalizar la práctica, pulsa sobre el botón que se genera a ejecutar la siguiente celda para borrar los datos.

¡Aseguraté de haber terminado todos los análisis!

```python
out_borrar = Output()
import time

def remove_data(clicked):

    global job_path

    out_borrar.clear_output()

    if not clicked:
        return None
    # Unclikc if clicked
    remove_button.value = False

    if os.path.exists(job_path):
        shutil.rmtree(job_path, ignore_errors=True)
        with out_borrar:
            print('Datos borrados')
        time.sleep(2)
        out_borrar.clear_output()
    else:
        with out_borrar:
            print('No hay datos que borrar')
        time.sleep(2)
        out_borrar.clear_output()

    return None

remove_button = widgets.ToggleButton(description = 'Borrar datos',
                                     icon = 'remove')
controls = interactive(remove_data,
           clicked = remove_button)
VBox([controls, out_borrar])
```