481 lines
16 KiB
Python
481 lines
16 KiB
Python
|
"""
|
|||
|
:class:`.Point` data structure.
|
|||
|
"""
|
|||
|
|
|||
|
import collections.abc
|
|||
|
import re
|
|||
|
import warnings
|
|||
|
from itertools import islice
|
|||
|
from math import fmod, isfinite
|
|||
|
|
|||
|
from geopy import units, util
|
|||
|
from geopy.format import DEGREE, DOUBLE_PRIME, PRIME, format_degrees, format_distance
|
|||
|
|
|||
|
POINT_PATTERN = re.compile(r"""
|
|||
|
.*?
|
|||
|
(?P<latitude>
|
|||
|
(?P<latitude_direction_front>[NS])?[ ]*
|
|||
|
(?P<latitude_degrees>[+-]?%(FLOAT)s)(?:[%(DEGREE)sD\*\u00B0\s][ ]*
|
|||
|
(?:(?P<latitude_arcminutes>%(FLOAT)s)[%(PRIME)s'm][ ]*)?
|
|||
|
(?:(?P<latitude_arcseconds>%(FLOAT)s)[%(DOUBLE_PRIME)s"s][ ]*)?
|
|||
|
)?(?P<latitude_direction_back>[NS])?)
|
|||
|
%(SEP)s
|
|||
|
(?P<longitude>
|
|||
|
(?P<longitude_direction_front>[EW])?[ ]*
|
|||
|
(?P<longitude_degrees>[+-]?%(FLOAT)s)(?:[%(DEGREE)sD\*\u00B0\s][ ]*
|
|||
|
(?:(?P<longitude_arcminutes>%(FLOAT)s)[%(PRIME)s'm][ ]*)?
|
|||
|
(?:(?P<longitude_arcseconds>%(FLOAT)s)[%(DOUBLE_PRIME)s"s][ ]*)?
|
|||
|
)?(?P<longitude_direction_back>[EW])?)(?:
|
|||
|
%(SEP)s
|
|||
|
(?P<altitude>
|
|||
|
(?P<altitude_distance>[+-]?%(FLOAT)s)[ ]*
|
|||
|
(?P<altitude_units>km|m|mi|ft|nm|nmi)))?
|
|||
|
\s*$
|
|||
|
""" % {
|
|||
|
"FLOAT": r'\d+(?:\.\d+)?',
|
|||
|
"DEGREE": DEGREE,
|
|||
|
"PRIME": PRIME,
|
|||
|
"DOUBLE_PRIME": DOUBLE_PRIME,
|
|||
|
"SEP": r'\s*[,;/\s]\s*',
|
|||
|
}, re.VERBOSE | re.UNICODE)
|
|||
|
|
|||
|
|
|||
|
def _normalize_angle(x, limit):
|
|||
|
"""
|
|||
|
Normalize angle `x` to be within `[-limit; limit)` range.
|
|||
|
"""
|
|||
|
double_limit = limit * 2.0
|
|||
|
modulo = fmod(x, double_limit) or 0.0 # `or 0` is to turn -0 to +0.
|
|||
|
if modulo < -limit:
|
|||
|
return modulo + double_limit
|
|||
|
if modulo >= limit:
|
|||
|
return modulo - double_limit
|
|||
|
return modulo
|
|||
|
|
|||
|
|
|||
|
def _normalize_coordinates(latitude, longitude, altitude):
|
|||
|
latitude = float(latitude or 0.0)
|
|||
|
longitude = float(longitude or 0.0)
|
|||
|
altitude = float(altitude or 0.0)
|
|||
|
|
|||
|
is_all_finite = all(isfinite(x) for x in (latitude, longitude, altitude))
|
|||
|
if not is_all_finite:
|
|||
|
raise ValueError('Point coordinates must be finite. %r has been passed '
|
|||
|
'as coordinates.' % ((latitude, longitude, altitude),))
|
|||
|
|
|||
|
if abs(latitude) > 90:
|
|||
|
warnings.warn('Latitude normalization has been prohibited in the newer '
|
|||
|
'versions of geopy, because the normalized value happened '
|
|||
|
'to be on a different pole, which is probably not what was '
|
|||
|
'meant. If you pass coordinates as positional args, '
|
|||
|
'please make sure that the order is '
|
|||
|
'(latitude, longitude) or (y, x) in Cartesian terms.',
|
|||
|
UserWarning, stacklevel=3)
|
|||
|
raise ValueError('Latitude must be in the [-90; 90] range.')
|
|||
|
|
|||
|
if abs(longitude) > 180:
|
|||
|
# Longitude normalization is pretty straightforward and doesn't seem
|
|||
|
# to be error-prone, so there's nothing to complain about.
|
|||
|
longitude = _normalize_angle(longitude, 180.0)
|
|||
|
|
|||
|
return latitude, longitude, altitude
|
|||
|
|
|||
|
|
|||
|
class Point:
|
|||
|
"""
|
|||
|
A geodetic point with latitude, longitude, and altitude.
|
|||
|
|
|||
|
Latitude and longitude are floating point values in degrees.
|
|||
|
Altitude is a floating point value in kilometers. The reference level
|
|||
|
is never considered and is thus application dependent, so be consistent!
|
|||
|
The default for all values is 0.
|
|||
|
|
|||
|
Points can be created in a number of ways...
|
|||
|
|
|||
|
With latitude, longitude, and altitude::
|
|||
|
|
|||
|
>>> p1 = Point(41.5, -81, 0)
|
|||
|
>>> p2 = Point(latitude=41.5, longitude=-81)
|
|||
|
|
|||
|
With a sequence of 2 to 3 values (latitude, longitude, altitude)::
|
|||
|
|
|||
|
>>> p1 = Point([41.5, -81, 0])
|
|||
|
>>> p2 = Point((41.5, -81))
|
|||
|
|
|||
|
Copy another `Point` instance::
|
|||
|
|
|||
|
>>> p2 = Point(p1)
|
|||
|
>>> p2 == p1
|
|||
|
True
|
|||
|
>>> p2 is p1
|
|||
|
False
|
|||
|
|
|||
|
Give a string containing at least latitude and longitude::
|
|||
|
|
|||
|
>>> p = Point('41.5,-81.0')
|
|||
|
>>> p = Point('+41.5 -81.0')
|
|||
|
>>> p = Point('41.5 N -81.0 W')
|
|||
|
>>> p = Point('-41.5 S, 81.0 E, 2.5km')
|
|||
|
>>> p = Point('23 26m 22s N 23 27m 30s E 21.0mi')
|
|||
|
>>> p = Point('''3 26' 22" N 23 27' 30" E''')
|
|||
|
|
|||
|
Point values can be accessed by name or by index::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 0)
|
|||
|
>>> p.latitude == p[0]
|
|||
|
True
|
|||
|
>>> p.longitude == p[1]
|
|||
|
True
|
|||
|
>>> p.altitude == p[2]
|
|||
|
True
|
|||
|
|
|||
|
When unpacking (or iterating), a ``(latitude, longitude, altitude)`` tuple is
|
|||
|
returned::
|
|||
|
|
|||
|
>>> latitude, longitude, altitude = p
|
|||
|
|
|||
|
Textual representations::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 12.3)
|
|||
|
>>> str(p) # same as `p.format()`
|
|||
|
'41 30m 0s N, 81 0m 0s W, 12.3km'
|
|||
|
>>> p.format_unicode()
|
|||
|
'41° 30′ 0″ N, 81° 0′ 0″ W, 12.3km'
|
|||
|
>>> repr(p)
|
|||
|
'Point(41.5, -81.0, 12.3)'
|
|||
|
>>> repr(tuple(p))
|
|||
|
'(41.5, -81.0, 12.3)'
|
|||
|
"""
|
|||
|
|
|||
|
__slots__ = ("latitude", "longitude", "altitude")
|
|||
|
|
|||
|
POINT_PATTERN = POINT_PATTERN
|
|||
|
|
|||
|
def __new__(cls, latitude=None, longitude=None, altitude=None):
|
|||
|
"""
|
|||
|
:param float latitude: Latitude of point.
|
|||
|
:param float longitude: Longitude of point.
|
|||
|
:param float altitude: Altitude of point.
|
|||
|
"""
|
|||
|
single_arg = latitude is not None and longitude is None and altitude is None
|
|||
|
if single_arg and not isinstance(latitude, util.NUMBER_TYPES):
|
|||
|
arg = latitude
|
|||
|
if isinstance(arg, Point):
|
|||
|
return cls.from_point(arg)
|
|||
|
elif isinstance(arg, str):
|
|||
|
return cls.from_string(arg)
|
|||
|
else:
|
|||
|
try:
|
|||
|
seq = iter(arg)
|
|||
|
except TypeError:
|
|||
|
raise TypeError(
|
|||
|
"Failed to create Point instance from %r." % (arg,)
|
|||
|
)
|
|||
|
else:
|
|||
|
return cls.from_sequence(seq)
|
|||
|
|
|||
|
if single_arg:
|
|||
|
raise ValueError(
|
|||
|
'A single number has been passed to the Point '
|
|||
|
'constructor. This is probably a mistake, because '
|
|||
|
'constructing a Point with just a latitude '
|
|||
|
'seems senseless. If this is exactly what was '
|
|||
|
'meant, then pass the zero longitude explicitly '
|
|||
|
'to get rid of this error.'
|
|||
|
)
|
|||
|
|
|||
|
latitude, longitude, altitude = \
|
|||
|
_normalize_coordinates(latitude, longitude, altitude)
|
|||
|
|
|||
|
self = super().__new__(cls)
|
|||
|
self.latitude = latitude
|
|||
|
self.longitude = longitude
|
|||
|
self.altitude = altitude
|
|||
|
return self
|
|||
|
|
|||
|
def __getitem__(self, index):
|
|||
|
return tuple(self)[index] # tuple handles slices
|
|||
|
|
|||
|
def __setitem__(self, index, value):
|
|||
|
point = list(self)
|
|||
|
point[index] = value # list handles slices
|
|||
|
self.latitude, self.longitude, self.altitude = \
|
|||
|
_normalize_coordinates(*point)
|
|||
|
|
|||
|
def __iter__(self):
|
|||
|
return iter((self.latitude, self.longitude, self.altitude))
|
|||
|
|
|||
|
def __getstate__(self):
|
|||
|
return tuple(self)
|
|||
|
|
|||
|
def __setstate__(self, state):
|
|||
|
self.latitude, self.longitude, self.altitude = state
|
|||
|
|
|||
|
def __repr__(self):
|
|||
|
return "Point(%r, %r, %r)" % tuple(self)
|
|||
|
|
|||
|
def format(self, altitude=None, deg_char='', min_char='m', sec_char='s'):
|
|||
|
"""
|
|||
|
Format decimal degrees (DD) to degrees minutes seconds (DMS)::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 12.3)
|
|||
|
>>> p.format()
|
|||
|
'41 30m 0s N, 81 0m 0s W, 12.3km'
|
|||
|
>>> p = Point(41.5, 0, 0)
|
|||
|
>>> p.format()
|
|||
|
'41 30m 0s N, 0 0m 0s E'
|
|||
|
|
|||
|
See also :meth:`.format_unicode`.
|
|||
|
|
|||
|
:param bool altitude: Whether to include ``altitude`` value.
|
|||
|
By default it is automatically included if it is non-zero.
|
|||
|
"""
|
|||
|
latitude = "%s %s" % (
|
|||
|
format_degrees(abs(self.latitude), symbols={
|
|||
|
'deg': deg_char, 'arcmin': min_char, 'arcsec': sec_char
|
|||
|
}),
|
|||
|
self.latitude >= 0 and 'N' or 'S'
|
|||
|
)
|
|||
|
longitude = "%s %s" % (
|
|||
|
format_degrees(abs(self.longitude), symbols={
|
|||
|
'deg': deg_char, 'arcmin': min_char, 'arcsec': sec_char
|
|||
|
}),
|
|||
|
self.longitude >= 0 and 'E' or 'W'
|
|||
|
)
|
|||
|
coordinates = [latitude, longitude]
|
|||
|
|
|||
|
if altitude is None:
|
|||
|
altitude = bool(self.altitude)
|
|||
|
if altitude:
|
|||
|
if not isinstance(altitude, str):
|
|||
|
altitude = 'km'
|
|||
|
coordinates.append(self.format_altitude(altitude))
|
|||
|
|
|||
|
return ", ".join(coordinates)
|
|||
|
|
|||
|
def format_unicode(self, altitude=None):
|
|||
|
"""
|
|||
|
:meth:`.format` with pretty unicode chars for degrees,
|
|||
|
minutes and seconds::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 12.3)
|
|||
|
>>> p.format_unicode()
|
|||
|
'41° 30′ 0″ N, 81° 0′ 0″ W, 12.3km'
|
|||
|
|
|||
|
:param bool altitude: Whether to include ``altitude`` value.
|
|||
|
By default it is automatically included if it is non-zero.
|
|||
|
"""
|
|||
|
return self.format(
|
|||
|
altitude, DEGREE, PRIME, DOUBLE_PRIME
|
|||
|
)
|
|||
|
|
|||
|
def format_decimal(self, altitude=None):
|
|||
|
"""
|
|||
|
Format decimal degrees with altitude::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 12.3)
|
|||
|
>>> p.format_decimal()
|
|||
|
'41.5, -81.0, 12.3km'
|
|||
|
>>> p = Point(41.5, 0, 0)
|
|||
|
>>> p.format_decimal()
|
|||
|
'41.5, 0.0'
|
|||
|
|
|||
|
:param bool altitude: Whether to include ``altitude`` value.
|
|||
|
By default it is automatically included if it is non-zero.
|
|||
|
"""
|
|||
|
coordinates = [str(self.latitude), str(self.longitude)]
|
|||
|
|
|||
|
if altitude is None:
|
|||
|
altitude = bool(self.altitude)
|
|||
|
if altitude:
|
|||
|
if not isinstance(altitude, str):
|
|||
|
altitude = 'km'
|
|||
|
coordinates.append(self.format_altitude(altitude))
|
|||
|
|
|||
|
return ", ".join(coordinates)
|
|||
|
|
|||
|
def format_altitude(self, unit='km'):
|
|||
|
"""
|
|||
|
Format altitude with unit::
|
|||
|
|
|||
|
>>> p = Point(41.5, -81.0, 12.3)
|
|||
|
>>> p.format_altitude()
|
|||
|
'12.3km'
|
|||
|
>>> p = Point(41.5, -81.0, 0)
|
|||
|
>>> p.format_altitude()
|
|||
|
'0.0km'
|
|||
|
|
|||
|
:param str unit: Resulting altitude unit. Supported units
|
|||
|
are listed in :meth:`.from_string` doc.
|
|||
|
"""
|
|||
|
return format_distance(self.altitude, unit=unit)
|
|||
|
|
|||
|
def __str__(self):
|
|||
|
return self.format()
|
|||
|
|
|||
|
def __eq__(self, other):
|
|||
|
if not isinstance(other, collections.abc.Iterable):
|
|||
|
return NotImplemented
|
|||
|
return tuple(self) == tuple(other)
|
|||
|
|
|||
|
def __ne__(self, other):
|
|||
|
return not (self == other)
|
|||
|
|
|||
|
@classmethod
|
|||
|
def parse_degrees(cls, degrees, arcminutes, arcseconds, direction=None):
|
|||
|
"""
|
|||
|
Convert degrees, minutes, seconds and direction (N, S, E, W)
|
|||
|
to a single degrees number.
|
|||
|
|
|||
|
:rtype: float
|
|||
|
"""
|
|||
|
degrees = float(degrees)
|
|||
|
negative = degrees < 0
|
|||
|
arcminutes = float(arcminutes)
|
|||
|
arcseconds = float(arcseconds)
|
|||
|
|
|||
|
if arcminutes or arcseconds:
|
|||
|
more = units.degrees(arcminutes=arcminutes, arcseconds=arcseconds)
|
|||
|
if negative:
|
|||
|
degrees -= more
|
|||
|
else:
|
|||
|
degrees += more
|
|||
|
|
|||
|
if direction in [None, 'N', 'E']:
|
|||
|
return degrees
|
|||
|
elif direction in ['S', 'W']:
|
|||
|
return -degrees
|
|||
|
else:
|
|||
|
raise ValueError("Invalid direction! Should be one of [NSEW].")
|
|||
|
|
|||
|
@classmethod
|
|||
|
def parse_altitude(cls, distance, unit):
|
|||
|
"""
|
|||
|
Parse altitude managing units conversion::
|
|||
|
|
|||
|
>>> Point.parse_altitude(712, 'm')
|
|||
|
0.712
|
|||
|
>>> Point.parse_altitude(712, 'km')
|
|||
|
712.0
|
|||
|
>>> Point.parse_altitude(712, 'mi')
|
|||
|
1145.852928
|
|||
|
|
|||
|
:param float distance: Numeric value of altitude.
|
|||
|
:param str unit: ``distance`` unit. Supported units
|
|||
|
are listed in :meth:`.from_string` doc.
|
|||
|
"""
|
|||
|
if distance is not None:
|
|||
|
distance = float(distance)
|
|||
|
CONVERTERS = {
|
|||
|
'km': lambda d: d,
|
|||
|
'm': lambda d: units.kilometers(meters=d),
|
|||
|
'mi': lambda d: units.kilometers(miles=d),
|
|||
|
'ft': lambda d: units.kilometers(feet=d),
|
|||
|
'nm': lambda d: units.kilometers(nautical=d),
|
|||
|
'nmi': lambda d: units.kilometers(nautical=d)
|
|||
|
}
|
|||
|
try:
|
|||
|
return CONVERTERS[unit](distance)
|
|||
|
except KeyError:
|
|||
|
raise NotImplementedError(
|
|||
|
'Bad distance unit specified, valid are: %r' %
|
|||
|
CONVERTERS.keys()
|
|||
|
)
|
|||
|
else:
|
|||
|
return distance
|
|||
|
|
|||
|
@classmethod
|
|||
|
def from_string(cls, string):
|
|||
|
"""
|
|||
|
Create and return a ``Point`` instance from a string containing
|
|||
|
latitude and longitude, and optionally, altitude.
|
|||
|
|
|||
|
Latitude and longitude must be in degrees and may be in decimal form
|
|||
|
or indicate arcminutes and arcseconds (labeled with Unicode prime and
|
|||
|
double prime, ASCII quote and double quote or 'm' and 's'). The degree
|
|||
|
symbol is optional and may be included after the decimal places (in
|
|||
|
decimal form) and before the arcminutes and arcseconds otherwise.
|
|||
|
Coordinates given from south and west (indicated by S and W suffixes)
|
|||
|
will be converted to north and east by switching their signs. If no
|
|||
|
(or partial) cardinal directions are given, north and east are the
|
|||
|
assumed directions. Latitude and longitude must be separated by at
|
|||
|
least whitespace, a comma, or a semicolon (each with optional
|
|||
|
surrounding whitespace).
|
|||
|
|
|||
|
Altitude, if supplied, must be a decimal number with given units.
|
|||
|
The following unit abbrevations (case-insensitive) are supported:
|
|||
|
|
|||
|
- ``km`` (kilometers)
|
|||
|
- ``m`` (meters)
|
|||
|
- ``mi`` (miles)
|
|||
|
- ``ft`` (feet)
|
|||
|
- ``nm``, ``nmi`` (nautical miles)
|
|||
|
|
|||
|
Some example strings that will work include:
|
|||
|
|
|||
|
- ``41.5;-81.0``
|
|||
|
- ``41.5,-81.0``
|
|||
|
- ``41.5 -81.0``
|
|||
|
- ``41.5 N -81.0 W``
|
|||
|
- ``-41.5 S;81.0 E``
|
|||
|
- ``23 26m 22s N 23 27m 30s E``
|
|||
|
- ``23 26' 22" N 23 27' 30" E``
|
|||
|
- ``UT: N 39°20' 0'' / W 74°35' 0''``
|
|||
|
|
|||
|
"""
|
|||
|
match = re.match(cls.POINT_PATTERN, re.sub(r"''", r'"', string))
|
|||
|
if match:
|
|||
|
latitude_direction = None
|
|||
|
if match.group("latitude_direction_front"):
|
|||
|
latitude_direction = match.group("latitude_direction_front")
|
|||
|
elif match.group("latitude_direction_back"):
|
|||
|
latitude_direction = match.group("latitude_direction_back")
|
|||
|
|
|||
|
longitude_direction = None
|
|||
|
if match.group("longitude_direction_front"):
|
|||
|
longitude_direction = match.group("longitude_direction_front")
|
|||
|
elif match.group("longitude_direction_back"):
|
|||
|
longitude_direction = match.group("longitude_direction_back")
|
|||
|
latitude = cls.parse_degrees(
|
|||
|
match.group('latitude_degrees') or 0.0,
|
|||
|
match.group('latitude_arcminutes') or 0.0,
|
|||
|
match.group('latitude_arcseconds') or 0.0,
|
|||
|
latitude_direction
|
|||
|
)
|
|||
|
longitude = cls.parse_degrees(
|
|||
|
match.group('longitude_degrees') or 0.0,
|
|||
|
match.group('longitude_arcminutes') or 0.0,
|
|||
|
match.group('longitude_arcseconds') or 0.0,
|
|||
|
longitude_direction
|
|||
|
)
|
|||
|
altitude = cls.parse_altitude(
|
|||
|
match.group('altitude_distance'),
|
|||
|
match.group('altitude_units')
|
|||
|
)
|
|||
|
return cls(latitude, longitude, altitude)
|
|||
|
else:
|
|||
|
raise ValueError(
|
|||
|
"Failed to create Point instance from string: unknown format."
|
|||
|
)
|
|||
|
|
|||
|
@classmethod
|
|||
|
def from_sequence(cls, seq):
|
|||
|
"""
|
|||
|
Create and return a new ``Point`` instance from any iterable with 2 to
|
|||
|
3 elements. The elements, if present, must be latitude, longitude,
|
|||
|
and altitude, respectively.
|
|||
|
"""
|
|||
|
args = tuple(islice(seq, 4))
|
|||
|
if len(args) > 3:
|
|||
|
raise ValueError('When creating a Point from sequence, it '
|
|||
|
'must not have more than 3 items.')
|
|||
|
return cls(*args)
|
|||
|
|
|||
|
@classmethod
|
|||
|
def from_point(cls, point):
|
|||
|
"""
|
|||
|
Create and return a new ``Point`` instance from another ``Point``
|
|||
|
instance.
|
|||
|
"""
|
|||
|
return cls(point.latitude, point.longitude, point.altitude)
|