Authors: Salvatore Mandra (salvatore.mandra@nasa.gov)

    Jeffrey Marshall (jeffrey.s.marshall@nasa.gov)

Copyright © 2021, United States Government, as represented by the Administrator
of the National Aeronautics and Space Administration. All rights reserved.

The *HybridQ: A Hybrid Simulator for Quantum Circuits* platform is licensed under
the Apache License, Version 2.0 (the "License"); you may not use this file
except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.

Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.

# 01 - Gates

Gates are at the core of **HybridQ**. In this tutorial, we will show how gates are designed, and how to use them. Also, we will show how to create and implement new gates in **HybridQ**.

In **HybridQ** we use the concept of *properties* to define gates. More precisely, instead of using monolithic gates which are hard to maintain and extend, *every* property is independently implemented so that different object may share multiple properties. This approach not only simplifies the development by allowing a large reuse of the code, it also simplify the maintenance and extension of the code as new features and fix are applied at the level of the properties, and then propagated to all objects using them.

For instance, the rotation gate on the X axis inherits the following properties:

```python
%load_ext autoreload
%autoreload 2

# Import NumPy
import numpy as np

# Load gates
from hybridq.gate import Gate

# Get inheritance from rotation gate
type(Gate('RX')).mro()
```

    [hybridq.base.base.Gate_RX,
     hybridq.gate.gate.BaseGate,
     hybridq.gate.property.RotationGate,
     hybridq.gate.property.ParamGate,
     hybridq.base.property.Params,
     hybridq.gate.property.UnitaryGate,
     hybridq.gate.property.PowerMatrixGate,
     hybridq.gate.property.PowerGate,
     hybridq.gate.property.QubitGate,
     hybridq.base.property.Tags,
     hybridq.base.property.Name,
     hybridq.base.base.__Base__,
     object]

Every single property add (or modify) some characteristic of the gate. For instance, PowerGate allows to specify any power to the given gate while PowerMatrixGate takes a matrix representing the gate and actually implement the operation.

For the full set of properties that each gate supports, please refer to the technical [documentation](https://github.com/nasa/hybridq/tree/main/docs).

## Gates

At the moment, **HybridQ** supports two types of gates: *named* gates and *functional* gates. Named gates, as the name suggests, are the commonly used gates while functional gates allow to interact with the quantum state in a non-trivial way.

### Named Gates

A full list of available named gates can be obtained as follows:

```python
from hybridq.gate.utils import get_available_gates

get_available_gates()
```

    ('I',
     'H',
     'X',
     'Y',
     'Z',
     'U3',
     'R_PI_2',
     'ZZ',
     'CZ',
     'CX',
     'SWAP',
     'ISWAP',
     'CPHASE',
     'FSIM',
     'RX',
     'RY',
     'RZ',
     'SQRT_X',
     'SQRT_Y',
     'P',
     'T',
     'SQRT_SWAP',
     'SQRT_ISWAP')

```python
# All named gates can be created by using the following syntax
# (the name of the gate is case insensitive):
Gate('iswap')
```

    Gate_ISWAP(name='ISWAP', n_qubits=2, M=numpy.ndarray(shape=(4, 4), dtype=complex128))

Some gates require parameters to specify details on the gate. For example, the rx gate expects one parameter, the rotation angle.

HybridQ provides an easy way to determine the number of qubits and parameters required to specify the gate

```python
for name in ['rx', 'fsim', 'x']:
    g = Gate(name)
    print(f"{name.upper():5s} - Number of qubits:", g.n_qubits)
    print(f"{'':5s} - Number of params:",
          g.n_params if g.provides('n_params') else 'none')
```

    RX    - Number of qubits: 1
          - Number of params: 1
    FSIM  - Number of qubits: 2
          - Number of params: 2
    X     - Number of qubits: 1
          - Number of params: none

Qubits and parameters can be specified either before or after the creation of the gate. **HybridQ** allows the use of *any* object for qubits and parameters, including sympy symbols.

```python
# Example 1: Symbolic qubits and params

from sympy import symbols

# Define a few symbols
α, β, γ = symbols('α β γ')

# Specify all parameters at the creation of the gate
g1 = Gate('RX', qubits=[α], params=[β], power=γ)

print(g1)
```

    Gate_RX(name='RX', qubits=(α,), φ=Mod(1.0*β*γ, 12.5663706143592))

```python
# Example 2: Specify inplace after creation

# Get a rotation gate without qubits nor parameters
g2 = Gate('RX')

# Add some qubits inplace
g2.on([α], inplace=True)

# Set parameters as new object and elevate to the power of γ
g2 = g2.set_params([β])**γ

# Check g1 == g2
assert (g1 == g2)

print(g2)
```

    Gate_RX(name='RX', qubits=(α,), φ=Mod(1.0*β*γ, 12.5663706143592))

```python
# Because Gate('RX') is a 'RotationGate', raising it to any
# power will actually change the rotation angle
assert (g1.power == 1)
assert (g1.params[0] == (β * γ) % (4 * np.pi))
```

All gates provide a way to *tag* them. This is particularly useful when one wants to identify some specific gates in a circuit.

```python
g1 = g1.set_tags(dict(a=1, b=2))
g2.set_tags(dict(a=2), inplace=True)

# Tags are not used when gates are compared
assert (g1 == g2)

g1, g2, g1.tags['a'], g2.tags['a']
```

    (Gate_RX(name='RX', qubits=(α,), φ=Mod(1.0*β*γ, 12.5663706143592), tags={'a': 1, 'b': 2}),
     Gate_RX(name='RX', qubits=(α,), φ=Mod(1.0*β*γ, 12.5663706143592), tags={'a': 2}),
     1,
     2)

It is possible to get the matrix representing the gate by using the following command:

```python
try:
    # This should fail because no values have been provided to symbols
    g1.matrix()
except:
    pass
else:
    raise RuntimeError("Something went wrong")

# Let's specify the rotation angle
g1.set_params([g1.params[0].subs({β: 1.1, γ: 2.2})], inplace=True).matrix()
```

    array([[0.3530194+0.j      , 0.       -0.935616j],
           [0.       -0.935616j, 0.3530194+0.j      ]])

Gates can be also created by providing their matrix representation using the named gate *MatrixGate*

```python
from hybridq.gate import MatrixGate

# Create a gate using its matrix representation
gm = MatrixGate(qubits=g1.qubits, U=g1.matrix())

# It is also possible to use Gate directly
gm2 = Gate('matrix', qubits=g1.qubits, U=g1.matrix())
assert (gm2 == gm)

# Observe that the strict equivalence '==' requires that the
# two objects are identical (including qubits and parameters)
assert (gm != g1)

# However, HybridQ provides a way to compare two gates
assert (gm.isclose(g1))

# 'isclose' will return False if qubits are different
assert (not gm.isclose(g1.on([42])))
```

```python
# The order of qubits is important. For instance the following
# CNOT gate, will flip the qubit 'b' only if 'a' is one
g = Gate('cnot', qubits=['a', 'b'])

# The matrix representation assumes that the first qubit
# is 'a' and the second qubit is 'b'
g.matrix()
```

    /home/dagart/local/miniconda3/envs/hybridq/lib/python3.8/site-packages/hybridq/gate/gate.py:291: UserWarning: 'CNOT' is an alias for 'CX'. Using Gate(name='{_gate_aliases[name]}').
      warn(f"'{name}' is an alias for '{_gate_aliases[name]}'. "

    array([[1, 0, 0, 0],
           [0, 1, 0, 0],
           [0, 0, 0, 1],
           [0, 0, 1, 0]])

```python
# If a different order is required, it must be specified
g.matrix(order=['b', 'a'])
```

    array([[1, 0, 0, 0],
           [0, 0, 0, 1],
           [0, 0, 1, 0],
           [0, 1, 0, 0]])

**HybridQ** also allows users to split any gate in terms of its Schmidt decomposition. More precisely, given any gate $G$ acting on two subset of qubits, it is possible to write $G$ as:
$$
    G = \sum_i s_i\, G^{(1)}_i \otimes G^{(2)}_i
$$
with $s_i$ being real and positive weights, and $G_i^{(1)}$ and $G_i^{(2)}$ being gates acting only on either the first or the second subset of qubits respectively.

```python
# Load the gate utils
from hybridq.gate import utils as gate_utils

# Get gate
g = Gate('FSIM', qubits=[1, 2], params=[1.1, 2.2])**3.3

# Return the Schmidt decomposition of gate
s = gate_utils.decompose(g, qubits=[1])

# Print weights
print('Weights:', s.s)

# Print "left" gates
print('Left gates:')
for _g in s.gates[0]:
    print('\t', _g)

# Print "right" gates
print('\nRight gates:')
for _g in s.gates[0]:
    print('\t', _g)

# Gate can be easily recomposed using the complementary function
g_merged = MatrixGate(sum(s * gate_utils.merge(g1, g2).matrix(order=g.qubits)
                          for s, g1, g2 in zip(s.s, *s.gates)),
                      qubits=g.qubits)

# Check
assert (g.isclose(g_merged))
```

    Weights: [1.82742925 0.46922004 0.46922004 0.46922004]
    Left gates:
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))

    Right gates:
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    	 MatrixGate(name='MATRIX', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=complex128))

```python
# HybridQ also provides a way to check if two gates commute
g1 = Gate('CX', [0, 1])
g2 = Gate('X', [1])

# Since the 'X' gate is acting on the controlled qubit, g1 and g2 should commute
assert (g1.commutes_with(g2))

# However, it won't commute if X would act on the controlling qubit
assert (not g1.commutes_with(g2.on([0])))

# Gates not sharing any qubits always commute
assert (g1.commutes_with(Gate('U3', qubits=[3], params=[1, 2, 3])))

# Commutation is only available for gate with qubits
try:
    g1.commutes_with(Gate('X'))
except Exception as e:
    print(e)
else:
    raise RuntimeError("Something went wrong")
```

    Cannot check commutation between virtual gates.

Gates can be conjugated and transposed:

```python
#
g = Gate('FSIM', params=[1, 2])
gt = g.T()
gc = g.conj()
ga = g.adj()

print(gt)
print(gc)
print(ga)

assert (gt.is_transposed())
assert (gc.is_conjugated())
assert (ga.is_conjugated())
assert (ga.is_transposed())

# Checks
np.testing.assert_allclose(g.matrix().T, gt.matrix())
np.testing.assert_allclose(g.matrix().conj(), gc.matrix())
np.testing.assert_allclose(g.matrix().conj().T, ga.matrix())
```

    Gate_FSIM^T(name='FSIM', n_qubits=2, params=(1, 2))
    Gate_FSIM^*(name='FSIM', n_qubits=2, params=(1, 2))
    Gate_FSIM^+(name='FSIM', n_qubits=2, params=(1, 2))

**HybridQ** supports self-adjoint gates

```python
g = Gate('Y')

# Print gates
print(g)
print(g.conj())
print(g.T())
print(g.conj().T())

# Check
assert (g.conj().T() == g)
assert (not g.conj() == g)
assert (not g.T() == g)
assert (g.adj() == g)

# This also apply for the inverse
assert (g.inv() == g)
assert (g.adj() == g)

# This is not true anymore if any power different from 1 or -1 is applied
print((g**1.1).adj())

# Check
assert (g**-1 == g)
assert ((g**1.1).adj() != g)
```

    Gate_Y(name='Y', n_qubits=1, M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    Gate_Y^*(name='Y', n_qubits=1, M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    Gate_Y^T(name='Y', n_qubits=1, M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    Gate_Y(name='Y', n_qubits=1, M=numpy.ndarray(shape=(2, 2), dtype=complex128))
    Gate_Y^+(name='Y', n_qubits=1, M=numpy.ndarray(shape=(2, 2), dtype=complex128))**1.1

### Functional Gate

**HybridQ** supports different functional gates that allow a great control on the quantum circuit simulation. At the moment, **HybridQ** supports the following functional gates:

<od>

Control

TupleGate

SchmidtGate

StochasticGate

Projection

Measure

FunctionalGate
</od>

#### Control

As the name suggests, this functional gate allows to create any controlled gate. Controlled gates extend any gate to only activate when all of the control qbits are |1>.

```python
# Import control gate
from hybridq.gate import Control

# Let's create a random gate
g = MatrixGate(np.random.random((2**4, 2**4)), qubits='abcd')

# Create a control gate with 3 controlling qubits
cg = Control(c_qubits=[('x', 1), ('y', 2), ('z', 3)], gate=g)
# g is provided as the gate to be controlled.
# Now the g gate will only be applied when the 3 controlling qubits are all |1>

# Print gate
print(cg)

# Initialize matrix with identity
U = np.reshape(np.eye(2**cg.n_qubits),
               (2, ) * 3 + (2**4, ) + (2, ) * 3 + (2**4, ))

# Apply g.matrix only when all controlling qubits are 1
U[(1, 1, 1, slice(2**4), 1, 1, 1, slice(2**4))] = g.matrix()

# Reshape to the right shape
U = np.reshape(U, (2**7, 2**7))

# Check
np.testing.assert_allclose(U, cg.matrix())
```

    Controlled_MatrixGate(name='CCCMATRIX', n_qubits=7, c_qubits=(('x', 1), ('y', 2), ('z', 3)), qubits=(('x', 1), ('y', 2), ..., c, d), gate=MatrixGate(name='MATRIX', qubits=('a', 'b', 'c', 'd'), M=numpy.ndarray(shape=(16, 16), dtype=float64)))

#### TupleGate

TupleGate gathers together multiple gates. The combined TupleGate can then be treated as a single gate. For example,

```python
# Import TupleGate
from hybridq.gate import TupleGate
from hybridq.utils import sort

# Let's define some gates ...
g1 = Gate('X')
g2 = Gate('Y', qubits='a')
g3 = Gate('swap', qubits='ab')

# ... and create a TupleGate
tg = TupleGate((g1, g2, g3))

# If some gates do not have qubits, both qubits and n_qubits are None
assert (tg.qubits is None and tg.n_qubits is None)

# If every gate has qubits, when TupleGate will have
# all qubits (counted once) from every gate
tg[0].on([1], inplace=True)

# For consistency, qubits are always sorted using 'hybridq.utils.sort'
print(f'Qubits: {tg.qubits}')
print(f'Number of qubits: {tg.n_qubits}')
```

    Qubits: (1, 'a', 'b')
    Number of qubits: 3

Once a tuple is created, you can add additional gates using the addition operator (i.e., '+'),

```python
# It is possible to add gates ...
tg = tg + (g2, g3)

# ... and one can concatenate multiple tuples
tg = tg + (tg, tg)
tg = tg + (tg, tg)

# Check that gates and number of gates haven't changed
assert (tg.n_qubits == 3 and tg.qubits == tuple(sort(['a', 'b', 1])))
```

 The TupleGate also provides a way to flatten the collection

```python
print(tg.flatten())
```

    TupleGate(name='TUPLE', Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_X(name='X', qubits=(1,), M=numpy.ndarray(shape=(2, 2), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)), Gate_Y(name='Y', qubits=('a',), M=numpy.ndarray(shape=(2, 2), dtype=complex128)), Gate_SWAP(name='SWAP', qubits=('a', 'b'), M=numpy.ndarray(shape=(4, 4), dtype=int64)))

#### SchmidtGate

As the name suggest, a SchmidtGate allow to create a gate from its Schmidt decomposition. More precisely, it allows to define gates as:
$$
S = \sum_{ij} s_{ij}\,G^{(1)}_i \otimes G^{(2)}_j
$$
with $s_{ij}$ being an arbitrary matrix of real and positive weights, and $G^{(1)}_i$ and $G^{(2)}_j$ are gates acting on two different set of qubits. The SchmidtGate is also the gate returned by hybridq.gate.utils.decompose.

```python
# Import SchmidtGate
from hybridq.gate import SchmidtGate, MatrixGate

# Let's create some random gates ...
G1 = [
    MatrixGate(np.random.random((4, 4)), qubits='ab'),
    MatrixGate(np.random.random((4, 4)), qubits='cb'),
    MatrixGate(np.random.random((2, 2)), qubits='b'),
]
G2 = [
    MatrixGate(np.random.random((4, 4)), qubits=[2, 3]),
    MatrixGate(np.random.random((4, 4)), qubits=[5, 3]),
    MatrixGate(np.random.random((4, 4)), qubits=[(0, 1), 2]),
    MatrixGate(np.random.random((2, 2)), qubits=[2]),
]

# ... and random weights
w = np.random.random((3, 4))

# Create SchmidtGate
S = SchmidtGate(gates=(G1, G2), s=w)

#assert (type(gate_utils.decompose(Gate('X', [1]), [1])) == type(S))

# Qubits in SchmidtGate's are ordered so that "left" qubits
# appear before than the "right" qubits
assert (S.qubits == S.gates[0].qubits + S.gates[1].qubits)

# Let's check the SchmidtGate by manually merging the gates
#
# First, we need to extend all gates in G1 and G2 to the right number of qubits, ...
_G1 = [gate_utils.merge(g, Gate('I', qubits=S.qubits)) for g in G1]
_G2 = [gate_utils.merge(g, Gate('I', qubits=S.qubits)) for g in G2]

# ... we can then sum all matrices using the right order ...
U = np.sum([
    S.s[i, j] * gate_utils.merge(_G1[i], _G2[j]).matrix(order=S.qubits)
    for i in range(len(G1)) for j in range(len(G2))
],
           axis=0)

# ... and check that everything is ok
np.testing.assert_allclose(U, S.matrix())
```

#### StochasticGate

StochasticGate's are used to define a set of gates that are stochastically applied to a circuit with a given probability p. Let's consider the case of a simple depolarizing channel of the form:
$$
    \rho \rightarrow \mathcal{E}(\rho) = (1-p)\,\rho
        + \frac{p}{3}\,\sigma_x \rho\, \sigma_z
        + \frac{p}{3}\,\sigma_y \rho\, \sigma_z
        + \frac{p}{3}\,\sigma_z \rho\, \sigma_z
$$
with $\sigma_x$, $\sigma_y$ and $\sigma_z$ being the Pauli matrices. Since the noise channel is composed of unitary matrices, the evolution of the quantum state can be done by running the quantum circuit simulations multiple times, each time randomly sampling from the noise channel either the identity (with probability $1-p$), or the $\sigma_x$/$\sigma_y$/$\sigma_z$ gate (with probability $p/3$). The StochasticGate allows to easily represent such cases.

```python
# Import StochasticGate
from hybridq.gate import StochasticGate
from collections import Counter

# Let's define the depolarizing probability
p = 0.42

# Define the four gates in the depolarizing channel
I, X, Y, Z = [Gate(g, [0]) for g in 'IXYZ']

# Define the stochastic gate
S = StochasticGate(gates=[I, X, Y, Z], p=[1 - p, p / 3, p / 3, p / 3])

# We can sample multiple times from S
print([g.name for g in S.sample(10)])

# Let's count the number of times the gate appear in the sample
# (it may take a while)
_count = Counter(g.name for g in S.sample(1000000))

# Check
assert (np.isclose(_count['I'] / 1000000, 1 - p, atol=1e-3))
assert (np.isclose(_count['X'] / 1000000, p / 3, atol=1e-3))
assert (np.isclose(_count['Y'] / 1000000, p / 3, atol=1e-3))
assert (np.isclose(_count['Z'] / 1000000, p / 3, atol=1e-3))
```

    ['Z', 'I', 'I', 'Z', 'I', 'I', 'I', 'I', 'I', 'Z']

#### Projection and Measure

The gate Projection can be used to project qubits to a specific state.

```python
# Import Projection
from hybridq.gate import Projection

# Let's define a random quantum state of 5 qubits
psi = np.random.random((2, ) * 5) + 1j * np.random.random((2, ) * 5)
psi /= np.linalg.norm(psi.ravel())

# Define some qubits ...
qubits = np.random.randint(2**32, size=5)

# ... and the subset to project
p_qubits = np.random.choice(qubits, size=2, replace=False).tolist()

# Set the desired state to project the p_qubits
p_state = bin(np.random.randint(2**2))[2:].zfill(2)

# Create projection
P = Projection(state=p_state, qubits=p_qubits)

# Project quantum state
p_psi, _ = P(psi=psi, order=qubits)

# Let's check that the projection is correct
_psi = np.zeros_like(psi)
_proj = tuple(
    int(p_state[p_qubits.index(q)]) if q in p_qubits else slice(2)
    for q in qubits)
_psi[_proj] = psi[_proj]
_psi /= np.linalg.norm(_psi.ravel())

# Check
np.testing.assert_allclose(_psi, p_psi)
```

Measure gates is to Projection gates. However, unlike Projection gates, Measure gates project qubits to a given state with a probability given by the quantum state (that is, projection are sampled with the right quantum distribution).

```python
# Import Measure
from hybridq.gate import Measure

# Let's define a random quantum state of 5 qubits
psi = np.random.random((2, ) * 5) + 1j * np.random.random((2, ) * 5)
psi /= np.linalg.norm(psi.ravel())

# Define some qubits ...
qubits = np.random.randint(2**32, size=5)

# ... and the subset two project
p_qubits = np.random.choice(qubits, size=2, replace=False).tolist()

# Set the desired state to project the p_qubits
p_state = bin(np.random.randint(2**2))[2:].zfill(2)

# Create measurement
M = Measure(qubits=p_qubits)

# Measure quantum state
p_psi, _ = M(psi=psi, order=qubits)
```

    /home/dagart/local/miniconda3/envs/hybridq/lib/python3.8/site-packages/hybridq/utils/transpose.py:166: UserWarning: Fallback to 'numpy.transpose'
      warn("Fallback to 'numpy.transpose'")

#### FunctionalGate

FunctionalGate's are the most general gate that are implementable in **HybridQ**. More precisely, FunctionalGate's allow to directly manipulate the quantum state using a defined function to easily implement gates (such as oracle) that would be hard to implement otherwise.

In the following example, we will show how to implement a Grover oracle in **HybridQ**.

```python
# Import FunctionalGate
from hybridq.gate import FunctionalGate, Projection

# Let's define the target state
target_state = bin(np.random.randint(2**3))[2:].zfill(3)

# FunctionalGate requires two objects: the function to apply
# to the quantum state and the qubits the gate will act on
#
# First, let's define the functional for the FunctionalGate
def grover(self, psi, order):
    """
    Given a quantum state ‘psi‘ with qubits given
    by ‘order‘, invert the phase of the state
    0....0.

    Parameters
    ----------
    psi:
        Because of internal optimizations, any functional
        for the 'FunctionalGate' must accept two different
        format for 'psi': either an n-dimensional array of
        complex numbers (with 'n' the number of qubits) or
        a pair of n-dimensional arrays of real number, the
        first one representing the real part of the quantum
        state while the second one representing its imaginary
        part.

        In this case, 'Projection' is already able to distinguish
        between the two cases.
    order:
        It is the order of qubits used to represent 'psi'

    Returns
    -------
    new_psi, new_order
    """

    # Check
    assert (len(target_state) == self.n_qubits)

    # First, let’s create a projector
    proj = Projection(state=target_state, qubits=self.qubits)

    # Project quantum state
    psi0, new_order = proj(psi=psi, order=order, renormalize=False)

    # Check that order hasn’t changed
    assert (new_order == order)

    # Update quantum state
    psi -= 2 * psi0

    # Return quantum state
    return psi, order

# Then, one can create the Grover gate as follows
grover_gate = FunctionalGate(f=grover, n_qubits=3)

# Print
print(grover_gate)

# FunctionalGate's provide qubits and n_qubits, but not matrix
assert (grover_gate.provides('qubits,n_qubits'))
assert (not grover_gate.provides('matrix'))

# Define a superposition of three qubits
psi = np.ones(shape=(2, 2, 2), dtype='complex')
psi /= np.linalg.norm(psi)

# Apply Grover gate to state
new_psi, _ = grover_gate.on([0, 1, 2])(psi, order=[0, 1, 2])

# Print output (the state with '*' should have the sign inverted)
for i, x in enumerate(new_psi.ravel()):
    # Pad bitstring
    i = bin(i)[2:].zfill(3)

    # Print
    print('{0} {1}: {2:+g}'.format('*' if i == target_state else ' ', i,
                                   np.real_if_close(x)))
```

    FunctionalGate(name='FUNCTIONAL', n_qubits=3)
      000: +0.353553
      001: +0.353553
      010: +0.353553
      011: +0.353553
      100: +0.353553
      101: +0.353553
      110: +0.353553
    * 111: -0.353553

## Define new Named Gates

**HybridQ** allows the users to define new NamedGates to use across the library.

```python
# All named gates are defined in 'hybridq.gate.gate._available_gates'
from hybridq.gate.gate import _available_gates

# '_available_gates' is a dictionary with each key being a gate.
# All keys must be upper case.
_available_gates.keys()
```

    dict_keys(['I', 'H', 'X', 'Y', 'Z', 'U3', 'R_PI_2', 'ZZ', 'CZ', 'CX', 'SWAP', 'ISWAP', 'CPHASE', 'FSIM', 'RX', 'RY', 'RZ', 'SQRT_X', 'SQRT_Y', 'P', 'T', 'SQRT_SWAP', 'SQRT_ISWAP'])

```python
# Every gate requires a dictionary with the following keys:
# - 'mro': The set of properties the NamedGate will inherit from
# - 'methods': Extra methods to add to the NamedGate
# - 'static_dict': A dictionary will all the required static variables
# - 'docstring': Help string to add to the gate

# For instance, the controlled phase 'CPHASE' is defined as follows:
_available_gates['CPHASE']

# In this case, 'CPHASE' inherits from 'ParamGate' (because it requires
# 1 parameter), from 'QubitGate' (because it requires qubits), from 'Tags'
# (because we can add tags to the gate) and finally from 'Name' (because
# it has a name). The required static variables are then added to
# 'static_dict'. In this case, 'n_qubits == 2' (because the gate requires
# 2 qubits), 'n_params == 1' (because the gate requires 1 parameter).
# 'ParamGate' also requires a generator of the matrix representing the gate
# ('Matrix_gen'), which has the following signature:
#
# def Matrix_Gen(self, param_1, param_2, ..., param_k):
#     ...
```

    {'mro': (hybridq.gate.property.ParamGate,
      hybridq.gate.property.UnitaryGate,
      hybridq.gate.property.QubitGate,
      hybridq.base.property.Tags,
      hybridq.base.property.Name),
     'methods': {},
     'static_dict': {'n_qubits': 2,
      'n_params': 1,
      'Matrix_gen': <function hybridq.gate.gate.<lambda>(self, p)>}}

```python
# All properties for gates are in 'hybridq.gate.property'
from hybridq.gate import property as gate_pr
from hybridq.gate import Gate

# In this example, let's create a simple NamedGate that
# applies 'H' to the first qubits and 'RX' to the second
# qubits
Matrix_gen = lambda self, p: np.kron(
    Gate('H').matrix(),
    Gate('RX', params=[p]).matrix())
Matrix_gen(None, 0.42)
```

    array([[ 0.69157229+0.j        ,  0.        -0.14740341j,
             0.69157229+0.j        ,  0.        -0.14740341j],
           [ 0.        -0.14740341j,  0.69157229+0.j        ,
             0.        -0.14740341j,  0.69157229+0.j        ],
           [ 0.69157229+0.j        ,  0.        -0.14740341j,
            -0.69157229+0.j        ,  0.        +0.14740341j],
           [ 0.        -0.14740341j,  0.69157229+0.j        ,
             0.        +0.14740341j, -0.69157229+0.j        ]])

```python
# To add a new NamedGate, it is just enough to update
# '_available_gates'
_available_gates['HRX'] = dict(mro=(gate_pr.ParamGate, gate_pr.UnitaryGate,
                                    gate_pr.QubitGate, gate_pr.TagGate,
                                    gate_pr.NameGate),
                               methods={},
                               static_dict=dict(n_params=1,
                                                n_qubits=2,
                                                Matrix_gen=Matrix_gen))

# Aliases to gates are defined in '_gate_aliases'
from hybridq.gate.gate import _gate_aliases
_gate_aliases['NEW_SPECIAL_GATE'] = 'HRX'

# Once '_available_gates' and '_gate_aliases' are set,
# the newly created NamedGate can be generated as usual
print(Gate('new_special_gate').on([1, 2]).set_params([0.42]))

Gate('new_special_gate').set_params([1.32]).matrix()
```

    Gate_HRX(name='HRX', qubits=(1, 2), params=(0.42,))

    /home/dagart/local/miniconda3/envs/hybridq/lib/python3.8/site-packages/hybridq/gate/gate.py:291: UserWarning: 'NEW_SPECIAL_GATE' is an alias for 'HRX'. Using Gate(name='{_gate_aliases[name]}').
      warn(f"'{name}' is an alias for '{_gate_aliases[name]}'. "

    array([[ 0.55860886+0.j        ,  0.        -0.43353908j,
             0.55860886+0.j        ,  0.        -0.43353908j],
           [ 0.        -0.43353908j,  0.55860886+0.j        ,
             0.        -0.43353908j,  0.55860886+0.j        ],
           [ 0.55860886+0.j        ,  0.        -0.43353908j,
            -0.55860886+0.j        ,  0.        +0.43353908j],
           [ 0.        -0.43353908j,  0.55860886+0.j        ,
             0.        +0.43353908j, -0.55860886+0.j        ]])

```python
# It is important to keep in mind the order of qubits matter.
# In this case, H is applied to the first qubits and RX to the
# second qubits. Therefore Gate('HRX').on([1, 2]) is different
# from Gate('HRX').on([2, 1])
try:
    np.testing.assert_allclose(
        Gate('hrx', params=[0.42]).on([1, 2]).matrix(),
        Gate('hrx', params=[0.42]).on([2, 1]).matrix(order=[1, 2]))
except Exception as e:
    print(e)
else:
    raise RuntimeError('Something went wrong')
```

    Not equal to tolerance rtol=1e-07, atol=0

    Mismatched elements: 10 / 16 (62.5%)
    Max absolute difference: 1.38314458
    Max relative difference: 4.79708568
     x: array([[ 0.691572+0.j      ,  0.      -0.147403j,  0.691572+0.j      ,
             0.      -0.147403j],
           [ 0.      -0.147403j,  0.691572+0.j      ,  0.      -0.147403j,...
     y: array([[ 0.691572+0.j      ,  0.691572+0.j      ,  0.      -0.147403j,
             0.      -0.147403j],
           [ 0.691572+0.j      , -0.691572+0.j      ,  0.      -0.147403j,...

```python
# If the gate doesn't require any parameters, one can drop the
# 'ParamGate' property and use 'MatrixGate' instead:
_available_gates['HH'] = dict(
    mro=(gate_pr.MatrixGate, gate_pr.UnitaryGate, gate_pr.QubitGate,
         gate_pr.TagGate, gate_pr.NameGate),
    methods={},
    static_dict=dict(n_qubits=2,
                     Matrix=np.kron(Gate('H').matrix(),
                                    Gate('H').matrix())))

# Get gate
print(Gate('hh'))
Gate('hh').matrix()
```

    Gate_HH(name='HH', n_qubits=2, M=numpy.ndarray(shape=(4, 4), dtype=float64))

    array([[ 0.5,  0.5,  0.5,  0.5],
           [ 0.5, -0.5,  0.5, -0.5],
           [ 0.5,  0.5, -0.5, -0.5],
           [ 0.5, -0.5, -0.5,  0.5]])

## Gate Implementation

Every property must inherit from \_\_Base__, which will add to the object the basic functionality to work with the **HybridQ** library.

```python
from hybridq.base import __Base__

class A(__Base__):
    pass
```

The **HybridQ** library provides useful decorator to help developing new properties. At the moment, **HybridQ** provides:
<od>

staticvars

compare

requires

generate
</od>

staticvars is used to add variables which must provided while creating the object. compare is used to specify which variables should be compared in \_\_eq__. Similarly, requires is used to specify which variables the final object should have once created. Finally, generate is used to create **HybridQ** objects.

```python
from hybridq.base import staticvars, generate

# Let's start creating an object with two static variables a and b,
# with a having a default value a=1. We want to make sure that a is
# always an integer between 0 and 10 while b can be any string.
@staticvars('a,b',
            a=1,
            transform=dict(a=int, b=str),
            check=dict(a=lambda x: 0 <= x <= 10))
class A(__Base__):
    pass

# Generate new type
new_type = generate('NewType', (A, ), b=2.2)

# Get new object
o = new_type()

# Get values
print((o.a, o.b))

# Trying to create a new type with a not in [0, 10] will fail
try:
    generate('NewType', (A, ), a=-1, b='this is a string')
except Exception as e:
    print(e)
else:
    raise RuntimeError("Something went wrong")

# Also, not specifying b will fail
try:
    generate('NewType', (A, ))
except Exception as e:
    print(e)
else:
    raise RuntimeError("Something went wrong")

# Static variables are by default read-only
try:
    o.a = 1
except Exception as e:
    print(e)
else:
    raise RuntimeError("Something went wrong")
```

    (1, '2.2')
    Check failed for 'a'
    Static variable 'b' must be provided.
    Cannot set static variable

```python
from hybridq.base import compare

# By default, '__eq__' does not compare any variables
o1 = generate('NewType', (A, ), a=1, b=2)()
o2 = generate('NewType', (A, ), a=3, b='hello!')()

# Even if the two objects have different static vars,
# __eq__ returns True
assert (o1 == o2)

# The decorator 'compare' can be used to specify which
# variables to compare
@compare('a,c', cmp=dict(c=lambda x, y: int(x) == int(y)))
class B(A, b='default'):
    def __init_subclass__(cls, b=None, **kwargs):
        # If 'b' is not provided, use the default value
        b = cls.__get_staticvar__('b') if b is None else b

        # Call super
        super().__init_subclass__(b=b, **kwargs)

    def __init__(self, c):
        self.c = c

# Generate new objects
o1 = generate('NewTypeB', (B, ))(c=1)
o2 = generate('NewTypeB', (B, ))(c=2)
o3 = generate('NewTypeB', (B, ), b='not default')(c=1.3)

# Because a and c are compared, only o1 == o3
assert (o1 != o2)
assert (o1 == o3)
assert (o2 != o3)
```

```python
from hybridq.base import requires

# Object may require variables that are not directly
# implemented but provided at the creation time
@requires('b')
class A(__Base__):
    pass

@staticvars('b')
class B(__Base__):
    pass

# Creating a new type with 'A' alone will fail
try:
    generate('NewType', (A, ))
except Exception as e:
    print(e)
else:
    raise RuntimeError("Something went wrong")

# However, since 'B' provides 'b', generating a
# new type from (B, A) will succede
generate('NewType', (B, A), b=42)
```

    The following required attributes are not provided: ('b',)

    hybridq.base.base.NewType

```python
import pickle

# All objects derived from __Base__ are pickable. This
# is very useful when the use of 'multiprocessing' is
# required
@staticvars('a,b,c', a=1, b=2, c=3)
class A(__Base__):
    pass

@staticvars('x,y', x='a', y='b')
class B(A):
    def __init__(self, q=None):
        self.q = q

# Get new object
o = generate('NewType', (B, A))(q='test')

# Print
for k in 'abcxyq':
    print(f'o[{k}] = {getattr(o, k)}')

# Dump/load using pickle
o = pickle.loads(pickle.dumps(o))

# Print
print('---')
for k in 'abcxyq':
    print(f'o[{k}] = {getattr(o, k)}')

# __Base__ objects can also be hashed. Hashing is
# done by using binary provided by pickle
print('---')
print(f'hash(o) = {hash(o)}')
```

    o[a] = 1
    o[b] = 2
    o[c] = 3
    o[x] = a
    o[y] = b
    o[q] = test
    ---
    o[a] = 1
    o[b] = 2
    o[c] = 3
    o[x] = a
    o[y] = b
    o[q] = test
    ---
    hash(o) = -6118662939848612053

## Conclusion
Thank you for trying out this tutorial. See the examples in the `examples/` folder for more details on how to use the package. Please checkout the other tutorials in the tutorials folder. Please open an issue here: https://github.com/nasa/hybridq/issues if you run into any issues or have a question. You can also contact Salvatore Mandra at salvatore.madra@nasa.gov.


# 17. Random Forest and Gradient Boosted Trees Classifier

[](https://colab.research.google.com/github/rhennig/EMA6938/blob/main/Notebooks/17.RandomForest.ipynb)

Previously, we used a Decision Tree Classifier to learn the fcc, bcc, and hcp crystal structure of 47 elements with Scikit-learn. Now, we will train a random forest and a gradient boosted trees model.

Let's first load the required libraries.

```python
# Install the mendeleev and poymatgen packages using pip in the current Jupyter kernel
# To use them, you may need to restart the kernel
import sys
!{sys.executable} -m pip install mendeleev
!{sys.executable} -m pip install pymatgen

import pymatgen as pymat
from pymatgen.core.periodic_table import Element
import mendeleev as mendel
import pandas as pd
import numpy as np
import random

import matplotlib.pyplot as plt
```

    Requirement already satisfied: mendeleev in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (0.9.0)
    Requirement already satisfied: numpy<2.0.0,>=1.19.5 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (1.22.2)
    Requirement already satisfied: pyfiglet<0.9,>=0.8.post1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (0.8.post1)
    Requirement already satisfied: colorama<0.5.0,>=0.4.4 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (0.4.4)
    Requirement already satisfied: pandas>=0.25.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (1.3.5)
    Requirement already satisfied: SQLAlchemy<2.0.0,>=1.3.23 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (1.4.31)
    Requirement already satisfied: six<2.0.0,>=1.15.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (1.15.0)
    Requirement already satisfied: Pygments<3.0.0,>=2.8.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from mendeleev) (2.10.0)
    Requirement already satisfied: python-dateutil>=2.7.3 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pandas>=0.25.0->mendeleev) (2.8.2)
    Requirement already satisfied: pytz>=2017.3 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pandas>=0.25.0->mendeleev) (2021.3)
    Requirement already satisfied: greenlet!=0.4.17 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from SQLAlchemy<2.0.0,>=1.3.23->mendeleev) (1.1.2)
    Requirement already satisfied: pymatgen in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (2022.1.9)
    Requirement already satisfied: pybtex in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (0.24.0)
    Requirement already satisfied: sympy in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (1.9)
    Requirement already satisfied: pandas in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (1.3.5)
    Requirement already satisfied: networkx>=2.2 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (2.6.3)
    Requirement already satisfied: uncertainties>=3.1.4 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (3.1.6)
    Requirement already satisfied: scipy>=1.5.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (1.7.3)
    Requirement already satisfied: tqdm in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (4.62.3)
    Requirement already satisfied: ruamel.yaml>=0.15.6 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (0.17.20)
    Requirement already satisfied: spglib>=1.9.9.44 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (1.16.3)
    Requirement already satisfied: matplotlib>=1.5 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (3.4.3)
    Requirement already satisfied: palettable>=3.1.1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (3.3.0)
    Requirement already satisfied: plotly>=4.5.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (5.5.0)
    Requirement already satisfied: tabulate in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (0.8.9)
    Requirement already satisfied: monty>=3.0.2 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (2022.1.12.1)
    Requirement already satisfied: Cython>=0.29.23 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (0.29.26)
    Requirement already satisfied: requests in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (2.27.1)
    Requirement already satisfied: numpy>=1.20.1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pymatgen) (1.22.2)
    Requirement already satisfied: pillow>=6.2.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from matplotlib>=1.5->pymatgen) (8.3.2)
    Requirement already satisfied: pyparsing>=2.2.1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from matplotlib>=1.5->pymatgen) (2.4.7)
    Requirement already satisfied: kiwisolver>=1.0.1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from matplotlib>=1.5->pymatgen) (1.3.2)
    Requirement already satisfied: cycler>=0.10 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from matplotlib>=1.5->pymatgen) (0.11.0)
    Requirement already satisfied: python-dateutil>=2.7 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from matplotlib>=1.5->pymatgen) (2.8.2)
    Requirement already satisfied: tenacity>=6.2.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from plotly>=4.5.0->pymatgen) (8.0.1)
    Requirement already satisfied: six in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from plotly>=4.5.0->pymatgen) (1.15.0)
    Requirement already satisfied: ruamel.yaml.clib>=0.2.6 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from ruamel.yaml>=0.15.6->pymatgen) (0.2.6)
    Requirement already satisfied: future in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from uncertainties>=3.1.4->pymatgen) (0.18.2)
    Requirement already satisfied: pytz>=2017.3 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pandas->pymatgen) (2021.3)
    Requirement already satisfied: latexcodec>=1.0.4 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pybtex->pymatgen) (2.0.1)
    Requirement already satisfied: PyYAML>=3.01 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from pybtex->pymatgen) (5.4.1)
    Requirement already satisfied: idna<4,>=2.5 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from requests->pymatgen) (3.1)
    Requirement already satisfied: certifi>=2017.4.17 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from requests->pymatgen) (2021.10.8)
    Requirement already satisfied: urllib3<1.27,>=1.21.1 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from requests->pymatgen) (1.26.7)
    Requirement already satisfied: charset-normalizer~=2.0.0 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from requests->pymatgen) (2.0.9)
    Requirement already satisfied: mpmath>=0.19 in /Users/rhennig/opt/anaconda3/envs/tf2/lib/python3.8/site-packages (from sympy->pymatgen) (1.2.1)

### Getting the dataset

We select 47 elements that occur in the fcc, hcp, and bcc structure. The elements listed were chosen because querying them for these properties yields a dataset with no unknown values, and because they represent the three most common crystallographic structures.

We then query both Pymatgen and Mendeleev to get a complete set of properties per element. We will use this data to create the features from which the model will train and test.

```python
fcc_elements = ["Ag", "Al", "Au", "Cu", "Ir", "Ni", "Pb", "Pd", "Pt", "Rh", "Th", "Yb"]
bcc_elements = ["Ba", "Ca", "Cr", "Cs", "Eu", "Fe", "Li", "Mn", "Mo", "Na", "Nb", "Rb", "Ta", "V", "W" ]
hcp_elements = ["Be", "Cd", "Co", "Dy", "Er", "Gd", "Hf", "Ho", "Lu", "Mg", "Re",
                "Ru", "Sc", "Tb", "Ti", "Tl", "Tm", "Y", "Zn", "Zr"]

elements = fcc_elements + bcc_elements + hcp_elements

random.Random(1).shuffle(elements)

querable_mendeleev = ["atomic_number", "atomic_volume", "boiling_point", "en_ghosh",  "evaporation_heat", "heat_of_formation",
                      "melting_point", "specific_heat"]
querable_pymatgen = ["atomic_mass", "atomic_radius", "electrical_resistivity","molar_volume", "bulk_modulus", "youngs_modulus",
                     "average_ionic_radius", "density_of_solid", "coefficient_of_linear_thermal_expansion"]
querable_values = querable_mendeleev + querable_pymatgen
```

We will use the database queries to populate a pandas dataframe.

```python
all_values = [] # Values for Attributes
all_labels = [] # Crystal structure labels (0 = fcc, 1 = bcc, 2 = hcp)

for item in elements:
    element_values = []

    # This section queries Mendeleev
    element_object = mendel.element(item)
    for i in querable_mendeleev:
        element_values.append(getattr(element_object,i))

    # This section queries Pymatgen
    element_object = Element(item)
    for i in querable_pymatgen:
        element_values.append(getattr(element_object,i))

    all_values.append(element_values) # All lists are appended to another list, creating a List of Lists

    if (item in fcc_elements):
        all_labels.append(0) # The crystal structure labels are assigned here
    elif (item in bcc_elements):
        all_labels.append(1) # The crystal structure labels are assigned here
    elif (item in hcp_elements):
        all_labels.append(2) # The crystal structure labels are assigned here

# Pandas Dataframe
df = pd.DataFrame(all_values, columns=querable_values)

# We will patch some of the values that are not available in the datasets.

# Value for the CTE of Cesium
index_Cs = df.index[df['atomic_number'] == 55]
df.iloc[index_Cs, df.columns.get_loc("coefficient_of_linear_thermal_expansion")] = 0.000097
# Value from: David R. Lide (ed), CRC Handbook of Chemistry and Physics, 84th Edition. CRC Press. Boca Raton, Florida, 2003

# Value for the CTE of Rubidium
index_Rb = df.index[df['atomic_number'] == 37]
df.iloc[index_Rb, df.columns.get_loc("coefficient_of_linear_thermal_expansion")] = 0.000090
# Value from: https://www.azom.com/article.aspx?ArticleID=1834

# Value for the Evaporation Heat of Ruthenium
index_Ru = df.index[df['atomic_number'] == 44]
df.iloc[index_Ru, df.columns.get_loc("evaporation_heat")] = 595 # kJ/mol
# Value from: https://www.webelements.com/ruthenium/thermochemistry.html

# Value for the Bulk Modulus of Zirconium
index_Zr = df.index[df['atomic_number'] == 40]
df.iloc[index_Zr, df.columns.get_loc("bulk_modulus")] = 94 # GPa
# Value from: https://materialsproject.org/materials/mp-131/

df.head(n=10)
```

      atomic_number
      atomic_volume
      boiling_point
      en_ghosh
      evaporation_heat
      heat_of_formation
      melting_point
      specific_heat
      atomic_mass
      atomic_radius
      electrical_resistivity
      molar_volume
      bulk_modulus
      youngs_modulus
      average_ionic_radius
      density_of_solid
      coefficient_of_linear_thermal_expansion

      0
      27
      6.70
      3143.0
      0.143236
      389.1
      426.7
      1768.00
      0.456
      58.933195
      1.35
      6.000000e-08
      6.67
      180.0
      209.0
      0.768333
      8900.0
      0.000013

      1
      69
      18.10
      2220.0
      0.216724
      232.0
      232.2
      1818.00
      0.160
      168.934210
      1.75
      6.760000e-07
      19.10
      45.0
      74.0
      1.095000
      9321.0
      0.000013

      2
      39
      19.80
      3611.0
      0.121699
      367.0
      424.7
      1795.00
      0.284
      88.905850
      1.80
      6.000000e-07
      19.88
      41.0
      64.0
      1.040000
      4472.0
      0.000011

      3
      75
      8.85
      5900.0
      0.243516
      704.0
      774.0
      3453.00
      0.138
      186.207000
      1.35
      1.800000e-07
      8.86
      370.0
      463.0
      0.712500
      21020.0
      0.000006

      4
      28
      6.60
      3005.0
      0.147207
      378.6
      430.1
      1726.00
      0.443
      58.693400
      1.35
      7.200000e-08
      6.59
      180.0
      200.0
      0.740000
      8908.0
      0.000013

      5
      67
      18.70
      2968.0
      0.207795
      301.0
      300.6
      1747.00
      0.164
      164.930320
      1.75
      8.140000e-07
      18.74
      40.0
      65.0
      1.041000
      8795.0
      0.000011

      6
      79
      10.20
      3080.0
      0.261370
      340.0
      368.2
      1337.58
      0.129
      196.966569
      1.35
      2.200000e-08
      10.21
      220.0
      78.0
      1.070000
      19300.0
      0.000014

      7
      21
      15.00
      3104.0
      0.119383
      332.7
      377.8
      1814.00
      0.556
      44.955912
      1.60
      5.500000e-07
      15.00
      57.0
      74.0
      0.885000
      2985.0
      0.000010

      8
      45
      8.30
      4000.0
      0.140838
      494.0
      556.0
      2239.00
      0.244
      102.905500
      1.35
      4.300000e-08
      8.28
      380.0
      275.0
      0.745000
      12450.0
      0.000008

      9
      74
      9.53
      5930.0
      0.239050
      824.0
      851.0
      3680.00
      0.133
      183.840000
      1.35
      5.400000e-08
      9.47
      310.0
      411.0
      0.766667
      19250.0
      0.000005

### Processing and Organizing Data

We normalize the data and randomly split it into training and testing sets.

##### SETS

We have 47 elements for which the crystal structure is known and we will use 40 of these as a training set and the remaining 7 as testing set.

##### NORMALIZATION

We will again use the Standard Score Normalization, which subtracts the mean of the feature and divide by its standard deviation.
$$
\frac{X - µ}{σ}
$$
While our model might converge without feature normalization, the resultant model would be difficult to train and would be dependent on the choice of units used in the input.

```python
# SETS
all_values = [list(df.iloc[x]) for x in range(len(all_values))]

# List of lists are turned into Numpy arrays to facilitate calculations in steps to follow
# (Normalization).
all_values = np.array(all_values, dtype = float)
print("Shape of Values:", all_values.shape)
all_labels = np.array(all_labels, dtype = int)
print("Shape of Labels:", all_labels.shape)

# Training Set
train_values = all_values[:40, :]
train_labels = all_labels[:40]

# Testing Set
test_values = all_values[-7:, :]
test_labels = all_labels[-7:]

# NORMALIZATION

mean = np.nanmean(train_values, axis = 0) # mean
std = np.nanstd(train_values, axis = 0) # standard deviation

train_values = (train_values - mean) / std # input scaling
test_values = (test_values - mean) / std # input scaling

print(train_values[0]) # print a sample entry from the training set
print(train_labels[0])
```

    Shape of Values: (47, 17)
    Shape of Labels: (47,)
    [-0.80084167 -0.75983551 -0.00894162 -0.40732945  0.15599373  0.16654528
      0.09455406  0.02631292 -0.82400017 -0.80570946 -0.67799461 -0.75661221
      0.70972845  0.6516648  -0.77257498  0.11409173 -0.3075323 ]
    2

### Creating the Random Forest Model

For this classification, we will use a random forest.

```python
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn import tree

# Create Decision Tree classifer object
model = RandomForestClassifier()

# Train Decision Tree Classifer
model.fit(train_values, train_labels)
```

    RandomForestClassifier()

### Validation

We calculate the accuracy score on the training and the testing sets.

```python
#Predict the response for training and testing dataset
train_pred = model.predict(train_values)
test_pred = model.predict(test_values)

# Model Accuracy for training and testing set, how often is the classifier correct?

print('Training accuracy = %.3f ' % accuracy_score(train_labels, train_pred))
print('Testing accuracy  = %.3f ' % accuracy_score(test_labels, test_pred))
```

    Training accuracy = 1.000
    Testing accuracy  = 0.429

### Visualize the random forest model

Since the random forest consists of many decision trees, we can visualize the individual decision trees.

```python
label_names = ('fcc', 'bcc', 'hcp')
fig = plt.figure(figsize=(25,20))

# Select an individual decision tree, here 0.
_ = tree.plot_tree(model.estimators_[0], feature_names=querable_values,
                   class_names = label_names, filled=True, impurity=True, rounded=True)
```

```python
train_predictions = model.predict(train_values)
test_predictions = model.predict(test_values)

print("train_labels = ", train_labels)
print("test_labels = ", test_labels)

all_labels = np.hstack((train_labels, test_labels))
all_predictions = np.hstack((train_predictions, test_predictions))

predicted_labels = []
true_labels = []

for i in range(all_predictions.shape[0]):
    if (all_predictions[i] == 0):
        predicted_labels.append("FCC")
    if (all_labels[i] == 0):
        true_labels.append("FCC")
    if (all_predictions[i] == 1):
        predicted_labels.append("BCC")
    if (all_labels[i] == 1):
        true_labels.append("BCC")
    if (all_predictions[i] == 2):
        predicted_labels.append("HCP")
    if (all_labels[i] == 2):
        true_labels.append("HCP")

predicted_labels = np.array(predicted_labels).reshape((-1, 1))
true_labels = np.array(true_labels).reshape((-1, 1))
headings = ["Atomic number", "True crystal structure", "Predicted crystal structure"]

atomic_number_array = np.array(df.iloc[:, 0]).reshape((-1, 1))
plot_table = np.concatenate((atomic_number_array, true_labels, predicted_labels), axis=1)

plot_df = pd.DataFrame(plot_table, columns=headings)
```

    train_labels =  [2 2 2 2 0 2 0 2 0 1 2 2 2 0 1 2 1 1 1 1 1 2 0 0 1 2 1 1 2 1 1 0 2 2 0 2 0
     1 1 2]
    test_labels =  [2 2 0 1 0 2 0]

```python
plot_df
```

      Atomic number
      True crystal structure
      Predicted crystal structure

      0
      27
      HCP
      HCP

      1
      69
      HCP
      HCP

      2
      39
      HCP
      HCP

      3
      75
      HCP
      HCP

      4
      28
      FCC
      FCC

      5
      67
      HCP
      HCP

      6
      79
      FCC
      FCC

      7
      21
      HCP
      HCP

      8
      45
      FCC
      FCC

      9
      74
      BCC
      BCC

      10
      64
      HCP
      HCP

      11
      65
      HCP
      HCP

      12
      72
      HCP
      HCP

      13
      70
      FCC
      FCC

      14
      55
      BCC
      BCC

      15
      30
      HCP
      HCP

      16
      56
      BCC
      BCC

      17
      25
      BCC
      BCC

      18
      26
      BCC
      BCC

      19
      42
      BCC
      BCC

      20
      11
      BCC
      BCC

      21
      71
      HCP
      HCP

      22
      90
      FCC
      FCC

      23
      29
      FCC
      FCC

      24
      3
      BCC
      BCC

      25
      81
      HCP
      HCP

      26
      23
      BCC
      BCC

      27
      37
      BCC
      BCC

      28
      40
      HCP
      HCP

      29
      24
      BCC
      BCC

      30
      41
      BCC
      BCC

      31
      47
      FCC
      FCC

      32
      4
      HCP
      HCP

      33
      44
      HCP
      HCP

      34
      13
      FCC
      FCC

      35
      22
      HCP
      HCP

      36
      82
      FCC
      FCC

      37
      20
      BCC
      BCC

      38
      73
      BCC
      BCC

      39
      66
      HCP
      HCP

      40
      48
      HCP
      HCP

      41
      68
      HCP
      HCP

      42
      46
      FCC
      HCP

      43
      63
      BCC
      BCC

      44
      77
      FCC
      HCP

      45
      12
      HCP
      BCC

      46
      78
      FCC
      HCP

### Questions:

#### Hyperparameter optimization

1. We can select the `criterion` parameter to measure the quality of a split. The default value is `'squared_error'`.

2. When the algorithm performs a split, the main goal is to decrease impurity as much as possible. The more the impurity decreases, the more informative power that split gains. As the tree gets deeper, the amount of impurity decrease becomes lower. We can use this to prevent the tree from doing further splits. The hyperparameter for this task is `min_impurity_decrease`. Its default is zero. Try changing it to see the difference.

3. If the algorithm keeps splitting nodes, the model will probably be overfit. The `min_samples_split` parameter can be used to control the tree based on impurity values. It sets a threshold on gini. Try setting it to 0.3, so a node needs to have a gini value that is more then 0.3 to be further split.

4. Another hyperparameter to control the depth of a tree is `max_depth`. It does not make any calculations regarding impurity or sample ratio. The model stops splitting when max_depth is reached. Note that `max_depth` is less flexible compared to min_impurity_decrease.

5. Another hyperparameter is `min_samples_leaf`. It indicates the minimum number of samples required to be at a leaf node.

6. We can also limit the number of leaf nodes using `max_leaf_nodes` parameter which grows the tree in best-first fashion until max_leaf_nodes reached. The best split is decided based on impurity decrease.

7. Another important hyperparameter of decision trees is `max_features` which is the number of features to consider when looking for the best split. If not specified, the model considers all of the features. There is only 1 feature in our dataset.

To change the hyperparameters:

`regressor = RandomForestRegressor(hyperparameter = value)`

Change the `max_depth` and `min_samples_split` to see how this affects the training and prediction error.

```python
# Create Decision Tree classifer object
model = RandomForestClassifier(max_depth=3, min_samples_split=0.5)

# Train Decision Tree Classifer
model.fit(train_values, train_labels)

#Predict the response for training and testing dataset
train_pred = model.predict(train_values)
test_pred = model.predict(test_values)

# Model Accuracy for training and testing set, how often is the classifier correct?

print('Training accuracy = %.3f ' % accuracy_score(train_labels, train_pred))
print('Testing accuracy  = %.3f ' % accuracy_score(test_labels, test_pred))

label_names = ('fcc', 'bcc', 'hcp')
fig = plt.figure(figsize=(25,20))
_ = tree.plot_tree(model.estimators_[0], feature_names=querable_values, class_names = label_names, filled=True)
```

### Creating the Gradient Boosted Trees Model

Next, we will test gradient boosted trees for this classification.

```python
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score
from sklearn import tree

# Create Decision Tree classifer object
model = GradientBoostingClassifier()

# Train Decision Tree Classifer
model.fit(train_values, train_labels)
```

    GradientBoostingClassifier()

### Validation

We calculate the accuracy score on the training and the testing sets.

```python
#Predict the response for training and testing dataset
train_pred = model.predict(train_values)
test_pred = model.predict(test_values)

# Model Accuracy for training and testing set, how often is the classifier correct?

print('Training accuracy = %.3f ' % accuracy_score(train_labels, train_pred))
print('Testing accuracy  = %.3f ' % accuracy_score(test_labels, test_pred))
```

    Training accuracy = 1.000
    Testing accuracy  = 0.571

As we can see, for this dataset all the tree-based models do comparably well.

```python

```


Table of Contents

1  Introduction and Statement of Intent

2  Pulse Response Zero Forcing

# DFE Modeling for Equalization Optimization

Original author: David Banas <capn.freako@gmail.com>
Original date:   December 11, 2016

Copyright (c) 2016 David Banas; all rights reserved World wide.

This [iPython notebook](http://ipython.org/ipython-doc/3/notebook/notebook.html#introduction) explores the topic of modeling [DFE](https://www.quora.com/How-does-a-Decision-Feedback-Equalizer-DFE-work) behavior when [optimizing equalization](http://www.ti.com/tool/TIDA-00353).

It draws upon the [pybert](https://github.com/capn-freako/PyBERT/wiki) Python package. You will need this package installed, in order to run/modify this notebook.

## Introduction and Statement of Intent

When optimizing high speed serial communication link equalization, it is important to include the effects of the decision feedback equalizer (DFE), when calculating the cost function of the equalization algorithm, as not doing so might result in a gross underutilization of this very powerful component and a resultant non-optimum eye. However, it is often not feasible to allow the DFE to fully adapt and process the sample data stream, at every iteration of the optimization algorithm, due to modeling performance limitations. That is, the DFE model often simply takes too long to run, to be included in an algorithmic loop, which runs an appreciable number of times.

As a result, several "tricks" for modeling the DFE behavior with less computational overhead have been tried and published in the literature. Here, we assess one such trick: *pulse response zero forcing*. We first explain the technical details of this approach to DFE modeling and then test its practical usefulness, by attempting to use it in a real link equalization optimization exercise, evaluating the results.

## Pulse Response Zero Forcing

The idea behind pulse response zero forcing follows from the observation that the DFE tries to bring the system pulse response to zero, at every clock instant after the cursor, up to a maximum number, N, which is equal to the number of taps in the DFE filter. The code, below, along with its associated figures, illustrate this.

Simulation/Model Notes:

- The channel is modeled, using Dr. Howard Johnson's *Metalic Transmission Model* with the following assumptions:
  - Channel length = 1m
  - Channel material is 24 guage copper unshielded twisted pair.
  - Both Tx outputs and both Rx inputs are assumed to have 0.5 pF parasitic capacitance.
- The simulation control parameters are as follows:
  - Bit rate = 10 Gbps
  - Pattern length = 127 bits
  - Samples per bit = 32
  - Modulation = NRZ
  - CTLE peak location = 5 GHz
  - CTLE type = 'passive' (i.e. - |H(f)| <= 1)
  - Unequalized Rx signal path bandwidth = 12 GHz

```python
%matplotlib inline
from matplotlib import pyplot   as plt
from numpy      import array
from time       import sleep
from pybert     import pybert

myPyBERT = pybert.PyBERT(run_simulation=False)
myPyBERT.use_dfe_tune=True
myPyBERT.ctle_mode_tune = 'Passive'
myPyBERT._btn_opt_rx_fired()
while(myPyBERT.rx_opt_thread.isAlive()):
    sleep(0.1)
print("Settings:")
print("\tUse DFE:", myPyBERT.use_dfe_tune)
print("\t# of DFE taps:", pybert.gNtaps)
print("Results:")
print("\tCTLE Magnitude:", myPyBERT.peak_mag_tune, "dB")
print("\tRelative Optimization:", myPyBERT.rel_opt)
t                = myPyBERT.t
t_ns             = t * 1.e9
pulse_resp_ideal = myPyBERT.plotdata['ctle_out_h_tune']
clocks_tune      = myPyBERT.plotdata['clocks_tune']
plt.plot(t_ns[:len(pulse_resp_ideal)], pulse_resp_ideal)
plt.plot(t_ns[:len(clocks_tune)],      clocks_tune)
plt.title('System Pulse Response After Optimization - Perfect DFE assumed.')
plt.ylabel('Pulse Response (V)')
plt.xlabel('Time (ns)')
plt.axis(xmin=0.5, xmax=1.5)
```

The plot, above, shows the *assumed*, or *ideal*, system pulse response after Rx optimization, $\hat{p}(t)$, along with the estimated sample clock instants. The reported final optimization metric (i.e. - "Relative Optimization") is simply taken as the negative of the cost function used during optimization. And that cost function is defined as:

$$
cost = \sum_{n \neq 0}{abs[\hat{p}(nT)]} - \hat{p}(0)
$$

where *T* is the clock period and *n* is the clock instant index, with index "0" referring to the main lobe.

Essentially, we are defining *cost* as *ISI* minus the main lobe height.

Note that, for the first 5 post-cursor clocking instants, the system pulse response has been assumed zero, due to the action of an ideal DFE. This is the *pulse response zero forcing* assumption.

Let's see how our eye looks, using the "optimum" CTLE peak magnitude found, above...

```python
from numpy              import linspace
from pybert.pybert_util import calc_eye

self = myPyBERT  # In order to avoid changing "self" to "myPyBERT" in many places, below.

self._btn_save_eq_fired()
self.gain            = 0.5  # DFE correction gain.
self.decision_scaler = 0.4  # Approx. pulse response magnitude, above.
pybert.my_run_simulation(self, initial_run=True, update_plots=False)

# The code, below, is taken from 'pybert_cntrl.py'.
# I'm recreating this functionality, here, instead of just letting 'myPyBERT' do the job, for two reasons:
#  1. The eye generation is one of the slower parts of the code, and many eyes are generated;
#     we're only interested in one of those, here.
#  2. There is the potential for graphics back-end conflict, between myPyBERT and this notebook,
#     to occur, if I call upon the graphics-related functionality of myPyBERT, here.
ui            = self.ui
samps_per_ui  = self.nspui
eye_uis       = self.eye_uis
num_ui        = self.nui
clock_times   = self.clock_times
ignore_until  = (num_ui - eye_uis) * ui

width    = 2 * samps_per_ui
xs       = linspace(-ui * 1.e12, ui * 1.e12, width)
height   = 100
i = 0
while(clock_times[i] <= ignore_until):
    i += 1
    assert i < len(clock_times), "ERROR: Insufficient coverage in 'clock_times' vector."
y_max    = 1.1 * max(abs(array(self.dfe_out)))
eye_dfe  = calc_eye(ui, samps_per_ui, height, self.dfe_out, y_max, clock_times[i:])
plt.imshow(eye_dfe, extent=[-ui * 1.e9, ui * 1.e9, -y_max, y_max], aspect='auto')
plt.xlabel('Time (ns)')
plt.ylabel('Eye (V)')
plt.title('Eye at Slicer Input - Perfect DFE assumed, during optimization.')

```

Okay, looks pretty good. Note that the eye, above, was generated after letting the *actual* DFE adapt normally. The "Perfect DFE assumed." in the plot title refers only to the optimization of the CTLE peaking magnitude.

Let's make sure the DFE fully adapted...

```python
i = 1
for item in array(myPyBERT.adaptation).transpose():
    plt.plot(item, label='tap_{}'.format(i))
    i += 1
plt.legend(loc='upper left')
plt.title('DFE Adaptation - Perfect DFE assumed, during optimization.')
plt.ylabel('Coefficient Value')
plt.xlabel('Time')

```

It certainly appears that the DFE has adapted to a stable state.

Let's check the pulse response of this stable, adapted system against our ideal assumption, above...

```python
# Calculate and plot the pulse response of fully adapted PyBERT instance.
# We assume everything up to the DFE is LTI.
# Therefore, we take, as the input to the DFE, the system pulse response up to that point.
from numpy      import concatenate, convolve, repeat
from pybert.dfe import DFE

self = myPyBERT

nspui           = self.nspui
delta_t         = self.delta_t * 1.e-12
alpha           = self.alpha
ui              = self.ui
n_taps          = self.n_taps
gain            = 0.                       # Kills adaptation.
n_ave           = self.n_ave
decision_scaler = self.decision_scaler
n_lock_ave      = self.n_lock_ave
rel_lock_tol    = self.rel_lock_tol
lock_sustain    = self.lock_sustain
bandwidth       = self.sum_bw * 1.e9
rel_thresh      = self.thresh
mod_type        = self.mod_type[0]

# We instantiate a pre-adapted, static DFE, as follows:
#  - Pass '0' for the second parameter (adaptation gain); this kills adaptation.
#  - Load the tap weights, after instantiation, with those of the adapted DFE, above.
dfe = DFE(  n_taps, gain, delta_t, alpha, ui, nspui, decision_scaler, mod_type,
            n_ave=n_ave, n_lock_ave=n_lock_ave, rel_lock_tol=rel_lock_tol,
            lock_sustain=lock_sustain, bandwidth=bandwidth, ideal=self.sum_ideal)
dfe.tap_weights = self.adaptation[-1]  # In Python, "-1" given as an array index returns the last element.

dfe_in = self.ctle_out_p.copy()         # System pulse response, up to DFE input.
(dfe_out, tap_weights, ui_ests, clocks, lockeds, clock_times, bits_out) = dfe.run(t[:len(dfe_in)], dfe_in)
plt.plot(t_ns[:len(dfe_out)], dfe_out, label='Actual')
plt.plot(t_ns[:len(pulse_resp_ideal[nspui:])], pulse_resp_ideal[nspui:], label='Ideal')
plt.legend(loc='upper right')
plt.axis(xmin=0.5, xmax=1.5)
plt.title('Actual vs. Ideal Pulse Response')
plt.xlabel('Time (ns)')
plt.ylabel('p(t) (V)')

```

The plot, above, illustrates an interesting property of DFEs: they oscillate, when their inputs are too close to zero! And, unfortunately, this prevents us from testing our *perfect DFE* assumption, using the pulse response. However, there's always the *step* response...

We can derive the step response required, in order to produce the *perfect DFE* pulse response, above, as follows:

\begin{align}
\hat{p}(t) & = \hat{s}(t) - \hat{s}(t - T) \\
\hat{p}(nT) & = \hat{s}(nT) - \hat{s}((n - 1)T) = 0 , \; n > 0 \\
\end{align}

or

$$\hat{s}(nT) = \hat{s}((n - 1)T) , \; n > 0$$

where:

- $\hat{p}$ is the *perfect DFE* system pulse response shown, above,
- $\hat{s}$ is the equivalent step response, and
- $T$ is the unit interval.

We can easily construct such a step response, starting with our actual post-CTLE system step response, as follows:

```python
from numpy import where

s_hat = myPyBERT.ctle_out_s.copy()
idx = where(myPyBERT.ctle_out_p == max(myPyBERT.ctle_out_p))[0][0]
idx += 2 * nspui  # There's a 1 UI delay in the pulse response calculated by PyBERT.
while(idx < len(s_hat)):
    s_hat[idx - nspui//2:] -= s_hat[idx] - s_hat[idx - nspui]
    idx += nspui
plt.plot(t_ns[:len(myPyBERT.ctle_out_s)], myPyBERT.ctle_out_s, label='Actual')
plt.plot(t_ns[:len(s_hat)], s_hat, label='Ideal')
plt.title('Actual System Step Response vs. As Corrected by Perfect DFE')
plt.xlabel('Time (ns)')
plt.ylabel('s(t) (V)')
plt.legend(loc='upper left')

```

And, now, we can compare this ideal step response with our actual step response, by sending the step response of our system, up to the DFE input, through the fully adapted DFE...

```python
# Calculate and plot the step response of fully adapted PyBERT instance.
# We assume everything up to the DFE is LTI.
# Therefore, we take, as the input to the DFE, the system step response up to that point.

# We instantiate a pre-adapted, static DFE, as follows:
#  - Pass '0' for the second parameter (adaptation gain); this kills adaptation.
#  - Load the tap weights, after instantiation, with those of the adapted DFE, above.
dfe = DFE(  n_taps, gain, delta_t, alpha, ui, nspui, decision_scaler, mod_type,
            n_ave=n_ave, n_lock_ave=n_lock_ave, rel_lock_tol=rel_lock_tol,
            lock_sustain=lock_sustain, bandwidth=bandwidth, ideal=self.sum_ideal)
dfe.tap_weights = self.adaptation[-1]

dfe_in = self.ctle_out_s.copy()         # System step response, up to DFE input.
dfe_in -= 0.5 * max(dfe_in)             # Make it d.c. balanced.
dfe_in *= 3.                            # Provide just enough scaling to avoid DFE oscillation.

ref = s_hat.copy()                      # Reference waveform is arbitrarily scaled and offset,
ref *= 2.9                              # in order to match "front porch" and first positive
ref -= 0.38                             # sample values.

(dfe_out, tap_weights, ui_ests, clocks, lockeds, clock_times, bits_out) = dfe.run(t[:len(dfe_in)], dfe_in)
plt.plot(t_ns[:len(dfe_out)], dfe_out, label='Actual')
plt.plot(t_ns[:len(ref)], ref, label='Ideal')
plt.axis(xmin=0.5)
plt.title('Actual vs. Ideal Step Response')
plt.xlabel('Time (ns)')
plt.ylabel('s(t) (V)')
plt.legend(loc='lower right')

```

Hmm, not a very good match. Let's calculate the pulse response implied by this new step response, and compare back to our ideal pulse response...

```python
from numpy import pad

p_test = dfe_out - pad(dfe_out[:-nspui], (nspui,0), 'constant', constant_values=(0,0))
plt.plot(t_ns[:len(p_test)], p_test, label='Actual')
plt.plot(t_ns[:len(pulse_resp_ideal)], pulse_resp_ideal * 3., label='Ideal')
plt.axis(xmin=0.5, xmax=1.2)
plt.legend(loc='lower right')
plt.title('Actual vs. Ideal Pulse Response')
plt.xlabel('Time (ns)')
plt.ylabel('p(t) (V)')

```

So, our *zero forcing* approach to including the DFE behavior in link optimization is not perfect. But, how does it compare to the results we get, when we optimize Rx equalization assuming no DFE?

```python
myPyBERT.use_dfe_tune = False
myPyBERT._btn_opt_rx_fired()
while(myPyBERT.rx_opt_thread.is_alive()):
    sleep(0.1)
print("Results:")
print("\tCTLE Magnitude:", myPyBERT.peak_mag_tune, "dB")
print("\tRelative Optimization:", myPyBERT.rel_opt)
pulse_resp_nodfe = myPyBERT.plotdata['ctle_out_h_tune']
clocks_tune      = myPyBERT.plotdata['clocks_tune']
plt.plot(t_ns[:len(pulse_resp_nodfe)], pulse_resp_nodfe)
plt.plot(t_ns[:len(clocks_tune)],      clocks_tune)
plt.title('System Pulse Response After Optimization - No DFE assumed, during optimization.')
plt.ylabel('Pulse Response (V)')
plt.xlabel('Time (ns)')
plt.axis(xmin=0.5, xmax=1.2)

```

The optimum CTLE peaking magnitude found is much larger than in the previous case (14 dB vs. 2.8 dB), while the reported *Relative Optimization* is less than half (0.13 vs. 0.28)!

Let's see what the new eye looks like...

```python
self._btn_save_eq_fired()
self.use_dfe = True  # It gets turned off, by the above step.
pybert.my_run_simulation(self, initial_run=True, update_plots=False)

ui            = self.ui
samps_per_ui  = self.nspui
eye_uis       = self.eye_uis
num_ui        = self.nui
clock_times   = self.clock_times
ignore_until  = (num_ui - eye_uis) * ui

width    = 2 * samps_per_ui
xs       = linspace(-ui * 1.e12, ui * 1.e12, width)
height   = 100
i = 0
while(clock_times[i] <= ignore_until):
    i += 1
    assert i < len(clock_times), "ERROR: Insufficient coverage in 'clock_times' vector."
y_max    = 1.1 * max(abs(array(self.dfe_out)))
eye_nodfe  = calc_eye(ui, samps_per_ui, height, self.dfe_out, y_max, clock_times[i:])
plt.set_cmap('jet')
plt.imshow(eye_nodfe, extent=[-ui * 1.e9, ui * 1.e9, -y_max, y_max], aspect='auto')
plt.xlabel('Time (ns)')
plt.ylabel('Eye (V)')
plt.title('Eye at Slicer Input - No DFE assumed.')

```

The inner eye height is significantly diminished, in this case (approx. 300mV vs. 500 mV, previously).

But, what about the DFE, this time?

```python
i = 1
for item in array(myPyBERT.adaptation).transpose():
    plt.plot(item, label='tap_{}'.format(i))
    i += 1
plt.legend(loc='upper left')
plt.title('DFE Adaptation - No DFE assumed, during optimization.')
plt.ylabel('Coefficient Value')
plt.xlabel('Time')

```

As we might have suspected, the DFE is hardly being used, in this case. This is not really a surprise, as we optimized the Rx equalization under the assumption that no DFE was present. And, as is evidenced by the diminished inner eye height, we have paid a price for ignoring the DFE contribution to overall link equalization, during optimization. It would appear that, despite its imperfections, the *zero forcing* approach to including DFE behavior in the Rx equalization optimization process definitely yields superior results than simply ignoring the DFE, during optimizatioon.

```python

```


```python
# This cell is mandatory in all Dymos documentation notebooks.
missing_packages = []
try:
    import openmdao.api as om
except ImportError:
    if 'google.colab' in str(get_ipython()):
        !python -m pip install openmdao[notebooks]
    else:
        missing_packages.append('openmdao')
try:
    import dymos as dm
except ImportError:
    if 'google.colab' in str(get_ipython()):
        !python -m pip install dymos
    else:
        missing_packages.append('dymos')
try:
    import pyoptsparse
except ImportError:
    if 'google.colab' in str(get_ipython()):
        !pip install -q condacolab
        import condacolab
        condacolab.install_miniconda()
        !conda install -c conda-forge pyoptsparse
    else:
        missing_packages.append('pyoptsparse')
if missing_packages:
    raise EnvironmentError('This notebook requires the following packages '
                           'please install them and restart this notebook\'s runtime: {",".join(missing_packages)}')
```

(examples:the_mountain_car_problem)=
# The Mountain Car Problem

The mountain car problem proposes a vehicle stuck in a "well."  It lacks the power to directly climb out of the well, but instead must accelerate repeatedly forwards and backwards until it has achieved the energy necessary to exit the well.

The problem is a popular machine learning test case, though the methods in Dymos are capable of solving it.
It first appeared in the PhD thesis of Andrew Moore in 1990. {cite}`moore1990efficient`.
The implementation here is based on that given by Melnikov, Makmal, and Briegel {cite}`melnikov2014projective`.

## State and control variables

This system has two state variables, the position ($x$) and velocity ($v$) of the car.

This system has a single control variable ($u$), the effort put into moving.  This control is contrained to the range $[-1 \, 1]$.

The dynamics of the system are governed by

\begin{align}
  \dot{x} &= v \\
  \dot{v} &= 0.001 * u - 0.0025 * \cos(3 x)
\end{align}

## Problem Definition

We seek to minimize the time required to exit the well in the positive direction.

\begin{align}
    \mathrm{Minimize} \, J &= t_f
\end{align}

Subject to the initial conditions

\begin{align}
    x_0 &= -0.5 \\
    v_0 &= 0.0
\end{align}

the control constraints

\begin{align}
    |u| \le 1
\end{align}

and the terminal constraints

\begin{align}
    x_f &= 0.5 \\
    v_f &\ge 0.0
\end{align}

## Defining the ODE

The following code implements the equations of motion for the mountain car problem.

A few things to note:

1. By providing the tag `dymos.state_rate_source:{name}`, we're letting Dymos know what states need to be integrated, there's no need to specify a rate source when using this ODE in our Phase.
2. Pairing the above tag with `dymos.state_units:{units}` means we don't have to specify units when setting properties for the state in our run script.
3. We only use compute_partials to override the values of $\frac{\partial \dot{v}}{\partial x}$ because $\frac{\partial \dot{v}}{\partial u}$ and $\frac{\partial \dot{x}}{\partial v}$ are constant and their value is specified during `setup`.

```python
import numpy as np
import openmdao.api as om

class MountainCarODE(om.ExplicitComponent):

    def initialize(self):
        self.options.declare('num_nodes', types=int)

    def setup(self):
        nn = self.options['num_nodes']

        self.add_input('x', shape=(nn,), units='m')
        self.add_input('v', shape=(nn,), units='m/s')
        self.add_input('u', shape=(nn,), units='unitless')

        self.add_output('x_dot', shape=(nn,), units='m/s',
                        tags=['dymos.state_rate_source:x', 'dymos.state_units:m'])
        self.add_output('v_dot', shape=(nn,), units='m/s**2',
                        tags=['dymos.state_rate_source:v', 'dymos.state_units:m/s'])

        ar = np.arange(nn, dtype=int)

        self.declare_partials(of='x_dot', wrt='v', rows=ar, cols=ar, val=1.0)
        self.declare_partials(of='v_dot', wrt='u', rows=ar, cols=ar, val=0.001)
        self.declare_partials(of='v_dot', wrt='x', rows=ar, cols=ar)

    def compute(self, inputs, outputs):
        x = inputs['x']
        v = inputs['v']
        u = inputs['u']
        outputs['x_dot'] = v
        outputs['v_dot'] = 0.001 * u - 0.0025 * np.cos(3*x)

    def compute_partials(self, inputs, partials):
        x = inputs['x']
        partials['v_dot', 'x'] = 3 * 0.0025 * np.sin(3 * x)

```

## Solving the minimum-time mountain car problem with Dymos

The following script solves the minimum-time mountain car problem with Dymos.
Note that this example requires the IPOPT optimizer via the `pyoptsparse` package.
Scipy's SLSQP optimizer is generally not capable of solving this problem.

To begin, import the packages we require:

```python
import dymos as dm
import matplotlib.pyplot as plt
from matplotlib import animation
```

Next, we set two constants.
`U_MAX` is the maximum allowable magnitude of the acceleration.
The references show this problem being solved with $-1 \le u \le 1$.

Variable `NUM_SEG` is the number of equally spaced polynomial segments into which time is being divided.
Within each of these segments, the time-history of each state and control is being treated as a polynomial (we're using the default order of 3).

```python
# The maximum absolute value of the acceleration authority of the car
U_MAX = 1.0

# The number of segments into which the problem is discretized
NUM_SEG = 30
```

We then instantiate an OpenMDAO problem and set the optimizer and its options.

For IPOPT, setting option `nlp_scaling_method` to `'gradient-based'` can substantially improve the convergence of the optimizer without the need for us to set all of the scaling manually.

The call to `declare_coloring` tells the optimizer to attempt to find a sparsity pattern that minimizes the work required to compute the derivatives across the model.

```python
#
# Initialize the Problem and the optimization driver
#
p = om.Problem()

p.driver = om.pyOptSparseDriver(optimizer='IPOPT')
p.driver.opt_settings['print_level'] = 5
p.driver.opt_settings['max_iter'] = 500
p.driver.opt_settings['mu_strategy'] = 'adaptive'
p.driver.opt_settings['bound_mult_init_method'] = 'mu-based'
p.driver.opt_settings['mu_init'] = 0.01
p.driver.opt_settings['nlp_scaling_method'] = 'gradient-based'  # for faster convergence

p.driver.declare_coloring()
```

Next, we add a Dymos Trajectory group to the problem's model and add a phase to it.

In this case we're using the Radau pseudospectral transcription to solve the problem.

```python
#
# Create a trajectory and add a phase to it
#
traj = p.model.add_subsystem('traj', dm.Trajectory())
tx = transcription=dm.Radau(num_segments=NUM_SEG)
phase = traj.add_phase('phase0', dm.Phase(ode_class=MountainCarODE, transcription=tx))
```

At this point, we set the options on the main variables used in a Dymos phase.

In addition to `time`, we have two states (`x` and `v`) and a single control (`u`).

There are no parameters and no polynomial controls.
We could have tried to use a polynomial control here, but as we will see the solution contains large discontinuities in the control value, which make it ill-suited for a polynomial control.  Polynomial controls are modeled as a single (typically low-order) polynomial across the entire phase.

We're fixing the initial time and states to whatever values we provide before executing the problem.  We will constrain the final values with nonlinear constraints in the next step.

The scaler values (`ref`) are all set to 1 here.  We're using IPOPT's `gradient-based` scaling option and will let it work the scaling out for us.

Bounds on time duration are guesses, and the bounds on the states and controls come from the implementation in the references.

Also, we don't need to specify targets for any of the variables here because their names _are_ the targets in the top-level of the model.
The rate source and units for the states are obtained from the tags in the ODE component we previously defined.

```python
#
# Set the variables
#
phase.set_time_options(fix_initial=True, duration_bounds=(.05, 10000), duration_ref=1)

phase.add_state('x', fix_initial=True, fix_final=False, lower=-1.2, upper=0.5, ref=1, defect_ref=1)
phase.add_state('v', fix_initial=True, fix_final=False, lower=-0.07, upper=0.07, ref=1, defect_ref=1)
phase.add_control('u', lower=-U_MAX, upper=U_MAX, ref=1, continuity=True, rate_continuity=False)
```

Next we define the optimal control problem by specifying the objective, boundary constraints, and path constraints.

**Why do we have a path constraint on the control `u` when we've already specified its bounds?**

Excellent question!
In the `Radau` transcription, the $n^{th}$ order control polynomial is governed by design variables provided at $n$ points in the segment that **do not contain the right-most endpoint**.
Instead, this value is interpolated based on the values of the first $(n-1)$.
Since this value is not a design variable, it is necessary to constrain its value separately.
We could forgo specifying any bounds on `u` since it's completely covered by the path constraint, but specifying the bounds on the design variable values can sometimes help by telling the optimizer, "Don't even bother trying values outside of this range.".

Note that sometimes the opposite is true, and giving the optimizer the freedom to explore a larger design space, only to eventually be "reined-in" by the path constraint can sometimes be valuable.

The purpose of this interactive documentation is to let the user experiment.
If you remove the path constraint, you might notice some outlying control values in the solution below.

```python
#
# Minimize time at the end of the phase
#
phase.add_objective('time', loc='final', ref=1000)

phase.add_boundary_constraint('x', loc='final', lower=0.5)
phase.add_boundary_constraint('v', loc='final', lower=0.0)
phase.add_path_constraint('u', lower=-U_MAX, upper=U_MAX)

#
# Setup the Problem
#
p.setup()
```

We then set the initial guesses for the variables in the problem and solve it.

Since `fix_initial=True` is set for time and the states, those values are not design variables and will remain at the values given below throughout the solution process.

We're using the phase `interp` method to provide initial guesses for the states and controls.
In this case, by giving it two values, it is linearly interpolating from the first value to the second value, and then returning the interpolated value at the input nodes for the given variable.

Finally, we use the `dymos.run_problem` method to execute the problem.
This interface allows us to do some things that the standard OpenMDAO `problem.run_driver` interface does not.
It will automatically record the final solution achieved by the optimizer in case named `'final'` in a file called `dymos_solution.db`.
By specifying `simulate=True`, it will automatically follow the solution with an explicit integration using `scipy.solve_ivp`.
The results of the simulation are stored in a case named `final` in the file `dymos_simulation.db`.
This explicit simulation demonstrates how the system evolved with the given controls, and serves as a check that we're using a dense enough grid (enough segments and segments of sufficient order) to accurately represent the solution.

If those two solution didn't agree reasonably well, we could rerun the problem with a more dense grid.
Instead, we're asking Dymos to automatically change the grid if necessary by specifying `refine_method='ph'`.
This will attempt to repeatedly solve the problem and change the number of segments and segment orders until the solution is in reasonable agreement.

```python
#
# Set the initial values
#
p['traj.phase0.t_initial'] = 0.0
p['traj.phase0.t_duration'] = 500.0

p.set_val('traj.phase0.states:x', phase.interp('x', ys=[-0.5, 0.5]))
p.set_val('traj.phase0.states:v', phase.interp('v', ys=[0, 1.0]))
p.set_val('traj.phase0.controls:u', np.sin(phase.interp('u', ys=[0, 1.0])))

#
# Solve for the optimal trajectory
#
dm.run_problem(p, run_driver=True, simulate=True, refine_method='ph', refine_iteration_limit=5)

```

## Plotting the solution

The recommended practice is to obtain values from the recorded cases.
While the problem object can also be queried for values, building plotting scripts that use the case recorder files as the data source means that the problem doesn't need to be solved just to change a plot.
Here we load values of various variables from the solution and simulation for use in the animation to follow.

```python
sol = om.CaseReader('dymos_solution.db').get_case('final')
sim = om.CaseReader('dymos_simulation.db').get_case('final')

t = sol.get_val('traj.phase0.timeseries.time')
x = sol.get_val('traj.phase0.timeseries.states:x')
v = sol.get_val('traj.phase0.timeseries.states:v')
u = sol.get_val('traj.phase0.timeseries.controls:u')
h = np.sin(3 * x) / 3

t_sim = sim.get_val('traj.phase0.timeseries.time')
x_sim = sim.get_val('traj.phase0.timeseries.states:x')
v_sim = sim.get_val('traj.phase0.timeseries.states:v')
u_sim = sim.get_val('traj.phase0.timeseries.controls:u')
h_sim = np.sin(3 * x_sim) / 3
```

```python
from openmdao.utils.assert_utils import assert_near_equal

assert_near_equal(t[-1, 0], 102.479, tolerance=1.0E-3)
assert_near_equal(x[-1, 0], 0.5, tolerance=1.0E-3)
```

## Animating the Solution

The collapsed code cell below contains the code used to produce an animation of the mountain car solution using Matplotlib.

The green area represents the hilly terrain the car is traversing.  The black circle is the center of the car, and the orange arrow is the applied control.

The applied control _generally_ has the same sign as the velocity and is 'bang-bang', that is, it wants to be at its maximum possible magnitude.  Interestingly, the sign of the control flips shortly before the sign of the velocity changes.

```python
fig = plt.figure(constrained_layout=True, figsize=(12, 6))
gs = fig.add_gridspec(3, 2)

anim_ax = fig.add_subplot(gs[:, 0])
anim_ax.set_aspect('equal')

x_ax = fig.add_subplot(gs[0, 1:])
v_ax = fig.add_subplot(gs[1, 1:])
u_ax = fig.add_subplot(gs[2, 1:])

x_ax.set_ylabel('x')
v_ax.set_ylabel('v')
u_ax.set_ylabel('u')
u_ax.set_xlabel('t')

# set up the subplots as needed
anim_ax.set_xlim((-1.75, 0.75));
anim_ax.set_ylim((-1.25, 1.25));
anim_ax.set_xlabel('x');
anim_ax.set_ylabel('h');

x_sol_line, = x_ax.plot(t, x, 'o', ms=1, label='solution')
v_ax.plot(t, v, 'o', ms=1)
u_ax.plot(t, u, 'o', ms=1)

x_sim_line, = x_ax.plot([], [], '-', linewidth=3, label='simulation')
v_sim_line, = v_ax.plot([], [], '-', linewidth=3)
u_sim_line, = u_ax.plot([], [], '-', linewidth=3)

plt.figlegend(ncol=2, handles=[x_sol_line, x_sim_line], loc='upper center',
              bbox_to_anchor=(0.78,0.98))

x_ax.grid(alpha=0.2)
txt_x = x_ax.text(0.8, 0.1, f'x = {x_sim[0, 0]:6.3f}', horizontalalignment='left',
                  verticalalignment='center', transform=x_ax.transAxes)

v_ax.grid(alpha=0.2)
txt_v = v_ax.text(0.8, 0.1, f'v = {v_sim[0, 0]:6.3f}', horizontalalignment='left',
                  verticalalignment='center', transform=v_ax.transAxes)

u_ax.grid(alpha=0.2)
txt_u = u_ax.text(0.8, 0.1, f'u = {u_sim[0, 0]:6.3f}', horizontalalignment='left',
                  verticalalignment='center', transform=u_ax.transAxes)

x_terrain = np.linspace(-1.75, 0.75, 100);
h_terrain = np.sin(3 * x_terrain) / 3;
terrain_line, = anim_ax.plot(x_terrain, h_terrain, '-', color='tab:gray', lw=2);
terrain = anim_ax.fill_between(x_terrain, h_terrain, -1.25*np.ones_like(x_terrain), color='tab:green');
car, = anim_ax.plot([], [], 'ko', ms=12);
u_vec = anim_ax.quiver(x_sim[0] + 0.005, h_sim[0] + 0.005, u_sim[0], [0], scale=10, angles='xy', color='tab:orange')

# See https://brushingupscience.com/2019/08/01/elaborate-matplotlib-animations/ for quiver animation

ANIM_DURATION = 5
PAUSE_DURATION = 2
ANIM_FPS = 20

num_points = t_sim.size
num_frames = ANIM_DURATION * ANIM_FPS
pause_frames = PAUSE_DURATION * ANIM_FPS

idx_from_frame_num = np.linspace(0, num_points-1, num_frames, dtype=int)

def drawframe(n):

    if n >= idx_from_frame_num.size:
        idx = num_points - 1
    else:
        idx = idx_from_frame_num[n]

    x = x_sim[idx]
    v = v_sim[idx]
    u = u_sim[idx]
    t = t_sim[idx]
    h = np.sin(3 * x) / 3 + 0.025
    car.set_data(x, h)

    dh_dx = np.cos(3 * x)

    u_vec.set_offsets(np.atleast_2d(np.asarray([x + 0.005, h + 0.005]).T))
    u_vec.set_UVC(u * np.cos(dh_dx), u * np.sin(dh_dx))

    x_sim_line.set_data(t_sim[:idx], x_sim[:idx])
    v_sim_line.set_data(t_sim[:idx], v_sim[:idx])
    u_sim_line.set_data(t_sim[:idx], u_sim[:idx])

    txt_x.set_text(f'x = {x[0]:6.3f}')
    txt_v.set_text(f'v = {v[0]:6.3f}')
    txt_u.set_text(f'u = {u[0]:6.3f}')

    return car, u_vec, x_sim_line, v_sim_line, u_sim_line

# # blit=True re-draws only the parts that have changed.
# # repeat_delay has no effect when using to_jshtml, so pad drawframe to show the final frame for PAUSE_FRAMES extra frames.
anim = animation.FuncAnimation(fig, drawframe, frames=num_frames + pause_frames, interval=1000/ANIM_FPS, blit=True);
plt.close()  # Don't let jupyter display the un-animated plot

from IPython.display import HTML
js = anim.to_jshtml()
with open('mountain_car_anim.html', 'w') as f:
    print(js, file=f)
HTML(filename='mountain_car_anim.html')
```

## References

```{bibliography}
:filter: docname in docnames
```