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