Source code for upoints.point

#
# coding=utf-8
"""point - Classes for working with locations on Earth."""
# Copyright © 2008-2017  James Rowe <jnrowe@gmail.com>
#
# This file is part of upoints.
#
# upoints is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# upoints is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# upoints.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import division

import math

from . import utils
from .compat import mangle_repr_type


def _manage_location(attr):
    """Build managed property interface.

    Args:
        attr (str): Property's name

    Returns:
        property: Managed property interface
    """
    return property(lambda self: getattr(self, '_%s' % attr),
                    lambda self, value: self._set_location(attr, value))


def _dms_formatter(latitude, longitude, mode, unistr=False):
    """Generate a human readable DM/DMS location string.

    Args:
        latitude (float): Location's latitude
        longitude (float): Location's longitude
        mode (str): Coordinate formatting system to use
        unistr (bool): Whether to use extended character set
    """
    if unistr:
        chars = ('°', '′', '″')
    else:
        chars = ('°', "'", '"')

    latitude_dms = tuple(map(abs, utils.to_dms(latitude, mode)))
    longitude_dms = tuple(map(abs, utils.to_dms(longitude, mode)))
    text = []
    if mode == 'dms':
        text.append('%%02i%s%%02i%s%%02i%s' % chars % latitude_dms)
    else:
        text.append('%%02i%s%%05.2f%s' % chars[:2] % latitude_dms)
    text.append('S' if latitude < 0 else 'N')
    if mode == 'dms':
        text.append(', %%03i%s%%02i%s%%02i%s' % chars % longitude_dms)
    else:
        text.append(', %%03i%s%%05.2f%s' % chars[:2] % longitude_dms)
    text.append('W' if longitude < 0 else 'E')
    return text


[docs]@mangle_repr_type class Point(object): """Simple class for representing a location on a sphere. .. versionadded:: 0.2.0 """ __slots__ = ('units', '_latitude', '_longitude', '_rad_latitude', '_rad_longitude', 'timezone', '_angle') def __init__(self, latitude, longitude, units='metric', angle='degrees', timezone=0): """Initialise a new ``Point`` object. Args: latitude (float, tuple or list): Location's latitude longitude (float, tuple or list): Location's longitude angle (str): Type for specified angles units (str): Units type to be used for distances timezone (int): Offset from UTC in minutes Raises: ValueError: Unknown value for ``angle`` ValueError: Unknown value for ``units`` ValueError: Invalid value for ``latitude`` or ``longitude`` """ super(Point, self).__init__() if angle in ('degrees', 'radians'): self._angle = angle else: raise ValueError('Unknown angle type %r' % angle) self._set_location('latitude', latitude) self._set_location('longitude', longitude) if units in ('imperial', 'metric', 'nautical'): self.units = units elif units == 'km': self.units = 'metric' elif units in ('US customary', 'sm'): self.units = 'imperial' elif units == 'nm': self.units = 'nautical' else: raise ValueError('Unknown units type %r' % units) self.timezone = timezone def _set_location(self, ltype, value): """Check supplied location data for validity, and update.""" if self._angle == 'degrees': if isinstance(value, (tuple, list)): value = utils.to_dd(*value) setattr(self, '_%s' % ltype, float(value)) setattr(self, '_rad_%s' % ltype, math.radians(float(value))) elif self._angle == 'radians': setattr(self, '_rad_%s' % ltype, float(value)) setattr(self, '_%s' % ltype, math.degrees(float(value))) else: raise ValueError('Unknown angle type %r' % self._angle) if ltype == 'latitude' and not -90 <= self._latitude <= 90: raise ValueError('Invalid latitude value %r' % value) elif ltype == 'longitude' and not -180 <= self._longitude <= 180: raise ValueError('Invalid longitude value %r' % value) latitude = _manage_location('latitude') longitude = _manage_location('longitude') rad_latitude = _manage_location('rad_latitude') rad_longitude = _manage_location('rad_longitude') @property def __dict__(self): """Emulate ``__dict__`` class attribute for class. Returns: dict: Object attributes, as would be provided by a class that didn't set ``__slots__`` """ slots = [] cls = self.__class__ # Build a tuple of __slots__ from all parent classes while cls is not object: slots.extend(cls.__slots__) cls = cls.__base__ return dict((item, getattr(self, item)) for item in slots) def __repr__(self): """Self-documenting string representation. Returnns: str: String to recreate ``Point`` object """ return utils.repr_assist(self, {'angle': 'degrees'}) def __str__(self): """Pretty printed location string. Returns: str: Human readable string representation of ``Point`` object """ return format(self) def __unicode__(self): """Pretty printed Unicode location string. Returns: str: Human readable Unicode representation of ``Point`` object """ return _dms_formatter(self, 'dd', True) def __format__(self, format_spec='dd'): """Extended pretty printing for location strings. Args: format_spec (str): Coordinate formatting system to use Returns: str: Human readable string representation of ``Point`` object Raises: ValueError: Unknown value for ``format_spec`` """ text = [] if not format_spec: # default format calls set format_spec to '' format_spec = 'dd' if format_spec == 'dd': text.append('S' if self.latitude < 0 else 'N') text.append('%06.3f°; ' % abs(self.latitude)) text.append('W' if self.longitude < 0 else 'E') text.append('%07.3f°' % abs(self.longitude)) elif format_spec in ('dm', 'dms'): text = _dms_formatter(self.latitude, self.longitude, format_spec) elif format_spec == 'locator': text.append(self.to_grid_locator()) else: raise ValueError('Unknown format_spec %r' % format_spec) return ''.join(text) def __eq__(self, other, accuracy=None): """Compare ``Point`` objects for equality with optional accuracy amount. Args: other (Point): Object to test for equality against accuracy (float): Objects are considered equal if within ``accuracy`` ``units`` distance of each other Returns: bool: True if objects are equal within given bounds """ if accuracy is None: return hash(self) == hash(other) else: return self.distance(other) < accuracy def __ne__(self, other, accuracy=None): """Compare ``Point`` objects for inequality with optional accuracy amount. Args: other (Point): Object to test for inequality against accuracy (float): Objects are considered equal if within ``accuracy`` ``units`` distance Returns: bool: True if objects are not equal within given bounds """ return not self.__eq__(other, accuracy) def __hash__(self): """Produce an object hash for equality checks. This method returns the hash of the return value from the ``__str__`` method. It guarantees equality for objects that have the same latitude and longitude. See also: __str__ Returns: int: Hash of string representation """ return hash(repr(self))
[docs] def to_grid_locator(self, precision='square'): """Calculate Maidenhead locator from latitude and longitude. Args: precision (str): Precision with which generate locator string Returns: str: Maidenhead locator for latitude and longitude """ return utils.to_grid_locator(self.latitude, self.longitude, precision)
[docs] def distance(self, other, method='haversine'): """Calculate the distance from self to other. As a smoke test this check uses the example from Wikipedia's `Great-circle distance entry`_ of Nashville International Airport to Los Angeles International Airport, and is correct to within 2 kilometres of the calculation there. Args: other (Point): Location to calculate distance to method (str): Method used to calculate distance Returns: float: Distance between self and other in ``units`` Raises: ValueError: Unknown value for ``method`` .. _Great-circle distance entry: http://en.wikipedia.org/wiki/Great-circle_distance """ longitude_difference = other.rad_longitude - self.rad_longitude latitude_difference = other.rad_latitude - self.rad_latitude if method == 'haversine': temp = math.sin(latitude_difference / 2) ** 2 + \ math.cos(self.rad_latitude) * \ math.cos(other.rad_latitude) * \ math.sin(longitude_difference / 2) ** 2 distance = 2 * utils.BODY_RADIUS * math.atan2(math.sqrt(temp), math.sqrt(1 - temp)) elif method == 'sloc': distance = math.acos(math.sin(self.rad_latitude) * math.sin(other.rad_latitude) + math.cos(self.rad_latitude) * math.cos(other.rad_latitude) * math.cos(longitude_difference)) * \ utils.BODY_RADIUS else: raise ValueError('Unknown method type %r' % method) if self.units == 'imperial': return distance / utils.STATUTE_MILE elif self.units == 'nautical': return distance / utils.NAUTICAL_MILE else: return distance
[docs] def bearing(self, other, format='numeric'): """Calculate the initial bearing from self to other. Note: Applying common plane Euclidean trigonometry to bearing calculations suggests to us that the bearing between point A to point B is equal to the inverse of the bearing from Point B to Point A, whereas spherical trigonometry is much more fun. If the ``bearing`` method doesn't make sense to you when calculating return bearings there are plenty of resources on the web that explain spherical geometry. .. todo:: Add Rhumb line calculation Args: other (Point): Location to calculate bearing to format (str): Format of the bearing string to return Returns: float: Initial bearing from self to other in degrees Raises: ValueError: Unknown value for ``format`` """ longitude_difference = other.rad_longitude - self.rad_longitude y = math.sin(longitude_difference) * math.cos(other.rad_latitude) x = math.cos(self.rad_latitude) * math.sin(other.rad_latitude) - \ math.sin(self.rad_latitude) * math.cos(other.rad_latitude) * \ math.cos(longitude_difference) bearing = math.degrees(math.atan2(y, x)) # Always return positive North-aligned bearing bearing = (bearing + 360) % 360 if format == 'numeric': return bearing elif format == 'string': return utils.angle_to_name(bearing) else: raise ValueError('Unknown format type %r' % format)
[docs] def midpoint(self, other): """Calculate the midpoint from self to other. See also: bearing Args: other (Point): Location to calculate midpoint to Returns: Point: Great circle midpoint from self to other """ longitude_difference = other.rad_longitude - self.rad_longitude y = math.sin(longitude_difference) * math.cos(other.rad_latitude) x = math.cos(other.rad_latitude) * math.cos(longitude_difference) latitude = math.atan2(math.sin(self.rad_latitude) + math.sin(other.rad_latitude), math.sqrt((math.cos(self.rad_latitude) + x) ** 2 + y ** 2)) longitude = self.rad_longitude \ + math.atan2(y, math.cos(self.rad_latitude) + x) return Point(latitude, longitude, angle='radians')
[docs] def final_bearing(self, other, format='numeric'): """Calculate the final bearing from self to other. See also: bearing Args: other (Point): Location to calculate final bearing to format (str): Format of the bearing string to return Returns: float: Final bearing from self to other in degrees Raises: ValueError: Unknown value for ``format`` """ final_bearing = (other.bearing(self) + 180) % 360 if format == 'numeric': return final_bearing elif format == 'string': return utils.angle_to_name(final_bearing) else: raise ValueError('Unknown format type %r' % format)
[docs] def destination(self, bearing, distance): """Calculate the destination from self given bearing and distance. Args: bearing (float): Bearing from self distance (float): Distance from self in ``self.units`` Returns: Point: Location after travelling ``distance`` along ``bearing`` """ bearing = math.radians(bearing) if self.units == 'imperial': distance *= utils.STATUTE_MILE elif self.units == 'nautical': distance *= utils.NAUTICAL_MILE angular_distance = distance / utils.BODY_RADIUS dest_latitude = math.asin(math.sin(self.rad_latitude) * math.cos(angular_distance) + math.cos(self.rad_latitude) * math.sin(angular_distance) * math.cos(bearing)) dest_longitude = self.rad_longitude + \ math.atan2(math.sin(bearing) * math.sin(angular_distance) * math.cos(self.rad_latitude), math.cos(angular_distance) - math.sin(self.rad_latitude) * math.sin(dest_latitude)) return Point(dest_latitude, dest_longitude, angle='radians')
[docs] def sunrise(self, date=None, zenith=None): """Calculate the sunrise time for a ``Point`` object. See also: utils.sun_rise_set Args: date (datetime.date): Calculate rise or set for given date zenith (str): Calculate rise/set events, or twilight times Returns: datetime.datetime: The time for the given event in the specified timezone """ return utils.sun_rise_set(self.latitude, self.longitude, date, 'rise', self.timezone, zenith)
[docs] def sunset(self, date=None, zenith=None): """Calculate the sunset time for a ``Point`` object. See also: utils.sun_rise_set Args: date (datetime.date): Calculate rise or set for given date zenith (str): Calculate rise/set events, or twilight times Returns: datetime.datetime: The time for the given event in the specified timezone """ return utils.sun_rise_set(self.latitude, self.longitude, date, 'set', self.timezone, zenith)
[docs] def sun_events(self, date=None, zenith=None): """Calculate the sunrise time for a ``Point`` object. See also: utils.sun_rise_set Args: date (datetime.date): Calculate rise or set for given date zenith (str): Calculate rise/set events, or twilight times Returns: tuple of datetime.datetime: The time for the given events in the specified timezone """ return utils.sun_events(self.latitude, self.longitude, date, self.timezone, zenith)
# Inverse and forward are the common functions expected by people that are # familiar with geodesics.
[docs] def inverse(self, other): """Calculate the inverse geodesic from self to other. Args: other (Point): Location to calculate inverse geodesic to Returns: tuple of float objects: Bearing and distance from self to other """ return (self.bearing(other), self.distance(other))
# Forward geodesic function maps directly to destination method forward = destination
[docs]class TimedPoint(Point): """Class for representing a location with an associated time. .. versionadded:: 0.12.0 """ __slots__ = ('time', ) def __init__(self, latitude, longitude, units='metric', angle='degrees', timezone=0, time=None): """Initialise a new ``TimedPoint`` object. Args: latitude (float, tuple or list): Location's latitude longitude (float, tuple or list): Location's longitude angle (str): Type for specified angles units (str): Units type to be used for distances timezone (int): Offset from UTC in minutes time (datetime.datetime): Time associated with the location """ super(TimedPoint, self).__init__(latitude, longitude, units, angle, timezone) self.time = time
[docs]@mangle_repr_type class Points(list): """Class for representing a group of :class:`Point` objects. .. versionadded:: 0.2.0 """ def __init__(self, points=None, parse=False, units='metric'): """Initialise a new ``Points`` object. Args: points (list of Point): :class:`Point` objects to wrap parse (bool): Whether to attempt import of ``points`` units (str): Unit type to be used for distances when parsing string locations """ super(Points, self).__init__() self._parse = parse self.units = units if points: if parse: self.import_locations(points) else: if not all(x for x in points if isinstance(x, Point)): raise TypeError('All `points` elements must be an ' 'instance of the `Point` class') self.extend(points) def __repr__(self): """Self-documenting string representation. Returns: str: String to recreate ``Points`` object """ return utils.repr_assist(self, {'points': self[:]})
[docs] def import_locations(self, locations): """Import locations from arguments. Args: locations (list of str or tuple): Location identifiers """ for location in locations: data = utils.parse_location(location) if data: latitude, longitude = data else: latitude, longitude = utils.from_grid_locator(location) self.append(Point(latitude, longitude, self.units))
[docs] def distance(self, method='haversine'): """Calculate distances between locations. Args: method (str): Method used to calculate distance Returns: list of float: Distance between points in series """ if not len(self) > 1: raise RuntimeError('More than one location is required') return (self[i].distance(self[i + 1], method) for i in range(len(self) - 1))
[docs] def bearing(self, format='numeric'): """Calculate bearing between locations. Args: format (str): Format of the bearing string to return Returns: list of float: Bearing between points in series """ if not len(self) > 1: raise RuntimeError('More than one location is required') return (self[i].bearing(self[i + 1], format) for i in range(len(self) - 1))
[docs] def final_bearing(self, format='numeric'): """Calculate final bearing between locations. Args: format (str): Format of the bearing string to return Returns: list of float: Bearing between points in series """ if len(self) == 1: raise RuntimeError('More than one location is required') return (self[i].final_bearing(self[i + 1], format) for i in range(len(self) - 1))
[docs] def inverse(self): """Calculate the inverse geodesic between locations. Returns: list of 2-tuple of float: Bearing and distance between points in series """ return ((self[i].bearing(self[i + 1]), self[i].distance(self[i + 1])) for i in range(len(self) - 1))
[docs] def midpoint(self): """Calculate the midpoint between locations. Returns: list of Point: Midpoint between points in series """ return (self[i].midpoint(self[i + 1]) for i in range(len(self) - 1))
[docs] def range(self, location, distance): """Test whether locations are within a given range of ``location``. Args: location (Point): Location to test range against distance (float): Distance to test location is within Returns: list of Point: Points within range of the specified location """ return (x for x in self if location.__eq__(x, distance))
[docs] def destination(self, bearing, distance): """Calculate destination locations for given distance and bearings. Args: bearing (float): Bearing to move on in degrees distance (float): Distance in kilometres Returns: list of Point: Points shifted by ``distance`` and ``bearing`` """ return (x.destination(bearing, distance) for x in self)
forward = destination
[docs] def sunrise(self, date=None, zenith=None): """Calculate sunrise times for locations. Args: date (datetime.date): Calculate sunrise for given date zenith (str): Calculate sunrise events, or end of twilight Returns: list of datetime.datetime: The time for the sunrise for each point """ return (x.sunrise(date, zenith) for x in self)
[docs] def sunset(self, date=None, zenith=None): """Calculate sunset times for locations. Args: date (datetime.date): Calculate sunset for given date zenith (str): Calculate sunset events, or start of twilight Returns: list of datetime.datetime: The time for the sunset for each point """ return (x.sunset(date, zenith) for x in self)
[docs] def sun_events(self, date=None, zenith=None): """Calculate sunrise/sunset times for locations. Args: date (datetime.date): Calculate rise or set for given date zenith (str): Calculate rise/set events, or twilight times Returns: list of 2-tuple of datetime.datetime: The time for the sunrise and sunset events for each point """ return (x.sun_events(date, zenith) for x in self)
[docs] def to_grid_locator(self, precision='square'): """Calculate Maidenhead locator for locations. Args: precision (str): Precision with which generate locator string Returns: list of str: Maidenhead locator for each point """ return (x.to_grid_locator(precision) for x in self)
[docs]class TimedPoints(Points):
[docs] def speed(self): """Calculate speed between :class:`Points`. Returns: list of float: Speed between :class:`Point` elements in km/h """ if not len(self) > 1: raise RuntimeError('More than one location is required') try: times = [i.time for i in self] except AttributeError: raise NotImplementedError('Not all Point objects include time ' 'attribute') return (distance / ((times[i + 1] - times[i]).seconds / 3600) for i, distance in enumerate(self.distance()))
[docs]@mangle_repr_type class KeyedPoints(dict): """Class for representing a keyed group of :class:`Point` objects. .. versionadded:: 0.2.0 """ def __init__(self, points=None, parse=False, units='metric'): """Initialise a new ``KeyedPoints`` object. Args: points (dict of Point): :class:`Point` objects to wrap points (bool): Whether to attempt import of ``points`` units (str): Unit type to be used for distances when parsing string locations """ super(KeyedPoints, self).__init__() self._parse = parse self.units = units if points: if parse: self.import_locations(points) else: if not all(x for x in points.values() if isinstance(x, Point)): raise TypeError("All `points` element's values must be an " 'instance of the `Point` class') self.update(points) def __repr__(self): """Self-documenting string representation. Returns: str: String to recreate ``KeyedPoints`` object """ return utils.repr_assist(self, {'points': dict(self.items())})
[docs] def import_locations(self, locations): """Import locations from arguments. Args: locations (list of 2-tuple of str): Identifiers and locations """ for identifier, location in locations: data = utils.parse_location(location) if data: latitude, longitude = data else: latitude, longitude = utils.from_grid_locator(location) self[identifier] = Point(latitude, longitude, self.units)
[docs] def distance(self, order, method='haversine'): """Calculate distances between locations. Args: order (list): Order to process elements in method (str): Method used to calculate distance Returns: list of float: Distance between points in ``order`` """ if not len(self) > 1: raise RuntimeError('More than one location is required') return (self[order[i]].distance(self[order[i + 1]], method) for i in range(len(order) - 1))
[docs] def bearing(self, order, format='numeric'): """Calculate bearing between locations. Args: order (list): Order to process elements in format (str): Format of the bearing string to return Returns: list of float: Bearing between points in series """ if not len(self) > 1: raise RuntimeError('More than one location is required') return (self[order[i]].bearing(self[order[i + 1]], format) for i in range(len(order) - 1))
[docs] def final_bearing(self, order, format='numeric'): """Calculate final bearing between locations. Args: order (list): Order to process elements in format (str): Format of the bearing string to return Returns: list of float: Bearing between points in series """ if len(self) == 1: raise RuntimeError('More than one location is required') return (self[order[i]].final_bearing(self[order[i + 1]], format) for i in range(len(order) - 1))
[docs] def inverse(self, order): """Calculate the inverse geodesic between locations. Args: order (list): Order to process elements in Returns: list of 2-tuple of float: Bearing and distance between points in series """ return ((self[order[i]].bearing(self[order[i + 1]]), self[order[i]].distance(self[order[i + 1]])) for i in range(len(order) - 1))
[docs] def midpoint(self, order): """Calculate the midpoint between locations. Args: order (list): Order to process elements in Returns: list of Point: Midpoint between points in series """ return (self[order[i]].midpoint(self[order[i + 1]]) for i in range(len(order) - 1))
[docs] def range(self, location, distance): """Test whether locations are within a given range of the first. Args: location (Point): Location to test range against distance (float): Distance to test location is within Returns: list of Point: Objects within specified range """ return (x for x in self.items() if location.__eq__(x[1], distance))
[docs] def destination(self, bearing, distance): """Calculate destination locations for given distance and bearings. Args: bearing (float): Bearing to move on in degrees distance (float): Distance in kilometres """ return ((x[0], x[1].destination(bearing, distance)) for x in self.items())
forward = destination
[docs] def sunrise(self, date=None, zenith=None): """Calculate sunrise times for locations. Args: date (datetime.date): Calculate sunrise for given date zenith (str): Calculate sunrise events, or end of twilight Returns: list of datetime.datetime: The time for the sunrise for each point """ return ((x[0], x[1].sunrise(date, zenith)) for x in self.items())
[docs] def sunset(self, date=None, zenith=None): """Calculate sunset times for locations. Args: date (datetime.date): Calculate sunset for given date zenith (str): Calculate sunset events, or start of twilight Returns: list of datetime.datetime: The time for the sunset for each point """ return ((x[0], x[1].sunset(date, zenith)) for x in self.items())
[docs] def sun_events(self, date=None, zenith=None): """Calculate sunrise/sunset times for locations. Args: date (datetime.date): Calculate rise or set for given date zenith (str): Calculate rise/set events, or twilight times Returns: list of 2-tuple of datetime.datetime: The time for the sunrise and sunset events for each point """ return ((x[0], x[1].sun_events(date, zenith)) for x in self.items())
[docs] def to_grid_locator(self, precision='square'): """Calculate Maidenhead locator for locations. Args: precision (str): Precision with which generate locator string Returns: list of str: Maidenhead locator for each point """ return ((x[0], x[1].to_grid_locator(precision)) for x in self.items())