pymeeus.Interpolation — PyMeeus 0.5.11 documentation

# Source code for pymeeus.Interpolation

    # -*- coding: utf-8 -*-

    # PyMeeus: Python module implementing astronomical algorithms.
    # Copyright (C) 2018  Dagoberto Salazar
    #
    # This program is free software: you can redistribute it and/or modify
    # it under the terms of the GNU Lesser General Public License as published by
    # the Free Software Foundation, either version 3 of the License, or
    # (at your option) any later version.
    #
    # This program is distributed in the hope that it will be useful,
    # but WITHOUT ANY WARRANTY; without even the implied warranty of
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    # GNU Lesser General Public License for more details.
    #
    # You should have received a copy of the GNU Lesser General Public License
    # along with this program.  If not, see <https://www.gnu.org/licenses/>.

    from math import sqrt, degrees

    from pymeeus.base import TOL
    from pymeeus.Angle import Angle

    """
    .. module:: Interpolation
       :synopsis: Basic interpolation routines
       :license: GNU Lesser General Public License v3 (LGPLv3)

    .. moduleauthor:: Dagoberto Salazar
    """

    class Interpolation(object):
        """
        Class Interpolation deals with finding intermediate values to those given
        in a table.

        Besides the basic interpolation, this class also provides methods to find
        the extrema (maximum or minimum value) in a given interval (if present),
        and it also has methods to find roots (the values of the argument 'x' for
        which the function 'y' becomes zero).

        Please note that it seems that Meeus uses the Bessel interpolation method
        (Chapter 3). However, this class uses the Newton interpolation method
        because it is (arguably) more flexible regarding the number of entries
        provided in the interpolation table.

        The constructor takes pairs of (x, y) values from the table of interest.
        These pairs of values can be given as a sequence of int/floats, tuples,
        lists or Angles. It is also possible to provide an Interpolation object to
        the constructor in order to get a copy.

        .. note:: When using Angles, be careful with the 360-to-0 discontinuity.

        If a sequence of int, floats or Angles is given, the values in the odd
        positions are considered to belong to the 'x' set, while the values in the
        even positions belong to the 'y' set. If only one tuple or list is
        provided, it is assumed that it is the 'y' set, and the 'x' set is build
        from 0 onwards with steps of length 1.

        Please keep in mind that a minimum of two data pairs are needed in order to
        carry out any interpolation. If only one value pair is provided, a
        ValueError exception will be raised.
        """

        def __init__(self, *args):
            """Interpolation constructor.

            This takes pairs of (x, y) values from the table of interest. These
            pairs of values can be given as a sequence of int/floats, tuples, lists
            or Angles. It is also possible to provide an Interpolation object to
            the constructor in order to get a copy.

            .. note:: When using Angles, be careful with the 360-to-0 discontinuity

            If a sequence of int, floats or Angles is given, the values in the odd
            positions are considered to belong to the 'x' set, while the values in
            the even positions belong to the 'y' set. If only one tuple or list is
            provided, it is assumed that it is the 'y' set, and the 'x' set is
            build from 0 onwards with steps of length 1.

            Please keep in mind that a minimum of two data pairs are needed in
            order to carry out any interpolation. If only one value pair is
            provided, a ValueError exception will be raised.

            :param args: Input tabular values, or another Interpolation object.
            :type args: int, float, list, tuple, :py:class:`Angle`,
               :py:class:`Interpolation`

            :returns: Interpolation object.
            :rtype: :py:class:`Interpolation`
            :raises: ValueError if not enough input data pairs are provided.
            :raises: TypeError if input values are of wrong type.

            >>> i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
            >>> print(i._x)
            [1, 2, 3, 4, 5, 6]
            >>> print(i._y)
            [2, 4, 6, 8, 10, 12]
            >>> j = Interpolation([3, -8, 1, 12, 2, 5, 8])
            >>> print(j._x)
            [0, 1, 2, 3, 4, 5, 6]
            >>> print(j._y)
            [3, -8, 1, 12, 2, 5, 8]
            >>> k = Interpolation(3, -8, 1, 12, 2, 5, 8)
            >>> print(k._x)
            [1, 2, 3]
            >>> print(k._y)
            [12, 5, -8]
            """

            self._x = []
            self._y = []
            self._table = []
            self._tol = TOL
            self.set(*args)  # Let's use 'set()' method to handle the setup

        def _order_points(self):
            """Method to put the data points in ascending order w.r.t. 'x'."""

            # Let's work with copies of the original lists
            x = list(self._x)
            y = list(self._y)

            xnew = []
            ynew = []
            xmax = max(x) + 1.0
            for _ in range(len(x)):
                # Get the index of the minimum value
                imin = x.index(min(x))
                # Append the current minimum value to the new 'x' list
                xnew.append(x[imin])
                # Store the *position* of the current minimum value to new 'y' list
                ynew.append(imin)
                # The current minimum value will no longer be the minimum
                x[imin] = xmax

            # In the new 'y' list, substitute the positions by the real values
            for i in range(len(x)):
                ynew[i] = y[ynew[i]]

            # Store the results in the corresponding fields
            self._x = xnew
            self._y = ynew

        def set(self, *args):
            """Method used to define the value pairs of Interpolation object.

            This takes pairs of (x, y) values from the table of interest. These
            pairs of values can be given as a sequence of int/floats, tuples,
            lists, or Angles. It is also possible to provide an Interpolation
            object to this method in order to get a copy.

            .. note:: When using Angles, be careful with the 360-to-0 discontinuity

            If a sequence of int, floats or Angles is given, the values in the odd
            positions are considered to belong to the 'x' set, while the values in
            the even positions belong to the 'y' set. If only one tuple or list is
            provided, it is assumed that it is the 'y' set, and the 'x' set is
            build from 0 onwards with steps of length 1.

            Please keep in mind that a minimum of two data pairs are needed in
            order to carry out any interpolation. If only one value pair is
            provided, a ValueError exception will be raised.

            :param args: Input tabular values, or another Interpolation object.
            :type args: int, float, list, tuple, :py:class:`Angle`

            :returns: None.
            :rtype: None
            :raises: ValueError if not enough input data pairs are provided.
            :raises: TypeError if input values are of wrong type.

            >>> i = Interpolation()
            >>> i.set([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
            >>> print(i._x)
            [1, 2, 3, 4, 5, 6]
            >>> print(i._y)
            [2, 4, 6, 8, 10, 12]
            >>> j = Interpolation()
            >>> j.set([3, -8, 1, 12, 2, 5, 8])
            >>> print(j._x)
            [0, 1, 2, 3, 4, 5, 6]
            >>> print(j._y)
            [3, -8, 1, 12, 2, 5, 8]
            >>> k = Interpolation(3, -8, 1, 12, 2, 5, 8)
            >>> print(k._x)
            [1, 2, 3]
            >>> print(k._y)
            [12, 5, -8]
            >>> m = Interpolation(k)
            >>> print(m._x)
            [1, 2, 3]
            >>> print(m._y)
            [12, 5, -8]
            """

            # Clean up the internal data tables
            self._x = []
            self._y = []
            self._table = []
            # If no arguments are given, return. Internal data tables are empty
            if len(args) == 0:
                return
            # If we have only one argument, it can be a single value or tuple/list
            elif len(args) == 1:
                if isinstance(args[0], Interpolation):  # Copy constructor
                    self._x = args[0]._x
                    self._y = args[0]._y
                    self._table = args[0]._table
                    self._tol = args[0]._tol
                    return
                elif isinstance(args[0], (int, float, Angle)):
                    # Insuficient data to interpolate. Raise ValueError exception
                    raise ValueError("Invalid number of input values")
                elif isinstance(args[0], (list, tuple)):
                    seq = args[0]
                    if len(seq) < 2:
                        raise ValueError("Invalid number of input values")
                    else:
                        # Read input values into 'y', and create 'x'
                        i = 0
                        for value in seq:
                            self._x.append(i)
                            self._y.append(value)
                            i += 1
                else:
                    raise TypeError("Invalid input value")
            elif len(args) == 2:
                if isinstance(args[0], (int, float, Angle)) or isinstance(
                    args[1], (int, float, Angle)
                ):
                    # Insuficient data to interpolate. Raise ValueError exception
                    raise ValueError("Invalid number of input values")
                elif isinstance(args[0], (list, tuple)) and isinstance(
                    args[1], (list, tuple)
                ):
                    x = args[0]
                    y = args[1]
                    # Check if they have the same length. If not, make them equal
                    length_min = min(len(x), len(y))
                    x = x[:length_min]
                    y = y[:length_min]
                    if len(x) < 2 or len(y) < 2:
                        raise ValueError("Invalid number of input values")
                    else:
                        # Read input values into 'x' and 'y'
                        for xval, yval in zip(x, y):
                            self._x.append(xval)
                            self._y.append(yval)
                else:
                    raise TypeError("Invalid input value")
            elif len(args) == 3:
                # In this case, no combination of input values is valid
                raise ValueError("Invalid number of input values")
            else:
                # If there is an odd number of arguments, drop the last one
                if len(args) % 2 != 0:
                    args = args[:-1]
                # Check that all the arguments are ints, floats or Angles
                all_numbers = True
                for arg in args:
                    all_numbers = (all_numbers
                                   and isinstance(arg, (int, float, Angle)))
                # If any of the values failed the test, raise an exception
                if not all_numbers:
                    raise TypeError("Invalid input value")
                # Now, extract the data: Odds are x's, evens are y's
                for i in range(int(len(args) / 2.0)):
                    self._x.append(args[2 * i])
                    self._y.append(args[2 * i + 1])
            # After self._x is found, confirm that x's are different to each other
            for i in range(len(self._x) - 1):
                for k in range(i + 1, len(self._x)):
                    if abs(self._x[i] - self._x[k]) < self._tol:
                        raise ValueError("Invalid input: Values in 'x' are equal")
            # Order the data points if needed
            self._order_points()
            # Create table containing Newton coefficientes, only if values given
            if len(self._x) > 0:
                self._compute_table()

        def __str__(self):
            """Method used when trying to print the object.

            :returns: Internal tabular values as strings.
            :rtype: string

            >>> i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
            >>> print(i)
            X: [1, 2, 3, 4, 5, 6]
            Y: [2, 4, 6, 8, 10, 12]
            """

            xstr = "X: " + str(self._x) + "\n"
            ystr = "Y: " + str(self._y)
            return xstr + ystr

        def __repr__(self):
            """Method providing the 'official' string representation of the object.
            It provides a valid expression that could be used to recreate the
            object.

            :returns: As string with a valid expression to recreate the object
            :rtype: string

            >>> i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
            >>> repr(i)
            'Interpolation([1, 2, 3, 4, 5, 6], [2, 4, 6, 8, 10, 12])'
            """

            return "{}({}, {})".format(self.__class__.__name__, self._x, self._y)

        def __len__(self):
            """This method returns the number of interpolation points (x, y, pairs)
            stored in this :class:`Interpolation` object.

            :returns: Number of interpolation points internally stored
            :rtype: int

            >>> i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
            >>> len(i)
            6
            """

            return len(self._x)

        def get_tolerance(self):
            """Gets the internal tolerance value used for comparisons.

            .. note:: The default tolerance value is TOL.

            :returns: Internal tolerance.
            :rtype: float
            """

            return self._tol

        def set_tolerance(self, tol):
            """Changes the internal tolerance value used for comparisons.

            :param tol: New tolerance value.
            :type tol: int, float

            :returns: None
            :rtype: None
            """

            self._tol = tol
            return

        def _compute_table(self):
            """Method to compute coefficients of Newton interpolation method."""

            for i in range(len(self._x)):
                self._table.append(self._newton_diff(0, i))

        def _newton_diff(self, start, end):
            """Auxiliary method to compute the elements of the Newton table.

            :param start: Starting index
            :type start: int
            :param end: Ending index
            :type end: int

            :returns: Resulting value of the element of the Newton table.
            :rtype: float
            """

            if abs(end - start) < self._tol:
                val = self._y[start]
            else:
                x = list(self._x)  # Let's make a copy, just in case
                val = (self._newton_diff(start, end - 1)
                       - self._newton_diff(start + 1, end)) / (x[start] - x[end])

            return val

        def __call__(self, x):
            """Method to interpolate the function at a given 'x'.

            :param x: Point where the interpolation will be carried out.
            :type x: int, float, :py:class:`Angle`

            :returns: Resulting value of the interpolation.
            :rtype: float
            :raises: ValueError if input value is outside of interpolation range.
            :raises: TypeError if input value is of wrong type.

            >>> i = Interpolation([7, 8, 9], [0.884226, 0.877366, 0.870531])
            >>> y = round(i(8.18125), 6)
            >>> print(y)
            0.876125
            """

            # Check if input value is of correct type
            if isinstance(x, (int, float, Angle)):
                # Check if 'x' already belongs to the data table
                for i in range(len(self._x)):
                    if abs(x - self._x[i]) < self._tol:
                        return self._y[i]  # We don't need to look further
                # Check if Newton coefficients table is not empty
                if len(self._table) == 0:
                    raise RuntimeError("Internal table is empty. Use set().")
                # Check that x is within interpolation table values
                if x < self._x[0] or x > self._x[-1]:
                    raise ValueError("Input value outside of interpolation range.")
                # Horner's method is used to efficiently compute the result
                val = self._table[-1]
                for i in range(len(self._table) - 1, 0, -1):
                    val = self._table[i - 1] + (x - self._x[i - 1]) * val

                return val
            else:
                raise TypeError("Invalid input value")

        def derivative(self, x):
            """Method to compute the derivative from interpolation polynomial.

            :param x: Point where the interpolation derivative will be carried out.
            :type x: int, float, :py:class:`Angle`

            :returns: Resulting value of the interpolation derivative.
            :rtype: float
            :raises: ValueError if input value is outside of interpolation range.
            :raises: TypeError if input value is of wrong type.

            >>> m = Interpolation([-1.0, 0.0, 1.0], [-2.0, 3.0, 2.0])
            >>> m.derivative(-1.0)
            8.0
            >>> m.derivative(0.5)
            -1.0
            """

            # Check if input value is of correct type
            if isinstance(x, (int, float, Angle)):
                # Check that x is within interpolation table values
                if x < self._x[0] or x > self._x[-1]:
                    raise ValueError("Input value outside of interpolation range.")
                # If we only have two interpolation points, derivative is simple
                if len(self._x) == 2:
                    return (self._y[1] - self._y[0]) / (self._x[1] - self._x[0])
                else:
                    res = self._table[1]
                    for k in range(len(self._table) - 1, 1, -1):
                        val = 0.0
                        for j in range(k):
                            s = 1.0
                            for i in range(k):
                                if i != j:
                                    s *= x - self._x[i]
                            val += s
                        res += val * self._table[k]
                    return res
            else:
                raise TypeError("Invalid input value")

        def root(self, xl=0, xh=0, max_iter=1000):
            """Method to find the root inside the [xl, xh] range.

            This method applies, in principle, the Newton method to find the root;
            however, if conditions are such that Newton method may not bei properly
            behaving or converging, then it switches to the linear Interpolation
            method.

            If values xl, xh are not given, the limits of the interpolation table
            values will be used.

            .. note:: This method returns a ValueError exception if the
               corresponding yl = f(xl) and yh = f(xh) values have the same sign.
               In that case, the method assumes there is no root in the [xl, xh]
               interval.

            .. note:: If any of the xl, xh values is beyond the limits given by the
               interpolation values, its value will be set to the corresponding
               limit.

            .. note:: If xl == xh (and not zero), a ValueError exception is raised.

            .. note:: If the method doesn't converge within max_iter ierations,
               then a ValueError exception is raised.

            :param xl: Lower limit of interval where the root will be looked for.
            :type xl: int, float, :py:class:`Angle`
            :param xh: Higher limit of interval where the root will be looked for.
            :type xh: int, float, :py:class:`Angle`
            :param max_iter: Maximum number of iterations allowed.
            :type max_iter: int

            :returns: Root of the interpolated function within [xl, xh] interval.
            :rtype: int, float, :py:class:`Angle`
            :raises: ValueError if yl = f(xl), yh = f(xh) have same sign.
            :raises: ValueError if xl == xh.
            :raises: ValueError if maximum number of iterations is exceeded.
            :raises: TypeError if input value is of wrong type.

            >>> m = Interpolation([-1.0, 0.0, 1.0], [-2.0, 3.0, 2.0])
            >>> round(m.root(), 8)
            -0.72075922
            """

            # Get the limits of the interpolation table
            xmin = self._x[0]
            xmax = self._x[-1]
            # Check if input value is of correct type
            if (isinstance(xl, (int, float, Angle))
                    and isinstance(xh, (int, float, Angle))):
                # Check if BOTH values are zero
                if xl == 0 and xh == 0:
                    xl = xmin
                    xh = xmax
                # Check if limits are equal
                if abs(xl - xh) < self._tol:
                    raise ValueError("Invalid limits: xl and xh values are equal")
                # Put limits in order. Swap them if necessary
                if xl > xh:
                    xl, xh = xh, xl
                # Check limits against interpolation table. Reset if necessary
                if xl < self._x[0]:
                    xl = xmin
                if xh < self._x[-1]:
                    xh = xmax
                yl = self.__call__(xl)
                yh = self.__call__(xh)
                # Check for a couple special cases
                if abs(yl) < self._tol:
                    return xl  # xl is a root
                if abs(yh) < self._tol:
                    return xh  # xh is a root
                # Check if signs of ordinates are the same
                if (yl * yh) > 0.0:
                    raise ValueError("Invalid interval: Probably no root exists")
                # We are good to go. First option: Newton's root-finding method
                x = (xl + xh) / 2.0  # Start in the middle of interval
                y = self.__call__(x)
                num_iter = 0  # Count the number of iterations
                while abs(y) > self._tol:
                    if num_iter >= max_iter:
                        raise ValueError(
                            "Too many iterations: Probably no root\
                                         exists"
                        )
                    num_iter += 1
                    yp = self.derivative(x)
                    # If derivative is too small, switch to linear interpolation
                    if abs(yp) < 1e-3:
                        x = (xl * yh - xh * yl) / (yh - yl)
                        y = self.__call__(x)
                    else:
                        x = x - y / yp
                        # Check if x is within limits
                        if x < xmin or x > xmax:
                            # Switch to linear interpolation
                            x = (xl * yh - xh * yl) / (yh - yl)
                            y = self.__call__(x)
                        else:
                            y = self.__call__(x)
                    if (y * yl) >= 0.0:
                        xl = x
                        yl = y
                    else:
                        xh = x
                        yh = y
                return x
            else:
                raise TypeError("Invalid input value")

        def minmax(self, xl=0, xh=0, max_iter=1000):
            """Method to find the minimum or maximum inside the [xl, xh] range.

            Finding the minimum or maximum (extremum) of a function within a given
            interval is akin to find the root of its derivative. Therefore, this
            method creates an interpolation object for the derivative function, and
            calls the root method of that object. See :meth:`root` method for more
            details.

            If values xl, xh are not given, the limits of the interpolation table
            values will be used.

            .. note::

               This method returns a ValueError exception if the corresponding
               derivatives yl' = f'(xl) and yh' = f'(xh) values have the same sign.
               In that case, the method assumes there is no extremum in the
               [xl, xh] interval.

            .. note::

               If any of the xl, xh values is beyond the limits given by the
               interpolation values, its value will be set to the corresponding
               limit.

            .. note:: If xl == xh (and not zero), a ValueError exception is raised.

            .. note:: If the method doesn't converge within max_iter ierations,
               then a ValueError exception is raised.

            :param xl: Lower limit of interval where a extremum will be looked for.
            :type xl: int, float, :py:class:`Angle`
            :param xh: Higher limit of interval where extremum will be looked for.
            :type xh: int, float, :py:class:`Angle`
            :param max_iter: Maximum number of iterations allowed.
            :type max_iter: int

            :returns: Extremum of interpolated function within [xl, xh] interval.
            :rtype: int, float, :py:class:`Angle`
            :raises: ValueError if yl = f(xl), yh = f(xh) have same sign.
            :raises: ValueError if xl == xh.
            :raises: ValueError if maximum number of iterations is exceeded.
            :raises: TypeError if input value is of wrong type.

            >>> m = Interpolation([-1.0, 0.0, 1.0], [-2.0, 3.0, 2.0])
            >>> round(m.minmax(), 8)
            0.33333333
            """

            # Compute the derivatives for the current data
            x = list(self._x)
            y = []
            for xi in x:
                y.append(self.derivative(xi))
            # Create a new Interpolation object
            prime = Interpolation(x, y)
            # Find the root within that object, and return it
            return prime.root(xl, xh, max_iter)

    def main():

        # Let's define a small helper function
        def print_me(msg, val):
            print("{}: {}".format(msg, val))

        # Let's now work with the Interpolation class
        print("\n" + 35 * "*")
        print("*** Use of Interpolation class")
        print(35 * "*" + "\n")

        i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])
        print("i = Interpolation([5, 3, 6, 1, 2, 4, 9], [10, 6, 12, 2, 4, 8])")
        print(i)
        print("NOTE:")
        print("   a. They are ordered in 'x'")
        print("   b. The extra value in 'x' was dropped")

        print("")

        # Use the copy constructor
        print("We can easily make a copy of an Interpolation object")
        j = Interpolation(i)
        print("j = Interpolation(i)")
        print(j)

        print("")

        # Get the number of interpolation points stored
        print_me("Number or interpolation points in 'j'", len(j))  # 6

        print("")

        j = Interpolation([0.0, 1.0, 3.0], [-1.0, -2.0, 2.0])
        print("j = Interpolation([0.0, 1.0, 3.0], [-1.0, -2.0, 2.0])")
        print(j)
        print_me("j(2)", j(2))
        print_me("j(0.5)", j(0.5))
        # Test with a value already in the data table
        print_me("j(1)", j(1))

        print("")

        # We can interpolate Angles too
        k = Interpolation(
            [27.0, 27.5, 28.0, 28.5, 29.0],
            [
                Angle(0, 54, 36.125),
                Angle(0, 54, 24.606),
                Angle(0, 54, 15.486),
                Angle(0, 54, 8.694),
                Angle(0, 54, 4.133),
            ],
        )
        print(
            "k = Interpolation([27.0, 27.5, 28.0, 28.5, 29.0],\n\
                          [Angle(0, 54, 36.125), Angle(0, 54, 24.606),\n\
                           Angle(0, 54, 15.486), Angle(0, 54, 8.694),\n\
                           Angle(0, 54, 4.133)])"
        )

        print_me("k(28.278)", Angle(k(28.278)).dms_str())

        print("")

        m = Interpolation([-1.0, 0.0, 1.0], [-2.0, 3.0, 2.0])
        print("m = Interpolation([-1.0, 0.0, 1.0], [-2.0, 3.0, 2.0])")
        print(m)
        # Get interpolated values
        print_me("m(-0.5)", m(-0.5))
        print_me("m(0.5)", m(0.5))
        # Get derivatives
        print_me("m'(-1.0)", m.derivative(-1.0))
        print_me("m'(-0.5)", m.derivative(-0.5))
        print_me("m'(0.0)", m.derivative(0.0))
        print_me("m'(0.5)", m.derivative(0.5))
        print_me("m'(1.0)", m.derivative(1.0))
        # Get the root within the interval
        print_me("m.root()", m.root())
        # Get the extremum within the interval
        print_me("m.minmax()", m.minmax())

        m = Interpolation(
            [29.43, 30.97, 27.69, 28.11, 31.58, 33.05],
            [
                0.4913598528,
                0.5145891926,
                0.4646875083,
                0.4711658342,
                0.5236885653,
                0.5453707057,
            ],
        )

        print_me("sin(29.5)\t", m(29.5))
        print_me("sin(30.0)\t", m(30.0))
        print_me("sin(30.5)\t", m(30.5))
        # Derivative must be adjusted because degrees were used instead of radians
        print_me("sin'(29.5)\t", degrees(m.derivative(29.5)))
        print_me("sin'(30.0)\t", degrees(m.derivative(30.0)))
        print_me("sqrt(3.0)/2.0\t", sqrt(3.0) / 2.0)
        print_me("sin'(30.5)\t", degrees(m.derivative(30.5)))

    if __name__ == "__main__":

        main()