diff --git a/doc/source/api_reference/astro.rst b/doc/source/api_reference/astro.rst index b0ef7547..a3272f0d 100644 --- a/doc/source/api_reference/astro.rst +++ b/doc/source/api_reference/astro.rst @@ -17,12 +17,6 @@ Calling Sequence .. __: https://github.com/tsutterley/pyTMD/blob/main/pyTMD/astro.py -.. autofunction:: pyTMD.astro.polynomial_sum - -.. autofunction:: pyTMD.astro.normalize_angle - -.. autofunction:: pyTMD.astro.rotate - .. autofunction:: pyTMD.astro.mean_longitudes .. autofunction:: pyTMD.astro.doodson_arguments diff --git a/doc/source/api_reference/math.rst b/doc/source/api_reference/math.rst new file mode 100644 index 00000000..c9b91cb7 --- /dev/null +++ b/doc/source/api_reference/math.rst @@ -0,0 +1,27 @@ +==== +math +==== + +- Special functions of mathematical physics + +Calling Sequence +---------------- + +.. code-block:: python + + import pyTMD.math + P = pyTMD.math.legendre(2, x, m=0) + +`Source code`__ + +.. __: https://github.com/tsutterley/pyTMD/blob/main/pyTMD/math.py + +.. autofunction:: pyTMD.math.polynomial_sum + +.. autofunction:: pyTMD.math.normalize_angle + +.. autofunction:: pyTMD.math.rotate + +.. autofunction:: pyTMD.math.legendre + +.. autofunction:: pyTMD.math.sph_harm diff --git a/doc/source/api_reference/spatial.rst b/doc/source/api_reference/spatial.rst index 79ac17ad..b11c178b 100644 --- a/doc/source/api_reference/spatial.rst +++ b/doc/source/api_reference/spatial.rst @@ -97,4 +97,6 @@ General Methods .. autofunction:: pyTMD.spatial.from_ENU +.. autofunction:: pyTMD.spatial.to_horizontal + .. autofunction:: pyTMD.spatial.scale_factors diff --git a/doc/source/getting_started/Glossary.rst b/doc/source/getting_started/Glossary.rst index bee2f936..a35afc69 100644 --- a/doc/source/getting_started/Glossary.rst +++ b/doc/source/getting_started/Glossary.rst @@ -174,6 +174,9 @@ Glossary Pole Tide apparent tide due to variations in the Earth's axis of rotation about its mean + Radiational Tide + tidal constituents or components induced by the absorption and re-emission of solar radiation + Range height difference between the :term:`High Water Height` and the :term:`Low Water Height` diff --git a/doc/source/index.rst b/doc/source/index.rst index 18559057..8b88f0f8 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -38,6 +38,7 @@ ocean, load, solid Earth and pole tides api_reference/ellipse.rst api_reference/interpolate.rst api_reference/io/io.rst + api_reference/math.rst api_reference/predict.rst api_reference/solve/solve.rst api_reference/spatial.rst diff --git a/doc/source/release_notes/release-v2.1.8.rst b/doc/source/release_notes/release-v2.1.8.rst index 6db5c276..f9123f30 100644 --- a/doc/source/release_notes/release-v2.1.8.rst +++ b/doc/source/release_notes/release-v2.1.8.rst @@ -15,7 +15,7 @@ * ``docs``: add citations to included data `(#353) `_ * ``fix``: remove default bounds being ``None`` for `#356 `_ `(#357) `_ * ``docs``: move notebooks to docs and use myst to render `(#359) `_ -* ``fix``: correct error when using default bounds in `extract_constants` for `#356 `_ `(#359) `_ +* ``fix``: correct error when using default bounds in ``extract_constants`` for `#356 `_ `(#359) `_ * ``fix``: correct ``TPXO10-atlas-v2`` binary grid filename for `#358 `_ `(#359) `_ * ``fix``: some `Cartwright and Edden (1973) `_ table entries `(#359) `_ * ``docs``: use cards for notebook examples page `(#360) `_ diff --git a/pyTMD/__init__.py b/pyTMD/__init__.py index c0790b95..00148696 100644 --- a/pyTMD/__init__.py +++ b/pyTMD/__init__.py @@ -16,6 +16,7 @@ import pyTMD.compute import pyTMD.ellipse import pyTMD.interpolate +import pyTMD.math import pyTMD.predict import pyTMD.spatial import pyTMD.tools diff --git a/pyTMD/astro.py b/pyTMD/astro.py index 5fdb6af0..c5e75caf 100644 --- a/pyTMD/astro.py +++ b/pyTMD/astro.py @@ -1,7 +1,7 @@ #!/usr/bin/env python u""" astro.py -Written by Tyler Sutterley (07/2024) +Written by Tyler Sutterley (11/2024) Astronomical and nutation routines PYTHON DEPENDENCIES: @@ -16,6 +16,7 @@ Oliver Montenbruck, Practical Ephemeris Calculations, 1989. UPDATE HISTORY: + Updated 11/2024: moved three generic mathematical functions to math.py Updated 07/2024: made a wrapper function for normalizing angles make number of days to convert days since an epoch to MJD variables Updated 04/2024: use wrapper to importlib for optional dependencies @@ -53,6 +54,11 @@ import numpy as np import timescale.eop import timescale.time +from pyTMD.math import ( + polynomial_sum, + normalize_angle, + rotate +) from pyTMD.utilities import ( get_data_path, import_dependency, @@ -62,9 +68,6 @@ jplephem_spk = import_dependency('jplephem.spk') __all__ = [ - "polynomial_sum", - "normalize_angle", - "rotate", "mean_longitudes", "phase_angles", "doodson_arguments", @@ -101,74 +104,6 @@ # Julian century _century = 36525.0 -# PURPOSE: calculate the sum of a polynomial function of time -def polynomial_sum(coefficients: list | np.ndarray, t: np.ndarray): - """ - Calculates the sum of a polynomial function using Horner's method - - Parameters - ---------- - coefficients: list or np.ndarray - leading coefficient of polynomials of increasing order - t: np.ndarray - delta time in units for a given astronomical longitudes calculation - """ - # convert time to array if importing a single value - t = np.atleast_1d(t) - return np.sum([c * (t ** i) for i, c in enumerate(coefficients)], axis=0) - -def normalize_angle(theta: float | np.ndarray, circle: float = 360.0): - """ - Normalize an angle to a single rotation - - Parameters - ---------- - theta: float or np.ndarray - Angle to normalize - circle: float, default 360.0 - Circle of the angle - """ - return np.mod(theta, circle) - -def rotate(theta: float | np.ndarray, axis: str = 'x'): - """ - Rotate a 3-dimensional matrix about a given axis - - Parameters - ---------- - theta: float or np.ndarray - Angle of rotation in radians - axis: str - Axis of rotation (``'x'``, ``'y'``, or ``'z'``) - """ - # allocate for output rotation matrix - R = np.zeros((3, 3, len(np.atleast_1d(theta)))) - if (axis.lower() == 'x'): - # rotate about x-axis - R[0,0,:] = 1.0 - R[1,1,:] = np.cos(theta) - R[1,2,:] = np.sin(theta) - R[2,1,:] = -np.sin(theta) - R[2,2,:] = np.cos(theta) - elif (axis.lower() == 'y'): - # rotate about y-axis - R[0,0,:] = np.cos(theta) - R[0,2,:] = -np.sin(theta) - R[1,1,:] = 1.0 - R[2,0,:] = np.sin(theta) - R[2,2,:] = np.cos(theta) - elif (axis.lower() == 'z'): - # rotate about z-axis - R[0,0,:] = np.cos(theta) - R[0,1,:] = np.sin(theta) - R[1,0,:] = -np.sin(theta) - R[1,1,:] = np.cos(theta) - R[2,2,:] = 1.0 - else: - raise ValueError(f'Invalid axis {axis}') - # return the rotation matrix - return R - # PURPOSE: compute the basic astronomical mean longitudes def mean_longitudes( MJD: np.ndarray, @@ -352,8 +287,9 @@ def doodson_arguments( # Equinox method converted to degrees TAU = 360.0*ts.st + 180.0 - S else: - TAU = ((hour*15.0) - S + polynomial_sum(np.array([280.4606184, - 36000.7700536, 3.8793e-4, -2.58e-8]), T)) + LAMBDA = polynomial_sum(np.array([280.4606184, + 36000.7700536, 3.8793e-4, -2.58e-8]), T) + TAU = (hour*15.0) - S + LAMBDA # calculate correction for mean lunar longitude (degrees) if apply_correction: PR = polynomial_sum(np.array([0.0, 1.396971278, diff --git a/pyTMD/math.py b/pyTMD/math.py new file mode 100644 index 00000000..24cee294 --- /dev/null +++ b/pyTMD/math.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python +u""" +math.py +Written by Tyler Sutterley (11/2024) +Special functions of mathematical physics + +PYTHON DEPENDENCIES: + numpy: Scientific Computing Tools For Python + https://numpy.org + https://numpy.org/doc/stable/user/numpy-for-matlab-users.html + scipy: Scientific Tools for Python + https://docs.scipy.org/doc/ + +UPDATE HISTORY: + Written 11/2024 +""" +import numpy as np +from scipy.special import factorial + +__all__ = [ + "polynomial_sum", + "normalize_angle", + "rotate", + "legendre", + "sph_harm" +] + +# PURPOSE: calculate the sum of a polynomial function of time +def polynomial_sum(coefficients: list | np.ndarray, t: np.ndarray): + """ + Calculates the sum of a polynomial function using Horner's method [1]_ + + Parameters + ---------- + coefficients: list or np.ndarray + leading coefficient of polynomials of increasing order + t: np.ndarray + delta time in units for a given astronomical longitudes calculation + + References + ---------- + .. [1] W. G. Horner and D. Gilbert, "A new method of solving numerical + equations of all orders, by continuous approximation," *Philosophical + Transactions of the Royal Society of London*, 109, 308--335, (1819). + `doi: 10.1098/rstl.1819.0023 `_ + """ + # convert time to array if importing a single value + t = np.atleast_1d(t) + return np.sum([c * (t ** i) for i, c in enumerate(coefficients)], axis=0) + +def normalize_angle(theta: float | np.ndarray, circle: float = 360.0): + """ + Normalize an angle to a single rotation + + Parameters + ---------- + theta: float or np.ndarray + Angle to normalize + circle: float, default 360.0 + Circle of the angle + """ + return np.mod(theta, circle) + +def rotate(theta: float | np.ndarray, axis: str = 'x'): + """ + Rotate a 3-dimensional matrix about a given axis + + Parameters + ---------- + theta: float or np.ndarray + Angle of rotation in radians + axis: str + Axis of rotation (``'x'``, ``'y'``, or ``'z'``) + """ + # allocate for output rotation matrix + R = np.zeros((3, 3, len(np.atleast_1d(theta)))) + if (axis.lower() == 'x'): + # rotate about x-axis + R[0,0,:] = 1.0 + R[1,1,:] = np.cos(theta) + R[1,2,:] = np.sin(theta) + R[2,1,:] = -np.sin(theta) + R[2,2,:] = np.cos(theta) + elif (axis.lower() == 'y'): + # rotate about y-axis + R[0,0,:] = np.cos(theta) + R[0,2,:] = -np.sin(theta) + R[1,1,:] = 1.0 + R[2,0,:] = np.sin(theta) + R[2,2,:] = np.cos(theta) + elif (axis.lower() == 'z'): + # rotate about z-axis + R[0,0,:] = np.cos(theta) + R[0,1,:] = np.sin(theta) + R[1,0,:] = -np.sin(theta) + R[1,1,:] = np.cos(theta) + R[2,2,:] = 1.0 + else: + raise ValueError(f'Invalid axis {axis}') + # return the rotation matrix + return R + +def legendre( + l: int, + x: np.ndarray, + m: int = 0 + ): + """ + Computes associated Legendre functions for a particular degree + and order [1]_ [2]_ + + Parameters + ---------- + l: int + degree of Legrendre polynomials (0 to 3) + x: np.ndarray + elements ranging from -1 to 1 + + Typically ``cos(theta)``, where ``theta`` is the colatitude in radians + m: int, default = 0 + order of the Legendre polynomial + + Returns + ------- + Plm: np.ndarray + Legendre polynomials of degree ``l`` and order ``m`` + + References + ---------- + .. [1] W. H. Munk, D. E. Cartwright, and E. C. Bullard, "Tidal + spectroscopy and prediction," *Philosophical Transactions of the + Royal Society of London. Series A, Mathematical and Physical + Sciences*, 259(1105), 533--581, (1966). + `doi: 10.1098/rsta.1966.0024 `_ + .. [2] B. Hofmann-Wellenhof and H. Moritz, *Physical Geodesy*, + 2nd Edition, 403 pp., (2006). `doi: 10.1007/978-3-211-33545-1 + `_ + """ + # verify values are integers + l = np.int64(l) + m = np.int64(m) + # assert values + assert (l >= 0) and (l <= 3), 'Degree must be between 0 and 3' + assert (m >= 0) and (m <= l), 'Order must be between 0 and l' + # verify dimensions + singular_values = (np.ndim(x) == 0) + x = np.atleast_1d(x).flatten() + # if x is the cos of colatitude, u is the sine + u = np.sqrt(1.0 - x**2) + # size of the x array + nx = len(x) + # complete matrix of associated legendre functions + # up to degree and order 3 + Plm = np.zeros((4, 4, nx), dtype=np.float64) + # since tides only use low-degree harmonics: + # functions are hard coded rather than using a recursion relation + Plm[0, 0, :] = 1.0 + Plm[1, 0, :] = x + Plm[1, 1, :] = u + Plm[2, 0, :] = 0.5*(3.0*x**2 - 1.0) + Plm[2, 1, :] = 3.0*x*u + Plm[2, 2, :] = 3.0*u**2 + Plm[3, 0, :] = 0.5*(5.0*x**3 - 3.0*x) + Plm[3, 1, :] = 1.5*(5.0*x**2 - 1.0)*u + Plm[3, 2, :] = 15.0*x*u**2 + Plm[3, 3, :] = 15.0*u**3 + # return values + if singular_values: + return np.pow(-1.0, m)*Plm[l, m, 0] + else: + return np.pow(-1.0, m)*Plm[l, m, :] + +def sph_harm( + l: int, + theta: np.ndarray, + phi: np.ndarray, + m: int = 0 + ): + """ + Computes the spherical harmonics for a particular degree + and order [1]_ [2]_ + + Parameters + ---------- + l: int + degree of spherical harmonics (0 to 3) + theta: np.ndarray + colatitude in radians + phi: np.ndarray + longitude in radians + m: int, default 0 + order of the spherical harmonics (0 to l) + + Returns + ------- + Ylm: np.ndarray + complex spherical harmonics of degree ``l`` and order ``m`` + + References + ---------- + .. [1] W. H. Munk, D. E. Cartwright, and E. C. Bullard, "Tidal + spectroscopy and prediction," *Philosophical Transactions of the + Royal Society of London. Series A, Mathematical and Physical + Sciences*, 259(1105), 533--581, (1966). + `doi: 10.1098/rsta.1966.0024 `_ + .. [2] B. Hofmann-Wellenhof and H. Moritz, *Physical Geodesy*, + 2nd Edition, 403 pp., (2006). `doi: 10.1007/978-3-211-33545-1 + `_ + """ + # verify dimensions + singular_values = (np.ndim(theta) == 0) + theta = np.atleast_1d(theta).flatten() + phi = np.atleast_1d(phi).flatten() + # assert dimensions + assert len(theta) == len(phi), 'coordinates must have the same dimensions' + # normalize associated Legendre functions + # following Munk and Cartwright (1966) + norm = np.sqrt(factorial(l - m)/factorial(l + m)) + Plm = norm*legendre(l, np.cos(theta), m=m) + # spherical harmonics of degree l and order m + dfactor = np.sqrt((2.0*l + 1.0)/(4.0*np.pi)) + Ylm = dfactor*Plm*np.sin(theta)*np.exp(1j*m*phi) + # return values + if singular_values: + return Ylm[0] + else: + return Ylm diff --git a/pyTMD/predict.py b/pyTMD/predict.py index abde477b..0ffb9026 100644 --- a/pyTMD/predict.py +++ b/pyTMD/predict.py @@ -67,6 +67,7 @@ import numpy as np import pyTMD.arguments import pyTMD.astro +import pyTMD.math from pyTMD.crs import datum import timescale.time @@ -1165,7 +1166,7 @@ def equilibrium_tide( s, h, p, N, pp = pyTMD.astro.mean_longitudes(MJD + kwargs['deltat'], ASTRO5=ASTRO5) # convert to negative mean longitude of the ascending node (N') - n = pyTMD.astro.normalize_angle(360.0 - N) + n = pyTMD.math.normalize_angle(360.0 - N) # determine equilibrium arguments fargs = np.c_[s, h, p, n, pp] diff --git a/pyTMD/spatial.py b/pyTMD/spatial.py index bfd7610d..56499deb 100644 --- a/pyTMD/spatial.py +++ b/pyTMD/spatial.py @@ -1,7 +1,7 @@ #!/usr/bin/env python u""" spatial.py -Written by Tyler Sutterley (09/2024) +Written by Tyler Sutterley (11/2024) Utilities for reading, writing and operating on spatial data @@ -30,6 +30,7 @@ crs.py: Coordinate Reference System (CRS) routines UPDATE HISTORY: + Updated 11/2024: added function to calculate the altitude and azimuth Updated 09/2024: deprecation fix case where an array is output to scalars Updated 08/2024: changed from 'geotiff' to 'GTiff' and 'cog' formats added functions to convert to and from East-North-Up coordinates @@ -155,6 +156,7 @@ "_zhu_closed_form", "to_ENU", "from_ENU", + "to_horizontal", "scale_areas", "scale_factors", ] @@ -1519,18 +1521,20 @@ def convert_ellipsoid( return (phi2, h2) def compute_delta_h( + lat: np.ndarray, a1: float, f1: float, a2: float, - f2: float, - lat: np.ndarray + f2: float ): """ Compute difference in elevation for two ellipsoids at a given - latitude using a simplified empirical equation + latitude using a simplified empirical relation Parameters ---------- + lat: np.ndarray + latitudes (degrees north) a1: float semi-major axis of input ellipsoid f1: float @@ -1539,8 +1543,6 @@ def compute_delta_h( semi-major axis of output ellipsoid f2: float flattening of output ellipsoid - lat: np.ndarray - latitudes (degrees north) Returns ------- @@ -1551,18 +1553,16 @@ def compute_delta_h( ---------- .. [1] J Meeus, *Astronomical Algorithms*, pp. 77--82, (1991). """ - # force phi into range -90 <= phi <= 90 - gt90, = np.nonzero((lat < -90.0) | (lat > 90.0)) - lat[gt90] = np.sign(lat[gt90])*90.0 - # semiminor axis of input and output ellipsoid + # force latitudes to be within -90 to 90 and convert to radians + phi = np.clip(lat, -90.0, 90.0)*np.pi/180.0 + # semi-minor axis of input and output ellipsoid b1 = (1.0 - f1)*a1 b2 = (1.0 - f2)*a2 - # compute delta_a and delta_b coefficients + # compute differences in semi-major and semi-minor axes delta_a = a2 - a1 delta_b = b2 - b1 # compute differences between ellipsoids # delta_h = -(delta_a * cos(phi)^2 + delta_b * sin(phi)^2) - phi = lat * np.pi/180.0 delta_h = -(delta_a*np.cos(phi)**2 + delta_b*np.sin(phi)**2) return delta_h @@ -1740,11 +1740,11 @@ def to_geodetic( Parameters ---------- - x, float + x, np.ndarray cartesian x-coordinates - y, float + y, np.ndarray cartesian y-coordinates - z, float + z, np.ndarray cartesian z-coordinates a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -1807,11 +1807,11 @@ def _moritz_iterative( Parameters ---------- - x, float + x, np.ndarray cartesian x-coordinates - y, float + y, np.ndarray cartesian y-coordinates - z, float + z, np.ndarray cartesian z-coordinates a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -1874,11 +1874,11 @@ def _bowring_iterative( Parameters ---------- - x, float + x, np.ndarray cartesian x-coordinates - y, float + y, np.ndarray cartesian y-coordinates - z, float + z, np.ndarray cartesian z-coordinates a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -1951,11 +1951,11 @@ def _zhu_closed_form( Parameters ---------- - x, float + x, np.ndarray cartesian x-coordinates - y, float + y, np.ndarray cartesian y-coordinates - z, float + z, np.ndarray cartesian z-coordinates a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -2015,9 +2015,9 @@ def to_ENU( x: np.ndarray, y: np.ndarray, z: np.ndarray, - lon0: float = 0.0, - lat0: float = 0.0, - h0: float = 0.0, + lon0: float | np.ndarray = 0.0, + lat0: float | np.ndarray = 0.0, + h0: float | np.ndarray = 0.0, a_axis: float = _wgs84.a_axis, flat: float = _wgs84.flat, ): @@ -2027,17 +2027,17 @@ def to_ENU( Parameters ---------- - x, float + x, np.ndarray cartesian x-coordinates - y, float + y, np.ndarray cartesian y-coordinates - z, float + z, np.ndarray cartesian z-coordinates - lon0: float, default 0.0 + lon0: float or np.ndarray, default 0.0 reference longitude (degrees east) - lat0: float, default 0.0 + lat0: float or np.ndarray, default 0.0 reference latitude (degrees north) - h0: float, default 0.0 + h0: float or np.ndarray, default 0.0 reference height (meters) a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -2086,9 +2086,9 @@ def from_ENU( E: np.ndarray, N: np.ndarray, U: np.ndarray, - lon0: float = 0.0, - lat0: float = 0.0, - h0: float = 0.0, + lon0: float | np.ndarray = 0.0, + lat0: float | np.ndarray = 0.0, + h0: float | np.ndarray = 0.0, a_axis: float = _wgs84.a_axis, flat: float = _wgs84.flat, ): @@ -2098,17 +2098,17 @@ def from_ENU( Parameters ---------- - E, float + E, np.ndarray east coordinates - N, float + N, np.ndarray north coordinates - U, float + U, np.ndarray up coordinates - lon0: float, default 0.0 + lon0: float or np.ndarray, default 0.0 reference longitude (degrees east) - lat0: float, default 0.0 + lat0: float or np.ndarray, default 0.0 reference latitude (degrees north) - h0: float, default 0.0 + h0: float or np.ndarray, default 0.0 reference height (meters) a_axis: float, default 6378137.0 semimajor axis of the ellipsoid @@ -2157,6 +2157,42 @@ def from_ENU( else: return (x, y, z) +def to_horizontal( + E: np.ndarray, + N: np.ndarray, + U: np.ndarray, + ): + """ + Convert from East-North-Up coordinates (ENU) to a + celestial horizontal coordinate system (alt-az) + + Parameters + ---------- + E: np.ndarray + east coordinates + N: np.ndarray + north coordinates + U: np.ndarray + up coordinates + + Returns + ------- + alpha: np.ndarray + altitude (elevation) angle in degrees + phi: np.ndarray + azimuth angle in degrees + D: np.ndarray + distance from observer to object in meters + """ + # calculate distance to object + # convert coordinates to unit vectors + D = np.sqrt(E**2 + N**2 + U**2) + # altitude (elevation) angle in degrees + alpha = np.arcsin(U/D)*180.0/np.pi + # azimuth angle in degrees (fixed to 0 to 360) + phi = np.mod(np.arctan2(E/D, N/D)*180.0/np.pi, 360.0) + return (alpha, phi, D) + def scale_areas(*args, **kwargs): warnings.warn("Deprecated. Please use pyTMD.spatial.scale_factors instead", DeprecationWarning) diff --git a/scripts/reduce_OTIS_files.py b/scripts/reduce_OTIS_files.py index 0b03a672..0ccfa482 100644 --- a/scripts/reduce_OTIS_files.py +++ b/scripts/reduce_OTIS_files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python u""" reduce_OTIS_files.py -Written by Tyler Sutterley (07/2024) +Written by Tyler Sutterley (11/2024) Read OTIS-format tidal files and reduce to a regional subset COMMAND LINE OPTIONS: @@ -28,6 +28,7 @@ crs.py: Coordinate Reference System (CRS) routines UPDATE HISTORY: + Updated 11/2024: use "stem" instead of "basename" Updated 07/2024: renamed format for ATLAS to ATLAS-compact Updated 04/2024: add debug mode printing input arguments use wrapper to importlib for optional dependencies @@ -204,10 +205,10 @@ def make_regional_OTIS_files(tide_dir, TIDE_MODEL, def create_unique_filename(filename): # split filename into parts filename = pathlib.Path(filename) - basename = filename.stem + stem = filename.stem suffix = '' if (filename.suffix in ('.out','.oce')) else filename.suffix # replace extension with reduced flag - filename = filename.with_name(f'{basename}{suffix}.reduced') + filename = filename.with_name(f'{stem}{suffix}.reduced') # create counter to add to the end of the filename if existing counter = 1 while counter: @@ -220,8 +221,8 @@ def create_unique_filename(filename): # close the file descriptor and return the filename fd.close() return filename - # new filename adds counter - filename = filename.with_name(f'{basename}{suffix}.reduced_{counter:d}') + # new filename adds counter before the file extension + filename = filename.with_name(f'{stem}{suffix}.reduced_{counter:d}') counter += 1 # PURPOSE: create argument parser diff --git a/test/test_arguments.py b/test/test_arguments.py index 0ff77f37..02e1c26f 100644 --- a/test/test_arguments.py +++ b/test/test_arguments.py @@ -1,10 +1,11 @@ #!/usr/bin/env python u""" -test_arguments.py (10/2024) +test_arguments.py (11/2024) Verify arguments table matches prior arguments array Verify nodal corrections match prior estimates UPDATE HISTORY: + Updated 11/2024: moved normalize_angle test to test_math.py Updated 10/2024: add comparisons for formatted Doodson numbers add function to parse tide potential tables Updated 08/2024: add comparisons for nodal corrections @@ -822,15 +823,3 @@ def test_parse_tables(): assert p == line['p'], line assert n == line['n'], line assert pp == line['pp'], line - -def test_normalize_angle(): - """ - Tests the normalization of angles to between 0 and 360 degrees - """ - # test angles - angles = np.array([-180, -90, 0, 90, 180, 270, 360, 450]) - # expected values - exp = np.array([180, 270, 0, 90, 180, 270, 0, 90]) - # test normalization of angles - test = pyTMD.astro.normalize_angle(angles) - assert np.all(exp == test) diff --git a/test/test_equilibrium_tide.py b/test/test_equilibrium_tide.py index 1deb4956..663d49f8 100644 --- a/test/test_equilibrium_tide.py +++ b/test/test_equilibrium_tide.py @@ -1,5 +1,5 @@ """ -test_solid_earth.py (10/2024) +test_equilibrium_tide.py (11/2024) Tests the calculation of long-period equilibrium tides with respect to the LPEQMT subroutine @@ -11,11 +11,13 @@ https://pypi.org/project/timescale/ UPDATE HISTORY: + Updated 11/2024: moved normalize_angle to math.py Written 10/2024 """ import pytest import numpy as np import pyTMD.predict +import pyTMD.math import timescale.time # PURPOSE: test the estimation of long-period equilibrium tides @@ -72,7 +74,7 @@ def test_equilibrium_tide(TYPE): for N in range(4): # convert time from days relative to 1992-01-01 to 1987-01-01 ANGLE = PHC[N] + (t.tide + 1826.0)*DPD[N] - SHPN[N,:] = np.pi*pyTMD.astro.normalize_angle(ANGLE)/180.0 + SHPN[N,:] = np.pi*pyTMD.math.normalize_angle(ANGLE)/180.0 # assemble long-period tide potential from 15 CTE terms greater than 1 mm # nodal term is included but not the constant term. diff --git a/test/test_math.py b/test/test_math.py new file mode 100644 index 00000000..cee0bfac --- /dev/null +++ b/test/test_math.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +u""" +test_math.py (11/2024) +""" +import pytest +import numpy as np +import pyTMD.math + +def test_normalize_angle(): + """ + Tests the normalization of angles to between 0 and 360 degrees + """ + # test angles + angles = np.array([-180, -90, 0, 90, 180, 270, 360, 450]) + # expected values + exp = np.array([180, 270, 0, 90, 180, 270, 0, 90]) + # test normalization of angles + test = pyTMD.math.normalize_angle(angles) + assert np.all(exp == test) + +@pytest.mark.parametrize("l", [1, 2, 3]) +def test_legendre(l, x=[-1.0, -0.9, -0.8]): + """test the calculation of unnormalized Legendre polynomials + """ + # calculate Legendre polynomials + nx = len(x) + obs = np.zeros((l+1, nx)) + for m in range(l+1): + obs[m,:] = pyTMD.math.legendre(l, x, m=m) + # expected values for each spherical harmonic degree + if (l == 1): + expected = np.array([ + [-1.00000, -0.90000, -0.80000], + [ 0.00000, -0.43589, -0.60000] + ]) + elif (l == 2): + expected = np.array([ + [1.00000, 0.71500, 0.46000], + [0.00000, 1.17690, 1.44000], + [0.00000, 0.57000, 1.08000] + ]) + elif (l == 3): + expected = np.array([ + [-1.00000, -0.47250, -0.08000], + [0.00000, -1.99420, -1.98000], + [0.00000, -2.56500, -4.32000], + [0.00000, -1.24229, -3.24000] + ]) + # check with expected values + assert np.isclose(obs, expected, atol=1e-05).all() diff --git a/test/test_solid_earth.py b/test/test_solid_earth.py index a746b11a..10fa8f0a 100644 --- a/test/test_solid_earth.py +++ b/test/test_solid_earth.py @@ -1,5 +1,5 @@ """ -test_solid_earth.py (07/2024) +test_solid_earth.py (11/2024) Tests the steps for calculating the solid earth tides PYTHON DEPENDENCIES: @@ -10,6 +10,7 @@ https://pypi.org/project/timescale/ UPDATE HISTORY: + Updated 11/2024: moved normalize_angle and polynomial_sum to math.py Updated 07/2024: use normalize_angle from pyTMD astro module Updated 04/2024: use timescale for temporal operations Updated 01/2024: refactored lunisolar ephemerides functions @@ -22,6 +23,7 @@ import pyTMD.astro import pyTMD.compute import pyTMD.predict +import pyTMD.math import pyTMD.utilities import timescale.time @@ -137,7 +139,7 @@ def test_phase_angles(): # convert from MJD to centuries relative to 2000-01-01T12:00:00 T = (MJD - 51544.5)/36525.0 s, h, p, N, PP = pyTMD.astro.mean_longitudes(MJD, ASTRO5=True) - PR = dtr*pyTMD.astro.polynomial_sum(np.array([0.0, 1.396971278, + PR = dtr*pyTMD.math.polynomial_sum(np.array([0.0, 1.396971278, 3.08889e-4, 2.1e-8, 7.0e-9]), T) TAU, S, H, P, ZNS, PS = pyTMD.astro.doodson_arguments(MJD) assert np.isclose(dtr*s + PR, S) @@ -353,7 +355,7 @@ def test_greenwich(): ts = timescale.time.Timescale(MJD=55414.0) # Meeus approximation hour_angle = 280.46061837504 + 360.9856473662862*(ts.T*36525.0) - GHA = pyTMD.astro.normalize_angle(hour_angle) + GHA = pyTMD.math.normalize_angle(hour_angle) # compare with pyTMD calculation assert np.isclose(GHA, ts.gha) diff --git a/test/test_spatial.py b/test/test_spatial.py index 1e4edb7f..40f36f16 100644 --- a/test/test_spatial.py +++ b/test/test_spatial.py @@ -455,3 +455,32 @@ def test_ECEF_to_ENU(): assert np.isclose(X, Xexp) assert np.isclose(Y, Yexp) assert np.isclose(Z, Zexp) + +# PURPOSE: test the conversion of ECEF to celestial horizontal coordinates +def test_ECEF_to_horizontal(): + # US Naval Observatory (USNO) + lon0 = -77.0669 + lat0 = 38.9215 + h0 = 92.0 + # solar ephemerides at J2000 + SX = 1.353631936e11 + SY = 1.938584775e9 + SZ = -5.755477511e10 + # lunar ephemerides at J2000 + LX = 2.09322658e8 + LY = -3.35161630e8 + LZ = -7.60803221e7 + # convert from ECEF to east-north-up (ENU) coordinates + SE, SN, SU = pyTMD.spatial.to_ENU(SX, SY, SZ, + lon0=lon0, lat0=lat0, h0=h0) + LE, LN, LU = pyTMD.spatial.to_ENU(LX, LY, LZ, + lon0=lon0, lat0=lat0, h0=h0) + # convert from ENU to horizontal coordinates + salt, saz, sdist = pyTMD.spatial.to_horizontal(SE, SN, SU) + lalt, laz, ldist = pyTMD.spatial.to_horizontal(LE, LN, LU) + # check solar azimuth and elevation + assert np.isclose(salt, -5.486, atol=0.001) + assert np.isclose(saz, 115.320, atol=0.001) + # check lunar azimuth and elevation + assert np.isclose(lalt, 36.381, atol=0.001) + assert np.isclose(laz, 156.297, atol=0.001)