acgc.mapping

Package of mapping functions

  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):
 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):
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$