====================
Simple Interpolation
====================

One of the main purposes of `brille` is to perform linear interpolation.
This short tutorial will teach you how to perform simple linear interpolation using `brille`.
All examples here are used in the continuous testing of `brille` and are
from the file `wrap/tests/test_4_interpolation.py` available in the repository.

Interpolation of a scalar field
-------------------------------

A scalar field is any function :math:`\phi(\mathbf{Q})` which is single-valued
at all :math:`\mathbf{Q}`. Here we will interpolate a scalar field of the form
:math:`\phi(\mathbf{Q}) = \mathbf{Q}\cdot\hat{\mathbf{x}}`, which is a linear
scalar field.

Construct the lattice
^^^^^^^^^^^^^^^^^^^^^

Linear interpolation can only be performed using `brille` if a grid is constructed first.
The construction of a grid requires that we define a space-filling lattice,
so we start with that:

.. code-block:: python

  from brille import Lattice
  lattice = Lattice((1, 1, 1), (90, 90, 90), real_space=False)

The created `lattice` spans reciprocal space with three orthogonal basis vectors
each of unit length, :math:`1 \AA^{-1}`, and as a result has a unit cell with
volume :math:`1 \AA^{-3}` which we can access through `lattice.volume`.

Construct the Brillouin zone polyhedron
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

To construct the grid we must define its extent, which in `brille` is always a
first or irreducible Brillouin zone polyhedron. Finding this polyhedron may not
be trivial, so it is an independent step (with options to control the algorithm
behaviour, see :py:class:`~brille._brille.BrillouinZone` for details).

.. code-block:: python

  from brille import BrillouinZone
  brillouin_zone = BrillouinZone(lattice)

We did not specify any symmetry information for this lattice, so the default
:math:`P 1` is used, which has an identical first and irreducible Brillouin zone.
Therefore `brillouin_zone` contains an 'irreducible' polyhedron which identical to the
first Brillouin zone polyhedron, accessible through `brillouin_zone.ir_polyhedron`
and `brillouin_zone.polyhedron`, respectively.

Construct the interpolation grid
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Finally, we can construct the interpolation grid. We have multiple options
within `brille`, but nearly always we should use one of the :ref:`Hybrid grids <hybrid-grids>`.
In this short tutorial we will interpolate purely real-valued data, so we pick
a :py:class:`~brille._brille.BZTrellisQdd` which holds real-valued eigenvalues
and eigenvectors for every vertex in its grid.
To create a grid we must specify an upper-limit to the grid-cell size, which
also specifies a lower-limit to the number of points in the grid.
Each grid cell contributes one or more vertex to the grid, with the exact
number dependent on details of the grid and bounding polyhedron.
Here we will aim for at least :math:`100` grid points, or a maximum cell
volume of :math:`1\times10^{-2} \AA^{-3}`.

.. code-block:: python

  from brille import BZTrellisQdd
  maximum_cell_volume = lattice.volume / 100
  grid = BZTrellisQdd(brillouin_zone, maximum_cell_volume)

The points in the grid are accessible through `grid.rlu` or `grid.invA` in
units of the reciprocal lattice units or :math:`\AA^{-1}`, respectively;
in this case the two units are the same.

Provide the data to be interpolated
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The grid must be provided with data corresponding to each grid point in order
to be used to perform linear interpolation. This is accomplished using one of
the :py:meth:`~brille._brille.BZTrellisQdd.fill` method overloads.

.. note::
  The `fill` methods require input for both the eigenvalues and eigenvectors
  to be interpolated. This specialization is a limitation which may be removed
  in a future version of :py:mod:`brille`.

Here we will provide the same data as eigenvalue and eigenvector.
In addition we must provide metadata for the eigenvalues and eigenvectors stored
at each grid point. The metadata is a list-like object consisting of the number
of scalar values, number of vector-elements, and number of matrix-elements stored
at each grid point for each eigenvalue-eigenvector pair (if a system has multiple
branches there will be multiple pairs).
A fourth piece of metadata determines how the vector- and matrix- elements are
handled when symmetry operations are used in the interpolation.
The scalar function we want to interpolate has one scalar value per grid point,
and we will not use symmetry operations in the interpolation so the
default, :math:`0`, is acceptable here.

.. code-block:: python

  def phi(q):
    return q[:, 0]

  phi_of_q = phi(grid.invA)
  metadata = (1, 0, 0, 0)

  grid.fill(phi_of_q, metadata, phi_of_q, metadata)


Perform the interpolation
^^^^^^^^^^^^^^^^^^^^^^^^^

The extent of the grid is the first Brillouin zone polyhedron which, in this
case, has all coordinates :math:`x_i \in (-0.5, 0.5)`.
If the grid is used to interpolate at a :math:`\mathbf{Q}` inside of the first
Brillouin zone, the result should be identical to :math:`\phi(\mathbf{Q})`;
but if :math:`\mathbf{Q}` is not in the first Brillouin zone the result will be
:math:`\phi(\mathbf{Q} - \mathbf{G})` where :math:`\mathbf{G}` is the nearest
integer lattice point to :math:`\mathbf{Q}`.

.. code-block:: python

  import numpy as np
  q_pts = np.random.rand(10,3) - 0.5  # all in range (-0.5, 0.5)

  interpolated_values, interpolated_vectors = grid.interpolate_at(q_pts)

  if np.allclose(interpolated_values, interpolated_vectors):
    print('The interpolated values and vectors match!')
  else:
    print('This should be an error, and impossible.')

  if np.allclose(interpolated_values, phi(q_pts)):
    print('The interpolation worked as expected!')
  else:
    print('Linear interpolation of a linear scalar field did not work?!')

  q_pts *= 20  # now all in the range (-10, 10)
  interpolated_values, interpolated_vectors = grid.interpolate_at(q_pts)
  if np.allclose(interpolated_values, phi(q_pts - np.round(q_pts))):
    print('The interpolation still worked as expected!')
  else:
    print('Linear interpolation or subtracting G did not work?!')

The interpolated results should be exact (to machine precision) in this case
since the linear interpolation of a linear function is exactly independent of the
interpolation grid step size.
Interpolating any non-linear function will naturally introduce some error in its
estimate of the function.


Interpolation of a dispersing excitation
----------------------------------------

Excitations which have energies dependent on :math:`\mathbf{Q}` are said to be
dispersive. One such dispersive excitation is the accoustic ferromagnetic
spinwave in iron, which has a dispersion

.. math::
  \omega(\mathbf{Q}) = \delta + 8 J \left(1 - \prod_i\cos \frac{\pi \mathbf{Q}\cdot\hat{\mathbf{x}}_i}{a}\right)

where :math:`\delta` is a single-ion anisotropy term, :math:`J` is the exchange
energy, :math:`a` is the iron lattice parameter, and the :math:`\hat{\mathbf{x}}_i`
are the three Cartesian directions.

The spinwave dispersion has the same periodicity as the iron crystal lattice so
it is possible to use :py:mod:`brille` to estimate its energy at arbitrary
:math:`\mathbf{Q}` using linear interpolation.

Setup
^^^^^
The lattice parameter of iron is :math:`a = 2.87 \AA` and its cubic lattice has
the symmetry of the spacegroup with Hermann-Mauguin symbol :math:`I m \bar{3} m`.
We will construct an irreducible Brillouin zone polyhedron, and then use it to
produce a hybrid interpolation grid with at least :math:`1000` points.

.. code:: python

  from brille import Lattice, BrillouinZone, BZTrellisQdd
  a_fe = 2.87
  direct_lattice = Lattice((a_fe, a_fe, a_fe), (90, 90, 90), 'I m -3 m')
  brillouin_zone = BrillouinZone(direct_lattice.star)
  grid = BZTrellisQdd(brillouin_zone, brillouin_zone.ir_polyhedron.volume/1000)

Fill
^^^^

We then can use the relative lattice unit grid points to fill the dispersion
information into the grid. Again we will provide the same information for the
eigenvalues and eigenvectors, and again there is a single scalar value for the
single dispersion branch. The default fourth metadata value would not be
appropriate for interpolation of true eigenvectors in this case, but has no
effect for scalars and so does not impact this case.

.. code:: python

  def omega_fe(q, J=16, delta=0.01):
    from numpy import cos, pi, prod
    return delta + 8*J*(1 - prod(cos(pi*q), axis=1))

  omega = omega_fe(grid.rlu)
  metadata = (1, 0, 0, 0)
  grid.fill(omega, metadata, omega, metadata)


Interpolate
^^^^^^^^^^^

Since we have provided symmetry information and produced a grid bounded by
an irreducible Brillouin zone polyhedron, we must use the irreducible
interpolation method of the grid, :py:meth:`~brille._brille.BZTrellisQdd.ir_interpolate_at`.

As the dispersion relation is not a linear function, the linear interpolation
will always introduce error when compared to the true function.
The size of this error depends on the grid vertex spacing and the local
curvature of the dispersion.
Here we can only verify that the interpolation returns the stored values at the
grid points.

.. code:: python

  from numpy import allclose

  values, vectors = grid.ir_interpolate_at(grid.rlu)

  if not allclose(values, omega) or not allclose(values, grid.values):
    print("This should be an error")

  if not allclose(vectors, omega) or not allclose(vectors, grid.vectors):
    print("This should be an error")


With the assurance that the interpolation works we can examine more interesting
paths through reciprocal space:

.. code:: python

  from matplotlib.pyplot import plot, gca, setp
  from numpy import linspace, vstack, array, arange
  x = array([[0, 0, 0], [1, 0, 0], [0.5, 0.5, 0], [0, 0, 0], [0.5, 0.5, 0.5]])
  n_pts = 100
  path = vstack([linspace(x[i], x[i+1], n_pts) for i in range(len(x)-1)])

  values, _ = grid.ir_interpolate_at(path)

  x_plot = arange(len(path))

  ticks_at = n_pts * arange(len(x))
  tick_labels = [f'({z[0]} {z[1]} {z[2]})' for z in x]

  plot(x_plot, values, '-k', x_plot, omega_fe(path), '--r')
  setp(gca(), xticks=ticks_at, xticklabels=tick_labels)
  setp(gca(), ylabel=r'$\omega(\mathbf{Q})$', xlabel=r'$\mathbf{Q}$')

Produces the following plot:

.. figure:: tutorial_02_fig0.svg
  :align: center

  Interpolated iron spinwave dispersion (black) and exact solution (red).


On closer inspection, the linear-interpolation introduced errors are more obvious:

.. code:: python

  plot(x_plot, values, '-k', x_plot, omega_fe(path), '--r')
  setp(gca(), xticks=ticks_at, xticklabels=tick_labels)
  setp(gca(), ylabel=r'$\omega(\mathbf{Q})$', xlabel=r'$\mathbf{Q}$')
  setp(gca(), xlim=(80, 120), ylim=(247,257))

.. figure:: tutorial_02_fig1.svg
  :align: center

  Enlarged region of interpolated iron spinwave dispersion (black) and exact solution (red).