1""" 2:class:`.Point` data structure. 3""" 4 5import collections.abc 6import re 7import warnings 8from itertools import islice 9from math import fmod, isfinite 10 11from geopy import units, util 12from geopy.format import DEGREE, DOUBLE_PRIME, PRIME, format_degrees, format_distance 13 14POINT_PATTERN = re.compile(r""" 15 .*? 16 (?P<latitude> 17 (?P<latitude_direction_front>[NS])?[ ]* 18 (?P<latitude_degrees>[+-]?%(FLOAT)s)(?:[%(DEGREE)sD\*\u00B0\s][ ]* 19 (?:(?P<latitude_arcminutes>%(FLOAT)s)[%(PRIME)s'm][ ]*)? 20 (?:(?P<latitude_arcseconds>%(FLOAT)s)[%(DOUBLE_PRIME)s"s][ ]*)? 21 )?(?P<latitude_direction_back>[NS])?) 22 %(SEP)s 23 (?P<longitude> 24 (?P<longitude_direction_front>[EW])?[ ]* 25 (?P<longitude_degrees>[+-]?%(FLOAT)s)(?:[%(DEGREE)sD\*\u00B0\s][ ]* 26 (?:(?P<longitude_arcminutes>%(FLOAT)s)[%(PRIME)s'm][ ]*)? 27 (?:(?P<longitude_arcseconds>%(FLOAT)s)[%(DOUBLE_PRIME)s"s][ ]*)? 28 )?(?P<longitude_direction_back>[EW])?)(?: 29 %(SEP)s 30 (?P<altitude> 31 (?P<altitude_distance>[+-]?%(FLOAT)s)[ ]* 32 (?P<altitude_units>km|m|mi|ft|nm|nmi)))? 33 \s*$ 34""" % { 35 "FLOAT": r'\d+(?:\.\d+)?', 36 "DEGREE": DEGREE, 37 "PRIME": PRIME, 38 "DOUBLE_PRIME": DOUBLE_PRIME, 39 "SEP": r'\s*[,;/\s]\s*', 40}, re.VERBOSE | re.UNICODE) 41 42 43def _normalize_angle(x, limit): 44 """ 45 Normalize angle `x` to be within `[-limit; limit)` range. 46 """ 47 double_limit = limit * 2.0 48 modulo = fmod(x, double_limit) or 0.0 # `or 0` is to turn -0 to +0. 49 if modulo < -limit: 50 return modulo + double_limit 51 if modulo >= limit: 52 return modulo - double_limit 53 return modulo 54 55 56def _normalize_coordinates(latitude, longitude, altitude): 57 latitude = float(latitude or 0.0) 58 longitude = float(longitude or 0.0) 59 altitude = float(altitude or 0.0) 60 61 is_all_finite = all(isfinite(x) for x in (latitude, longitude, altitude)) 62 if not is_all_finite: 63 raise ValueError('Point coordinates must be finite. %r has been passed ' 64 'as coordinates.' % ((latitude, longitude, altitude),)) 65 66 if abs(latitude) > 90: 67 warnings.warn('Latitude normalization has been prohibited in the newer ' 68 'versions of geopy, because the normalized value happened ' 69 'to be on a different pole, which is probably not what was ' 70 'meant. If you pass coordinates as positional args, ' 71 'please make sure that the order is ' 72 '(latitude, longitude) or (y, x) in Cartesian terms.', 73 UserWarning, stacklevel=3) 74 raise ValueError('Latitude must be in the [-90; 90] range.') 75 76 if abs(longitude) > 180: 77 # Longitude normalization is pretty straightforward and doesn't seem 78 # to be error-prone, so there's nothing to complain about. 79 longitude = _normalize_angle(longitude, 180.0) 80 81 return latitude, longitude, altitude 82 83 84class Point: 85 """ 86 A geodetic point with latitude, longitude, and altitude. 87 88 Latitude and longitude are floating point values in degrees. 89 Altitude is a floating point value in kilometers. The reference level 90 is never considered and is thus application dependent, so be consistent! 91 The default for all values is 0. 92 93 Points can be created in a number of ways... 94 95 With latitude, longitude, and altitude:: 96 97 >>> p1 = Point(41.5, -81, 0) 98 >>> p2 = Point(latitude=41.5, longitude=-81) 99 100 With a sequence of 2 to 3 values (latitude, longitude, altitude):: 101 102 >>> p1 = Point([41.5, -81, 0]) 103 >>> p2 = Point((41.5, -81)) 104 105 Copy another `Point` instance:: 106 107 >>> p2 = Point(p1) 108 >>> p2 == p1 109 True 110 >>> p2 is p1 111 False 112 113 Give a string containing at least latitude and longitude:: 114 115 >>> p = Point('41.5,-81.0') 116 >>> p = Point('+41.5 -81.0') 117 >>> p = Point('41.5 N -81.0 W') 118 >>> p = Point('-41.5 S, 81.0 E, 2.5km') 119 >>> p = Point('23 26m 22s N 23 27m 30s E 21.0mi') 120 >>> p = Point('''3 26' 22" N 23 27' 30" E''') 121 122 Point values can be accessed by name or by index:: 123 124 >>> p = Point(41.5, -81.0, 0) 125 >>> p.latitude == p[0] 126 True 127 >>> p.longitude == p[1] 128 True 129 >>> p.altitude == p[2] 130 True 131 132 When unpacking (or iterating), a ``(latitude, longitude, altitude)`` tuple is 133 returned:: 134 135 >>> latitude, longitude, altitude = p 136 137 Textual representations:: 138 139 >>> p = Point(41.5, -81.0, 12.3) 140 >>> str(p) # same as `p.format()` 141 '41 30m 0s N, 81 0m 0s W, 12.3km' 142 >>> p.format_unicode() 143 '41° 30′ 0″ N, 81° 0′ 0″ W, 12.3km' 144 >>> repr(p) 145 'Point(41.5, -81.0, 12.3)' 146 >>> repr(tuple(p)) 147 '(41.5, -81.0, 12.3)' 148 """ 149 150 __slots__ = ("latitude", "longitude", "altitude") 151 152 POINT_PATTERN = POINT_PATTERN 153 154 def __new__(cls, latitude=None, longitude=None, altitude=None): 155 """ 156 :param float latitude: Latitude of point. 157 :param float longitude: Longitude of point. 158 :param float altitude: Altitude of point. 159 """ 160 single_arg = latitude is not None and longitude is None and altitude is None 161 if single_arg and not isinstance(latitude, util.NUMBER_TYPES): 162 arg = latitude 163 if isinstance(arg, Point): 164 return cls.from_point(arg) 165 elif isinstance(arg, str): 166 return cls.from_string(arg) 167 else: 168 try: 169 seq = iter(arg) 170 except TypeError: 171 raise TypeError( 172 "Failed to create Point instance from %r." % (arg,) 173 ) 174 else: 175 return cls.from_sequence(seq) 176 177 if single_arg: 178 raise ValueError( 179 'A single number has been passed to the Point ' 180 'constructor. This is probably a mistake, because ' 181 'constructing a Point with just a latitude ' 182 'seems senseless. If this is exactly what was ' 183 'meant, then pass the zero longitude explicitly ' 184 'to get rid of this error.' 185 ) 186 187 latitude, longitude, altitude = \ 188 _normalize_coordinates(latitude, longitude, altitude) 189 190 self = super().__new__(cls) 191 self.latitude = latitude 192 self.longitude = longitude 193 self.altitude = altitude 194 return self 195 196 def __getitem__(self, index): 197 return tuple(self)[index] # tuple handles slices 198 199 def __setitem__(self, index, value): 200 point = list(self) 201 point[index] = value # list handles slices 202 self.latitude, self.longitude, self.altitude = \ 203 _normalize_coordinates(*point) 204 205 def __iter__(self): 206 return iter((self.latitude, self.longitude, self.altitude)) 207 208 def __getstate__(self): 209 return tuple(self) 210 211 def __setstate__(self, state): 212 self.latitude, self.longitude, self.altitude = state 213 214 def __repr__(self): 215 return "Point(%r, %r, %r)" % tuple(self) 216 217 def format(self, altitude=None, deg_char='', min_char='m', sec_char='s'): 218 """ 219 Format decimal degrees (DD) to degrees minutes seconds (DMS):: 220 221 >>> p = Point(41.5, -81.0, 12.3) 222 >>> p.format() 223 '41 30m 0s N, 81 0m 0s W, 12.3km' 224 >>> p = Point(41.5, 0, 0) 225 >>> p.format() 226 '41 30m 0s N, 0 0m 0s E' 227 228 See also :meth:`.format_unicode`. 229 230 :param bool altitude: Whether to include ``altitude`` value. 231 By default it is automatically included if it is non-zero. 232 """ 233 latitude = "%s %s" % ( 234 format_degrees(abs(self.latitude), symbols={ 235 'deg': deg_char, 'arcmin': min_char, 'arcsec': sec_char 236 }), 237 self.latitude >= 0 and 'N' or 'S' 238 ) 239 longitude = "%s %s" % ( 240 format_degrees(abs(self.longitude), symbols={ 241 'deg': deg_char, 'arcmin': min_char, 'arcsec': sec_char 242 }), 243 self.longitude >= 0 and 'E' or 'W' 244 ) 245 coordinates = [latitude, longitude] 246 247 if altitude is None: 248 altitude = bool(self.altitude) 249 if altitude: 250 if not isinstance(altitude, str): 251 altitude = 'km' 252 coordinates.append(self.format_altitude(altitude)) 253 254 return ", ".join(coordinates) 255 256 def format_unicode(self, altitude=None): 257 """ 258 :meth:`.format` with pretty unicode chars for degrees, 259 minutes and seconds:: 260 261 >>> p = Point(41.5, -81.0, 12.3) 262 >>> p.format_unicode() 263 '41° 30′ 0″ N, 81° 0′ 0″ W, 12.3km' 264 265 :param bool altitude: Whether to include ``altitude`` value. 266 By default it is automatically included if it is non-zero. 267 """ 268 return self.format( 269 altitude, DEGREE, PRIME, DOUBLE_PRIME 270 ) 271 272 def format_decimal(self, altitude=None): 273 """ 274 Format decimal degrees with altitude:: 275 276 >>> p = Point(41.5, -81.0, 12.3) 277 >>> p.format_decimal() 278 '41.5, -81.0, 12.3km' 279 >>> p = Point(41.5, 0, 0) 280 >>> p.format_decimal() 281 '41.5, 0.0' 282 283 :param bool altitude: Whether to include ``altitude`` value. 284 By default it is automatically included if it is non-zero. 285 """ 286 coordinates = [str(self.latitude), str(self.longitude)] 287 288 if altitude is None: 289 altitude = bool(self.altitude) 290 if altitude: 291 if not isinstance(altitude, str): 292 altitude = 'km' 293 coordinates.append(self.format_altitude(altitude)) 294 295 return ", ".join(coordinates) 296 297 def format_altitude(self, unit='km'): 298 """ 299 Format altitude with unit:: 300 301 >>> p = Point(41.5, -81.0, 12.3) 302 >>> p.format_altitude() 303 '12.3km' 304 >>> p = Point(41.5, -81.0, 0) 305 >>> p.format_altitude() 306 '0.0km' 307 308 :param str unit: Resulting altitude unit. Supported units 309 are listed in :meth:`.from_string` doc. 310 """ 311 return format_distance(self.altitude, unit=unit) 312 313 def __str__(self): 314 return self.format() 315 316 def __eq__(self, other): 317 if not isinstance(other, collections.abc.Iterable): 318 return NotImplemented 319 return tuple(self) == tuple(other) 320 321 def __ne__(self, other): 322 return not (self == other) 323 324 @classmethod 325 def parse_degrees(cls, degrees, arcminutes, arcseconds, direction=None): 326 """ 327 Convert degrees, minutes, seconds and direction (N, S, E, W) 328 to a single degrees number. 329 330 :rtype: float 331 """ 332 degrees = float(degrees) 333 negative = degrees < 0 334 arcminutes = float(arcminutes) 335 arcseconds = float(arcseconds) 336 337 if arcminutes or arcseconds: 338 more = units.degrees(arcminutes=arcminutes, arcseconds=arcseconds) 339 if negative: 340 degrees -= more 341 else: 342 degrees += more 343 344 if direction in [None, 'N', 'E']: 345 return degrees 346 elif direction in ['S', 'W']: 347 return -degrees 348 else: 349 raise ValueError("Invalid direction! Should be one of [NSEW].") 350 351 @classmethod 352 def parse_altitude(cls, distance, unit): 353 """ 354 Parse altitude managing units conversion:: 355 356 >>> Point.parse_altitude(712, 'm') 357 0.712 358 >>> Point.parse_altitude(712, 'km') 359 712.0 360 >>> Point.parse_altitude(712, 'mi') 361 1145.852928 362 363 :param float distance: Numeric value of altitude. 364 :param str unit: ``distance`` unit. Supported units 365 are listed in :meth:`.from_string` doc. 366 """ 367 if distance is not None: 368 distance = float(distance) 369 CONVERTERS = { 370 'km': lambda d: d, 371 'm': lambda d: units.kilometers(meters=d), 372 'mi': lambda d: units.kilometers(miles=d), 373 'ft': lambda d: units.kilometers(feet=d), 374 'nm': lambda d: units.kilometers(nautical=d), 375 'nmi': lambda d: units.kilometers(nautical=d) 376 } 377 try: 378 return CONVERTERS[unit](distance) 379 except KeyError: 380 raise NotImplementedError( 381 'Bad distance unit specified, valid are: %r' % 382 CONVERTERS.keys() 383 ) 384 else: 385 return distance 386 387 @classmethod 388 def from_string(cls, string): 389 """ 390 Create and return a ``Point`` instance from a string containing 391 latitude and longitude, and optionally, altitude. 392 393 Latitude and longitude must be in degrees and may be in decimal form 394 or indicate arcminutes and arcseconds (labeled with Unicode prime and 395 double prime, ASCII quote and double quote or 'm' and 's'). The degree 396 symbol is optional and may be included after the decimal places (in 397 decimal form) and before the arcminutes and arcseconds otherwise. 398 Coordinates given from south and west (indicated by S and W suffixes) 399 will be converted to north and east by switching their signs. If no 400 (or partial) cardinal directions are given, north and east are the 401 assumed directions. Latitude and longitude must be separated by at 402 least whitespace, a comma, or a semicolon (each with optional 403 surrounding whitespace). 404 405 Altitude, if supplied, must be a decimal number with given units. 406 The following unit abbrevations (case-insensitive) are supported: 407 408 - ``km`` (kilometers) 409 - ``m`` (meters) 410 - ``mi`` (miles) 411 - ``ft`` (feet) 412 - ``nm``, ``nmi`` (nautical miles) 413 414 Some example strings that will work include: 415 416 - ``41.5;-81.0`` 417 - ``41.5,-81.0`` 418 - ``41.5 -81.0`` 419 - ``41.5 N -81.0 W`` 420 - ``-41.5 S;81.0 E`` 421 - ``23 26m 22s N 23 27m 30s E`` 422 - ``23 26' 22" N 23 27' 30" E`` 423 - ``UT: N 39°20' 0'' / W 74°35' 0''`` 424 425 """ 426 match = re.match(cls.POINT_PATTERN, re.sub(r"''", r'"', string)) 427 if match: 428 latitude_direction = None 429 if match.group("latitude_direction_front"): 430 latitude_direction = match.group("latitude_direction_front") 431 elif match.group("latitude_direction_back"): 432 latitude_direction = match.group("latitude_direction_back") 433 434 longitude_direction = None 435 if match.group("longitude_direction_front"): 436 longitude_direction = match.group("longitude_direction_front") 437 elif match.group("longitude_direction_back"): 438 longitude_direction = match.group("longitude_direction_back") 439 latitude = cls.parse_degrees( 440 match.group('latitude_degrees') or 0.0, 441 match.group('latitude_arcminutes') or 0.0, 442 match.group('latitude_arcseconds') or 0.0, 443 latitude_direction 444 ) 445 longitude = cls.parse_degrees( 446 match.group('longitude_degrees') or 0.0, 447 match.group('longitude_arcminutes') or 0.0, 448 match.group('longitude_arcseconds') or 0.0, 449 longitude_direction 450 ) 451 altitude = cls.parse_altitude( 452 match.group('altitude_distance'), 453 match.group('altitude_units') 454 ) 455 return cls(latitude, longitude, altitude) 456 else: 457 raise ValueError( 458 "Failed to create Point instance from string: unknown format." 459 ) 460 461 @classmethod 462 def from_sequence(cls, seq): 463 """ 464 Create and return a new ``Point`` instance from any iterable with 2 to 465 3 elements. The elements, if present, must be latitude, longitude, 466 and altitude, respectively. 467 """ 468 args = tuple(islice(seq, 4)) 469 if len(args) > 3: 470 raise ValueError('When creating a Point from sequence, it ' 471 'must not have more than 3 items.') 472 return cls(*args) 473 474 @classmethod 475 def from_point(cls, point): 476 """ 477 Create and return a new ``Point`` instance from another ``Point`` 478 instance. 479 """ 480 return cls(point.latitude, point.longitude, point.altitude) 481