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, thedata
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.