d66683cfa41775964bc8f2b432daa172d8b0fd24
[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
33 from ftplib import FTP
34 from optparse import OptionParser
35
36 import requests
37
38 import zeep
39 import zeep.cache
40 import zeep.transports
41
42 __version__ = '2.0.0'
43
44 LOG = logging.getLogger(__name__)
45 LOG.setLevel(logging.NOTSET)
46
47 def get_parser():
48 """Return OptionParser for this program"""
49 parser = OptionParser(version=__version__)
50
51 parser.add_option("--config",
52 dest="config",
53 metavar="FILE",
54 help="location of config file")
55
56 parser.add_option("--log-level",
57 dest="log_level",
58 default=None,
59 help="set the log level (debug, info, warning)")
60
61 parser.add_option("--dry-run",
62 action="store_true",
63 dest="dry_run",
64 default=False,
65 help="fetch data but don't upload to InfoEx")
66
67 return parser
68
69 def setup_config(config):
70 """Setup config variable based on values specified in the ini file"""
71 try:
72 infoex = {
73 'host': config['infoex']['host'],
74 'uuid': config['infoex']['uuid'],
75 'api_key': config['infoex']['api_key'],
76 'csv_filename': config['infoex']['csv_filename'],
77 'location_uuid': config['infoex']['location_uuid'],
78 'wx_data': {}, # placeholder key, values to come later
79 }
80
81 data = dict()
82 data['provider'] = config['station']['type']
83
84 if data['provider'] not in ['nrcs', 'mesowest']:
85 print("Please specify either nrcs or mesowest as the station type.")
86 sys.exit(1)
87
88 if data['provider'] == 'nrcs':
89 data['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
90 data['station_id'] = config['station']['station_id']
91
92 try:
93 data['desired_data'] = config['station']['desired_data'].split(',')
94 except:
95 # desired_data malformed or missing, setting default
96 data['desired_data'] = [
97 'TOBS', # AIR TEMPERATURE OBSERVED (degF)
98 'SNWD', # SNOW DEPTH (in)
99 'PREC' # PRECIPITATION ACCUMULATION (in)
100 ]
101
102 # XXX: For NRCS, we're manually overriding units for now! Once
103 # unit conversion is supported for NRCS, REMOVE THIS!
104 if 'units' not in data:
105 data['units'] = 'imperial'
106
107 if data['provider'] == 'mesowest':
108 data['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
109 data['station_id'] = config['station']['station_id']
110 data['units'] = config['station']['units']
111
112 try:
113 data['desired_data'] = config['station']['desired_data']
114 except:
115 # desired_data malformed or missing, setting default
116 data['desired_data'] = 'air_temp,snow_depth'
117
118 # construct full API URL (sans start/end time, added later)
119 data['source'] = data['source'] + '?token=' + config['station']['token'] + '&within=60&units=' + data['units'] + '&stid=' + data['station_id'] + '&vars=' + data['desired_data']
120
121 except KeyError as e:
122 LOG.critical("%s not defined in %s" % (e, options.config))
123 exit(1)
124 except Exception as exc:
125 LOG.critical("Exception occurred in config parsing: '%s'" % (exc))
126 exit(1)
127
128 # all sections/values present in config file, final sanity check
129 try:
130 for key in config.sections():
131 for subkey in config[key]:
132 if not len(config[key][subkey]):
133 raise ValueError;
134 except ValueError as exc:
135 LOG.critical("Config value '%s.%s' is empty" % (key, subkey))
136 exit(1)
137
138 return (infoex, data)
139
140 def setup_logging(log_level):
141 """Setup our logging infrastructure"""
142 try:
143 from systemd.journal import JournalHandler
144 LOG.addHandler(JournalHandler())
145 except:
146 ## fallback to syslog
147 #import logging.handlers
148 #LOG.addHandler(logging.handlers.SysLogHandler())
149 # fallback to stdout
150 handler = logging.StreamHandler(sys.stdout)
151 LOG.addHandler(handler)
152
153 # ugly, but passable
154 if log_level in [None, 'debug', 'info', 'warning']:
155 if log_level == 'debug':
156 LOG.setLevel(logging.DEBUG)
157 elif log_level == 'info':
158 LOG.setLevel(logging.INFO)
159 elif log_level == 'warning':
160 LOG.setLevel(logging.WARNING)
161 else:
162 LOG.setLevel(logging.NOTSET)
163 else:
164 return False
165
166 return True
167
168 def main():
169 """Main routine: sort through args, decide what to do, then do it"""
170 parser = get_parser()
171 (options, args) = parser.parse_args()
172
173 config = configparser.ConfigParser(allow_no_value=False)
174
175 if not options.config:
176 parser.print_help()
177 print("\nPlease specify a configuration file via --config.")
178 sys.exit(1)
179
180 config.read(options.config)
181
182 if not setup_logging(options.log_level):
183 parser.print_help()
184 print("\nPlease select an appropriate log level or remove the switch (--log-level).")
185 sys.exit(1)
186
187 (infoex, data) = setup_config(config)
188
189 LOG.debug('Config parsed, starting up')
190
191 # create mappings
192 (fmap, final_data) = setup_infoex_fields_mapping(infoex['location_uuid'])
193 iemap = setup_infoex_counterparts_mapping(data['provider'])
194
195 # override units if user selected metric
196 #
197 # NOTE: to update this, use the fmap<->final_data mapping laid out above
198 #
199 # NOTE: this only 'works' with MesoWest for now, as the MesoWest API
200 # itself handles the unit conversion; in the future, we will also
201 # support NRCS unit conversion, but this must be done by this
202 # program.
203 if data['units'] == 'metric':
204 final_data[fmap['tempPresUnit']] = 'C'
205 final_data[fmap['hsUnit']] = 'm'
206 final_data[fmap['windSpeedUnit']] = 'm/s'
207 final_data[fmap['windGustSpeedNumUnit']] = 'm/s'
208
209 # floor time to nearest hour
210 dt = datetime.datetime.now()
211 end_date = dt - datetime.timedelta(minutes=dt.minute % 60,
212 seconds=dt.second,
213 microseconds=dt.microsecond)
214 begin_date = end_date - datetime.timedelta(hours=3)
215
216 # get the data
217 LOG.debug("Getting %s data from %s to %s" % (str(data['desired_data']),
218 str(begin_date), str(end_date)))
219
220 time_all_elements = time.time()
221
222 # NRCS-specific code
223 if data['provider'] == 'nrcs':
224 transport = zeep.transports.Transport(cache=zeep.cache.SqliteCache())
225 client = zeep.Client(wsdl=data['source'], transport=transport)
226
227 for elementCd in data['desired_data']:
228 time_element = time.time()
229
230 # get the last three hours of data for this elementCd
231 tmp = client.service.getHourlyData(
232 stationTriplets=[data['station_id']],
233 elementCd=elementCd,
234 ordinal=1,
235 beginDate=begin_date,
236 endDate=end_date)
237
238 LOG.info("Time to get elementCd '%s': %.3f sec" % (elementCd,
239 time.time() - time_element))
240
241 values = tmp[0]['values']
242
243 # sort and isolate the most recent
244 #
245 # NOTE: we do this because sometimes there are gaps in hourly data
246 # in NRCS; yes, we may end up with slightly inaccurate data,
247 # so perhaps this decision will be re-evaluated in the future
248 if values:
249 ordered = sorted(values, key=lambda t: t['dateTime'], reverse=True)
250 infoex['wx_data'][elementCd] = ordered[0]['value']
251 else:
252 infoex['wx_data'][elementCd] = None
253
254 # MesoWest-specific code
255 elif data['provider'] == 'mesowest':
256 # massage begin/end date format
257 begin_date_str = begin_date.strftime('%Y%m%d%H%M')
258 end_date_str = end_date.strftime('%Y%m%d%H%M')
259
260 # construct final, completed API URL
261 api_req_url = data['source'] + '&start=' + begin_date_str + '&end=' + end_date_str
262 req = requests.get(api_req_url)
263
264 try:
265 json = req.json()
266 except ValueError:
267 LOG.error("Bad JSON in MesoWest response")
268 sys.exit(1)
269
270 try:
271 observations = json['STATION'][0]['OBSERVATIONS']
272 except ValueError:
273 LOG.error("Bad JSON in MesoWest response")
274 sys.exit(1)
275
276 pos = len(observations['date_time']) - 1
277
278 for elementCd in data['desired_data'].split(','):
279 # sort and isolate the most recent, see note above in NRCS for how and
280 # why this is done
281 #
282 # NOTE: Unlike in the NRCS case, the MesoWest API response contains all
283 # data (whereas with NRCS, we have to make a separate request for
284 # each element we want). This is nice for network efficiency but
285 # it means we have to handle this part differently for each.
286 #
287 # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
288 # provides hourly data, but MesoWest can often provide data every
289 # 10 minutes -- though this provides more opportunity for
290 # irregularities
291
292 # we may not have the data at all
293 key_name = elementCd + '_set_1'
294 if key_name in observations:
295 if observations[key_name][pos]:
296 infoex['wx_data'][elementCd] = observations[key_name][pos]
297 else:
298 infoex['wx_data'][elementCd] = None
299 else:
300 infoex['wx_data'][elementCd] = None
301
302 LOG.info("Time to get all data : %.3f sec" % (time.time() -
303 time_all_elements))
304
305 LOG.debug("infoex[wx_data]: %s", str(infoex['wx_data']))
306
307 # Now we only need to add in what we want to change thanks to that
308 # abomination of a variable declaration earlier
309 final_data[fmap['Location UUID']] = infoex['location_uuid']
310 final_data[fmap['obDate']] = end_date.strftime('%m/%d/%Y')
311 final_data[fmap['obTime']] = end_date.strftime('%H:%M')
312
313 for elementCd in infoex['wx_data']:
314 if elementCd not in iemap:
315 LOG.warning("BAD KEY wx_data['%s']" % (elementCd))
316 continue
317
318 # CONSIDER: Casting every value to Float() -- need to investigate if
319 # any possible elementCds we may want are any other data
320 # type than float.
321 #
322 # Another possibility is to query the API with
323 # getStationElements and temporarily store the
324 # storedUnitCd. But that's pretty network-intensive and
325 # may not even be worth it if there's only e.g. one or two
326 # exceptions to any otherwise uniformly Float value set.
327 final_data[fmap[iemap[elementCd]]] = infoex['wx_data'][elementCd]
328
329 LOG.debug("final_data: %s" % (str(final_data)))
330
331 if not write_local_csv(infoex['csv_filename'], final_data):
332 LOG.warning('Could not write local CSV file: %s',
333 infoex['csv_filename'])
334 return 1;
335
336 if not options.dry_run:
337 upload_csv(infoex['csv_filename'], infoex)
338
339 LOG.debug('DONE')
340 return 0
341
342 # Data structure operations
343 def setup_infoex_fields_mapping(location_uuid):
344 """
345 Create a mapping of InfoEx fields to the local data's indexing scheme.
346
347 INFOEX FIELDS
348
349 This won't earn style points in Python, but here we establish a couple
350 of helpful mappings variables. The reason this is helpful is that the
351 end result is simply an ordered set, the CSV file. But we still may
352 want to manipulate the values arbitrarily before writing that file.
353
354 Also note that the current Auto Wx InfoEx documentation shows these
355 keys in a graphical table with the "index" beginning at 1, but here we
356 sanely index beginning at 0.
357 """
358 fmap = {} ; final_data = [None] * 29
359 fmap['Location UUID'] = 0 ; final_data[0] = location_uuid
360 fmap['obDate'] = 1 ; final_data[1] = None
361 fmap['obTime'] = 2 ; final_data[2] = None
362 fmap['timeZone'] = 3 ; final_data[3] = 'Pacific'
363 fmap['tempMaxHour'] = 4 ; final_data[4] = None
364 fmap['tempMaxHourUnit'] = 5 ; final_data[5] = 'F'
365 fmap['tempMinHour'] = 6 ; final_data[6] = None
366 fmap['tempMinHourUnit'] = 7 ; final_data[7] = 'F'
367 fmap['tempPres'] = 8 ; final_data[8] = None
368 fmap['tempPresUnit'] = 9 ; final_data[9] = 'F'
369 fmap['precipitationGauge'] = 10 ; final_data[10] = None
370 fmap['precipitationGaugeUnit'] = 11 ; final_data[11] = 'in'
371 fmap['windSpeedNum'] = 12 ; final_data[12] = None
372 fmap['windSpeedUnit'] = 13 ; final_data[13] = 'mph'
373 fmap['windDirectionNum'] = 14 ; final_data[14] = None
374 fmap['hS'] = 15 ; final_data[15] = None
375 fmap['hsUnit'] = 16 ; final_data[16] = 'in'
376 fmap['baro'] = 17 ; final_data[17] = None
377 fmap['baroUnit'] = 18 ; final_data[18] = 'inHg'
378 fmap['rH'] = 19 ; final_data[19] = None
379 fmap['windGustSpeedNum'] = 20 ; final_data[20] = None
380 fmap['windGustSpeedNumUnit'] = 21 ; final_data[21] = 'mph'
381 fmap['windGustDirNum'] = 22 ; final_data[22] = None
382 fmap['dewPoint'] = 23 ; final_data[23] = None
383 fmap['dewPointUnit'] = 24 ; final_data[24] = 'F'
384 fmap['hn24Auto'] = 25 ; final_data[25] = None
385 fmap['hn24AutoUnit'] = 26 ; final_data[26] = 'in'
386 fmap['hstAuto'] = 27 ; final_data[27] = None
387 fmap['hstAutoUnit'] = 28 ; final_data[28] = 'in'
388
389 return (fmap, final_data)
390
391 def setup_infoex_counterparts_mapping(provider):
392 """
393 Create a mapping of the NRCS/MesoWest fields that this program supports to
394 their InfoEx counterparts
395 """
396 iemap = {}
397
398 if provider == 'nrcs':
399 iemap['PREC'] = 'precipitationGauge'
400 iemap['TOBS'] = 'tempPres'
401 iemap['SNWD'] = 'hS'
402 iemap['PRES'] = 'baro'
403 iemap['RHUM'] = 'rH'
404 iemap['WSPD'] = 'windSpeedNum'
405 iemap['WDIR'] = 'windDirectionNum'
406 # unsupported by NRCS:
407 # windGustSpeedNum
408 elif provider == 'mesowest':
409 iemap['precip_accum'] = 'precipitationGauge'
410 iemap['air_temp'] = 'tempPres'
411 iemap['snow_depth'] = 'hS'
412 iemap['pressure'] = 'baro'
413 iemap['relative_humidity'] = 'rH'
414 iemap['wind_speed'] = 'windSpeedNum'
415 iemap['wind_direction'] = 'windDirectionNum'
416 iemap['wind_gust'] = 'windGustSpeedNum'
417
418 return iemap
419
420 # CSV operations
421 def write_local_csv(path_to_file, data):
422 """Write the specified CSV file to disk"""
423 with open(path_to_file, 'w') as f:
424 # The requirement is that empty values are represented in the CSV
425 # file as "", csv.QUOTE_NONNUMERIC achieves that
426 LOG.debug("writing CSV file '%s'" % (path_to_file))
427 writer = csv.writer(f, quoting=csv.QUOTE_NONNUMERIC)
428 writer.writerow(data)
429 f.close()
430 return True
431
432 def upload_csv(path_to_file, infoex_data):
433 """Upload the specified CSV file to InfoEx FTP and remove the file"""
434 with open(path_to_file, 'rb') as file_object:
435 LOG.debug("uploading FTP file '%s'" % (infoex_data['host']))
436 ftp = FTP(infoex_data['host'], infoex_data['uuid'],
437 infoex_data['api_key'])
438 ftp.storlines('STOR ' + path_to_file, file_object)
439 ftp.close()
440 file_object.close()
441 os.remove(path_to_file)
442
443 if __name__ == "__main__":
444 sys.exit(main())