Touch up a few comments (typos, extraneous, etc.)
[infoex-autowx.git] / infoex-autowx.py
index a4e50a931f2fc9324174a3d9875441b6889833d4..598f24cd765d300b8d49f3e2b1884ebca63e8a41 100755 (executable)
@@ -2,14 +2,15 @@
 # -*- coding: utf-8 -*-
 
 """
-InfoEx <-> NRCS Auto Wx implementation
+InfoEx <-> NRCS/MesoWest Auto Wx implementation
 Alexander Vasarab
 Wylark Mountaineering LLC
 
-Version 1.0.0
+Version 2.0.0
 
-This program fetches data from an NRCS SNOTEL site and pushes it to
-InfoEx using the new automated weather system implementation.
+This program fetches data from either an NRCS SNOTEL site or MesoWest
+weather station and pushes it to InfoEx using the new automated weather
+system implementation.
 
 It is designed to be run hourly, and it asks for the last three hours
 of data of each desired type, and selects the most recent one. This
@@ -27,60 +28,132 @@ import configparser
 import csv
 import datetime
 import logging
+import os
+import sys
 import time
 
 from collections import OrderedDict
 from ftplib import FTP
 from optparse import OptionParser
 
+import requests
+
 import zeep
 import zeep.cache
 import zeep.transports
 
+__version__ = '2.0.0'
+
 log = logging.getLogger(__name__)
-log.setLevel(logging.DEBUG)
+log.setLevel(logging.NOTSET)
 
 try:
     from systemd.journal import JournalHandler
     log.addHandler(JournalHandler())
 except:
-    # fallback to syslog
-    import logging.handlers
-    log.addHandler(logging.handlers.SysLogHandler())
-
-parser = OptionParser()
-parser.add_option("--config", dest="config", metavar="FILE", help="location of config file")
+    ## fallback to syslog
+    #import logging.handlers
+    #log.addHandler(logging.handlers.SysLogHandler())
+    # fallback to stdout
+    handler = logging.StreamHandler(sys.stdout)
+    log.addHandler(handler)
+
+parser = OptionParser(version=__version__)
+
+parser.add_option("--config",
+    dest="config",
+    metavar="FILE",
+    help="location of config file")
+
+parser.add_option("--log-level",
+    dest="log_level",
+    default=None,
+    help="set the log level (debug, info, warning)")
+
+parser.add_option("--dry-run",
+    action="store_true",
+    dest="dry_run",
+    default=False,
+    help="fetch data but don't upload to InfoEx")
 
 (options, args) = parser.parse_args()
 
 config = configparser.ConfigParser(allow_no_value=False)
+
+if not options.config:
+    parser.print_help()
+    print("\nPlease specify a configuration file via --config.")
+    sys.exit(1)
+
 config.read(options.config)
 
-log.debug('STARTING UP')
+# ugly, but passable
+if options.log_level in [None, 'debug', 'info', 'warning']:
+    if options.log_level == 'debug':
+        log.setLevel(logging.DEBUG)
+    elif options.log_level == 'info':
+        log.setLevel(logging.INFO)
+    elif options.log_level == 'warning':
+        log.setLevel(logging.WARNING)
+    else:
+        log.setLevel(logging.NOTSET)
+else:
+    parser.print_help()
+    print("\nPlease select an appropriate log level or remove the switch (--log-level).")
+    sys.exit(1)
 
-wsdl = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
+log.debug('STARTING UP')
 
 try:
     infoex = {
-        'host': config['ftp']['host'],
-        'uuid': config['ftp']['uuid'],
-        'api_key': config['ftp']['api_key'],
-        'location_uuid': config['wxsite']['location_uuid'],
+        'host': config['infoex']['host'],
+        'uuid': config['infoex']['uuid'],
+        'api_key': config['infoex']['api_key'],
+        'csv_filename': config['infoex']['csv_filename'],
+        'location_uuid': config['infoex']['location_uuid'],
         'wx_data': {}, # placeholder key, values to come later
-        'csv_filename': config['wxsite']['csv_filename']
     }
 
-    station_triplet = config['wxsite']['station_triplet']
+    data = dict()
+    data['provider'] = config['station']['type']
+
+    if data['provider'] not in ['nrcs', 'mesowest']:
+        print("Please specify either nrcs or mesowest as the station type.")
+        sys.exit(1)
+
+    if data['provider'] == 'nrcs':
+        data['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
+        data['station_id'] = config['station']['station_id']
+
+        try:
+            desired_data = config['station']['desired_data'].split(',')
+        except:
+            # desired_data malformed or missing, setting default
+            desired_data = [
+                    'TOBS', # AIR TEMPERATURE OBSERVED (degF)
+                    'SNWD', # SNOW DEPTH (in)
+                    'PREC'  # PRECIPITATION ACCUMULATION (in)
+                    ]
+
+        # XXX: For NRCS, we're manually overriding units for now! Once
+        #      unit conversion is supported for NRCS, REMOVE THIS!
+        if 'units' not in data:
+            data['units'] = 'imperial'
+
+    if data['provider'] == 'mesowest':
+        data['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
+        data['station_id'] = config['station']['station_id']
+        data['units'] = config['station']['units']
+
+        try:
+            desired_data = config['station']['desired_data']
+        except:
+            # desired_data malformed or missing, setting default
+            desired_data = 'air_temp,snow_depth'
+
+        # construct full API URL (sans start/end time, added later)
+        data['source'] = data['source'] + '?token=' + config['station']['token'] + '&within=60&units=' + data['units'] + '&stid=' + data['station_id'] + '&vars=' + desired_data
 
-    try:
-        desired_data = config['wxsite']['desired_data'].split(',')
-    except:
-        # desired_data malformed or missing, setting default
-        desired_data = [
-                'TOBS', # AIR TEMPERATURE OBSERVED (degF)
-                'SNWD', # SNOW DEPTH (in)
-                'PREC'  # PRECIPITATION ACCUMULATION (in)
-                ]
 except KeyError as e:
     log.critical("%s not defined in %s" % (e, options.config))
     exit(1)
@@ -107,7 +180,7 @@ except ValueError as exc:
 #
 # Also note that the current Auto Wx InfoEx documentation shows these
 # keys in a graphical table with the "index" beginning at 1, but here we
-# are sanely indexing beginning at 0.
+# sanely index beginning at 0.
 fmap = {}                           ; final_data     = [None] * 29
 fmap['Location UUID'] = 0           ; final_data[0]  = infoex['location_uuid']
 fmap['obDate'] = 1                  ; final_data[1]  = None
@@ -139,12 +212,43 @@ fmap['hn24AutoUnit'] = 26           ; final_data[26] = 'in'
 fmap['hstAuto'] = 27                ; final_data[27] = None
 fmap['hstAutoUnit'] = 28            ; final_data[28] = 'in'
 
-# one final mapping, the NRCS fields that this program supports to
+# one final mapping, the NRCS/MesoWest fields that this program supports to
 # their InfoEx counterpart
 iemap = {}
-iemap['PREC'] = 'precipitationGauge'
-iemap['TOBS'] = 'tempPres'
-iemap['SNWD'] = 'hS'
+
+if data['provider'] == 'nrcs':
+    iemap['PREC'] = 'precipitationGauge'
+    iemap['TOBS'] = 'tempPres'
+    iemap['SNWD'] = 'hS'
+    iemap['PRES'] = 'baro'
+    iemap['RHUM'] = 'rH'
+    iemap['WSPD'] = 'windSpeedNum'
+    iemap['WDIR'] = 'windDirectionNum'
+    # unsupported by NRCS:
+    # windGustSpeedNum
+elif data['provider'] == 'mesowest':
+    iemap['precip_accum'] = 'precipitationGauge'
+    iemap['air_temp'] = 'tempPres'
+    iemap['snow_depth'] = 'hS'
+    iemap['pressure'] = 'baro'
+    iemap['relative_humidity'] = 'rH'
+    iemap['wind_speed'] = 'windSpeedNum'
+    iemap['wind_direction'] = 'windDirectionNum'
+    iemap['wind_gust'] = 'windGustSpeedNum'
+
+# override units if user selected metric
+#
+# NOTE: to update this, use the fmap<->final_data mapping laid out above
+#
+# NOTE: this only 'works' with MesoWest for now, as the MesoWest API
+#       itself handles the unit conversion; in the future, we will also
+#       support NRCS unit conversion, but this must be done by this
+#       program.
+if data['units'] == 'metric':
+    final_data[fmap['tempPresUnit']] = 'C'
+    final_data[fmap['hsUnit']] = 'm'
+    final_data[fmap['windSpeedUnit']] = 'm/s'
+    final_data[fmap['windGustSpeedNumUnit']] = 'm/s'
 
 # floor time to nearest hour
 dt = datetime.datetime.now()
@@ -153,41 +257,93 @@ end_date = dt - datetime.timedelta(minutes=dt.minute % 60,
                                    microseconds=dt.microsecond)
 begin_date = end_date - datetime.timedelta(hours=3)
 
-transport = zeep.transports.Transport(cache=zeep.cache.SqliteCache())
-client = zeep.Client(wsdl=wsdl, transport=transport)
-time_all_elements = time.time()
-
+# get the data
 log.debug("Getting %s data from %s to %s" % (str(desired_data),
     str(begin_date), str(end_date)))
 
-for elementCd in desired_data:
-    time_element = time.time()
-
-    # get the last three hours of data for this elementCd
-    tmp = client.service.getHourlyData(
-            stationTriplets=[station_triplet],
-            elementCd=elementCd,
-            ordinal=1,
-            beginDate=begin_date,
-            endDate=end_date)
-
-    log.info("Time to get elementCd '%s': %.3f sec" % (elementCd,
-        time.time() - time_element))
+time_all_elements = time.time()
 
-    values = tmp[0]['values']
+# NRCS-specific code
+if data['provider'] == 'nrcs':
+    transport = zeep.transports.Transport(cache=zeep.cache.SqliteCache())
+    client = zeep.Client(wsdl=data['source'], transport=transport)
+
+    for elementCd in desired_data:
+        time_element = time.time()
+
+        # get the last three hours of data for this elementCd
+        tmp = client.service.getHourlyData(
+                stationTriplets=[data['station_id']],
+                elementCd=elementCd,
+                ordinal=1,
+                beginDate=begin_date,
+                endDate=end_date)
+
+        log.info("Time to get elementCd '%s': %.3f sec" % (elementCd,
+            time.time() - time_element))
+
+        values = tmp[0]['values']
+
+        # sort and isolate the most recent
+        #
+        # NOTE: we do this because sometimes there are gaps in hourly data
+        #       in NRCS; yes, we may end up with slightly inaccurate data,
+        #       so perhaps this decision will be re-evaluated in the future
+        if values:
+            ordered = sorted(values, key=lambda t: t['dateTime'], reverse=True)
+            infoex['wx_data'][elementCd] = ordered[0]['value']
+        else:
+            infoex['wx_data'][elementCd] = None
+
+# MesoWest-specific code
+elif data['provider'] == 'mesowest':
+    # massage begin/end date format
+    begin_date_str = begin_date.strftime('%Y%m%d%H%M')
+    end_date_str = end_date.strftime('%Y%m%d%H%M')
+
+    # construct final, completed API URL
+    api_req_url = data['source'] + '&start=' + begin_date_str + '&end=' + end_date_str
+    req = requests.get(api_req_url)
 
-    # sort and isolate the most recent
-    #
-    # NOTE: we do this because sometimes there are gaps in hourly data
-    #       in NRCS; yes, we may end up with slightly inaccurate data,
-    #       so perhaps this decision will be re-evaluated in the future
-    if values:
-        ordered = sorted(values, key=lambda t: t['dateTime'], reverse=True)
-        infoex['wx_data'][elementCd] = ordered[0]['value']
-    else:
-        infoex['wx_data'][elementCd] = None
+    try:
+        json = req.json()
+    except ValueError:
+        log.error("Bad JSON in MesoWest response")
+        sys.exit(1)
 
-log.info("Time to get all elementCds : %.3f sec" % (time.time() -
+    try:
+        observations = json['STATION'][0]['OBSERVATIONS']
+    except ValueError:
+        log.error("Bad JSON in MesoWest response")
+        sys.exit(1)
+
+    pos = len(observations['date_time']) - 1
+
+    for elementCd in desired_data.split(','):
+        # sort and isolate the most recent, see note above in NRCS for how and
+        # why this is done
+        #
+        # NOTE: Unlike in the NRCS case, the MesoWest API response contains all
+        #       data (whereas with NRCS, we have to make a separate request for
+        #       each element we want). This is nice for network efficiency but
+        #       it means we have to handle this part differently for each.
+        #
+        # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
+        #       provides hourly data, but MesoWest can often provide data every
+        #       10 minutes -- though this provides more opportunity for
+        #       irregularities
+
+        # we may not have the data at all
+        key_name = elementCd + '_set_1'
+        if key_name in observations:
+            if observations[key_name][pos]:
+                infoex['wx_data'][elementCd] = observations[key_name][pos]
+            else:
+                infoex['wx_data'][elementCd] = None
+        else:
+            infoex['wx_data'][elementCd] = None
+
+log.info("Time to get all data : %.3f sec" % (time.time() -
     time_all_elements))
 
 log.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
@@ -207,7 +363,7 @@ for elementCd in infoex['wx_data']:
     #           any possible elementCds we may want are any other data
     #           type than float.
     #
-    #           Another possibility is to query the API  with
+    #           Another possibility is to query the API with
     #           getStationElements and temporarily store the
     #           storedUnitCd. But that's pretty network-intensive and
     #           may not even be worth it if there's only e.g. one or two
@@ -224,11 +380,13 @@ with open(infoex['csv_filename'], 'w') as f:
     writer.writerow(final_data)
     f.close()
 
-#with open(infoex['csv_filename'], 'rb') as f:
-#    log.debug("uploading FTP file '%s'" % (infoex['host']))
-#    ftp = FTP(infoex['host'], infoex['uuid'], infoex['api_key'])
-#    ftp.storlines('STOR ' + infoex['csv_filename'], f)
-#    ftp.close()
-#    f.close()
+if not options.dry_run:
+    with open(infoex['csv_filename'], 'rb') as f:
+        log.debug("uploading FTP file '%s'" % (infoex['host']))
+        ftp = FTP(infoex['host'], infoex['uuid'], infoex['api_key'])
+        ftp.storlines('STOR ' + infoex['csv_filename'], f)
+        ftp.close()
+        f.close()
+    os.remove(infoex['csv_filename'])
 
 log.debug('DONE')