2 # -*- coding: utf-8 -*-
5 InfoEx <-> NRCS/MesoWest Auto Wx implementation
7 Wylark Mountaineering LLC
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.
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.
21 For more information, see file: README
22 For licensing, see file: LICENSE
33 from ftplib
import FTP
34 from optparse
import OptionParser
40 import zeep
.transports
44 LOG
= logging
.getLogger(__name__
)
45 LOG
.setLevel(logging
.NOTSET
)
48 """Return OptionParser for this program"""
49 parser
= OptionParser(version
=__version__
)
51 parser
.add_option("--config",
54 help="location of config file")
56 parser
.add_option("--log-level",
59 help="set the log level (debug, info, warning)")
61 parser
.add_option("--dry-run",
65 help="fetch data but don't upload to InfoEx")
69 def setup_config(config
):
70 """Setup config variable based on values specified in the ini file"""
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
82 data
['provider'] = config
['station']['type']
84 if data
['provider'] not in ['nrcs', 'mesowest']:
85 print("Please specify either nrcs or mesowest as the station type.")
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']
93 data
['desired_data'] = config
['station']['desired_data'].split(',')
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)
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'
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']
113 data
['desired_data'] = config
['station']['desired_data']
115 # desired_data malformed or missing, setting default
116 data
['desired_data'] = 'air_temp,snow_depth'
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']
121 except KeyError as e
:
122 LOG
.critical("%s not defined in %s" % (e
, options
.config
))
124 except Exception as exc
:
125 LOG
.critical("Exception occurred in config parsing: '%s'" % (exc
))
128 # all sections/values present in config file, final sanity check
130 for key
in config
.sections():
131 for subkey
in config
[key
]:
132 if not len(config
[key
][subkey
]):
134 except ValueError as exc
:
135 LOG
.critical("Config value '%s.%s' is empty" % (key
, subkey
))
138 return (infoex
, data
)
140 def setup_logging(log_level
):
141 """Setup our logging infrastructure"""
143 from systemd
.journal
import JournalHandler
144 LOG
.addHandler(JournalHandler())
146 ## fallback to syslog
147 #import logging.handlers
148 #LOG.addHandler(logging.handlers.SysLogHandler())
150 handler
= logging
.StreamHandler(sys
.stdout
)
151 LOG
.addHandler(handler
)
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
)
162 LOG
.setLevel(logging
.NOTSET
)
169 """Main routine: sort through args, decide what to do, then do it"""
170 parser
= get_parser()
171 (options
, args
) = parser
.parse_args()
173 config
= configparser
.ConfigParser(allow_no_value
=False)
175 if not options
.config
:
177 print("\nPlease specify a configuration file via --config.")
180 config
.read(options
.config
)
182 if not setup_logging(options
.log_level
):
184 print("\nPlease select an appropriate log level or remove the switch (--log-level).")
187 (infoex
, data
) = setup_config(config
)
189 LOG
.debug('Config parsed, starting up')
192 (fmap
, final_data
) = setup_infoex_fields_mapping(infoex
['location_uuid'])
193 iemap
= setup_infoex_counterparts_mapping(data
['provider'])
195 # override units if user selected metric
197 # NOTE: to update this, use the fmap<->final_data mapping laid out above
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
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'
209 # floor time to nearest hour
210 dt
= datetime
.datetime
.now()
211 end_date
= dt
- datetime
.timedelta(minutes
=dt
.minute
% 60,
213 microseconds
=dt
.microsecond
)
214 begin_date
= end_date
- datetime
.timedelta(hours
=3)
217 LOG
.debug("Getting %s data from %s to %s" % (str(data
['desired_data']),
218 str(begin_date
), str(end_date
)))
220 time_all_elements
= time
.time()
223 if data
['provider'] == 'nrcs':
224 transport
= zeep
.transports
.Transport(cache
=zeep
.cache
.SqliteCache())
225 client
= zeep
.Client(wsdl
=data
['source'], transport
=transport
)
227 for elementCd
in data
['desired_data']:
228 time_element
= time
.time()
230 # get the last three hours of data for this elementCd
231 tmp
= client
.service
.getHourlyData(
232 stationTriplets
=[data
['station_id']],
235 beginDate
=begin_date
,
238 LOG
.info("Time to get elementCd '%s': %.3f sec" % (elementCd
,
239 time
.time() - time_element
))
241 values
= tmp
[0]['values']
243 # sort and isolate the most recent
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
249 ordered
= sorted(values
, key
=lambda t
: t
['dateTime'], reverse
=True)
250 infoex
['wx_data'][elementCd
] = ordered
[0]['value']
252 infoex
['wx_data'][elementCd
] = None
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')
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
)
267 LOG
.error("Bad JSON in MesoWest response")
271 observations
= json
['STATION'][0]['OBSERVATIONS']
273 LOG
.error("Bad JSON in MesoWest response")
276 pos
= len(observations
['date_time']) - 1
278 for elementCd
in data
['desired_data'].split(','):
279 # sort and isolate the most recent, see note above in NRCS for how and
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.
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
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
]
298 infoex
['wx_data'][elementCd
] = None
300 infoex
['wx_data'][elementCd
] = None
302 LOG
.info("Time to get all data : %.3f sec" % (time
.time() -
305 LOG
.debug("infoex[wx_data]: %s", str(infoex
['wx_data']))
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')
313 for elementCd
in infoex
['wx_data']:
314 if elementCd
not in iemap
:
315 LOG
.warning("BAD KEY wx_data['%s']" % (elementCd
))
318 # CONSIDER: Casting every value to Float() -- need to investigate if
319 # any possible elementCds we may want are any other data
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
]
329 LOG
.debug("final_data: %s" % (str(final_data
)))
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'])
336 if not options
.dry_run
:
337 upload_csv(infoex
['csv_filename'], infoex
)
342 # Data structure operations
343 def setup_infoex_fields_mapping(location_uuid
):
345 Create a mapping of InfoEx fields to the local data's indexing scheme.
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.
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.
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'
389 return (fmap
, final_data
)
391 def setup_infoex_counterparts_mapping(provider
):
393 Create a mapping of the NRCS/MesoWest fields that this program supports to
394 their InfoEx counterparts
398 if provider
== 'nrcs':
399 iemap
['PREC'] = 'precipitationGauge'
400 iemap
['TOBS'] = 'tempPres'
402 iemap
['PRES'] = 'baro'
404 iemap
['WSPD'] = 'windSpeedNum'
405 iemap
['WDIR'] = 'windDirectionNum'
406 # unsupported by NRCS:
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'
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
)
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
)
441 os
.remove(path_to_file
)
443 if __name__
== "__main__":