Source code for upoints.edist

#
# coding=utf-8
"""edist - Simple command line coordinate processing.

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::

    \b
    $ ./edist.py --location '52.015;-0.221' sunrise
    $ ./edist.py --location '52.015;0.221' destination 20 45

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

"""
# Copyright © 2007-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/>.

import logging
import os
import sys

from operator import itemgetter

import click

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

from .compat import mangle_repr_type
from . import (_version, point, utils)


[docs]class LocationsError(ValueError): """Error object for data parsing error. .. versionadded:: 0.6.0 Attributes: function: Function where error is raised. data: Location number and data """ def __init__(self, function=None, data=None): """Initialise a new ``LocationsError`` object. Args: function (str): Function where error is raised data (tuple): Location number and data """ super(LocationsError, self).__init__() self.function = function self.data = data def __str__(self): """Pretty printed error string. Returns: str: 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. See also: upoints.point.Point .. versionadded:: 0.6.0 Attributes: name: A name for location, or its position on the command line units: Unit type to be used for distances """ __slots__ = ('name', ) def __init__(self, latitude, longitude, name, units='km'): """Initialise a new ``NumberedPoint`` object. Args: latitude (float): Location's latitude longitude (float): Location's longitude name (str): Location's name or command line position units (str): 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. Args: format_spec (str): Coordinate formatting system to use Returns: str: Human readable string representation of ``NumberedPoint`` object Raises: 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. Args: locations (list of str): Location identifiers format (str): Coordinate formatting system to use verbose (bool): Whether to generate verbose output config_locations (dict): Locations imported from user's config file units (str): 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. Returns: str: String to recreate ``NumberedPoints`` object """ return utils.repr_assist(self, {'locations': self[:]})
[docs] def import_locations(self, locations, config_locations): """Import locations from arguments. Args: locations (list of str): Location identifiers config_locations (dict): 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. Args: locator (str): Accuracy of Maidenhead locator output """ for location in self: if locator: output = location.to_grid_locator(locator) else: output = format(location, self.format) if self.verbose: click.echo('Location %s is %s' % (location.name, output)) else: click.echo(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): click.echo(' '.join(leg_msg) % (self[number].name, self[number + 1].name, distance)) if len(distances) > 1: click.echo(' '.join(total_msg) % sum(distances)) else: click.echo(sum(distances))
[docs] def bearing(self, mode, string): """Calculate bearing/final bearing between locations. Args: mode (str): Type of bearing to calculate string (bool): 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: click.echo(verbose_fmt % (self[number].name, self[number + 1].name, bearing)) else: click.echo(bearing)
[docs] def range(self, distance): """Test whether locations are within a given range of the first. Args: distance (float): 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') click.echo(' '.join(text) % (location.name, distance, self[0].name)) else: click.echo(in_range)
[docs] def destination(self, distance, bearing, locator): """Calculate destination locations for given distance and bearings. Args: distance (float): Distance to travel bearing (float): Direction of travel locator (str): Accuracy of Maidenhead locator output """ destinations = super(NumberedPoints, self).destination(bearing, distance) for location, destination in zip(self, destinations): if locator: output = destination.to_grid_locator(locator) else: output = format(location, self.format) if self.verbose: click.echo('Destination from location %s is %s' % (location.name, output)) else: click.echo(output)
[docs] def sun_events(self, mode): """Calculate sunrise/sunset times for locations. Args: mode (str): 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: click.echo('%s at %s UTC in location %s' % (mode_str, time, location.name)) else: click.echo("The sun doesn't %s at location %s on this date" % (mode_str[3:], location.name)) else: click.echo(time)
[docs] def flight_plan(self, speed, time): """Output the flight plan corresponding to the given locations. .. todo:: Description Args: speed (float): Speed to use for elapsed time calculation time (str): Time unit to use for output """ if len(self) == 1: raise LocationsError('flight_plan') if self.verbose: click.echo('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): click.echo('%s,,,,%f,%f' % (loc.name, loc.latitude, loc.longitude)) else: leg_speed = '%.1f' % (leg[1] / speed) if speed != 0 else '' click.echo('%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) click.echo('-- OVERALL --%s,,%.1f,%s,,' % (speed_marker, overall_distance, overall_speed)) click.echo('-- DIRECT --%s,%i,%.1f,%s,,' % (speed_marker, self[0].bearing(self[-1]), direct_distance, direct_speed))
@click.group(help=__doc__[__doc__.find('\n\n')+2:__doc__.rfind('\n\n')], epilog='Please report bugs at ' 'https://github.com/JNRowe/upoints/issues', context_settings={'help_option_names': ['-h', '--help']}) @click.version_option(_version.dotted) @click.option('-v', '--verbose/--quiet', help='Change verbosity level of output.') @click.option('--config', type=click.Path(dir_okay=False, resolve_path=True, allow_dash=True), metavar='~/.edist.conf', default=os.path.expanduser('~/.edist.conf'), help='Config file to read custom locations from.') @click.option('--csv-file', type=click.Path(exists=True, dir_okay=False, resolve_path=True), help='CSV file (gpsbabel format) to read route/locations from.') @click.option('-o', '--format', type=click.Choice(['dms', 'dm', 'dd']), default='dms', help='Produce output in dms, dm or dd format.') @click.option('-u', '--units', type=click.Choice(['km', 'sm', 'nm']), metavar='km', default='km', help='Display distances in kilometres, statute miles or ' 'nautical miles.') @click.option('-l', '--location', multiple=True, help='Location to operate on.') @click.pass_context def cli(ctx, verbose, config, csv_file, format, units, location): if csv_file: config_locations, location = read_csv(csv_file) else: config_locations = read_locations(config) try: locations = NumberedPoints(location, format, verbose, config_locations, units) except LocationsError as error: raise click.BadParameter(str(error)) class Obj: pass ctx.obj = Obj() ctx.obj.locations = locations @cli.command() @click.option('-g', '--string', is_flag=True, help='Display named bearings.') @click.pass_obj def bearing(globs, string): """Calculate initial bearing between locations.""" globs.locations.bearing('bearing', string) @cli.command() @click.option('-l', '--locator', type=click.Choice(['square', 'subsquare', 'extsquare']), default='subsquare', help='Accuracy of Maidenhead locator output.') @click.argument('distance', type=float) @click.argument('bearing', type=float) @click.pass_obj def destination(globs, locator, distance, bearing): """Calculate destination from locations.""" globs.locations.destination(distance, bearing, locator) @cli.command() @click.option('-l', '--locator', type=click.Choice(['square', 'subsquare', 'extsquare']), help='Accuracy of Maidenhead locator output.') @click.pass_obj def display(globs, locator): """Pretty print the locations.""" globs.locations.display(locator) @cli.command() @click.pass_obj def distance(globs): """Calculate distance between locations.""" globs.locations.distance() @cli.command() @click.option('-g', '--string', is_flag=True, help='Display named bearings.') @click.pass_obj def final_bearing(globs, string): """Calculate final bearing between locations.""" globs.locations.bearing('final_bearing', string) @cli.command() @click.option('-s', '--speed', default=0, type=float, help='Speed to calculate elapsed time.') @click.option('-t', '--time', type=click.Choice(['h', 'm', 's']), help='Display time in hours, minutes or seconds.') @click.pass_obj def flight_plan(globs, speed, time): """Calculate flight plan for locations.""" globs.locations.flight_plan(speed, time) @cli.command() @click.argument('distance', type=float) @click.pass_obj def range(globs, distance): """Check locations are within a given range.""" globs.locations.range(distance) @cli.command() @click.pass_obj def sunrise(globs): """Calculate the sunrise time for locations.""" globs.locations.sun_events('sunrise') @cli.command() @click.pass_obj def sunset(globs): """Calculate the sunset time for locations.""" globs.locations.sun_events('sunset')
[docs]def read_locations(filename): """Pull locations from a user's config file. Args: filename (str): Config file to parse Returns: dict: List of locations from config file """ data = ConfigParser() if filename == '-': data.read_file(sys.stdin) else: data.read(filename) if not data.sections(): logging.debug('Config file is empty') 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/ Args: filename (str): CSV file to parse Returns: tuple of dict and list: List of locations as ``str`` objects """ field_names = ('latitude', 'longitude', 'name') data = utils.prepare_csv_read(filename, field_names, skipinitialspace=True) locations = {} args = [] for index, row in enumerate(data, 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. Returns: int: 0 for success, >1 error code """ logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s') try: cli() return 0 except LocationsError as error: print(error) return 2 except RuntimeError as error: print(error) return 255 except OSError as error: return error.errno