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