# -*- 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 . 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()