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