-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathroute_planner.py
284 lines (220 loc) · 7.04 KB
/
route_planner.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
"""
Road Trip Planner written in Python!
Uses a database of geographical locations to plan a trip between two cities,
perhaps not very efficiently, but surely more interestingly than Google Maps!
Authors:
Joao Rodrigues ([email protected])
"""
import argparse
import collections
import csv
import logging
import math
import pathlib
City = collections.namedtuple(
'City',
[
'name',
'state',
'lat',
'lon'
]
)
def read_input():
"""Parses and validates the command-line options provided by the user.
"""
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Mandatory Arguments
ap.add_argument(
'database',
help='Path to file containing the database of possible cities'
)
ap.add_argument(
'start',
help='Starting point of the route (e.g. "San Francisco, CA")'
)
ap.add_argument(
'finish',
help='End point of the route (e.g. "Boston, MA")'
)
# Optional Arguments
ap.add_argument(
'--stop',
type=str,
action='append',
help='Additional stops along the route. One per invocation.'
)
ap.add_argument(
'-r',
'--km-per-day',
type=float,
default=200.0,
help='Rate of travel, in maximum km travelled per day (def: 200.0)'
)
ap.add_argument(
'-v',
'--verbose',
action='store_true',
help='Enables debugging messages when running the program.'
)
return ap.parse_args()
def create_database(db_fpath):
"""Reads and creates a database of cities.
Args:
db_fpath (str): path to the database file on disk.
"""
city_db = {}
path = pathlib.Path(db_fpath)
with path.open('r') as db_file:
for line_idx, line in enumerate(csv.reader(db_file), start=1):
try:
name, *_, state_code, lat, lon = line
lat, lon = math.radians(float(lat)), math.radians(float(lon))
except Exception as err:
logging.warning(f'Error parsing line {line_idx}: {err}')
continue
else:
city = City(
name,
state_code,
lat,
lon
)
logging.debug(f'Added city: {city.name}, {city.state}')
city_db[(name, state_code)] = city
logging.info(f'Read {len(city_db)} cities into a database')
return city_db
def find_city_in_db(database, query):
"""Returns a City matching the query from the database.
Args:
database (dict): dictionary of City objects.
query (str): city information as "Name, State".
"""
try:
name, state = map(str.strip, query.split(','))
city = database[(name, state)]
except KeyError:
emsg = f'Query city "{name}, {state}" not found in database'
raise KeyError(emsg) from None
logging.info(f'Matched {city.name}, {city.state} to database')
return city
def validate_cities(database, args):
"""Validates input cities against a database.
Args:
database (dict): dictionary of City objects.
args (namespace): parsed arguments, as an argparse.NameSpace object.
"""
args.start = find_city_in_db(database, args.start)
args.finish = find_city_in_db(database, args.finish)
def get_distance(city_a, city_b):
"""Returns the distance in km between city_a and city_b.
Uses the Haversine formulate to calculate distances between
points on a sphere.
Args:
city_a (City): origin city namedtuple.
city_a (City): destination city namedtuple.
"""
lat_i, lon_i = city_a.lat, city_a.lon
lat_j, lon_j = city_b.lat, city_b.lon
# Haversine formula for distances between points on a sphere
# https://en.wikipedia.org/wiki/Haversine_formula
dlat = lat_j - lat_i
dlon = lon_j - lon_i
a = (
(math.sin(dlat/2) * math.sin(dlat/2)) + \
math.cos(lat_i) * math.cos(lat_j) * \
(math.sin(dlon/2) * math.sin(dlon/2))
)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
d = 6373 * c # R is 'a' radius of earth
return d
def find_city_neighbors(city, candidates, radius):
"""Returns all cities in candidates within radius of self.
Args:
city (City): query City.
candidates (list): list of City objects.
radius (float): distance cutoff to consider a City a neighbor
"""
return filter(
lambda c: get_distance(city, c) <= radius,
candidates
)
def find_route(database, start, finish, km_per_day):
"""Finds the shortest path between two cities.
Args:
database (dict): collection of possible stops encoded as
City objects.
start (City): first city on the route, as a City object.
finish (City): last city on the route, as a City object.
km_per_day (float): maximum distance travelled per day.
"""
route = [start]
visited = set(route)
list_of_cities = list(database.values())
distance_to_end = lambda city: get_distance(finish, city)
current = start
while current != finish:
neighbors = find_city_neighbors(
current,
list_of_cities,
km_per_day
)
sorted_neighbors = sorted(
neighbors,
key=distance_to_end
)
for city in sorted_neighbors:
if city not in visited:
current = city
break
else:
emsg = (
f'Could not find viable route after stop #{len(route)}: '
f' {current.name}, {current.state}'
)
raise Exception(emsg)
route.append(current)
visited.add(current)
logging.debug(
f'Added {current.name}, {current.state} to route: '
f'{get_distance(current, finish):5.2f} km to end'
)
return route
def write_route(route):
"""Outputs a route to the screen.
Args:
route (list): list of City objects.
"""
for stop_idx, city in enumerate(route):
print(f'[Day {stop_idx}] {city.name}, {city.state}')
def setup_logging(verbose):
"""Creates a logger object to relay messages to users.
Args:
verbose (bool): if True, sets the default logging level
to DEBUG. Otherwise, it's set to INFO.
"""
# Setup the logger
if verbose:
log_level = logging.DEBUG
else:
log_level = logging.INFO
logging.basicConfig(
format='[%(asctime)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
level=log_level
)
if __name__ == '__main__':
args = read_input()
setup_logging(args.verbose)
db = create_database(args.database)
validate_cities(db, args)
route = find_route(
db,
args.start,
args.finish,
args.km_per_day
)
write_route(route)