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

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

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

Great circle distance between two points on Earth

Usage: distance = great_circle( start_lon, start_lat, end_lon, end_lat ) distance = great_circle( start_points, end_points )

###### 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

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