Source code for skplatform.geodesy

import numbers
from typing import Union, List, Tuple
import math
import numpy as np
import scipy.optimize


def _unitvector(x: np.ndarray) -> np.ndarray:
    return x / np.linalg.norm(x, axis=-1, keepdims=True)


def _unitvector_debugversion(x: np.ndarray) -> np.ndarray:
    mag = np.linalg.norm(x, axis=-1, keepdims=True)
    bad = (mag < 1.0E-15)
    if np.any(bad):
        mag[bad] = 1.0
    val = x / mag
    return val


def _dotproduct(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    return np.nansum(a * b, axis=-1, keepdims=True)


def _vector_is_zero(x: np.ndarray):
    return np.linalg.norm(x, axis=-1) == 0


# -----------------------------------------------------------------------------
#               _HeightOffsetEvaluator
# -----------------------------------------------------------------------------
class _HeightOffsetEvaluator:
    '''
    An internal class used while locating a shell height location. The zbrent function calls the
    class function operator() to evaluate the offset in height of a given point from the target altitude.
    '''

    def __init__(self, geoid: 'Geodetic', tanpoint: np.ndarray, look: np.ndarray, H: float):

        self.geoid = geoid                  # The geoid used for calculations
        self.tangentpoint = tanpoint        # The location of the tangent point of this look vector
        self.look = look                    # The look direction as a unit vector (from the satellite).
        self.location = tanpoint            # Upon exit this will have the location of the height offset.
        self.H = H

    def evaluate(self, x: float):
        self.location = self.tangentpoint - self.look * x
        llh = self.geoid.llh_from_xyz(self.location)
        return llh[2] - self.H

    def __call__(self, x: float):
        return self.evaluate(x)

    def find_crossing_point(self, lmin: float, lmax: float) -> Tuple[np.ndarray, bool]:

        delta = 0.05 * lmin
        answer = self.evaluate(lmin)  # Get the height offset closest to tangent point
        while (answer >= 0.0):
            lmin -= delta
            answer = self.evaluate(lmin)

        answer = self.evaluate(lmax)
        while (answer <= 0.0):
            lmax += 0.05 * lmax
            answer = self.evaluate(lmax)

        answer, r = scipy.optimize.brentq(self, lmin, lmax, xtol=0.1, maxiter=50, full_output=True)

        if r.converged:
            self.evaluate(answer)
        return self.location, r.converged


# -----------------------------------------------------------------------------
#               Geodetic
# -----------------------------------------------------------------------------
[docs] class Geodetic(): ''' A class to manage both transform and tangent point calculations using oblate spheroid geometry. The transform operations allow conversion between geocentric, xyz, coordinates and geodetic, latitude, longitude and height coordinates. We also provide right-handed, geographic-based, unit vectors for any location The tangent point calculations include finding the location of a tangent point as well as the entrance and exit locations of a ray through altitude shells. The class is initialized with the default WGS84 oblate spheroid which should be suitable for most work. The class is fully implemented in pure python and has been written allow multiple calculations to be performed using array-based arithemetic. ''' def __init__(self): self.A: float = 0.0 self.F: float = 0.0 self._AEPS2 = 0.0 self._EC2 = 0.0 self._EC = 0.0 self._B = 0.0 self._E4T = 0.0 self.set_geoid(geoid='wgs84') # ----------------------------------------------------------------------------- # set_geoid # -----------------------------------------------------------------------------
[docs] def set_geoid(self, A: float=None, F: float=None, geoid: str=None): ''' Defines the shape parameters of the ellipsoid that will be used for future calculations. The shape is defined by the semi-major axis, ``A``, in meters and the Earth flattening parameter , ``F``. The most common usage is to use a standard, pre-defined ``geoid``. This method only needs to be called if the default oblate spheroid used in the constructor, `wgs84`, is not suitable. Parameters ---------- geoid: str, optional Predefined, standard oblate spheroids: * 'geocentric': (6371200.0, 0.0) * 'iau1976': (6378140.0, 0.00335281) * 'grs80': (6378137.0, 1.0/298.257222101) * 'merit83': (6378137.0, 1.0/298.257) * 'wgs84': (6378137.0, 1.0/298.257223563)} If it is not set the both ``A`` and ``F`` must be set. ``A`` and ``F`` are ignored in this option is set. A: float, optional The semi-major axis in meters. This argument is ignored if ``geoid`` is set. Argument ``F`` must also be set if this argument is set. F: float, optional The Earth flattening parameter. This argument is ignored if ``geoid`` is set. Argument ``A`` must also be set if this argument is set ''' if (geoid is not None): knowngeoids = {'geocentric': (6371200.0, 0.0), 'iau1976': (6378140.0, 0.00335281), 'grs80': (6378137.0, 1.0 / 298.257222101), 'merit83': (6378137.0, 1.0 / 298.257), 'wgs84': (6378137.0, 1.0 / 298.257223563)} key = geoid.lower() g = knowngeoids.get(key) if (g is not None): A = g[0] F = g[1] else: raise ValueError("Unsupported geoid {}. Try 'wgs84` or 'iau1976'".format(geoid)) else: assert ((A is not None) and (F is not None)) if (F < 0.0 or F >= 1.0): # Validate ellipsoid parameters. raise ValueError('Invalid Earth flattening parameter') if (A <= 0.0): raise ValueError('Invalid Earth radius parameter') # * Functions of ellipsoid parameters (with further validation of F). self.A = A self.F = F self._AEPS2 = A * A * 1.0E-32 self._E2 = (2.0 - F) * F self._E4T = self._E2 * self._E2 * 1.5 self._EC2 = 1.0 - self._E2 if (self._EC2 <= 0.0): raise ValueError('Invalid EC2 value') self._EC = math.sqrt(self._EC2) self._B = A * self._EC
# ----------------------------------------------------------------------------- # semi_major_axis # ----------------------------------------------------------------------------- @property def semi_major_axis(self): ''' Semi-major axis of the current ellipsoid in meters ''' return self.A # ----------------------------------------------------------------------------- # semi_minor_axis # ----------------------------------------------------------------------------- @property def semi_minor_axis(self): ''' Semi-minor axis of the current ellipsoid in meters ''' return self.A * (1.0 - self.F) # ----------------------------------------------------------------------------- # llh_from_xyz # -----------------------------------------------------------------------------
[docs] def llh_from_xyz(self, xyz: np.ndarray) -> np.ndarray: ''' Converts positions specified in xyz geocentric coordinates in meters to the equivalent geodetic (latitude, longitude, height) coordinates. The conversion is non-trivial and this implementation uses the algorithm developed by Fukushima. In theory, the algorithm is described as an approximation but in practice it is numerically comparable to exact solutions operating, which provide solutions at the nanometer level of precision, but struggle to solve the cubic equations with any higher precision. Notes ----- The algorithm is based upon the ``GCONV2H`` subroutine provided in file ``xgconv2.txt`` which is provided by Toshio Fukushima, `[1]`_ on his ResearchGate site. The code is implemented to efficiently convert a multi-dimensional array of positions in one pass of the routine. .. [1] Fukushima, T., "Transformation from Cartesian to geodetic coordinates accelerated by Halley's method", J.Geodesy (2006) 79: 689-693 Parameters ---------- llh: array[..., 3], optional An array of position vectors given in geocentric, xyz, meters. The array can be multi-dimensional but the last dimension must be used for the (x,y,z) fields and its size must be equal to 3, [0=X, 1=Y, 2=Z]. Returns ------- array [..., 3] Returns the geodetic coordinates of the input geocentric locations. The array is the same size and shape as the input array but the last dimensions, which is still size 3, is now used for the geodetic coordinates latitude=0, longitude = 1, height =2. latitude is given in degrees, -90.0 to +90.0, longitude is given in degrees, usually 0 to 360 (but this is not rigorously enforced), and height is given as meters above the geodetic surface (aka sea level) ''' if type(xyz) is not np.ndarray: xyz = np.array(xyz, dtype=float) if xyz.dtype.name != 'float64': xyz = np.array(xyz, dtype='float') X = xyz[..., 0:1] # Our X Y Z must be along the last axis Y = xyz[..., 1:2] Z = xyz[..., 2:3] P2 = X * X + Y * Y # Distance from polar axis squared. ELONG = np.arctan2(Y, X) # set the longitude ABSZ = np.abs(Z) # Unsigned z-coordinate. arepolar = P2 <= self._AEPS2 # find out if any points are polar P = np.sqrt(P2) # Distance from polar axis. S0 = ABSZ / self.A # Normalization. PN = P / self.A ZC = self._EC * S0 C0 = self._EC * PN # Prepare Newton correction factors. C02 = C0 * C0 C03 = C02 * C0 S02 = S0 * S0 S03 = S02 * S0 A02 = C02 + S02 A0 = np.sqrt(A02) A03 = A02 * A0 D0 = ZC * A03 + self._E2 * S03 F0 = PN * A03 - self._E2 * C03 B0 = self._E4T * S02 * C02 * PN * (A0 - self._EC) # Prepare Halley correction factor. S1 = D0 * F0 - B0 * S0 CC = self._EC * (F0 * F0 - B0 * C0) PHI = np.arctan(S1 / CC) # Evaluate latitude and height. S12 = S1 * S1 CC2 = CC * CC HEIGHT = (P * CC + ABSZ * S1 - self.A * np.sqrt(self._EC2 * S12 + CC2)) / np.sqrt(S12 + CC2) if np.any(arepolar): # Correct any points that are considered polart PHI[arepolar] = math.pi / 2.0 HEIGHT[arepolar] = ABSZ[arepolar] - self._B ELONG = np.degrees(ELONG) negative = ELONG < 0.0 ELONG[negative] += 360.0 southern = Z < 0.0 # find any points which are in the southern hemisphere PHI[southern] = -PHI[southern] # and restore sign of latitude. result = np.empty_like(xyz) result[..., 0:1] = np.degrees(PHI) result[..., 1:2] = ELONG result[..., 2:3] = HEIGHT return result
# ----------------------------------------------------------------------------- # xyz_from_llh # -----------------------------------------------------------------------------
[docs] def xyz_from_llh(self, llh: Union[np.ndarray, Tuple[float, float, float], List[float]]) -> np.ndarray: ''' Converts positions specified in geodetic coordinates (lat degrees,lon degrees, height meters) to the equivalent geocentric (x,y,z meters ) coordinates. The conversion is implemented to it can efficiently process a multi-dimensional array of positions in one pass. Notes ----- The conversion from geodetic to geocentric is straight-forward and quick. For more details see the reference: Page K11 Astronomical Almanac 1990. Parameters ---------- llh: array[..., 3], optional An array of position vectors given in geodetic latitude, longitude, height (degrees, degrees, meters). The array can be multi-dimensional but the last dimension is reserved for the lat, lon and height fields and it size must be equal to 3, [0=lat, 1=long, 2=height]. Returns ------- array[..., 3]: The geocentric, xyz, locations in meters corresponding to the input geodetic locations. The array is the same size as the input array except the last dimension, which is still of size 3, is now reserved for X=0, Y=1, Z=2. ''' if type(llh) is not np.ndarray: llh = np.array(llh, dtype=float) lat = np.radians(llh[..., 0:1]) lon = np.radians(llh[..., 1:2]) height_meters = llh[..., 2:3] cosphi = np.cos(lat) sinphi = np.sin(lat) coslam = np.cos(lon) sinlam = np.sin(lon) fm1 = (1.0 - self.F) fm12 = fm1 * fm1 C = 1.0 / np.sqrt(cosphi * cosphi + fm12 * sinphi * sinphi) S = fm12 * C P = (self.A * C + height_meters) * cosphi result = np.empty_like(llh) result[..., 0:1] = P * coslam result[..., 1:2] = P * sinlam result[..., 2:3] = (self.A * S + height_meters) * sinphi return result
# ----------------------------------------------------------------------------- # get_osculating_spheroid # -----------------------------------------------------------------------------
[docs] def get_osculating_spheroid(self, geodeticlatitude: float, geodeticlongitude: float): ''' Calculates the sphere that best fits the ellipsoid surface (height = 0) at the geodetic location. This algorithm best fits the spheroid in the latitudinal direction (ie North South). A better choice for the future may be to use a look vector. The osculating sphere is passed to radiative transfer models to "best" represent the true earth at the region of interest using a spherical system. Notes ----- The radius of curvature is given by:- .. math:: R = \\frac{ \\left[1 + \\left(\\frac{dy}{dx}\\right)^2\\right]^{\\frac{3}{2}}}{\\frac{d^2y}{dx^2}} and for an ellipse: .. math:: R = \\frac{1}{ab} \\left[ \\frac{a^2}{b^2}y^2 + \\frac{b^2}{a^2}x^2\\right]^{\\frac{3}{2}} Parameters ---------- geodeticlatitude: float The geodetic latitude in degrees of the location where we need to fit the osculating sphere geodeticlongitude: float The geodetic longitude in degrees of the location where we need to fit the osculating sphere. Returns ------- Tuple[ radius:float, offset:Tuple[float, float]]: Returns a two element tuple containing the radius of the best fit osculating spheroid in the first element and the offset of the origin of the osculating sphere from the origin of the true geoid in the second element. ''' xunit = np.array([math.cos(math.radians(geodeticlongitude)), math.sin(math.radians(geodeticlongitude)), 0.0]) yunit = np.array([0.0, 0.0, 1.0]) location = self.xyz_from_llh((geodeticlatitude, geodeticlongitude, 0.0)) y0 = location[2] # Get the vertical component of the point on the surface of the geoid x0 = np.sqrt(np.square(location[0]) + np.square(location[1])) # Get the horizontal component of the point on the surface of the geoid a = self.A b = self.A * (1.0 - self.F) a2 = a * a b2 = b * b a2y0 = a2 * y0 b2x0 = b2 * x0 r = 1.0 / (a * b) * ((a2y0 * y0 / b2 + b2x0 * x0 / a2) ** 1.5) # Get the radius of curvature at this location theta = np.arctan2(a2y0, b2x0) # Get the angle of the gradient of the vertical at the surface of the geoid dx = r * np.cos(theta) # Get the X offset of the center of curvature from the surface point. dy = r * np.sin(theta) # Get the Y offset of the center of curvature from the surface point. offset = location - dy * yunit - dx * xunit radius = r return (radius, offset)
# ----------------------------------------------------------------------------- # xyz_west_south_up # -----------------------------------------------------------------------------
[docs] def xyz_west_south_up(self, xyz=None, llh=None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ''' Returns the three right-handed, orthogonal unit vectors, `west`, `south` and `up` at the provided locations. The unit vectors are always given in xyz geocentric coordinates. The locations at which the unit vectors are required can be specified in either geocentric xyz coordinates using the keyword ``xyz`` or in geodetic latitude, longitude, height using keyword ``llh``. The algorithm allows all the locations to be specified using multi-dimensional arrays with the last dimension set to a size of 3 ali_elements. This allows the code to execute all location calculations in one pass of the code. It is not recommended, but it is possible to specify locations using both keywords in which case the user must ensure they are identically sized arrays and they have the geocentric and geodetic coordinates pertaining to the same exact locations Parameters ---------- xyz: array[..., 3], optional The geocentric, xyz, locations in meters where the three unit vectors will be calculated. It can be multidimensional with the last dimension equal to 3, [0=X, 1=Y, 2=Z]. It is usually left undefined if keyword ``llh`` is used. llh: array[..., 3], optional An array of look unit vectors given in geodetic lat, lon, height (derees, degrees, meters). It can be multi-dimensional but the last dimension must be equal to 3, [0=lat, 1=long, 2=height]. It is usually left undefined if keyword ``xyz`` is used. Returns ------- Tuple[ west:array[..., 3], south:array[..., 3], up:array[..., 3]] A three element tuple storing the west, south and up unit vectors expressed as dimensional numbers in geocentric, xyz coordinates. The array sizes are the same as the arrays used to define the input locations. Notes ----- The directions of west and south are undefined at either pole. It is technically possible that NaN values could be returned at the pole, which happens if the horizontal vector at the pole is calculated to be exactly 0.0. In practice, this very rarely happens even when you put locations at the pole as there is usually sufficient numerical round off that keeps the horizontal component very small but not zero. But it can happen and its not checked for. See Also -------- :meth:`xyz_north_east_down` ''' location: np.ndarray = xyz geo: np.ndarray = llh if (location is None): assert (geo is not None) location = self.xyz_from_llh(geo) if (geo is None): assert (location is not None) geo = self.llh_from_xyz(location) lat = np.radians(geo[..., 0:1]) coslat = np.cos(lat) sinlat = np.sin(lat) horiz = np.empty_like(location) # vector in equatorial plane and observers meridian horiz[..., 0:1] = location[..., 0:1] # Get the X coordinate of the geocentric location horiz[..., 1:2] = location[..., 1:2] # Get the Y coordinate of the geocentric location horiz[..., 2:3] = 0.0 horiz = _unitvector(horiz) vertical = np.zeros_like(location) # unit vector in equatorial plane and observers meridian vertical[..., 2:3] = 1.0 up = horiz * coslat + vertical * sinlat # get vertical unit vector at location south = horiz * sinlat - vertical * coslat # get due South unit vector at observer's location west = np.cross(south, up, axisa=-1, axisb=-1) # get due West unit vector at observer's location return (west, south, up)
# ----------------------------------------------------------------------------- # xyz_north_east_down # -----------------------------------------------------------------------------
[docs] def xyz_north_east_down(self, xyz=None, llh=None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ''' Returns the three right-handed, orthogonal unit vectors, `north`, `east` and `down` at the provided locations. It is a small wrapper around :meth:`xyz_west_south_up` and all the parameters are described in that function with the returning variables being changed from (``west``, ``south``, ``up``) to (``north``, ``east``, ``down``) ''' west, south, up = self.xyz_west_south_up(xyz=xyz, llh=llh) return (-south, -west, -up)
# ----------------------------------------------------------------------------- # _check_tangent_pt_convergence_old # ----------------------------------------------------------------------------- def _check_tangent_pt_convergence_old(self, nominaltanptxyz: np.ndarray, lookxyz: np.ndarray, startdistance: float) -> Tuple[np.ndarray, np.ndarray]: ''' Internal routine used to diagnose the tangent point location code. This algorithm assumes we have a decent approximation o fthe tangent point, say within a few meters. It then steps a little bit in front and behind and looks at the variation of altitude. The tangent point should occur at the minimum altitude. The code tries to be efficient and fits a parabola to height returned from three points at (+l, 0, -l) from the nominal tangent point. It can fit the quadratic coefficients and getthe location of the quadratic peak. Unfortunately the code seems too clever for its own good. It actually made some quite bad estimates, moving by 2 or 3 meters when it probably should have stayed within 0.2 meters. The code needs to be debugged and fixed or re-written so it just steps through, brute-force style, looking for the mnimum height. ''' locxyz = np.copy(nominaltanptxyz) # make a copy of the tangent point llh = self.llh_from_xyz(locxyz) # get the lat, lng, height of the tangent point l = startdistance # assume we are within 128 meters h0 = llh[..., 2:3] # get the height of the current tangent point for n in range(8): # then for 8 iterations, half the search space each time loc1 = locxyz + lookxyz * l # Step to a point distance "l" after tangent point loc2 = locxyz - lookxyz * l # Step to a point distance "l" before tangent point llh1 = self.llh_from_xyz(loc1) # get the lat/lon/height at the first llh2 = self.llh_from_xyz(loc2) # and second location h1 = llh1[..., 2:3] - h0 # And calculate the change in height h2 = llh2[..., 2:3] - h0 # at both locations # a = (h1 + h2)/(2* l *l) # fit a parabola (al^2 + bl + c = deltaH), c = 0 as deltaH = 0, when L = 0 # b = (h1 - h2)/(2 * l) # nice quick and dirty fit for this special config # pk = -b/(2.0 * a) # and standard formula for the peak pk = (h2 - h1) / (h1 + h2) * (0.5 * l) # alternatively , just go straight to the answer locxyz = locxyz + pk * lookxyz # update the tangent point location llh = self.llh_from_xyz(locxyz) # and its lat, lng, height h0 = llh[..., 2:3] # get the height of the new tangent point l = l / 2.0 # and go to 1/2 step size if l < 0.003: # anything less than 3 mm is just noise in the system break delta = np.linalg.norm(locxyz - nominaltanptxyz, axis=-1) return (locxyz, delta) # ----------------------------------------------------------------------------- # _check_tangent_pt_convergence # ----------------------------------------------------------------------------- def _check_tangent_pt_convergence(self, nominaltanptxyz: np.ndarray, lookxyz: np.ndarray, checkconvergence) -> Tuple[np.ndarray, np.ndarray]: ''' Internal routine used to diagnose the tangent point location code. This algrotithm assumes we have a decent approximation o fthe tangent point, say within a few meters. It then steps a little bit in front and behind and looks at the variation of altitude. The tangent point should occur at the minimum altitude. The code tries to be efficient and fits a parabola to height returned from three points at (+l, 0, -l) from the nominal tangent point. It can fit the quadratic coefficients and getthe location of the quadratic peak. Unfortunately the code seems too clever for its own good. It actually made some quite bad estimates, moving by 2 or 3 meters when it probably should have stayed within 0.2 meters. The code needs to be debugged and fixed or re-written so it just steps through, brute-force style, looking for the mnimum height. ''' llh = self.llh_from_xyz(nominaltanptxyz) # get the lat, lng, height of the tangent point h0 = llh[..., 2:3] l = np.linspace(-1.0, 1.0, 1001) loc = lookxyz * l[:, np.newaxis] # Step to a point distance "l" after tangent point loc2 = nominaltanptxyz + loc llh2 = self.llh_from_xyz(loc2) # and second location h2 = (llh2[..., 2:3] - h0).flatten() # at both locations minidx = np.argmin(h2) coeffs = np.polyfit(l, h2, 2) a = coeffs[0] b = coeffs[1] pk = -b / (2.0 * a) print('Tangent point convergence seems to be {} meters from the true tangent point'.format(pk)) return pk # ----------------------------------------------------------------------------- # _mask_out_bad_vectors # ----------------------------------------------------------------------------- def _mask_out_bad_vectors(self, badflags: np.ndarray, vectors: np.ndarray): ''' Masks out any bad vectors by setting the vector to NaN. Assume we have a multi-dimensional array of vectors: eg. vectors [N,M,K...,L,3] where the last dimension is the XYZ or Lat,Long,height. We also have an multi-dimensional array of bad vectors of exactly the same size except the last dimension is 1: badflags[N,M,K...,L,3] The code uses np.nonzero to find the indices of the bad vectors as a set of tuple indices for each dimension. We then manually toggle the index of the last dimension. ''' if np.any(badflags): badidx = np.nonzero(badflags) # Get a list of indices f where the vectors are bad. This is the same index as used for the X component vectors[badidx] = np.nan # set the X component, index 0 to Nan for i, j in enumerate(badidx[-1]): badidx[-1][i] = 1 # Set the bad indices for the Y component. vectors[badidx] = np.nan # Set the Y component to NaN for i, j in enumerate(badidx[-1]): badidx[-1][i] = 2 # Set the bad indices for the Z component. vectors[badidx] = np.nan # Set the Z component to NaN return vectors # ----------------------------------------------------------------------------- # xyz_tangent_point_location # -----------------------------------------------------------------------------
[docs] def xyz_tangent_point_location(self, observerxyz: np.ndarray, lookxyz: np.ndarray, checkconvergence=None, check_tangent_behind=False) -> np.ndarray: ''' Calculates the xyz geocentric coordinates of the tangent point given a look vector away from an observers location towards the Earth. The earth is modelled as an oblate spheroid. All calculations assume straight ray paths. This is the second technique we developed to find the tangent point and seems to be the adhoc default. In practice it is neither substantially quicker or more accurate than the original technique. It seems to work well at sub-meter accuracy (typically around 0.1 meter accuracy), but beyond that more careful handling of numeric roundoff may be required. The code is implemented to process multiple look vectors and observer positions in one pass of the code using multi-dimensional arrays. However both the observer and look arrays must be the same shape as the same shape flattening factor is iteratively applied to both fields; it fails if they have different shapes. The legacy version of this function can handle broadcasting, eg a single observer and multiple lines of sight. Algorithm Details ----------------- This technique performs an affine transformation (the ratio of distances is preserved under the transformation) that maps the oblate spheroid into a true sphere. The tangent point, using a transformed observer and look vector, is quickly found for a sphere using simple geometry. The transformed tangent point can then be shifted back to the oblate spheroid system. The technique is elegant but does have an achilles heal that we ideally need to use the oblate spheroid that goes through the tangent point rather than the one that follows the Earth's surface. This results in an iterative approach where the required oblate spheroid shape is estimated at the end of each iteration and the fit repeated. It seems to work okay but there are currently no tests for convergence and there is no theoretical basis showing that the new choice of flattening factor between iterations is correct. For example, its not even clear to me that the oblate spheroid for 40 km is parallel to the oblate spheroid at the ground and that the vertical vector at the ground is perpendicular to the oblate spheroid at 40 km. All of which play into the definition of tangent point. Limitations ----------- The lookxyz and observerxyz vector must not be parallel. If they are, then there is a whole family of solutions (probably the great circle) around the oblate spheroid. This condition is not tested for and may produce very stange results. Parameters ---------- observerxyz: array[...,3] An array of observer positions given in geocentric xyz coordinates (meters). It can be multidimensional with the last dimension equal to 3, [0=X, 1=Y, 2=Z]. It should have the same shape as variable ``lookxyz`` lookxyz: array[...,3] An array of look unit vectors given in geocentric xyz coordinates (dimensionless). The look vectors must be away from the observer towards the Earth. It can be multidimensional with the last dimension equal to 3, [0=X, 1=Y, 2=Z]. Its shape must be the same as ``observerxyz``. checkconvergence: float or None Diagnostic internal flag used to provide information about the true tangent point assuming its close to this solution. Its reserved for internal usage. Returns ------- array[...,3] The location of the tangent point in geocentric, xyz coordinates (meters). The final array size is determined from the broadcasting of ``observerxyz`` with ``lookxyz``, but the last dimension will be 3. Locations that failed to find a tangent point are set to Nan. This typically occurs if the look vector is not towards the Earths limb. See Also -------- :meth:`xyz_tangent_point_location_legacy` ''' F = self.F obsTransf = np.array(observerxyz) ellTransf = np.array(lookxyz) for n in range(5): transformFactor = 1.0 / (1.0 - F) obsTransf[..., 2:3] = observerxyz[..., 2:3] * transformFactor # apply the transform to the observer ellTransf[..., 2:3] = lookxyz[..., 2:3] * transformFactor # and the look vector ellt = _unitvector(ellTransf) # make the look vector into a unit vector s = -_dotproduct(ellt, obsTransf) # l = Robs.cos(theta) locxyz = obsTransf + s * ellt # get the tangent point in the transformed system locxyz[..., 2:3] /= transformFactor # then map back to the regular system llh = self.llh_from_xyz(locxyz) F = self.F * (self.A / (self.A + llh[..., 2:3])) # We have to use a flattening ratio that corresponds to the oblate spheroid at the tangent altitude. I'm not convinced this is completely correct if check_tangent_behind: bad = s < 0.0 locxyz = self._mask_out_bad_vectors(bad, locxyz) if (checkconvergence is not None): answer = self._check_tangent_pt_convergence(locxyz, lookxyz, checkconvergence) else: answer = locxyz return answer
# ----------------------------------------------------------------------------- # xyz_tangent_point_location_legacy # -----------------------------------------------------------------------------
[docs] def xyz_tangent_point_location_legacy(self, observerxyz: np.ndarray, lookxyz: np.ndarray, checkconvergence=None, do_high_precision: bool=True): ''' Calculates the xyz geocentric coordinates of the tangent point given a look vector away from an observers location towards the Earth. The earth is modelled as an oblate spheroid. All calculations assume straight ray paths. This is the first technique we developed to find the tangent point. It seems to work well at sub-meter accuracy (typically around 0.1 meter accuracy), but beyond that more careful handling of numeric roundoff may be required. The code is implemented to process multiple look vectors and observer positions in one pass of the code using multi-dimensional arrays and broadcasting. Algorithm Details ----------------- Basic idea is to transform from the standard (primed) geocentric coordinate system to another system such that the look vector defines the X direction, the spacecraft position is a second vector that defines the plane that includes the straight line ray and the center of the earth. The new Z axis is perpendicular to the plane. Convenient because Z is exactly zero in the new coordinate frame. Also convenient is the fact that the straight line ray is given by the equation y = yr where yr is the (constant) Y coordinate of the spacecraft in the new reference frame. First find the equation of the oblate spheroid projected onto this plane. Second define the tangent point as the place on the oblate spheroid that is parallel to the ray direction (which is aligned in the X direction) Transform the tangent point back to the normal coordinate system. Limitations ----------- The lookxyz and observerxyz vector must not be parallel. If they are, then there is a whole family of solutions (probably the great circle) around the oblate spheroid. This condition is not tested for and may produce very stange results. Parameters ---------- observerxyz: array[...,3] An array of observer positions given in geocentric xyz coordinates (meters). It can be multidimensional with the last dimension equal to 3, [0=X, 1=Y, 2=Z] lookxyz: array[...,3] An array of look unit vectors given in geocentric xyz coordinates (dimensionless). The look vectors must be away from the observer towards the Earth. It can be multidimensional with the last dimension equal to 3, [0=X, 1=Y, 2=Z]. Its shape must be the same as ``observerxyz`` or at least be broadcast compatible. checkconvergence: float or None Diagnostic internal flag used to provide information about the true tangent point assuming its close to this solution. Its reserved for internal usage. do_high_precision: bool A flag that requests that the calculation is iterated a second time to give it high precision (sub-millimeter). By default this option is enabled (True). It can be disabled for low precision calculations that want to go a little faster and are happy with height precision around ~0.5 m. Returns ------- array[...,3] The location of the tangent point in geocentric, xyz coordinates (meters). The final array size is determined from the broadcasting of ``observerxyz`` with ``lookxyz``, but the last dimension will be 3. Locations that failed to find a tangent point are set to Nan. This typically occurs if the look vector is not towards the Earths limb. See Also -------- :meth:`xyz_tangent_point_location` ''' a = self.semi_major_axis c = self.semi_minor_axis # semi minor axis (for earth's Z axis) west, south, up = self.xyz_west_south_up(xyz=observerxyz) # get the local "UP" vector at the observer xunit = _unitvector(lookxyz) # define X unit vector as parallel to the look vector zunit = _unitvector(np.cross(lookxyz, observerxyz)) # define Z unit vector as the ray tracing plane as perpendicular to the plane defined by the ray and the position vector. yunit = _unitvector(np.cross(zunit, xunit)) # define Y unit vector so it forms a right angled system. w11 = xunit[..., 0:1] # get the transformation numbers from the unit vectors w12 = yunit[..., 0:1] # we have to do the transpose to get the proper array (primes are Earth coords, unprimed are plane coords) w21 = xunit[..., 1:2] # x' = w11.x + w12.y + w13.z w22 = yunit[..., 1:2] # y' = w21.x + w22.y + w23.z w31 = xunit[..., 2:3] # z' = w31.x + w32.y + w33.z w32 = yunit[..., 2:3] # for our calculations z is exactly zero. a2 = a * a c2 = c * c A = (np.square(w11) + np.square(w21)) / a2 + np.square(w31) / c2 # Calculate coefficients of the equation of oblate spheroid B = (np.square(w12) + np.square(w22)) / a2 + np.square(w32) / c2 # projected onto the straight line ray C = 2.0 * ((w11 * w12 + w21 * w22) / a2 + (w31 * w32) / c2) # plane factor = 4.0 * A * B - C * C Ty = _dotproduct(observerxyz, yunit) # y coordinate of observer Tx = -C / A * np.sqrt(A / factor) # x coordinate tanpt_xyz = (Tx * xunit) + (Ty * yunit) # Now get Geocentric location in original coordinate system if (do_high_precision): # Apply a second iteration llh = self.llh_from_xyz(tanpt_xyz) # That calculates the height using the ground surface h = llh[..., 2:3] # extracts the height a2 = (a + h) * (a + h) # now re-adjust the semi-major and semi-minor axes so the "ground" is now at the nominal height c2 = (c + h) * (c + h) # and repeat the calculation. This should iterate very rapidly for all atmospheric altitudes. A = (np.square(w11) + np.square(w21)) / a2 + np.square(w31) / c2 # Calculate coefficients of the equation of oblate spheroid B = (np.square(w12) + np.square(w22)) / a2 + np.square(w32) / c2 # projected onto the straight line ray C = 2.0 * ((w11 * w12 + w21 * w22) / a2 + (w31 * w32) / c2) # plane factor = 4.0 * A * B - C * C Tx = -C / A * np.sqrt(A / factor) # x coordinate tanpt_xyz = (Tx * xunit) + (Ty * yunit) # Now get Geocentric location in original coordinate system bad = _dotproduct(lookxyz, observerxyz) > 0.0 # Look for any point looking away from Earth tanpt_xyz = self._mask_out_bad_vectors(bad, tanpt_xyz) # and set them to NaN if they are. if (checkconvergence is not None): # check convergence if requested. This is internal and not supported. answer = self._check_tangent_pt_convergence(tanpt_xyz, lookxyz, checkconvergence) else: answer = tanpt_xyz return answer
# ----------------------------------------------------------------------------- # xyz_altitude_intercepts # -----------------------------------------------------------------------------
[docs] def xyz_altitude_intercepts(self, Hnd: np.ndarray, observerxyznd: np.ndarray, lookxyznd) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ''' Calculate the entrance and exit points of a look vector with a given shell at height H. The routine allows the user to pass in a multi-dimensional arrays of look vector, observer position and shell height. The calculation is only meaningful in the look vector is towards the Earth and the required shell height is at or above the tangent point, i.e. the shell height must lie between the tangent point and the observer. The entrance and exit points are approximately symmetric about the tangent point. The entrance point is located towards the observer on the near-side of the tangent point. The exit point is on the far side, furthest from the tangent point. The algorithm is internally implemented to iterate over the arrays and process the intercepts one case at a time. It currently uses ``scipy.optimize.zbrentq`` to find the shell heights which is not conducive to array based operations. Consequently, the method, simply due to its array looping implementation, may be substantially slower than the other methods in this object. This should not be a significant issue for hundreds or thousands of rays but will be an issue for millions of rays. Parameters ---------- Hnd: np.ndarray The height in meters of the target shell intercepts. This is typically given as an array with the same shape as ``observerxynd`` and ``lookxynd`` except the last dimension is 1 rather than 3. It is also allowed to be a scalar or a simple sequence for situations where that is compatible with the other parameters observerxyznd: np.ndarray A multi-dimensional array that provides the geocentric, xyz location of the observer in meters. The array can be any shape but the last dimension must be 3. It is recommended that this array has the same shape as ``lookxyznd``. The observer is typically a spacecraft or high altitude platform. lookxyznd: np.ndarray A multi-dimensional array that provides the geocentric, xyz, look vector of each ray as a dimensionless unit vector. The rays are treated as being away from the observer, towards the Earth, which is opposite to the actual direction of light propagation. The array can be any shape but the last dimension must be 3. It is recommended that this array has the same shape as ``observerxyznd``. Returns ------- Tuple[entrypoint: np.ndarray, exitpoint: np.ndarray, ok: np.ndarray] Three element tuple containing (i) the location of the entry point of the ray into the shell, (ii) the location of the exit point of the ray from the shell and (iii) a boolean status flag array. All variables are the same shape as the input arrays. ''' if type(Hnd) is not np.ndarray: if isinstance(Hnd, numbers.Number): Hnd = np.array([Hnd]) else: Hnd = np.array(Hnd) N = lookxyznd.size // 3 ok = (lookxyznd.size == observerxyznd.size) and (N == Hnd.size) if not ok: raise ValueError('We can only support arrays where they all have the same number of observations [{}]'.format(N)) entrypointanswers = np.zeros_like(lookxyznd) exitpointanswers = np.zeros_like(lookxyznd) dims = list(lookxyznd.shape) dims[-1] = 1 status = np.ones(dims, dtype=bool) lookiter = np.nditer(lookxyznd) obsiter = np.nditer(observerxyznd) hiter = np.nditer(Hnd) entrypoint_iter = np.nditer(entrypointanswers, op_flags=['readwrite']) exitpoint_iter = np.nditer(exitpointanswers, op_flags=['readwrite']) status_iter = np.nditer(status, op_flags=['readwrite']) lookxyz = np.zeros([3]) observerxyz = np.zeros([3]) for iloop in range(N): # Loop over the number of lines of sight for i in range(3): lookxyz[i] = float(lookiter.value) observerxyz[i] = float(obsiter.value) lookiter.iternext() obsiter.iternext() H = float(hiter.value) hiter.iternext() # --- now process on observerxyz, lookxyz tanptxyz = self.xyz_tangent_point_location(observerxyz, lookxyz, check_tangent_behind=False) # Get the tangent point in xyz tanptllh = self.llh_from_xyz(tanptxyz) # and in lat, lon, alt Ht = tanptllh[2] # get the tangent height entrypoint = np.zeros([3]) exitpoint = np.zeros([3]) ok = (H > Ht) # and make sure there is a valid solution if (ok): # if there is then if (self.F == 0.0): # If we have a pure sphere then the solution lmax = np.sqrt((2.0 * self.A + H + Ht) * (H - Ht)) # is considerably simplified d = lmax * lookxyz entrypoint = tanptxyz - d exitpoint = tanptxyz + d else: evaluator = _HeightOffsetEvaluator(self, tanptxyz, lookxyz, H) # Create the height offset evaluator object Rmin = self.semi_minor_axis # Get the Semi Minor axis Rmax = self.semi_major_axis # Get the Semi major axis lmin = 0.9 * np.sqrt((H - Ht) * (H + Ht + 2 * Rmin)) # Minimum distance from tangent point to shell height lmax = 1.1 * np.sqrt((H - Ht) * (H + Ht + 2 * Rmax)) # Maximum distance from tangent point to intersection entrypoint, ok = evaluator.find_crossing_point(lmin, lmax) # Find the shell from tangent point towards observer if (ok): exitpoint, ok = evaluator.find_crossing_point(-lmin, -lmax) # Find the shell from tangent point away from observer for i in range(3): entrypoint_iter[0] = entrypoint[i] exitpoint_iter[0] = exitpoint[i] entrypoint_iter.iternext() exitpoint_iter.iternext() status_iter[0] = ok status_iter.iternext() return entrypointanswers, exitpointanswers, status
# ----------------------------------------------------------------------------- # xyz_lookvector_from_tangent_altitude # -----------------------------------------------------------------------------
[docs] def xyz_lookvector_from_tangent_altitude(self, h: float, observerxyz: np.ndarray, boresightazimuthxyz: Union[np.ndarray, float], convergence_meters=0.1): ''' Finds the look vector towards a given tangent altitude at a given azimuth at the observer location. The observer is typically a spacecraft position and should be (well) above the requested tangent point (e.g. more than 5 km). The algorithm makes a first approximation using the osculating sphere which may fail if the requested tangent altitude is too close to the observers altitude. An iterative method is used to find the look vector required for the given tangent altitude. The algorithm uses oblate spheroid Earth and straight line geometry. Solution iterates until calculated tangent altitude is within ``convergence_meters`` meters of the requested height. Parameters ---------- h: float The requested tangent height in meters above the surface of the Earth. This must be a scalar observerxyx: np.ndarray [3,] The geocentric, xyz, location of the observer in meters. boresightazimuthxyz: np.ndarray[3,] or float The horizontal unit vector at the observer's location that defines the azimuth of the required look vector. The parameter can also be specified as a floating point number that gives the compass bearing in degrees (0= North, 90= East etc.) of the desired unit vector. convergence_meters: float, optional Specifies the convergence. The returned solution will be within this height distance in meters from the target tangent point It may prove difficult to get consistency much better than 0.1 meters due to computational round off issues. default is 0.1 meters Returns ------- np.ndarray[3,] The geocentric, xyz, dimensionless unit vector that looks from the observer toward the target tangent point at the given horizontal azimuth. ''' obsllh = self.llh_from_xyz(observerxyz) # Get the observers lat/lon, height if isinstance(boresightazimuthxyz, numbers.Number): north, east, down = self.xyz_north_east_down(llh=obsllh) # Get the local geographic unit vectors azi = np.radians(boresightazimuthxyz) # set the azimuth o fthe look vector boresightazimuthxyz = north * np.cos(azi) + east * np.sin(azi) # and setup the horizontal look vector direction xunit = _unitvector(observerxyz) # Get unit vector to the observer yunit = boresightazimuthxyz - _dotproduct(boresightazimuthxyz, xunit) * xunit # Get unit vector perpendicular to observer and the observation plane.) re_geocentric, offset = self.get_osculating_spheroid(obsllh[0], obsllh[1]) # Get the osculating sphere suitable for this observer radius = np.linalg.norm(observerxyz - offset) # and get the radius of the observer, after adjusting for offset costheta = (re_geocentric + h) / radius # Get angle to tangent altitude on spherical Earth dh = np.nan ok = (costheta <= 1.0) if (not ok): raise RuntimeError('The target tangent altitude look vector must be below the observer') else: theta = np.arccos(costheta) sintheta = np.sin(theta) lookv = yunit * costheta - xunit * sintheta # and initialize the look vector ok = False numloops = 0 while (not ok) and (numloops < 100): tanxyz = self.xyz_tangent_point_location(observerxyz, lookv) # calculate the tangent point tanllh = self.llh_from_xyz(tanxyz) # and convert to lat,lon, height newh = tanllh[2] # get the geodetic height of this tangent point dh = (newh - h) # get the difference in height from guess to geodetic ok = (np.abs(dh) < convergence_meters) # see if we have converged if not ok: # we haven't dtheta = 0.8 * dh / (radius * sintheta) # adjust the estimate of theta theta += dtheta # get a new theta costheta = np.cos(theta) sintheta = np.sin(theta) lookv = yunit * costheta - xunit * sintheta # and unpdate the unit vectors numloops += 1 if (not ok): raise RuntimeWarning('Stopped looking for the target tangent altitude after {} iterations. Convergence = {} meters'.format(numloops, dh)) return lookv
# ----------------------------------------------------------------------------- # xyz_observer_sun_look_for_given_sun_and_tangent_point # -----------------------------------------------------------------------------
[docs] def xyz_observer_sun_look_for_given_sun_and_tangent_point(self, boresight_tanpt_llh: np.ndarray, omega_ocf: np.ndarray, lookazi_at_tanpt_deg: float=75.0, sza: float=None, ssa: float=None, saa: float=None, observer_height_m: float=500000.0): r""" Generates an observer who is looking along an instrument bore-sight at a given tangent point location. The Sun is chosen so it is at a given solar zenith angle and solar azimuth angle at that tangent point. The observer is located at a given height. The location of the observer and sun are calculated that meet the specified requirements. A set of look vectors are generated using the direction angles :math:`\Omega` given in the optical control frame, centered around the boresight. Parameters ---------- boresight_tanpt_llh: Tuple[lat, long , height] THe latitude, longitude and height of the tangent point that the boresight should look at omega_ocf: np.ndarray [N, 2] An array of N look directions specified using the :math:`\Omega_y` and :math:`\Omega_x` angles in the optical control frame. Briefly, :math:`\Omega_y` is upward elevation from the boresight and :math:`\Omega_x` is azimuth in an anti-clockwise direction from the bore sight, looking away from the instrument towards port (left). All angles are given in degrees. The array is typically a 2-D array with the last dimension set to 2. :math:`\Omega_y` corresponds to the [..., 0] elements and :math:`\Omega_x` corresponds to thge [..., 1] elements. The variable can be a list or tuple, or anything else that can be easily coerced into an array. The variable can also be 1 dimensional (eg [N]) in which case it is coerced into a 2 Dimensional array with :math:`\Omega_x` equal to zero. lookazi_at_tanpt_deg : float The geographic azimuth at the tangent point of the look vector from the observer to the tangent point. It is specified in degrees 0=N, 90=E etc. Default is 75 degrees. sza : float Solar zenith angle in degrees at the desired tangent point. Default is None. And it must be set. saa : float Solar azimuth angle in degrees at the desired tangent point. Default is None. One of the keywords ``saa`` or ``ssa`` must be set to a valid number. ssa : float Solar scattering angle in degrees at the desired tangent point. Default is None. One of the keywords ``saa`` or ``ssa`` must be set to a valid number. observer_height : float The height of the observer above the surface in meters. This must be higher than the requested tangent point. Default is 500,000.0 Returns ------- Dict[str, np.ndarray] A four element dictionary with fields ``observer``, ``sun``, ``los`` and ``icf_unitvectors``. The ``observer` element is the geocentric, xyz, location of the observer (in meters). The ``sun`` element is a geocentric, xyz, unit vector toward the sun. The ``los`` element is the array of look unit vectors away from the instrument corresponding to each of the look directions given in ``omega_ocf`. The look vectors are returned in the same shape as the look directions except the last dimension is now 3 instead of 2 and is the geocentric, xyz, unit vector of each look direction. The ``icf_unitvectors`` is a three element tuple containing three unit vectors, :math:`(\hat{x}_{icf}, \hat{y}_{icf}, \hat{z}_{icf}), associated with the :ref:`icf`. The three ICF unit vetors are return as geocentric, xyz, unit vectors. """ if type(omega_ocf) is not np.ndarray: omega_ocf = np.array(omega_ocf) if omega_ocf.ndim > 1: omegay = np.radians(omega_ocf[..., 0, np.newaxis]) omegax = np.radians(omega_ocf[..., 1, np.newaxis]) else: omegay = np.radians(omega_ocf[:, np.newaxis]) omegax = np.array(((0.0), (0.0), (0.0))) if (sza is None): raise ValueError('Keyword sza must be set to a valid value (0 to 180 degrees).') if (ssa is not None): if (saa is not None): raise ValueError('You cannot set both solar scattering angle (ssa) and solar azimuth angle (saa). Only choose one.') sza_r = np.radians(sza) ssa_r = np.radians(ssa) if np.cos(ssa_r) / np.sin(sza_r) > 1: raise ValueError('Unphysical geometry, the requested scattering angle is larger than the maximum value, {}'.format(np.degrees(np.arccos(np.sin(sza_r))))) else: saa = np.degrees(np.arccos(np.cos(ssa_r) / np.sin(sza_r))) + lookazi_at_tanpt_deg if (saa is None): raise ValueError('You must one keyword of either solar scattering angle (ssa) or solar azimuth angle (saa). Only choose one.') reftp = self.xyz_from_llh(boresight_tanpt_llh) # Get the location of the reference tangent point north, east, down = self.xyz_north_east_down(xyz=reftp) # and get the geographic unit vectors at the tangent point. sunh = np.cos(np.deg2rad(saa)) * north + np.sin(np.deg2rad(saa)) * east # Find the sun unit vector sun = -np.cos(np.deg2rad(sza)) * down + np.sin(np.deg2rad(sza)) * sunh # at the tangent point location reflook = np.cos(np.deg2rad(lookazi_at_tanpt_deg)) * north + np.sin(np.deg2rad(lookazi_at_tanpt_deg)) * east # look vector is tangential at tanegent point observer, exitpt, status = self.xyz_altitude_intercepts(observer_height_m, reftp, reflook) # get the location of the observer west, south, up = self.xyz_west_south_up(xyz=observer) # and get the geographic unit vectors at the tangent point. x_icf = reflook # get the x_icf coordinates of the bore-sight as the look vector to the desired tangent point y_icf = np.cross(up, x_icf) y_icf /= np.linalg.norm(y_icf, axis=-1) z_icf = np.cross(x_icf, y_icf) lookvectors = (np.cos(omegax) * x_icf + np.sin(omegax) * y_icf) * np.cos(omegay) + z_icf * np.sin(omegay) return {'observer': observer, 'sun': sun, 'los': lookvectors, 'icf_unitvectors': (x_icf, y_icf, z_icf)}