f285c42ae44de8fee51a9ee05afb055b3e8b58f2
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 argparse
import ArgumentParser
40 import zeep
.transports
44 LOG
= logging
.getLogger(__name__
)
45 LOG
.setLevel(logging
.NOTSET
)
48 """Return OptionParser for this program"""
49 parser
= ArgumentParser()
51 parser
.add_argument("--version",
55 parser
.add_argument("--config",
58 help="location of config file")
60 parser
.add_argument("--log-level",
63 help="set the log level (debug, info, warning)")
65 parser
.add_argument("--dry-run",
69 help="fetch data but don't upload to InfoEx")
73 def setup_config(config
):
74 """Setup config variable based on values specified in the ini file"""
77 'host': config
['infoex']['host'],
78 'uuid': config
['infoex']['uuid'],
79 'api_key': config
['infoex']['api_key'],
80 'csv_filename': config
['infoex']['csv_filename'],
81 'location_uuid': config
['infoex']['location_uuid'],
82 'wx_data': {}, # placeholder key, values to come later
86 station
['provider'] = config
['station']['type']
88 if station
['provider'] not in ['nrcs', 'mesowest', 'python']:
89 print("Please specify either nrcs or mesowest as the station type.")
92 if station
['provider'] == 'nrcs':
93 station
['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
94 station
['station_id'] = config
['station']['station_id']
95 station
['desired_data'] = config
['station']['desired_data'].split(',')
97 # XXX: For NRCS, we're manually overriding units for now! Once
98 # unit conversion is supported for NRCS, REMOVE THIS!
99 if 'units' not in station
:
100 station
['units'] = 'imperial'
102 if station
['provider'] == 'mesowest':
103 station
['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
104 station
['station_id'] = config
['station']['station_id']
105 station
['units'] = config
['station']['units']
106 station
['desired_data'] = config
['station']['desired_data']
108 # construct full API URL (sans start/end time, added later)
109 station
['source'] = station
['source'] + '?token=' + \
110 config
['station']['token'] + \
111 '&within=60&units=' + station
['units'] + \
112 '&stid=' + station
['station_id'] + \
113 '&vars=' + station
['desired_data']
115 if station
['provider'] == 'python':
116 station
['path'] = config
['station']['path']
118 except KeyError as err
:
119 LOG
.critical("%s not defined in configuration file", err
)
122 # all sections/values present in config file, final sanity check
124 for key
in config
.sections():
125 for subkey
in config
[key
]:
126 if not config
[key
][subkey
]:
129 LOG
.critical("Config value '%s.%s' is empty", key
, subkey
)
132 return (infoex
, station
)
134 def setup_logging(log_level
):
135 """Setup our logging infrastructure"""
137 from systemd
.journal
import JournalHandler
138 LOG
.addHandler(JournalHandler())
140 ## fallback to syslog
141 #import logging.handlers
142 #LOG.addHandler(logging.handlers.SysLogHandler())
144 handler
= logging
.StreamHandler(sys
.stdout
)
145 LOG
.addHandler(handler
)
148 if log_level
in [None, 'debug', 'info', 'warning']:
149 if log_level
== 'debug':
150 LOG
.setLevel(logging
.DEBUG
)
151 elif log_level
== 'info':
152 LOG
.setLevel(logging
.INFO
)
153 elif log_level
== 'warning':
154 LOG
.setLevel(logging
.WARNING
)
156 LOG
.setLevel(logging
.NOTSET
)
163 """Main routine: sort through args, decide what to do, then do it"""
164 parser
= get_parser()
165 options
= parser
.parse_args()
167 config
= configparser
.ConfigParser(allow_no_value
=False)
169 if not options
.config
:
171 print("\nPlease specify a configuration file via --config.")
174 config
.read(options
.config
)
176 if not setup_logging(options
.log_level
):
178 print("\nPlease select an appropriate log level or remove the switch (--log-level).")
181 (infoex
, station
) = setup_config(config
)
183 LOG
.debug('Config parsed, starting up')
186 (fmap
, final_data
) = setup_infoex_fields_mapping(infoex
['location_uuid'])
187 iemap
= setup_infoex_counterparts_mapping(station
['provider'])
189 # override units if user selected metric
191 if station
['units'] == 'metric':
192 final_data
= switch_units_to_metric(final_data
, fmap
)
194 if station
['provider'] != 'python':
195 LOG
.error("Please specify the units in the configuration "
199 (begin_date
, end_date
) = setup_time_values()
201 if station
['provider'] == 'python':
202 LOG
.debug("Getting custom data from external Python program")
204 LOG
.debug("Getting %s data from %s to %s",
205 str(station
['desired_data']),
206 str(begin_date
), str(end_date
))
208 time_all_elements
= time
.time()
211 if station
['provider'] == 'nrcs':
212 infoex
['wx_data'] = get_nrcs_data(begin_date
, end_date
, station
)
213 elif station
['provider'] == 'mesowest':
214 infoex
['wx_data'] = get_mesowest_data(begin_date
, end_date
,
216 elif station
['provider'] == 'python':
218 import importlib
.util
220 spec
= importlib
.util
.spec_from_file_location('custom_wx',
222 mod
= importlib
.util
.module_from_spec(spec
)
223 spec
.loader
.exec_module(mod
)
227 infoex
['wx_data'] = mod
.get_custom_data()
229 if infoex
['wx_data'] is None:
230 infoex
['wx_data'] = []
232 LOG
.error("Python program for custom Wx data failed in "
236 LOG
.info("Successfully executed external Python program")
238 LOG
.error("Please upgrade to Python 3.3 or later")
240 except FileNotFoundError
:
241 LOG
.error("Specified Python program for custom Wx data "
245 LOG
.info("Time taken to get all data : %.3f sec", time
.time() -
248 LOG
.debug("infoex[wx_data]: %s", str(infoex
['wx_data']))
250 # Now we only need to add in what we want to change thanks to that
251 # abomination of a variable declaration earlier
252 final_data
[fmap
['Location UUID']] = infoex
['location_uuid']
253 final_data
[fmap
['obDate']] = end_date
.strftime('%m/%d/%Y')
254 final_data
[fmap
['obTime']] = end_date
.strftime('%H:%M')
256 for element_cd
in infoex
['wx_data']:
257 if element_cd
not in iemap
:
258 LOG
.warning("BAD KEY wx_data['%s']", element_cd
)
261 # Massage precision of certain values to fit InfoEx's
264 # 0 decimal places: wind speed, wind direction, wind gust, snow depth
265 # 1 decimal place: air temp, baro
266 # Avoid transforming None values
267 if infoex
['wx_data'][element_cd
] is None:
269 elif element_cd
in ['wind_speed', 'WSPD', 'wind_direction',
270 'WDIR', 'wind_gust', 'SNWD', 'snow_depth']:
271 infoex
['wx_data'][element_cd
] = round(infoex
['wx_data'][element_cd
])
272 elif element_cd
in ['TOBS', 'air_temp', 'PRES', 'pressure']:
273 infoex
['wx_data'][element_cd
] = round(infoex
['wx_data'][element_cd
], 1)
275 # CONSIDER: Casting every value to Float() -- need to investigate if
276 # any possible elementCds we may want are any other data
279 # Another possibility is to query the API with
280 # getStationElements and temporarily store the
281 # storedUnitCd. But that's pretty network-intensive and
282 # may not even be worth it if there's only e.g. one or two
283 # exceptions to any otherwise uniformly Float value set.
284 final_data
[fmap
[iemap
[element_cd
]]] = infoex
['wx_data'][element_cd
]
286 LOG
.debug("final_data: %s", str(final_data
))
288 if len(infoex
['wx_data']) > 0:
289 if not write_local_csv(infoex
['csv_filename'], final_data
):
290 LOG
.warning('Could not write local CSV file: %s',
291 infoex
['csv_filename'])
294 if not options
.dry_run
:
295 upload_csv(infoex
['csv_filename'], infoex
)
300 # data structure operations
301 def setup_infoex_fields_mapping(location_uuid
):
303 Create a mapping of InfoEx fields to the local data's indexing scheme.
307 This won't earn style points in Python, but here we establish a couple
308 of helpful mappings variables. The reason this is helpful is that the
309 end result is simply an ordered set, the CSV file. But we still may
310 want to manipulate the values arbitrarily before writing that file.
312 Also note that the current Auto Wx InfoEx documentation shows these
313 keys in a graphical table with the "index" beginning at 1, but here we
314 sanely index beginning at 0.
316 # pylint: disable=too-many-statements,multiple-statements,bad-whitespace
317 fmap
= {} ; final_data
= [None] * 29
318 fmap
['Location UUID'] = 0 ; final_data
[0] = location_uuid
319 fmap
['obDate'] = 1 ; final_data
[1] = None
320 fmap
['obTime'] = 2 ; final_data
[2] = None
321 fmap
['timeZone'] = 3 ; final_data
[3] = 'Pacific'
322 fmap
['tempMaxHour'] = 4 ; final_data
[4] = None
323 fmap
['tempMaxHourUnit'] = 5 ; final_data
[5] = 'F'
324 fmap
['tempMinHour'] = 6 ; final_data
[6] = None
325 fmap
['tempMinHourUnit'] = 7 ; final_data
[7] = 'F'
326 fmap
['tempPres'] = 8 ; final_data
[8] = None
327 fmap
['tempPresUnit'] = 9 ; final_data
[9] = 'F'
328 fmap
['precipitationGauge'] = 10 ; final_data
[10] = None
329 fmap
['precipitationGaugeUnit'] = 11 ; final_data
[11] = 'in'
330 fmap
['windSpeedNum'] = 12 ; final_data
[12] = None
331 fmap
['windSpeedUnit'] = 13 ; final_data
[13] = 'mph'
332 fmap
['windDirectionNum'] = 14 ; final_data
[14] = None
333 fmap
['hS'] = 15 ; final_data
[15] = None
334 fmap
['hsUnit'] = 16 ; final_data
[16] = 'in'
335 fmap
['baro'] = 17 ; final_data
[17] = None
336 fmap
['baroUnit'] = 18 ; final_data
[18] = 'inHg'
337 fmap
['rH'] = 19 ; final_data
[19] = None
338 fmap
['windGustSpeedNum'] = 20 ; final_data
[20] = None
339 fmap
['windGustSpeedNumUnit'] = 21 ; final_data
[21] = 'mph'
340 fmap
['windGustDirNum'] = 22 ; final_data
[22] = None
341 fmap
['dewPoint'] = 23 ; final_data
[23] = None
342 fmap
['dewPointUnit'] = 24 ; final_data
[24] = 'F'
343 fmap
['hn24Auto'] = 25 ; final_data
[25] = None
344 fmap
['hn24AutoUnit'] = 26 ; final_data
[26] = 'in'
345 fmap
['hstAuto'] = 27 ; final_data
[27] = None
346 fmap
['hstAutoUnit'] = 28 ; final_data
[28] = 'in'
348 return (fmap
, final_data
)
350 def setup_infoex_counterparts_mapping(provider
):
352 Create a mapping of the NRCS/MesoWest fields that this program supports to
353 their InfoEx counterparts
357 if provider
== 'nrcs':
358 iemap
['PREC'] = 'precipitationGauge'
359 iemap
['TOBS'] = 'tempPres'
360 iemap
['TMAX'] = 'tempMaxHour'
361 iemap
['TMIN'] = 'tempMinHour'
363 iemap
['PRES'] = 'baro'
365 iemap
['WSPD'] = 'windSpeedNum'
366 iemap
['WDIR'] = 'windDirectionNum'
367 # unsupported by NRCS:
369 elif provider
== 'mesowest':
370 iemap
['precip_accum'] = 'precipitationGauge'
371 iemap
['air_temp'] = 'tempPres'
372 iemap
['air_temp_high_24_hour'] = 'tempMaxHour'
373 iemap
['air_temp_low_24_hour'] = 'tempMinHour'
374 iemap
['snow_depth'] = 'hS'
375 iemap
['pressure'] = 'baro'
376 iemap
['relative_humidity'] = 'rH'
377 iemap
['wind_speed'] = 'windSpeedNum'
378 iemap
['wind_direction'] = 'windDirectionNum'
379 iemap
['wind_gust'] = 'windGustSpeedNum'
380 elif provider
== 'python':
381 # we expect Python programs to use the InfoEx data type names
382 iemap
['precipitationGauge'] = 'precipitationGauge'
383 iemap
['tempPres'] = 'tempPres'
384 iemap
['tempMaxHour'] = 'tempMaxHour'
385 iemap
['tempMinHour'] = 'tempMinHour'
387 iemap
['baro'] = 'baro'
389 iemap
['windSpeedNum'] = 'windSpeedNum'
390 iemap
['windDirectionNum'] = 'windDirectionNum'
391 iemap
['windGustSpeedNum'] = 'windGustSpeedNum'
395 # provider-specific operations
396 def get_nrcs_data(begin
, end
, station
):
397 """get the data we're after from the NRCS WSDL"""
398 transport
= zeep
.transports
.Transport(cache
=zeep
.cache
.SqliteCache())
399 client
= zeep
.Client(wsdl
=station
['source'], transport
=transport
)
402 for element_cd
in station
['desired_data']:
403 time_element
= time
.time()
405 # get the last three hours of data for this elementCd/element_cd
406 tmp
= client
.service
.getHourlyData(
407 stationTriplets
=[station
['station_id']],
408 elementCd
=element_cd
,
413 LOG
.info("Time to get NRCS elementCd '%s': %.3f sec", element_cd
,
414 time
.time() - time_element
)
416 values
= tmp
[0]['values']
418 # sort and isolate the most recent
420 # NOTE: we do this because sometimes there are gaps in hourly data
421 # in NRCS; yes, we may end up with slightly inaccurate data,
422 # so perhaps this decision will be re-evaluated in the future
424 ordered
= sorted(values
, key
=lambda t
: t
['dateTime'], reverse
=True)
425 remote_data
[element_cd
] = ordered
[0]['value']
427 remote_data
[element_cd
] = None
431 def get_mesowest_data(begin
, end
, station
):
432 """get the data we're after from the MesoWest/Synoptic API"""
435 # massage begin/end date format
436 begin_date_str
= begin
.strftime('%Y%m%d%H%M')
437 end_date_str
= end
.strftime('%Y%m%d%H%M')
439 # construct final, completed API URL
440 api_req_url
= station
['source'] + '&start=' + begin_date_str
+ '&end=' + end_date_str
441 req
= requests
.get(api_req_url
)
446 LOG
.error("Bad JSON in MesoWest response")
450 observations
= json
['STATION'][0]['OBSERVATIONS']
452 LOG
.error("Bad JSON in MesoWest response")
455 pos
= len(observations
['date_time']) - 1
457 for element_cd
in station
['desired_data'].split(','):
458 # sort and isolate the most recent, see note above in NRCS for how and
461 # NOTE: Unlike in the NRCS case, the MesoWest API response contains all
462 # data (whereas with NRCS, we have to make a separate request for
463 # each element we want). This is nice for network efficiency but
464 # it means we have to handle this part differently for each.
466 # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
467 # provides hourly data, but MesoWest can often provide data every
468 # 10 minutes -- though this provides more opportunity for
471 # we may not have the data at all
472 key_name
= element_cd
+ '_set_1'
473 if key_name
in observations
:
474 if observations
[key_name
][pos
]:
475 remote_data
[element_cd
] = observations
[key_name
][pos
]
477 remote_data
[element_cd
] = None
479 remote_data
[element_cd
] = None
483 def switch_units_to_metric(data_map
, mapping
):
484 """replace units with metric counterparts"""
486 # NOTE: to update this, use the fmap<->final_data mapping laid out
487 # in setup_infoex_fields_mapping ()
489 # NOTE: this only 'works' with MesoWest for now, as the MesoWest API
490 # itself handles the unit conversion; in the future, we will also
491 # support NRCS unit conversion, but this must be done by this
493 data_map
[mapping
['tempPresUnit']] = 'C'
494 data_map
[mapping
['hsUnit']] = 'm'
495 data_map
[mapping
['windSpeedUnit']] = 'm/s'
496 data_map
[mapping
['windGustSpeedNumUnit']] = 'm/s'
501 def write_local_csv(path_to_file
, data
):
502 """Write the specified CSV file to disk"""
503 with
open(path_to_file
, 'w') as file_object
:
504 # The requirement is that empty values are represented in the CSV
505 # file as "", csv.QUOTE_NONNUMERIC achieves that
506 LOG
.debug("writing CSV file '%s'", path_to_file
)
507 writer
= csv
.writer(file_object
, quoting
=csv
.QUOTE_NONNUMERIC
)
508 writer
.writerow(data
)
512 def upload_csv(path_to_file
, infoex_data
):
513 """Upload the specified CSV file to InfoEx FTP and remove the file"""
514 with
open(path_to_file
, 'rb') as file_object
:
515 LOG
.debug("uploading FTP file '%s'", infoex_data
['host'])
516 ftp
= FTP(infoex_data
['host'], infoex_data
['uuid'],
517 infoex_data
['api_key'])
518 ftp
.storlines('STOR ' + path_to_file
, file_object
)
521 os
.remove(path_to_file
)
523 # other miscellaneous routines
524 def setup_time_values():
525 """establish time bounds of data request(s)"""
526 # floor time to nearest hour
527 date_time
= datetime
.datetime
.now()
528 end_date
= date_time
- datetime
.timedelta(minutes
=date_time
.minute
% 60,
529 seconds
=date_time
.second
,
530 microseconds
=date_time
.microsecond
)
531 begin_date
= end_date
- datetime
.timedelta(hours
=3)
532 return (begin_date
, end_date
)
534 if __name__
== "__main__":