Initial commit
authorAlexander Vasarab <alexander@wylark.com>
Wed, 17 Jun 2020 14:29:27 +0000 (07:29 -0700)
committerAlexander Vasarab <alexander@wylark.com>
Wed, 17 Jun 2020 14:34:10 +0000 (07:34 -0700)
This contains a working version of the program from after the initial
process of writing and testing it, and subsequently successfully
importing data from NRCS into InfoEx.

It also contains an example config file.

config.ini.example [new file with mode: 0644]
infoex-autowx.py [new file with mode: 0755]

diff --git a/config.ini.example b/config.ini.example
new file mode 100644 (file)
index 0000000..3d87250
--- /dev/null
@@ -0,0 +1,10 @@
+[wxsite]
+station_triplet = 655:OR:SNTL
+desired_data = TOBS,PREC,SNWD,XYZH
+location_uuid = a5bb872b-14c1-4367-bd81-177103acaef3
+csv_filename = INFOEX-MUDRIDGEAUTO.CSV
+
+[ftp]
+host = weather.infoex.ca
+uuid = <InfoEx-supplied UUID>
+api_key = <InfoEx-supplied API Key>
diff --git a/infoex-autowx.py b/infoex-autowx.py
new file mode 100755 (executable)
index 0000000..f951c0d
--- /dev/null
@@ -0,0 +1,229 @@
+#!/usr/bin/python3
+
+#
+# InfoEx <-> NRCS Auto Wx implementation
+# Alexander Vasarab
+# Wylark Mountaineering LLC
+# 2020-04-22
+#
+# Version 0.8
+#
+# This program fetches data from an NRCS SNOTEL site 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
+# lends some resiliency to the process and helps ensure that we have a
+# value to send, but it can lead to somewhat inconsistent/untruthful
+# data if e.g. the HS is from the last hour but the tempPres is from two
+# hours ago because the instrumentation had a hiccup. It's worth
+# considering if this is a bug or a feature.
+#
+
+import configparser
+import csv
+import datetime
+import logging
+import time
+import zeep
+import zeep.cache
+import zeep.transports
+from collections import OrderedDict
+from ftplib import FTP
+from optparse import OptionParser
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+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")
+
+(options, args) = parser.parse_args()
+
+config = configparser.ConfigParser(allow_no_value=False)
+config.read(options.config)
+
+log.debug('STARTING UP')
+
+wsdl = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
+
+try:
+    infoex = {
+        'host': config['ftp']['host'],
+        'uuid': config['ftp']['uuid'],
+        'api_key': config['ftp']['api_key'],
+        'location_uuid': config['wxsite']['location_uuid'],
+        'wx_data': {},
+        'csv_filename': config['wxsite']['csv_filename']
+    }
+
+    station_triplet = config['wxsite']['station_triplet']
+
+    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)
+except Exception as exc:
+    log.critical("Exception occurred in config parsing: '%s'" % (exc))
+    exit(1)
+
+# all sections/values present in config file, final sanity check
+try:
+    for key in config.sections():
+        for subkey in config[key]:
+            if not len(config[key][subkey]):
+                raise ValueError;
+except ValueError as exc:
+    log.critical("Config value '%s.%s' is empty" % (key, subkey))
+    exit(1)
+
+# INFOEX FIELDS
+#
+# This won't earn style points in Python, but here we establish a couple
+# of helpful mappings variables. The reason this is helpful is that the
+# end result is simply an ordered set, the CSV file. But we still may
+# want to manipulate the values arbitrarily before writing that file.
+#
+# 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.
+fmap = {}                           ; final_data     = [None] * 29
+fmap['Location UUID'] = 0           ; final_data[0]  = infoex['location_uuid']
+fmap['obDate'] = 1                  ; final_data[1]  = None
+fmap['obTime'] = 2                  ; final_data[2]  = None
+fmap['timeZone'] = 3                ; final_data[3]  = 'Pacific'
+fmap['tempMaxHour'] = 4             ; final_data[4]  = None
+fmap['tempMaxHourUnit'] = 5         ; final_data[5]  = 'F'
+fmap['tempMinHour'] = 6             ; final_data[6]  = None
+fmap['tempMinHourUnit'] = 7         ; final_data[7]  = 'F'
+fmap['tempPres'] = 8                ; final_data[8]  = None
+fmap['tempPresUnit'] = 9            ; final_data[9]  = 'F'
+fmap['precipitationGauge'] = 10     ; final_data[10] = None
+fmap['precipitationGaugeUnit'] = 11 ; final_data[11] = 'in'
+fmap['windSpeedNum'] = 12           ; final_data[12] = None
+fmap['windSpeedUnit'] = 13          ; final_data[13] = 'mph'
+fmap['windDirectionNum'] = 14       ; final_data[14] = None
+fmap['hS'] = 15                     ; final_data[15] = None
+fmap['hsUnit'] = 16                 ; final_data[16] = 'in'
+fmap['baro'] = 17                   ; final_data[17] = None
+fmap['baroUnit'] = 18               ; final_data[18] = 'inHg'
+fmap['rH'] = 19                     ; final_data[19] = None
+fmap['windGustSpeedNum'] = 20       ; final_data[20] = None
+fmap['windGustSpeedNumUnit'] = 21   ; final_data[21] = 'mph'
+fmap['windGustDirNum'] = 22         ; final_data[22] = None
+fmap['dewPoint'] = 23               ; final_data[23] = None
+fmap['dewPointUnit'] = 24           ; final_data[24] = 'F'
+fmap['hn24Auto'] = 25               ; final_data[25] = None
+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
+# their InfoEx counterpart
+iemap = {}
+iemap['PREC'] = 'precipitationGauge'
+iemap['TOBS'] = 'tempPres'
+iemap['SNWD'] = 'hS'
+
+# floor time to nearest hour
+dt = datetime.datetime.now()
+end_date = dt - datetime.timedelta(minutes=dt.minute % 60,
+                                   seconds=dt.second,
+                                   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()
+
+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))
+
+    values = tmp[0]['values']
+
+    # sort and isolate the most recent
+    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
+
+log.info("Time to get all elementCds : %.3f sec" % (time.time() -
+    time_all_elements))
+
+log.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
+
+# Only need to add in what we want to change thanks to that abomination
+# of a variable declaration earlier
+final_data[fmap['Location UUID']] = infoex['location_uuid']
+final_data[fmap['obDate']] = end_date.strftime('%m/%d/%Y')
+final_data[fmap['obTime']] = end_date.strftime('%H:%M')
+
+#final_data[fmap['tempPres']] = float(infoex['wx_data']['TOBS'])
+#final_data[fmap['precipitationGauge']] = float(infoex['wx_data']['PREC'])
+#final_data[fmap['hS']] = float(infoex['wx_data']['SNWD'])
+
+for elementCd in infoex['wx_data']:
+    if elementCd not in iemap:
+        log.warning("BAD KEY wx_data['%s']" % (elementCd))
+        continue
+
+    # CONSIDER: Casting every value to Float() -- need to investigate if
+    #           any possible elementCds we may want are any other data
+    #           type than float.
+    #
+    #           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
+    #           exceptions to any otherwise uniformly Float value set.
+    final_data[fmap[iemap[elementCd]]] = infoex['wx_data'][elementCd]
+
+log.debug("final_data: %s" % (str(final_data)))
+
+with open(infoex['csv_filename'], 'w') as f:
+    # The requirement is that empty values are represented in the CSV
+    # file as "", csv.QUOTE_NONNUMERIC achieves that
+    log.debug("writing CSV file '%s'" % (infoex['csv_filename']))
+    writer = csv.writer(f, quoting=csv.QUOTE_NONNUMERIC)
+    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()
+
+log.debug('DONE')