Source code for upoints.edist

#
# coding=utf-8
"""edist - Simple command line coordinate processing"""
# Copyright © 2007-2014  James Rowe <jnrowe@gmail.com>
#
# This program 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.
#
# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
#

from __future__ import print_function

from email.utils import parseaddr

from upoints import (__version__, __author__)
from upoints.compat import mangle_repr_type


__doc__ += """.

edist operates on one, or more, locations specified in the following format
``[+-]DD.DD;[+-]DDD.DD``.  For example, a location string of ``52.015;-0.221``
would be interpreted as 52.015 degrees North by 0.221 degrees West.  Positive
values can be specified with a ``+`` prefix, but it isn't required.

For example::

    $ ./edist.py --sunrise --sunset --ascii '52.015;-0.221'
    $ ./edist.py --destination 20@45 -- '-52.015;0.221'

In the second example the locations are separated by ``--``, which stops
processing options and allows you to specify locations beginning with
a hyphen(such as anywhere in the Southern hemisphere).

.. note::
   In most shells the locations must be quoted because of the special nature of
   the semicolon.

.. currentmodule:: edist
.. moduleauthor:: `%s <mailto:%s>`__
""" % parseaddr(__author__)

# Pull the first paragraph from the docstring
USAGE = __doc__[:__doc__.find('\n\n', 100)].replace('``', "'").splitlines()[2:]
# Replace script name with optparse's substitution var, and rebuild string
USAGE = '\n'.join(USAGE).replace('edist', '%(prog)s')

import logging
import os
import sys

from operator import itemgetter

import aaargh

try:
    from configparser import ConfigParser
except ImportError:
    from ConfigParser import ConfigParser


from upoints import (point, utils)


# Pull the first paragraph from the docstring
USAGE = __doc__[:__doc__.find('\n\n', 100)].splitlines()[2:]
# Replace script name with optparse's substitution var, and rebuild string
USAGE = '\n'.join(USAGE).replace('edist', '%(prog)s')

EPILOG = 'Please report bugs at https://github.com/JNRowe/upoints/'

APP = aaargh.App(description=USAGE, epilog=EPILOG)


[docs]class LocationsError(ValueError): """Error object for data parsing error. .. versionadded:: 0.6.0 .. attribute:: function Function where error is raised. .. attribute:: data Location number and data """ def __init__(self, function=None, data=None): """Initialise a new ``LocationsError`` object. :param str function: Function where error is raised :param tuple data: Location number and data """ super(LocationsError, self).__init__() self.function = function self.data = data def __str__(self): """Pretty printed error string. :rtype: ``str`` :return: Human readable error string """ if self.function: return 'More than one location is required for %s.' % self.function elif self.data: return 'Location parsing failure in location %i %r.' % self.data else: return 'Invalid location data.'
[docs]class NumberedPoint(point.Point): """Class for representing locations from command line. .. seealso:: :class:`upoints.point.Point`. .. versionadded:: 0.6.0 .. attribute:: name A name for location, or its position on the command line .. attribute:: units Unit type to be used for distances """ __slots__ = ('name') def __init__(self, latitude, longitude, name, units='km'): """Initialise a new ``NumberedPoint`` object. :param float latitude: Location's latitude :param float longitude: Location's longitude :param str name: Location's name or command line position :param str units: Unit type to be used for distances """ super(NumberedPoint, self).__init__(latitude, longitude, units) self.name = name def __format__(self, format_spec='dd'): """Extended pretty printing for location strings. :param str format_spec: Coordinate formatting system to use :rtype: ``str`` :return: Human readable string representation of ``NumberedPoint`` object :raise ValueError: Unknown value for ``format_spec`` """ return super(NumberedPoint, self).__format__('dm')
[docs]@mangle_repr_type class NumberedPoints(point.Points): """Class for representing a group of :class:`NumberedPoint` objects. .. versionadded:: 0.6.0 """ def __init__(self, locations=None, format='dd', verbose=True, config_locations=None, units='km'): """Initialise a new ``NumberedPoints`` object. :type locations: ``list`` of ``str`` objects :param locations: Location identifiers :param str format: Coordinate formatting system to use :param bool verbose: Whether to generate verbose output :param dict config_locations: Locations imported from user's config file :param str units: Unit type to be used for distances """ super(NumberedPoints, self).__init__() self.format = format self.verbose = verbose self._config_locations = config_locations self.units = units if locations: self.import_locations(locations, config_locations) def __repr__(self): """Self-documenting string representation. :rtype: ``str`` :return: String to recreate ``NumberedPoints`` object """ return utils.repr_assist(self, {'locations': self[:]})
[docs] def import_locations(self, locations, config_locations): """Import locations from arguments. :type locations: ``list`` of ``str`` :param locations: Location identifiers :param dict config_locations: Locations imported from user's config file """ for number, location in enumerate(locations): if config_locations and location in config_locations: latitude, longitude = config_locations[location] self.append(NumberedPoint(latitude, longitude, location, self.units)) else: try: data = utils.parse_location(location) if data: latitude, longitude = data else: latitude, longitude = utils.from_grid_locator(location) self.append(NumberedPoint(latitude, longitude, number + 1, self.units)) except ValueError: raise LocationsError(data=(number, location))
[docs] def display(self, locator): """Pretty print locations. :param str locator: Accuracy of Maidenhead locator output """ for location in self: if self.format == 'locator': output = location.to_grid_locator(locator) else: output = format(location, self.format) if self.verbose: print('Location %s is %s' % (location.name, output)) else: print(output)
[docs] def distance(self): """Calculate distances between locations. """ distances = list(super(NumberedPoints, self).distance()) leg_msg = ['Location %s to %s is %i', ] total_msg = ['Total distance is %i', ] if self.units == 'sm': leg_msg.append('miles') total_msg.append('miles') elif self.units == 'nm': leg_msg.append('nautical miles') total_msg.append('nautical miles') else: leg_msg.append('kilometres') total_msg.append('kilometres') if self.verbose: for number, distance in enumerate(distances): print(' '.join(leg_msg) % (self[number].name, self[number + 1].name, distance)) if len(distances) > 1: print(' '.join(total_msg) % sum(distances)) else: print(sum(distances))
[docs] def bearing(self, mode, string): """Calculate bearing/final bearing between locations. :param str mode: Type of bearing to calculate :param bool string: Use named directions """ bearings = getattr(super(NumberedPoints, self), mode)() if string: bearings = map(utils.angle_to_name, bearings) else: bearings = ['%i°' % bearing for bearing in bearings] if mode == 'bearing': verbose_fmt = 'Location %s to %s is %s' else: verbose_fmt = 'Final bearing from location %s to %s is %s' for number, bearing in enumerate(bearings): if self.verbose: print(verbose_fmt % (self[number].name, self[number + 1].name, bearing)) else: print(bearing)
[docs] def range(self, distance): """Test whether locations are within a given range of the first. :param float distance: Distance to test location is within """ test_location = self[0] for location in self[1:]: in_range = test_location.__eq__(location, distance) if self.verbose: text = ['Location %s is', ] if not in_range: text.append('not') text.append('within %i') if self.units == 'sm': text.append('miles') elif self.units == 'nm': text.append('nautical miles') else: text.append('kilometres') text.append('of location %s') print(' '.join(text) % (location.name, distance, self[0].name)) else: print(in_range)
[docs] def destination(self, distance, bearing, locator): """Calculate destination locations for given distance and bearings. :param float distance: Distance to travel :param float bearing: Direction of travel :param str locator: Accuracy of Maidenhead locator output """ destinations = super(NumberedPoints, self).destination(bearing, distance) for location, destination in zip(self, destinations): if self.format == 'locator': output = destination.to_grid_locator(locator) else: output = format(location, self.format) if self.verbose: print('Destination from location %s is %s' % (location.name, output)) else: print(output)
[docs] def sun_events(self, mode): """Calculate sunrise/sunset times for locations. :param str mode: Sun event to display """ mode_str = mode.capitalize() times = getattr(super(NumberedPoints, self), mode)() for location, time in zip(self, times): if self.verbose: if time: print('%s at %s UTC in location %s' % (mode_str, time, location.name)) else: print("The sun doesn't %s at location %s on this date" % (mode_str[3:], location.name)) else: print(time)
[docs] def flight_plan(self, speed, time): """Output the flight plan corresponding to the given locations. .. todo:: Description :param float speed: Speed to use for elapsed time calculation :param str time: Time unit to use for output """ if len(self) == 1: raise LocationsError('flight_plan') if self.verbose: print('WAYPOINT,BEARING[°],DISTANCE[%s],ELAPSED_TIME[%s],' 'LATITUDE[d.dd],LONGITUDE[d.dd]' % (self.units, time)) legs = [(0, 0), ] + list(self.inverse()) for leg, loc in zip(legs, self): if leg == (0, 0): print('%s,,,,%f,%f' % (loc.name, loc.latitude, loc.longitude)) else: leg_speed = '%.1f' % (leg[1] / speed) if speed != 0 else '' print('%s,%i,%.1f,%s,%f,%f' % (loc.name, leg[0], leg[1], leg_speed, loc.latitude, loc.longitude)) if self.verbose: overall_distance = sum(map(itemgetter(1), legs)) direct_distance = self[0].distance(self[-1]) if speed == 0: speed_marker = '#' overall_speed = '' direct_speed = '' else: speed_marker = '' overall_speed = '%.1f' % (overall_distance / speed) direct_speed = '%.1f' % (direct_distance / speed) print('-- OVERALL --%s,,%.1f,%s,,' % (speed_marker, overall_distance, overall_speed)) print('-- DIRECT --%s,%i,%.1f,%s,,' % (speed_marker, self[0].bearing(self[-1]), direct_distance, direct_speed))
@APP.cmd(help='pretty print the location(s)') @APP.cmd_arg('-l', '--locator', choices=('square', 'subsquare', 'extsquare'), default='subsquare', help='accuracy of Maidenhead locator output') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def display(args): args.locations.display(args.locator) @APP.cmd(help='calculate the distance between locations') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def distance(args): args.locations.distance() @APP.cmd(help='calculate the initial bearing between locations') @APP.cmd_arg('-g', '--string', action='store_true', help='display named bearings') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def bearing(args): args.locations.bearing('bearing', args.string) @APP.cmd(name='final-bearing', help='calculate the final bearing between locations') @APP.cmd_arg('-g', '--string', action='store_true', help='display named bearings') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def final_bearing(args): args.locations.bearing('final_bearing', args.string) @APP.cmd(help='calculate whether locations are within a given range') @APP.cmd_arg('-d', '--distance', type=float, help='range radius') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def range(args): args.locations.range(args.distance) @APP.cmd(help='calculate the destination for a given distance and bearing') @APP.cmd_arg('-l', '--locator', choices=('square', 'subsquare', 'extsquare'), default='subsquare', help='accuracy of Maidenhead locator output') @APP.cmd_arg('-d', '--distance', required=True, type=float, help='distance from start point') @APP.cmd_arg('-b', '--bearing', required=True, type=float, help='bearing from start point') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def destination(args): args.locations.destination(args.distance, args.bearing, args.locator) @APP.cmd(help='calculate the sunrise time for a given location') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def sunrise(args): args.locations.sun_events('sunrise') @APP.cmd(help='calculate the sunset time for a given location') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def sunset(args): args.locations.sun_events('sunset') @APP.cmd(name='flight-plan', help='calculate the flight plan corresponding to locations (route)') @APP.cmd_arg('-s', '--speed', default=0, type=float, help='speed to calculate elapsed time') @APP.cmd_arg('-t', '--time', choices=('h', 'm', 's'), help='display time in hours, minutes or seconds') @APP.cmd_arg('location', nargs='+', help='Locations to operate on') def flight_plan(args): args.locations.flight_plan(args.speed, args.time)
[docs]def read_locations(filename): """Pull locations from a user's config file. :param str filename: Config file to parse :rtype: ``dict`` :return: List of locations from config file """ data = ConfigParser() data.read(filename) if not data.sections(): logging.debug('Config file %r is empty' % filename) return {} locations = {} for name in data.sections(): if data.has_option(name, 'locator'): latitude, longitude = utils.from_grid_locator(data.get(name, 'locator')) else: latitude = data.getfloat(name, 'latitude') longitude = data.getfloat(name, 'longitude') locations[name] = (latitude, longitude) return locations
[docs]def read_csv(filename): """Pull locations from a user's CSV file. Read gpsbabel_'s CSV output format .. _gpsbabel: http://www.gpsbabel.org/ :param str filename: CSV file to parse (STDIN if '-') :rtype: ``tuple`` of ``dict`` and ``list`` :return: List of locations as ``str`` objects """ if filename == '-': filename = sys.stdin field_names = ('latitude', 'longitude', 'name') data = utils.prepare_csv_read(filename, field_names, skipinitialspace=True) index = 0 locations = {} args = [] for row in data: index += 1 name = '%02i:%s' % (index, row['name']) locations[name] = (row['latitude'], row['longitude']) args.append(name) return locations, args
[docs]def main(): """Main script handler. :rtype: ``int`` :return: 0 for success, >1 error code """ logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s') APP.arg('--version', action='version', version='%%(prog)s v%s' % __version__) APP.arg('-v', '--verbose', action='store_true', dest='verbose', default=True, help='produce verbose output') APP.arg('-q', '--quiet', action='store_false', dest='verbose', help='output only results and errors') APP.arg('--config-file', metavar='~/.edist.conf', default=os.path.expanduser('~/.edist.conf'), help='config file to read custom locations from') APP.arg('--csv-file', help='CSV file (gpsbabel format) to read route/locations from ' "('-' for STDIN)") APP.arg('-o', '--format', choices=('dms', 'dm', 'dd', 'locator'), default='dms', help='produce output in dms, dm, d format or Maidenhead locator') APP.arg('-g', '--string', action='store_true', help='display named bearings') APP.arg('-u', '--units', choices=('km', 'sm', 'nm'), metavar='km', default='km', help='display distances in kilometres(default), statute miles or ' 'nautical miles') APP.arg('-t', '--time', choices=('h', 'm', 's'), metavar='h', default='h', help='display time in hours(default), minutes or seconds') args = APP._parser.parse_args() func = args._func if args.csv_file: config_locations, args.location = read_csv(args.csv_file) else: config_locations = read_locations(args.config_file) try: args.locations = NumberedPoints(args.location, args.format, args.verbose, config_locations, args.units) except LocationsError as error: APP._parser.error(error) try: return func(args) except RuntimeError as error: APP._parser.error(error)