Adjust precision of particular values
[infoex-autowx.git] / infoex-autowx.py
index 598f24cd765d300b8d49f3e2b1884ebca63e8a41..6045991d1755f28c862056a6e71583722f095c22 100755 (executable)
@@ -6,8 +6,6 @@ InfoEx <-> NRCS/MesoWest Auto Wx implementation
 Alexander Vasarab
 Wylark Mountaineering LLC
 
-Version 2.0.0
-
 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.
@@ -32,9 +30,8 @@ import os
 import sys
 import time
 
-from collections import OrderedDict
 from ftplib import FTP
-from optparse import OptionParser
+from argparse import ArgumentParser
 
 import requests
 
@@ -42,245 +39,322 @@ import zeep
 import zeep.cache
 import zeep.transports
 
-__version__ = '2.0.0'
-
-log = logging.getLogger(__name__)
-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())
-    # 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)
-
-# 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)
+__version__ = '2.0.2'
+
+LOG = logging.getLogger(__name__)
+LOG.setLevel(logging.NOTSET)
+
+def get_parser():
+    """Return OptionParser for this program"""
+    parser = ArgumentParser()
+
+    parser.add_argument("--version",
+                        action="version",
+                        version=__version__)
+
+    parser.add_argument("--config",
+                        dest="config",
+                        metavar="FILE",
+                        help="location of config file")
+
+    parser.add_argument("--log-level",
+                        dest="log_level",
+                        default=None,
+                        help="set the log level (debug, info, warning)")
+
+    parser.add_argument("--dry-run",
+                        action="store_true",
+                        dest="dry_run",
+                        default=False,
+                        help="fetch data but don't upload to InfoEx")
+
+    return parser
+
+def setup_config(config):
+    """Setup config variable based on values specified in the ini file"""
+    try:
+        infoex = {
+            '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
+        }
+
+        station = dict()
+        station['provider'] = config['station']['type']
+
+        if station['provider'] not in ['nrcs', 'mesowest']:
+            print("Please specify either nrcs or mesowest as the station type.")
+            sys.exit(1)
+
+        if station['provider'] == 'nrcs':
+            station['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
+            station['station_id'] = config['station']['station_id']
+            station['desired_data'] = config['station']['desired_data'].split(',')
+
+            # XXX: For NRCS, we're manually overriding units for now! Once
+            #      unit conversion is supported for NRCS, REMOVE THIS!
+            if 'units' not in station:
+                station['units'] = 'imperial'
+
+        if station['provider'] == 'mesowest':
+            station['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
+            station['station_id'] = config['station']['station_id']
+            station['units'] = config['station']['units']
+            station['desired_data'] = config['station']['desired_data']
+
+            # construct full API URL (sans start/end time, added later)
+            station['source'] = station['source'] + '?token=' + \
+                                config['station']['token'] + \
+                                '&within=60&units=' + station['units'] + \
+                                '&stid=' + station['station_id'] + \
+                                '&vars=' + station['desired_data']
+
+    except KeyError as err:
+        LOG.critical("%s not defined in configuration file", err)
+        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 config[key][subkey]:
+                    raise ValueError
+    except ValueError:
+        LOG.critical("Config value '%s.%s' is empty", key, subkey)
+        exit(1)
+
+    return (infoex, station)
+
+def setup_logging(log_level):
+    """Setup our logging infrastructure"""
+    try:
+        from systemd.journal import JournalHandler
+        LOG.addHandler(JournalHandler())
+    except ImportError:
+        ## fallback to syslog
+        #import logging.handlers
+        #LOG.addHandler(logging.handlers.SysLogHandler())
+        # fallback to stdout
+        handler = logging.StreamHandler(sys.stdout)
+        LOG.addHandler(handler)
+
+    # ugly, but passable
+    if log_level in [None, 'debug', 'info', 'warning']:
+        if log_level == 'debug':
+            LOG.setLevel(logging.DEBUG)
+        elif log_level == 'info':
+            LOG.setLevel(logging.INFO)
+        elif log_level == 'warning':
+            LOG.setLevel(logging.WARNING)
+        else:
+            LOG.setLevel(logging.NOTSET)
     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)
-
-log.debug('STARTING UP')
-
-try:
-    infoex = {
-        '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
-    }
-
-    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.")
+        return False
+
+    return True
+
+def main():
+    """Main routine: sort through args, decide what to do, then do it"""
+    parser = get_parser()
+    options = 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)
+
+    if not setup_logging(options.log_level):
+        parser.print_help()
+        print("\nPlease select an appropriate log level or remove the switch (--log-level).")
         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
-
-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
-# 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
-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/MesoWest fields that this program supports to
-# their InfoEx counterpart
-iemap = {}
-
-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()
-end_date = dt - datetime.timedelta(minutes=dt.minute % 60,
-                                   seconds=dt.second,
-                                   microseconds=dt.microsecond)
-begin_date = end_date - datetime.timedelta(hours=3)
-
-# get the data
-log.debug("Getting %s data from %s to %s" % (str(desired_data),
-    str(begin_date), str(end_date)))
-
-time_all_elements = time.time()
-
-# NRCS-specific code
-if data['provider'] == 'nrcs':
+    (infoex, station) = setup_config(config)
+
+    LOG.debug('Config parsed, starting up')
+
+    # create mappings
+    (fmap, final_data) = setup_infoex_fields_mapping(infoex['location_uuid'])
+    iemap = setup_infoex_counterparts_mapping(station['provider'])
+
+    # override units if user selected metric
+    if station['units'] == 'metric':
+        final_data = switch_units_to_metric(final_data, fmap)
+
+    (begin_date, end_date) = setup_time_values()
+
+    # get the data
+    LOG.debug("Getting %s data from %s to %s", str(station['desired_data']),
+              str(begin_date), str(end_date))
+
+    time_all_elements = time.time()
+
+    # get the data
+    if station['provider'] == 'nrcs':
+        infoex['wx_data'] = get_nrcs_data(begin_date, end_date, station)
+    elif station['provider'] == 'mesowest':
+        infoex['wx_data'] = get_mesowest_data(begin_date, end_date,
+                                              station)
+
+    LOG.info("Time taken to get all data : %.3f sec", time.time() -
+             time_all_elements)
+
+    LOG.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
+
+    # Now we 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')
+
+    for element_cd in infoex['wx_data']:
+        if element_cd not in iemap:
+            LOG.warning("BAD KEY wx_data['%s']", element_cd)
+            continue
+
+        # Massage precision of certain values to fit InfoEx's
+        # expectations
+        #
+        # 0 decimal places: wind speed, wind direction, wind gust, snow depth
+        # 1 decimal place:  air temp, baro
+        # Avoid transforming None values
+        if infoex['wx_data'][element_cd] is None:
+            continue
+        elif element_cd in ['wind_speed', 'WSPD', 'wind_direction',
+                            'WDIR', 'wind_gust', 'SNWD', 'snow_depth']:
+            infoex['wx_data'][element_cd] = round(infoex['wx_data'][element_cd])
+        elif element_cd in ['TOBS', 'air_temp', 'PRES', 'pressure']:
+            infoex['wx_data'][element_cd] = round(infoex['wx_data'][element_cd], 1)
+
+        # 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[element_cd]]] = infoex['wx_data'][element_cd]
+
+    LOG.debug("final_data: %s", str(final_data))
+
+    if not write_local_csv(infoex['csv_filename'], final_data):
+        LOG.warning('Could not write local CSV file: %s',
+                    infoex['csv_filename'])
+        return 1
+
+    if not options.dry_run:
+        upload_csv(infoex['csv_filename'], infoex)
+
+    LOG.debug('DONE')
+    return 0
+
+# data structure operations
+def setup_infoex_fields_mapping(location_uuid):
+    """
+    Create a mapping of InfoEx fields to the local data's indexing scheme.
+
+    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
+    sanely index beginning at 0.
+    """
+    # pylint: disable=too-many-statements,multiple-statements,bad-whitespace
+    fmap = {}                           ; final_data     = [None] * 29
+    fmap['Location UUID'] = 0           ; final_data[0]  = 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'
+
+    return (fmap, final_data)
+
+def setup_infoex_counterparts_mapping(provider):
+    """
+    Create a mapping of the NRCS/MesoWest fields that this program supports to
+    their InfoEx counterparts
+    """
+    iemap = {}
+
+    if 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 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'
+
+    return iemap
+
+# provider-specific operations
+def get_nrcs_data(begin, end, station):
+    """get the data we're after from the NRCS WSDL"""
     transport = zeep.transports.Transport(cache=zeep.cache.SqliteCache())
-    client = zeep.Client(wsdl=data['source'], transport=transport)
+    client = zeep.Client(wsdl=station['source'], transport=transport)
+    remote_data = {}
 
-    for elementCd in desired_data:
+    for element_cd in station['desired_data']:
         time_element = time.time()
 
-        # get the last three hours of data for this elementCd
+        # get the last three hours of data for this elementCd/element_cd
         tmp = client.service.getHourlyData(
-                stationTriplets=[data['station_id']],
-                elementCd=elementCd,
-                ordinal=1,
-                beginDate=begin_date,
-                endDate=end_date)
+            stationTriplets=[station['station_id']],
+            elementCd=element_cd,
+            ordinal=1,
+            beginDate=begin,
+            endDate=end)
 
-        log.info("Time to get elementCd '%s': %.3f sec" % (elementCd,
-            time.time() - time_element))
+        LOG.info("Time to get NRCS elementCd '%s': %.3f sec", element_cd,
+                 time.time() - time_element)
 
         values = tmp[0]['values']
 
@@ -291,35 +365,39 @@ if data['provider'] == 'nrcs':
         #       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']
+            remote_data[element_cd] = ordered[0]['value']
         else:
-            infoex['wx_data'][elementCd] = None
+            remote_data[element_cd] = None
+
+    return remote_data
+
+def get_mesowest_data(begin, end, station):
+    """get the data we're after from the MesoWest/Synoptic API"""
+    remote_data = {}
 
-# 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')
+    begin_date_str = begin.strftime('%Y%m%d%H%M')
+    end_date_str = end.strftime('%Y%m%d%H%M')
 
     # construct final, completed API URL
-    api_req_url = data['source'] + '&start=' + begin_date_str + '&end=' + end_date_str
+    api_req_url = station['source'] + '&start=' + begin_date_str + '&end=' + end_date_str
     req = requests.get(api_req_url)
 
     try:
         json = req.json()
     except ValueError:
-        log.error("Bad JSON in MesoWest response")
+        LOG.error("Bad JSON in MesoWest response")
         sys.exit(1)
 
     try:
         observations = json['STATION'][0]['OBSERVATIONS']
     except ValueError:
-        log.error("Bad JSON in MesoWest response")
+        LOG.error("Bad JSON in MesoWest response")
         sys.exit(1)
 
     pos = len(observations['date_time']) - 1
 
-    for elementCd in desired_data.split(','):
+    for element_cd in station['desired_data'].split(','):
         # sort and isolate the most recent, see note above in NRCS for how and
         # why this is done
         #
@@ -334,59 +412,67 @@ elif data['provider'] == 'mesowest':
         #       irregularities
 
         # we may not have the data at all
-        key_name = elementCd + '_set_1'
+        key_name = element_cd + '_set_1'
         if key_name in observations:
             if observations[key_name][pos]:
-                infoex['wx_data'][elementCd] = observations[key_name][pos]
+                remote_data[element_cd] = observations[key_name][pos]
             else:
-                infoex['wx_data'][elementCd] = None
+                remote_data[element_cd] = None
         else:
-            infoex['wx_data'][elementCd] = None
-
-log.info("Time to get all data : %.3f sec" % (time.time() -
-    time_all_elements))
+            remote_data[element_cd] = None
 
-log.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
+    return remote_data
 
-# Now we 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')
+def switch_units_to_metric(data_map, mapping):
+    """replace units with metric counterparts"""
 
-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.
+    # NOTE: to update this, use the fmap<->final_data mapping laid out
+    #       in setup_infoex_fields_mapping ()
     #
-    #           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()
-
-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)
+    # 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.
+    data_map[mapping['tempPresUnit']] = 'C'
+    data_map[mapping['hsUnit']] = 'm'
+    data_map[mapping['windSpeedUnit']] = 'm/s'
+    data_map[mapping['windGustSpeedNumUnit']] = 'm/s'
+
+    return data_map
+
+# CSV operations
+def write_local_csv(path_to_file, data):
+    """Write the specified CSV file to disk"""
+    with open(path_to_file, 'w') as file_object:
+        # The requirement is that empty values are represented in the CSV
+        # file as "", csv.QUOTE_NONNUMERIC achieves that
+        LOG.debug("writing CSV file '%s'", path_to_file)
+        writer = csv.writer(file_object, quoting=csv.QUOTE_NONNUMERIC)
+        writer.writerow(data)
+        file_object.close()
+    return True
+
+def upload_csv(path_to_file, infoex_data):
+    """Upload the specified CSV file to InfoEx FTP and remove the file"""
+    with open(path_to_file, 'rb') as file_object:
+        LOG.debug("uploading FTP file '%s'", infoex_data['host'])
+        ftp = FTP(infoex_data['host'], infoex_data['uuid'],
+                  infoex_data['api_key'])
+        ftp.storlines('STOR ' + path_to_file, file_object)
         ftp.close()
-        f.close()
-    os.remove(infoex['csv_filename'])
-
-log.debug('DONE')
+        file_object.close()
+    os.remove(path_to_file)
+
+# other miscellaneous routines
+def setup_time_values():
+    """establish time bounds of data request(s)"""
+    # floor time to nearest hour
+    date_time = datetime.datetime.now()
+    end_date = date_time - datetime.timedelta(minutes=date_time.minute % 60,
+                                              seconds=date_time.second,
+                                              microseconds=date_time.microsecond)
+    begin_date = end_date - datetime.timedelta(hours=3)
+    return (begin_date, end_date)
+
+if __name__ == "__main__":
+    sys.exit(main())