Implement parsing of new config options
[infoex-autowx.git] / infoex-autowx.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 """
5 InfoEx <-> NRCS/MesoWest Auto Wx implementation
6 Alexander Vasarab
7 Wylark Mountaineering LLC
8
9 This program fetches data from either an NRCS SNOTEL site or MesoWest
10 weather station and pushes it to InfoEx using the new automated weather
11 system implementation.
12
13 It is designed to be run hourly, and it asks for the last three hours
14 of data of each desired type, and selects the most recent one. This
15 lends some resiliency to the process and helps ensure that we have a
16 value to send, but it can lead to somewhat inconsistent/untruthful
17 data if e.g. the HS is from the last hour but the tempPres is from two
18 hours ago because the instrumentation had a hiccup. It's worth
19 considering if this is a bug or a feature.
20
21 For more information, see file: README
22 For licensing, see file: LICENSE
23 """
24
25 import configparser
26 import csv
27 import datetime
28 import logging
29 import os
30 import sys
31 import time
32 import urllib3
33 import importlib.util
34
35 from ftplib import FTP
36 from argparse import ArgumentParser
37
38 import pytz
39
40 import requests
41
42 import zeep
43 import zeep.cache
44 import zeep.transports
45
46 __version__ = '3.2.4'
47
48 LOG = logging.getLogger(__name__)
49 LOG.setLevel(logging.NOTSET)
50
51 urllib3.disable_warnings()
52
53 def get_parser():
54 """Return OptionParser for this program"""
55 parser = ArgumentParser()
56
57 parser.add_argument("--version",
58 action="version",
59 version=__version__)
60
61 parser.add_argument("--config",
62 dest="config",
63 metavar="FILE",
64 help="location of config file")
65
66 parser.add_argument("--log-level",
67 dest="log_level",
68 default=None,
69 help="set the log level (debug, info, warning)")
70
71 parser.add_argument("--dry-run",
72 action="store_true",
73 dest="dry_run",
74 default=False,
75 help="fetch data but don't upload to InfoEx")
76
77 return parser
78
79 def setup_config(config):
80 """Setup config variable based on values specified in the ini file"""
81 try:
82 infoex = {
83 'host': config['infoex']['host'],
84 'uuid': config['infoex']['uuid'],
85 'api_key': config['infoex']['api_key'],
86 'csv_filename': config['infoex']['csv_filename'],
87 'location_uuid': config['infoex']['location_uuid'],
88 'wx_data': {}, # placeholder key, values to come later
89 }
90
91 station = dict()
92 station['provider'] = config['station']['type']
93
94 if station['provider'] not in ['nrcs', 'mesowest', 'python']:
95 print("Please specify either nrcs or mesowest as the station type.")
96 sys.exit(1)
97
98 if station['provider'] == 'nrcs':
99 station['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
100 station['station_id'] = config['station']['station_id']
101 station['desired_data'] = config['station']['desired_data'].split(',')
102 station['units'] = config['station']['units']
103
104 if station['provider'] == 'mesowest':
105 station['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
106 station['station_id'] = config['station']['station_id']
107 station['units'] = config['station']['units']
108 station['desired_data'] = config['station']['desired_data']
109
110 # construct full API URL (sans start/end time, added later)
111 station['source'] = station['source'] + '?token=' + \
112 config['station']['token'] + \
113 '&within=60&units=' + station['units'] + \
114 '&stid=' + station['station_id'] + \
115 '&vars=' + station['desired_data']
116
117 if station['provider'] == 'python':
118 station['path'] = config['station']['path']
119
120 tz = 'America/Los_Angeles'
121
122 if 'tz' in config['station']:
123 tz = config['station']['tz']
124
125 try:
126 station['tz'] = pytz.timezone(tz)
127 except pytz.exceptions.UnknownTimeZoneError:
128 LOG.critical("%s is not a valid timezone", tz)
129 sys.exit(1)
130
131 # HN24
132 if 'hn24' in config['station']:
133 if config['station']['hn24'] not in ['true', 'false']:
134 raise ValueError("hn24 must be either 'true' or 'false'")
135
136 if config['station']['hn24'] == "true":
137 station['hn24'] = True
138 else:
139 station['hn24'] = False
140 else:
141 # default to False
142 station['hn24'] = False
143
144 # Wind mode
145 if 'wind_mode' in config['station']:
146 if config['station']['wind_mode'] not in ['normal', 'average']:
147 raise ValueError("wind_mode must be either 'normal' or 'average'")
148
149 station['wind_mode'] = config['station']['wind_mode']
150 else:
151 # default to False
152 station['wind_mode'] = "normal"
153
154 except KeyError as err:
155 LOG.critical("%s not defined in configuration file", err)
156 sys.exit(1)
157 except ValueError as err:
158 LOG.critical("%s", err)
159 sys.exit(1)
160
161 # all sections/values present in config file, final sanity check
162 try:
163 for key in config.sections():
164 for subkey in config[key]:
165 if not config[key][subkey]:
166 raise ValueError
167 except ValueError:
168 LOG.critical("Config value '%s.%s' is empty", key, subkey)
169 sys.exit(1)
170
171 return (infoex, station)
172
173 def setup_logging(log_level):
174 """Setup our logging infrastructure"""
175 try:
176 from systemd.journal import JournalHandler
177 LOG.addHandler(JournalHandler())
178 except ImportError:
179 ## fallback to syslog
180 #import logging.handlers
181 #LOG.addHandler(logging.handlers.SysLogHandler())
182 # fallback to stdout
183 handler = logging.StreamHandler(sys.stdout)
184 formatter = logging.Formatter('%(asctime)s.%(msecs)03d '
185 '%(levelname)s %(module)s - '
186 '%(funcName)s: %(message)s',
187 '%Y-%m-%d %H:%M:%S')
188 handler.setFormatter(formatter)
189 LOG.addHandler(handler)
190
191 # ugly, but passable
192 if log_level in [None, 'debug', 'info', 'warning']:
193 if log_level == 'debug':
194 LOG.setLevel(logging.DEBUG)
195 elif log_level == 'info':
196 LOG.setLevel(logging.INFO)
197 elif log_level == 'warning':
198 LOG.setLevel(logging.WARNING)
199 else:
200 LOG.setLevel(logging.NOTSET)
201 else:
202 return False
203
204 return True
205
206 def main():
207 """Main routine: sort through args, decide what to do, then do it"""
208 parser = get_parser()
209 options = parser.parse_args()
210
211 config = configparser.ConfigParser(allow_no_value=False)
212
213 if not options.config:
214 parser.print_help()
215 print("\nPlease specify a configuration file via --config.")
216 sys.exit(1)
217
218 config.read(options.config)
219
220 if not setup_logging(options.log_level):
221 parser.print_help()
222 print("\nPlease select an appropriate log level or remove the switch (--log-level).")
223 sys.exit(1)
224
225 (infoex, station) = setup_config(config)
226
227 LOG.debug('Config parsed, starting up')
228
229 # create mappings
230 (fmap, final_data) = setup_infoex_fields_mapping(infoex['location_uuid'])
231 iemap = setup_infoex_counterparts_mapping(station['provider'])
232
233 # override units if user selected metric
234 if station['provider'] != 'python' and station['units'] == 'metric':
235 final_data = switch_units_to_metric(final_data, fmap)
236
237 (begin_date, end_date) = setup_time_values(station)
238
239 if station['provider'] == 'python':
240 LOG.debug("Getting custom data from external Python program")
241 else:
242 LOG.debug("Getting %s data from %s to %s (%s)",
243 str(station['desired_data']),
244 str(begin_date), str(end_date), end_date.tzinfo.zone)
245
246 time_all_elements = time.time()
247
248 # get the data
249 if station['provider'] == 'nrcs':
250 infoex['wx_data'] = get_nrcs_data(begin_date, end_date, station)
251 elif station['provider'] == 'mesowest':
252 infoex['wx_data'] = get_mesowest_data(begin_date, end_date,
253 station)
254 elif station['provider'] == 'python':
255 try:
256 spec = importlib.util.spec_from_file_location('custom_wx',
257 station['path'])
258 mod = importlib.util.module_from_spec(spec)
259 spec.loader.exec_module(mod)
260 mod.LOG = LOG
261
262 try:
263 infoex['wx_data'] = mod.get_custom_data()
264
265 if infoex['wx_data'] is None:
266 infoex['wx_data'] = []
267 except Exception as exc:
268 LOG.error("Python program for custom Wx data failed in "
269 "execution: %s", str(exc))
270 sys.exit(1)
271
272 LOG.info("Successfully executed external Python program")
273 except ImportError:
274 LOG.error("Please upgrade to Python 3.3 or later")
275 sys.exit(1)
276 except FileNotFoundError:
277 LOG.error("Specified Python program for custom Wx data "
278 "was not found")
279 sys.exit(1)
280 except Exception as exc:
281 LOG.error("A problem was encountered when attempting to "
282 "load your custom Wx program: %s", str(exc))
283 sys.exit(1)
284
285 LOG.info("Time taken to get all data : %.3f sec", time.time() -
286 time_all_elements)
287
288 LOG.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
289
290 # timezone massaging
291 final_end_date = end_date.astimezone(station['tz'])
292
293 # Now we only need to add in what we want to change thanks to that
294 # abomination of a variable declaration earlier
295 final_data[fmap['Location UUID']] = infoex['location_uuid']
296 final_data[fmap['obDate']] = final_end_date.strftime('%m/%d/%Y')
297 final_data[fmap['obTime']] = final_end_date.strftime('%H:%M')
298 final_data[fmap['timeZone']] = station['tz'].zone
299
300 for element_cd in infoex['wx_data']:
301 if element_cd not in iemap:
302 LOG.warning("BAD KEY wx_data['%s']", element_cd)
303 continue
304
305 if infoex['wx_data'][element_cd] is None:
306 continue
307
308 # do the conversion before the rounding
309 if station['provider'] == 'nrcs' and station['units'] == 'metric':
310 infoex['wx_data'][element_cd] = convert_nrcs_units_to_metric(element_cd, infoex['wx_data'][element_cd])
311
312 # Massage precision of certain values to fit InfoEx's
313 # expectations
314 #
315 # 0 decimal places: relative humidity, wind speed, wind
316 # direction, wind gust, snow depth
317 # 1 decimal place: air temp, baro
318 # Avoid transforming None values
319 if element_cd in ['wind_speed', 'WSPD', 'wind_direction',
320 'RHUM', 'relative_humidity', 'WDIR',
321 'wind_gust', 'SNWD', 'snow_depth']:
322 infoex['wx_data'][element_cd] = round(infoex['wx_data'][element_cd])
323 elif element_cd in ['TOBS', 'air_temp', 'PRES', 'pressure']:
324 infoex['wx_data'][element_cd] = round(infoex['wx_data'][element_cd], 1)
325 elif element_cd in ['PREC', 'precip_accum']:
326 infoex['wx_data'][element_cd] = round(infoex['wx_data'][element_cd], 2)
327
328 # CONSIDER: Casting every value to Float() -- need to investigate if
329 # any possible elementCds we may want are any other data
330 # type than float.
331 #
332 # Another possibility is to query the API with
333 # getStationElements and temporarily store the
334 # storedUnitCd. But that's pretty network-intensive and
335 # may not even be worth it if there's only e.g. one or two
336 # exceptions to any otherwise uniformly Float value set.
337 final_data[fmap[iemap[element_cd]]] = infoex['wx_data'][element_cd]
338
339 LOG.debug("final_data: %s", str(final_data))
340
341 if infoex['wx_data']:
342 if not write_local_csv(infoex['csv_filename'], final_data):
343 LOG.warning('Could not write local CSV file: %s',
344 infoex['csv_filename'])
345 return 1
346
347 if not options.dry_run:
348 upload_csv(infoex['csv_filename'], infoex)
349
350 LOG.debug('DONE')
351 return 0
352
353 # data structure operations
354 def setup_infoex_fields_mapping(location_uuid):
355 """
356 Create a mapping of InfoEx fields to the local data's indexing scheme.
357
358 INFOEX FIELDS
359
360 This won't earn style points in Python, but here we establish a couple
361 of helpful mappings variables. The reason this is helpful is that the
362 end result is simply an ordered set, the CSV file. But we still may
363 want to manipulate the values arbitrarily before writing that file.
364
365 Also note that the current Auto Wx InfoEx documentation shows these
366 keys in a graphical table with the "index" beginning at 1, but here we
367 sanely index beginning at 0.
368 """
369 # pylint: disable=too-many-statements,multiple-statements,bad-whitespace
370 fmap = {} ; final_data = [None] * 29
371 fmap['Location UUID'] = 0 ; final_data[0] = location_uuid
372 fmap['obDate'] = 1 ; final_data[1] = None
373 fmap['obTime'] = 2 ; final_data[2] = None
374 fmap['timeZone'] = 3 ; final_data[3] = 'Pacific'
375 fmap['tempMaxHour'] = 4 ; final_data[4] = None
376 fmap['tempMaxHourUnit'] = 5 ; final_data[5] = 'F'
377 fmap['tempMinHour'] = 6 ; final_data[6] = None
378 fmap['tempMinHourUnit'] = 7 ; final_data[7] = 'F'
379 fmap['tempPres'] = 8 ; final_data[8] = None
380 fmap['tempPresUnit'] = 9 ; final_data[9] = 'F'
381 fmap['precipitationGauge'] = 10 ; final_data[10] = None
382 fmap['precipitationGaugeUnit'] = 11 ; final_data[11] = 'in'
383 fmap['windSpeedNum'] = 12 ; final_data[12] = None
384 fmap['windSpeedUnit'] = 13 ; final_data[13] = 'mph'
385 fmap['windDirectionNum'] = 14 ; final_data[14] = None
386 fmap['hS'] = 15 ; final_data[15] = None
387 fmap['hsUnit'] = 16 ; final_data[16] = 'in'
388 fmap['baro'] = 17 ; final_data[17] = None
389 fmap['baroUnit'] = 18 ; final_data[18] = 'inHg'
390 fmap['rH'] = 19 ; final_data[19] = None
391 fmap['windGustSpeedNum'] = 20 ; final_data[20] = None
392 fmap['windGustSpeedNumUnit'] = 21 ; final_data[21] = 'mph'
393 fmap['windGustDirNum'] = 22 ; final_data[22] = None
394 fmap['dewPoint'] = 23 ; final_data[23] = None
395 fmap['dewPointUnit'] = 24 ; final_data[24] = 'F'
396 fmap['hn24Auto'] = 25 ; final_data[25] = None
397 fmap['hn24AutoUnit'] = 26 ; final_data[26] = 'in'
398 fmap['hstAuto'] = 27 ; final_data[27] = None
399 fmap['hstAutoUnit'] = 28 ; final_data[28] = 'in'
400
401 return (fmap, final_data)
402
403 def setup_infoex_counterparts_mapping(provider):
404 """
405 Create a mapping of the NRCS/MesoWest fields that this program supports to
406 their InfoEx counterparts
407 """
408 iemap = {}
409
410 if provider == 'nrcs':
411 iemap['PREC'] = 'precipitationGauge'
412 iemap['TOBS'] = 'tempPres'
413 iemap['TMAX'] = 'tempMaxHour'
414 iemap['TMIN'] = 'tempMinHour'
415 iemap['SNWD'] = 'hS'
416 iemap['PRES'] = 'baro'
417 iemap['RHUM'] = 'rH'
418 iemap['WSPD'] = 'windSpeedNum'
419 iemap['WDIR'] = 'windDirectionNum'
420 # unsupported by NRCS:
421 # windGustSpeedNum
422 elif provider == 'mesowest':
423 iemap['precip_accum'] = 'precipitationGauge'
424 iemap['air_temp'] = 'tempPres'
425 iemap['air_temp_high_24_hour'] = 'tempMaxHour'
426 iemap['air_temp_low_24_hour'] = 'tempMinHour'
427 iemap['snow_depth'] = 'hS'
428 iemap['pressure'] = 'baro'
429 iemap['relative_humidity'] = 'rH'
430 iemap['wind_speed'] = 'windSpeedNum'
431 iemap['wind_direction'] = 'windDirectionNum'
432 iemap['wind_gust'] = 'windGustSpeedNum'
433 elif provider == 'python':
434 # we expect Python programs to use the InfoEx data type names
435 iemap['precipitationGauge'] = 'precipitationGauge'
436 iemap['tempPres'] = 'tempPres'
437 iemap['tempMaxHour'] = 'tempMaxHour'
438 iemap['tempMinHour'] = 'tempMinHour'
439 iemap['hS'] = 'hS'
440 iemap['baro'] = 'baro'
441 iemap['rH'] = 'rH'
442 iemap['windSpeedNum'] = 'windSpeedNum'
443 iemap['windDirectionNum'] = 'windDirectionNum'
444 iemap['windGustSpeedNum'] = 'windGustSpeedNum'
445
446 return iemap
447
448 # provider-specific operations
449 def get_nrcs_data(begin, end, station):
450 """get the data we're after from the NRCS WSDL"""
451 transport = zeep.transports.Transport(cache=zeep.cache.SqliteCache())
452 transport.session.verify = False
453 client = zeep.Client(wsdl=station['source'], transport=transport)
454 remote_data = {}
455
456 # massage begin/end date format
457 begin_date_str = begin.strftime('%Y-%m-%d %H:%M:00')
458 end_date_str = end.strftime('%Y-%m-%d %H:%M:00')
459
460 for element_cd in station['desired_data']:
461 time_element = time.time()
462
463 # get the last three hours of data for this elementCd/element_cd
464 tmp = client.service.getHourlyData(
465 stationTriplets=[station['station_id']],
466 elementCd=element_cd,
467 ordinal=1,
468 beginDate=begin_date_str,
469 endDate=end_date_str)
470
471 LOG.info("Time to get NRCS elementCd '%s': %.3f sec", element_cd,
472 time.time() - time_element)
473
474 values = tmp[0]['values']
475
476 # sort and isolate the most recent
477 #
478 # NOTE: we do this because sometimes there are gaps in hourly data
479 # in NRCS; yes, we may end up with slightly inaccurate data,
480 # so perhaps this decision will be re-evaluated in the future
481 if values:
482 ordered = sorted(values, key=lambda t: t['dateTime'], reverse=True)
483 remote_data[element_cd] = ordered[0]['value']
484 else:
485 remote_data[element_cd] = None
486
487 return remote_data
488
489 def get_mesowest_data(begin, end, station):
490 """get the data we're after from the MesoWest/Synoptic API"""
491 remote_data = {}
492
493 # massage begin/end date format
494 begin_date_str = begin.strftime('%Y%m%d%H%M')
495 end_date_str = end.strftime('%Y%m%d%H%M')
496
497 # construct final, completed API URL
498 api_req_url = station['source'] + '&start=' + begin_date_str + '&end=' + end_date_str
499
500 try:
501 req = requests.get(api_req_url)
502 except requests.exceptions.ConnectionError:
503 LOG.error("Could not connect to '%s'", api_req_url)
504 sys.exit(1)
505
506 try:
507 json = req.json()
508 except ValueError:
509 LOG.error("Bad JSON in MesoWest response")
510 sys.exit(1)
511
512 try:
513 observations = json['STATION'][0]['OBSERVATIONS']
514 except KeyError as exc:
515 LOG.error("Unexpected JSON in MesoWest response: '%s'", exc)
516 sys.exit(1)
517 except IndexError as exc:
518 LOG.error("Unexpected JSON in MesoWest response: '%s'", exc)
519 try:
520 LOG.error("Detailed MesoWest response: '%s'",
521 json['SUMMARY']['RESPONSE_MESSAGE'])
522 except KeyError:
523 pass
524 sys.exit(1)
525 except ValueError as exc:
526 LOG.error("Bad JSON in MesoWest response: '%s'", exc)
527 sys.exit(1)
528
529 pos = len(observations['date_time']) - 1
530
531 for element_cd in station['desired_data'].split(','):
532 # sort and isolate the most recent, see note above in NRCS for how and
533 # why this is done
534 #
535 # NOTE: Unlike in the NRCS case, the MesoWest API response contains all
536 # data (whereas with NRCS, we have to make a separate request for
537 # each element we want). This is nice for network efficiency but
538 # it means we have to handle this part differently for each.
539 #
540 # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
541 # provides hourly data, but MesoWest can often provide data every
542 # 10 minutes -- though this provides more opportunity for
543 # irregularities
544
545 # we may not have the data at all
546 key_name = element_cd + '_set_1'
547
548 if key_name in observations:
549 if observations[key_name][pos]:
550 remote_data[element_cd] = observations[key_name][pos]
551
552 # mesowest by default provides wind_speed in m/s, but
553 # we specify 'english' units in the request; either way,
554 # we want mph
555 if element_cd in ('wind_speed', 'wind_gust'):
556 remote_data[element_cd] = kn_to_mph(remote_data[element_cd])
557
558 # mesowest provides HS in mm, not cm; we want cm
559 if element_cd == 'snow_depth' and station['units'] == 'metric':
560 remote_data[element_cd] = mm_to_cm(remote_data[element_cd])
561 else:
562 remote_data[element_cd] = None
563 else:
564 remote_data[element_cd] = None
565
566 return remote_data
567
568 def switch_units_to_metric(data_map, mapping):
569 """replace units with metric counterparts"""
570
571 # NOTE: to update this, use the fmap<->final_data mapping laid out
572 # in setup_infoex_fields_mapping ()
573 data_map[mapping['tempMaxHourUnit']] = 'C'
574 data_map[mapping['tempMinHourUnit']] = 'C'
575 data_map[mapping['tempPresUnit']] = 'C'
576 data_map[mapping['precipitationGaugeUnit']] = 'mm'
577 data_map[mapping['hsUnit']] = 'cm'
578 data_map[mapping['windSpeedUnit']] = 'm/s'
579 data_map[mapping['windGustSpeedNumUnit']] = 'm/s'
580 data_map[mapping['dewPointUnit']] = 'C'
581 data_map[mapping['hn24AutoUnit']] = 'cm'
582 data_map[mapping['hstAutoUnit']] = 'cm'
583
584 return data_map
585
586 def convert_nrcs_units_to_metric(element_cd, value):
587 """convert NRCS values from English to metric"""
588 if element_cd == 'TOBS':
589 value = f_to_c(value)
590 elif element_cd == 'SNWD':
591 value = in_to_cm(value)
592 elif element_cd == 'PREC':
593 value = in_to_mm(value)
594 return value
595
596 # CSV operations
597 def write_local_csv(path_to_file, data):
598 """Write the specified CSV file to disk"""
599 with open(path_to_file, 'w') as file_object:
600 # The requirement is that empty values are represented in the CSV
601 # file as "", csv.QUOTE_NONNUMERIC achieves that
602 LOG.debug("writing CSV file '%s'", path_to_file)
603 writer = csv.writer(file_object, quoting=csv.QUOTE_NONNUMERIC)
604 writer.writerow(data)
605 file_object.close()
606 return True
607
608 def upload_csv(path_to_file, infoex_data):
609 """Upload the specified CSV file to InfoEx FTP and remove the file"""
610 with open(path_to_file, 'rb') as file_object:
611 LOG.debug("uploading FTP file '%s'", infoex_data['host'])
612 ftp = FTP(infoex_data['host'], infoex_data['uuid'],
613 infoex_data['api_key'])
614 ftp.storlines('STOR ' + path_to_file, file_object)
615 ftp.close()
616 file_object.close()
617 os.remove(path_to_file)
618
619 # other miscellaneous routines
620 def setup_time_values(station):
621 """establish time bounds of data request(s)"""
622
623 # default timezone to UTC (for MesoWest)
624 tz = pytz.utc
625
626 # but for NRCS, use the config-specified timezone
627 if station['provider'] == 'nrcs':
628 tz = station['tz']
629
630 # floor time to nearest hour
631 date_time = datetime.datetime.now(tz=tz)
632 end_date = date_time - datetime.timedelta(minutes=date_time.minute % 60,
633 seconds=date_time.second,
634 microseconds=date_time.microsecond)
635 begin_date = end_date - datetime.timedelta(hours=3)
636 return (begin_date, end_date)
637
638 def f_to_c(f):
639 """convert Fahrenheit to Celsius"""
640 return (float(f) - 32) * 5.0/9.0
641
642 def in_to_cm(inches):
643 """convert inches to centimetrs"""
644 return float(inches) * 2.54
645
646 def in_to_mm(inches):
647 """convert inches to millimeters"""
648 return (float(inches) * 2.54) * 10.0
649
650 def ms_to_mph(ms):
651 """convert meters per second to miles per hour"""
652 return ms * 2.236936
653
654 def kn_to_mph(kn):
655 """convert knots to miles per hour"""
656 return kn * 1.150779
657
658 def mm_to_cm(mm):
659 """convert millimeters to centimetrs"""
660 return mm / 10.0
661
662 if __name__ == "__main__":
663 sys.exit(main())