acgc.mapping API documentation

# acgc.mapping

Package of mapping functions

View Source

      1#!/usr/bin/env python3
      2'''Package of mapping functions
      3
      4'''
      5
      6import numpy as np
      7import cartopy.crs as ccrs
      8import cartopy.geodesic as cgeo
      9
     10def great_circle(*args,data=None,radius=None,flattening=None):
     11    '''Great circle distance between two points on Earth
     12
     13    Usage examples:
     14    ```
     15    distance = great_circle( start_lon, start_lat, end_lon, end_lat )
     16    distance = great_circle( start_points, end_points )
     17    distance = great_circle( 'start_lon', 'start_lat', 'end_lon', 'end_lat', data=data )
     18    distance = great_circle( 'start_points', 'end_points', data=data )
     19    ```
     20
     21    Parameters
     22    ----------
     23    *args : array_likes or str
     24        Longitude and latitude of the start and end points, degrees
     25        Coordinates can be passed as four 1D arrays (n,) or two 2D arrays (n,2)
     26        If four arrays, they should be `start_lon`, `start_lat`, `end_lon`, `end_lat`
     27        If two arrays, they should be (n,2) shape with longitude as the first column
     28        If strings, the `data` keyword must be used and args are interpreted as key names
     29    data : dict_like, optional
     30        If provided, *args should be strings that are keys to `data`.
     31    radius : float, optional
     32        radius of the sphere in meters. If None, WGS84 will be used.
     33    flattening : float, optional
     34        flattening of the ellipsoid. Use 0 for a sphere. If None, WGS84 will be used.
     35
     36    Returns
     37    -------
     38    distance : ndarray
     39        distance between points, m
     40    '''
     41
     42    # Convert tuple -> list
     43    args = list(args)
     44
     45    # Check if any args are strings
     46    if np.any( [isinstance(arg,str) for arg in args] ):
     47        if data is None:
     48            raise ValueError('`data` keyword must be used when `*args` contain strings')
     49
     50        # Get the value from `data`
     51        for i,item in enumerate(args):
     52            if isinstance(item,str):
     53                args[i] = data[item]
     54
     55    # Number of arguments
     56    nargs = len(args)
     57
     58    if nargs == 4:
     59        # *args contain lon, lat values; broadcast them to same shape
     60        start_lon, start_lat = np.broadcast_arrays( args[0], args[1] )
     61        end_lon, end_lat     = np.broadcast_arrays( args[2], args[3] )
     62
     63        # Stack to (n,2) needed for Geodesic
     64        start_points = np.stack( [start_lon, start_lat], axis=-1 )
     65        end_points   = np.stack( [end_lon,   end_lat],   axis=-1 )
     66
     67    elif nargs == 2:
     68        # *args contain (lon,lat) arrays
     69        start_points = args[0]
     70        end_points = args[1]
     71
     72    else:
     73        raise ValueError(f'Function takes either 2 or 4 arguments but {nargs} were passed.')
     74
     75    # # Distance on a sphere. Cartopy is fast enough that there is no reason to use this
     76    #
     77    # # Lat and longitude endpoints, degrees -> radians
     78    # lat0 = start[:,0] * pi180
     79    # lon0 = start[:,1] * pi180
     80    # lat1 = end[:,0] * pi180
     81    # lon1 = end[:,1] * pi180
     82    # dlon = lon1 - lon0
     83    # # Haversine formula for distance on a sphere
     84    # # dist = 2 * radius * np.arcsin(np.sqrt(
     85    # #     np.sin( (lat1-lat0)/2 )**2
     86    # #     + np.cos(lat0) * np.cos(lat1)
     87    # #       * np.sin( (lon1-lon0)/2 )**2 ) )
     88    # # Equivalent formula with less roundoff error for antipodal points
     89    # dist = radius * np.arctan2(
     90    #     np.sqrt( (np.cos(lat1)*np.sin(dlon))**2
     91    #             + (np.cos(lat0)*np.sin(lat1)
     92    #                 - np.sin(lat0)*np.cos(lat1)*np.cos(dlon))**2 ),
     93    #     np.sin(lat0)*np.sin(lat1) + np.cos(lat0)*np.cos(lat1)*np.cos(dlon)
     94    # )
     95
     96    if radius is None:
     97        # Semi-major radius of Earth, WGS84, m
     98        radius = 6378137.
     99    if flattening is None:
    100        # Flattening of ellipsoid, WGS84
    101        flattening = 1/298.257223563
    102
    103    geoid = cgeo.Geodesic(radius,flattening)
    104
    105    # Calculate the line from all trajectory points to the target
    106    # The start and end points should be in (lon,lat) order
    107    vec= np.asarray( geoid.inverse( start_points, end_points ) )
    108
    109    # Distance, m
    110    dist = vec[:,0]
    111
    112    return dist
    113
    114def _axes_to_lonlat(ax, coords):
    115    """(lon, lat) from axes coordinates."""
    116    display = ax.transAxes.transform(coords)
    117    data = ax.transData.inverted().transform(display)
    118    lonlat = ccrs.PlateCarree().transform_point(*data, ax.projection)
    119
    120    return lonlat
    121
    122
    123def _upper_bound(start, direction, distance, dist_func):
    124    """A point farther than distance from start, in the given direction.
    125
    126    It doesn't matter which coordinate system start is given in, as long
    127    as dist_func takes points in that coordinate system.
    128
    129    Args:
    130        start:     Starting point for the line.
    131        direction  Nonzero (2, 1)-shaped array, a direction vector.
    132        distance:  Positive distance to go past.
    133        dist_func: A two-argument function which returns distance.
    134
    135    Returns:
    136        Coordinates of a point (a (2, 1)-shaped NumPy array).
    137    """
    138    if distance <= 0:
    139        raise ValueError(f"Minimum distance is not positive: {distance}")
    140
    141    if np.linalg.norm(direction) == 0:
    142        raise ValueError("Direction vector must not be zero.")
    143
    144    # Exponential search until the distance between start and end is
    145    # greater than the given limit.
    146    length = 0.1
    147    end = start + length * direction
    148
    149    while dist_func(start, end) < distance:
    150        length *= 2
    151        end = start + length * direction
    152
    153    return end
    154
    155
    156def _distance_along_line(start, end, distance, dist_func, tol):
    157    """Point at a distance from start on the segment  from start to end.
    158
    159    It doesn't matter which coordinate system start is given in, as long
    160    as dist_func takes points in that coordinate system.
    161
    162    Args:
    163        start:     Starting point for the line.
    164        end:       Outer bound on point's location.
    165        distance:  Positive distance to travel.
    166        dist_func: Two-argument function which returns distance.
    167        tol:       Relative error in distance to allow.
    168
    169    Returns:
    170        Coordinates of a point (a (2, 1)-shaped NumPy array).
    171    """
    172    initial_distance = dist_func(start, end)
    173    if initial_distance < distance:
    174        raise ValueError(f"End is closer to start ({initial_distance}) than "
    175                         f"given distance ({distance}).")
    176
    177    if tol <= 0:
    178        raise ValueError(f"Tolerance is not positive: {tol}")
    179
    180    # Binary search for a point at the given distance.
    181    left = start
    182    right = end
    183
    184    while not np.isclose(dist_func(start, right), distance, rtol=tol):
    185        midpoint = (left + right) / 2
    186
    187        # If midpoint is too close, search in second half.
    188        if dist_func(start, midpoint) < distance:
    189            left = midpoint
    190        # Otherwise the midpoint is too far, so search in first half.
    191        else:
    192            right = midpoint
    193
    194    return right
    195
    196
    197def _point_along_line(ax, start, distance, angle=0, tol=0.01):
    198    """Point at a given distance from start at a given angle.
    199
    200    Args:
    201        ax:       CartoPy axes.
    202        start:    Starting point for the line in axes coordinates.
    203        distance: Positive physical distance to travel.
    204        angle:    Anti-clockwise angle for the bar, in radians. Default: 0
    205        tol:      Relative error in distance to allow. Default: 0.01
    206
    207    Returns:
    208        Coordinates of a point (a (2, 1)-shaped NumPy array).
    209    """
    210    # Direction vector of the line in axes coordinates.
    211    direction = np.array([np.cos(angle), np.sin(angle)])
    212
    213    geodesic = cgeo.Geodesic()
    214
    215    # Physical distance between points.
    216    def dist_func(a_axes, b_axes):
    217        a_phys = _axes_to_lonlat(ax, a_axes)
    218        b_phys = _axes_to_lonlat(ax, b_axes)
    219
    220        # Geodesic().inverse returns a NumPy array or MemoryView of array
    221        # [[distance,start azimuth, end azimuth]].
    222        return np.asarray( geodesic.inverse(a_phys, b_phys) )[0, 0]
    223        # return geodesic.inverse(a_phys, b_phys).base[0, 0]
    224
    225    end = _upper_bound(start, direction, distance, dist_func)
    226
    227    return _distance_along_line(start, end, distance, dist_func, tol)
    228
    229
    230def scale_bar(ax, location, length, metres_per_unit=1000, unit_name='km',
    231              tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005,
    232              ha='center', va='bottom', plot_kwargs=None, text_kwargs=None,
    233              **kwargs):
    234    """Add a scale bar to CartoPy axes.
    235
    236    For angles between 0 and 90 the text and line may be plotted at
    237    slightly different angles for unknown reasons. To work around this,
    238    override the 'rotation' keyword argument with text_kwargs.
    239
    240    From StackOverflow
    241    https://stackoverflow.com/questions/32333870/how-can-i-show-a-km-ruler-on-a-cartopy-matplotlib-plot
    242
    243    Parameters
    244    ----------
    245    ax:
    246        CartoPy axes
    247    location:
    248        Position of left-side of bar in axes coordinates.
    249    length:
    250        Geodesic length of the scale bar.
    251    metres_per_unit: default=1000
    252        Number of metres in the given unit.
    253    unit_name: str, default='km'
    254        Name of the given unit.
    255    tol: float, default=0.01
    256        Allowed relative error in length of bar
    257    angle: float
    258        Anti-clockwise rotation of the bar.
    259    color: str, default='black'
    260        Color of the bar and text.
    261    linewidth: float
    262        Same argument as for plot.
    263    text_offset: float, default=0.005
    264        Perpendicular offset for text in axes coordinates.
    265    ha: str or float [0-1], default='center'
    266        Horizontal alignment.
    267    va: str or float [0-1], default='bottom'
    268        Vertical alignment.
    269    plot_kwargs: dict
    270        Keyword arguments for plot, overridden by **kwargs.
    271    text_kwargs: dict
    272        Keyword arguments for text, overridden by **kwargs.
    273    **kwargs:
    274        Keyword arguments for both plot and text.
    275    """
    276    # Setup kwargs, update plot_kwargs and text_kwargs.
    277    if plot_kwargs is None:
    278        plot_kwargs = {}
    279    if text_kwargs is None:
    280        text_kwargs = {}
    281
    282    plot_kwargs = {'linewidth': linewidth, 'color': color, **plot_kwargs,
    283                   **kwargs}
    284    text_kwargs = {'ha': ha, 'va': va, 'rotation': angle, 'color': color,
    285                   **text_kwargs, **kwargs}
    286
    287    # Convert all units and types.
    288    location = np.asarray(location)  # For vector addition.
    289    length_metres = length * metres_per_unit
    290    angle_rad = angle * np.pi / 180
    291
    292    # End-point of bar.
    293    end = _point_along_line(ax, location, length_metres, angle=angle_rad,
    294                            tol=tol)
    295
    296    # Coordinates are currently in axes coordinates, so use transAxes to
    297    # put into data coordinates. *zip(a, b) produces a list of x-coords,
    298    # then a list of y-coords.
    299    ax.plot(*zip(location, end), transform=ax.transAxes, **plot_kwargs)
    300
    301    # Push text away from bar in the perpendicular direction.
    302    midpoint = (location + end) / 2
    303    offset = text_offset * np.array([-np.sin(angle_rad), np.cos(angle_rad)])
    304    text_location = midpoint + offset
    305
    306    # 'rotation' keyword argument is in text_kwargs.
    307    ax.text(*text_location, f"{length} {unit_name}", rotation_mode='anchor',
    308            transform=ax.transAxes, **text_kwargs)
    309
    310pi180 = np.pi / 180
    311'''Constant $\\pi/180$'''

def great_circle(*args, data=None, radius=None, flattening=None): View Source

     11def great_circle(*args,data=None,radius=None,flattening=None):
     12    '''Great circle distance between two points on Earth
     13
     14    Usage examples:
     15    ```
     16    distance = great_circle( start_lon, start_lat, end_lon, end_lat )
     17    distance = great_circle( start_points, end_points )
     18    distance = great_circle( 'start_lon', 'start_lat', 'end_lon', 'end_lat', data=data )
     19    distance = great_circle( 'start_points', 'end_points', data=data )
     20    ```
     21
     22    Parameters
     23    ----------
     24    *args : array_likes or str
     25        Longitude and latitude of the start and end points, degrees
     26        Coordinates can be passed as four 1D arrays (n,) or two 2D arrays (n,2)
     27        If four arrays, they should be `start_lon`, `start_lat`, `end_lon`, `end_lat`
     28        If two arrays, they should be (n,2) shape with longitude as the first column
     29        If strings, the `data` keyword must be used and args are interpreted as key names
     30    data : dict_like, optional
     31        If provided, *args should be strings that are keys to `data`.
     32    radius : float, optional
     33        radius of the sphere in meters. If None, WGS84 will be used.
     34    flattening : float, optional
     35        flattening of the ellipsoid. Use 0 for a sphere. If None, WGS84 will be used.
     36
     37    Returns
     38    -------
     39    distance : ndarray
     40        distance between points, m
     41    '''
     42
     43    # Convert tuple -> list
     44    args = list(args)
     45
     46    # Check if any args are strings
     47    if np.any( [isinstance(arg,str) for arg in args] ):
     48        if data is None:
     49            raise ValueError('`data` keyword must be used when `*args` contain strings')
     50
     51        # Get the value from `data`
     52        for i,item in enumerate(args):
     53            if isinstance(item,str):
     54                args[i] = data[item]
     55
     56    # Number of arguments
     57    nargs = len(args)
     58
     59    if nargs == 4:
     60        # *args contain lon, lat values; broadcast them to same shape
     61        start_lon, start_lat = np.broadcast_arrays( args[0], args[1] )
     62        end_lon, end_lat     = np.broadcast_arrays( args[2], args[3] )
     63
     64        # Stack to (n,2) needed for Geodesic
     65        start_points = np.stack( [start_lon, start_lat], axis=-1 )
     66        end_points   = np.stack( [end_lon,   end_lat],   axis=-1 )
     67
     68    elif nargs == 2:
     69        # *args contain (lon,lat) arrays
     70        start_points = args[0]
     71        end_points = args[1]
     72
     73    else:
     74        raise ValueError(f'Function takes either 2 or 4 arguments but {nargs} were passed.')
     75
     76    # # Distance on a sphere. Cartopy is fast enough that there is no reason to use this
     77    #
     78    # # Lat and longitude endpoints, degrees -> radians
     79    # lat0 = start[:,0] * pi180
     80    # lon0 = start[:,1] * pi180
     81    # lat1 = end[:,0] * pi180
     82    # lon1 = end[:,1] * pi180
     83    # dlon = lon1 - lon0
     84    # # Haversine formula for distance on a sphere
     85    # # dist = 2 * radius * np.arcsin(np.sqrt(
     86    # #     np.sin( (lat1-lat0)/2 )**2
     87    # #     + np.cos(lat0) * np.cos(lat1)
     88    # #       * np.sin( (lon1-lon0)/2 )**2 ) )
     89    # # Equivalent formula with less roundoff error for antipodal points
     90    # dist = radius * np.arctan2(
     91    #     np.sqrt( (np.cos(lat1)*np.sin(dlon))**2
     92    #             + (np.cos(lat0)*np.sin(lat1)
     93    #                 - np.sin(lat0)*np.cos(lat1)*np.cos(dlon))**2 ),
     94    #     np.sin(lat0)*np.sin(lat1) + np.cos(lat0)*np.cos(lat1)*np.cos(dlon)
     95    # )
     96
     97    if radius is None:
     98        # Semi-major radius of Earth, WGS84, m
     99        radius = 6378137.
    100    if flattening is None:
    101        # Flattening of ellipsoid, WGS84
    102        flattening = 1/298.257223563
    103
    104    geoid = cgeo.Geodesic(radius,flattening)
    105
    106    # Calculate the line from all trajectory points to the target
    107    # The start and end points should be in (lon,lat) order
    108    vec= np.asarray( geoid.inverse( start_points, end_points ) )
    109
    110    # Distance, m
    111    dist = vec[:,0]
    112
    113    return dist

Great circle distance between two points on Earth

Usage examples:

    distance = great_circle( start_lon, start_lat, end_lon, end_lat )
    distance = great_circle( start_points, end_points )
    distance = great_circle( 'start_lon', 'start_lat', 'end_lon', 'end_lat', data=data )
    distance = great_circle( 'start_points', 'end_points', data=data )

###### Parameters

- *args (array_likes or str): Longitude and latitude of the start and end points, degrees Coordinates can be passed as four 1D arrays (n,) or two 2D arrays (n,2) If four arrays, they should be `start_lon`, `start_lat`, `end_lon`, `end_lat` If two arrays, they should be (n,2) shape with longitude as the first column If strings, the `data` keyword must be used and args are interpreted as key names
- data (dict_like, optional): If provided, *args should be strings that are keys to `data`.
- radius (float, optional): radius of the sphere in meters. If None, WGS84 will be used.
- flattening (float, optional): flattening of the ellipsoid. Use 0 for a sphere. If None, WGS84 will be used.

###### Returns

- distance (ndarray): distance between points, m

def scale_bar( ax, location, length, metres_per_unit=1000, unit_name='km', tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005, ha='center', va='bottom', plot_kwargs=None, text_kwargs=None, **kwargs): View Source

    231def scale_bar(ax, location, length, metres_per_unit=1000, unit_name='km',
    232              tol=0.01, angle=0, color='black', linewidth=3, text_offset=0.005,
    233              ha='center', va='bottom', plot_kwargs=None, text_kwargs=None,
    234              **kwargs):
    235    """Add a scale bar to CartoPy axes.
    236
    237    For angles between 0 and 90 the text and line may be plotted at
    238    slightly different angles for unknown reasons. To work around this,
    239    override the 'rotation' keyword argument with text_kwargs.
    240
    241    From StackOverflow
    242    https://stackoverflow.com/questions/32333870/how-can-i-show-a-km-ruler-on-a-cartopy-matplotlib-plot
    243
    244    Parameters
    245    ----------
    246    ax:
    247        CartoPy axes
    248    location:
    249        Position of left-side of bar in axes coordinates.
    250    length:
    251        Geodesic length of the scale bar.
    252    metres_per_unit: default=1000
    253        Number of metres in the given unit.
    254    unit_name: str, default='km'
    255        Name of the given unit.
    256    tol: float, default=0.01
    257        Allowed relative error in length of bar
    258    angle: float
    259        Anti-clockwise rotation of the bar.
    260    color: str, default='black'
    261        Color of the bar and text.
    262    linewidth: float
    263        Same argument as for plot.
    264    text_offset: float, default=0.005
    265        Perpendicular offset for text in axes coordinates.
    266    ha: str or float [0-1], default='center'
    267        Horizontal alignment.
    268    va: str or float [0-1], default='bottom'
    269        Vertical alignment.
    270    plot_kwargs: dict
    271        Keyword arguments for plot, overridden by **kwargs.
    272    text_kwargs: dict
    273        Keyword arguments for text, overridden by **kwargs.
    274    **kwargs:
    275        Keyword arguments for both plot and text.
    276    """
    277    # Setup kwargs, update plot_kwargs and text_kwargs.
    278    if plot_kwargs is None:
    279        plot_kwargs = {}
    280    if text_kwargs is None:
    281        text_kwargs = {}
    282
    283    plot_kwargs = {'linewidth': linewidth, 'color': color, **plot_kwargs,
    284                   **kwargs}
    285    text_kwargs = {'ha': ha, 'va': va, 'rotation': angle, 'color': color,
    286                   **text_kwargs, **kwargs}
    287
    288    # Convert all units and types.
    289    location = np.asarray(location)  # For vector addition.
    290    length_metres = length * metres_per_unit
    291    angle_rad = angle * np.pi / 180
    292
    293    # End-point of bar.
    294    end = _point_along_line(ax, location, length_metres, angle=angle_rad,
    295                            tol=tol)
    296
    297    # Coordinates are currently in axes coordinates, so use transAxes to
    298    # put into data coordinates. *zip(a, b) produces a list of x-coords,
    299    # then a list of y-coords.
    300    ax.plot(*zip(location, end), transform=ax.transAxes, **plot_kwargs)
    301
    302    # Push text away from bar in the perpendicular direction.
    303    midpoint = (location + end) / 2
    304    offset = text_offset * np.array([-np.sin(angle_rad), np.cos(angle_rad)])
    305    text_location = midpoint + offset
    306
    307    # 'rotation' keyword argument is in text_kwargs.
    308    ax.text(*text_location, f"{length} {unit_name}", rotation_mode='anchor',
    309            transform=ax.transAxes, **text_kwargs)

Add a scale bar to CartoPy axes.

For angles between 0 and 90 the text and line may be plotted at slightly different angles for unknown reasons. To work around this, override the 'rotation' keyword argument with text_kwargs.

From StackOverflow https://stackoverflow.com/questions/32333870/how-can-i-show-a-km-ruler-on-a-cartopy-matplotlib-plot

###### Parameters

- ax (): CartoPy axes
- location (): Position of left-side of bar in axes coordinates.
- length (): Geodesic length of the scale bar.
- metres_per_unit (default=1000): Number of metres in the given unit.
- unit_name (str, default='km'): Name of the given unit.
- tol (float, default=0.01): Allowed relative error in length of bar
- angle (float): Anti-clockwise rotation of the bar.
- color (str, default='black'): Color of the bar and text.
- linewidth (float): Same argument as for plot.
- text_offset (float, default=0.005): Perpendicular offset for text in axes coordinates.
- ha (str or float [0-1], default='center'): Horizontal alignment.
- va (str or float [0-1], default='bottom'): Vertical alignment.
- plot_kwargs (dict): Keyword arguments for plot, overridden by **kwargs.
- text_kwargs (dict): Keyword arguments for text, overridden by **kwargs.
- **kwargs (): Keyword arguments for both plot and text.

pi180 = 0.017453292519943295

Constant $\pi/180$