a1bcf004ef9900b42f33196a52a4ff7fa632bd69
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
35 from ftplib
import FTP
36 from argparse
import ArgumentParser
44 import zeep
.transports
48 LOG
= logging
.getLogger(__name__
)
49 LOG
.setLevel(logging
.NOTSET
)
51 urllib3
.disable_warnings()
54 """Return OptionParser for this program"""
55 parser
= ArgumentParser()
57 parser
.add_argument("--version",
61 parser
.add_argument("--config",
64 help="location of config file")
66 parser
.add_argument("--log-level",
69 help="set the log level (debug, info, warning)")
71 parser
.add_argument("--dry-run",
75 help="fetch data but don't upload to InfoEx")
79 def setup_config(config
):
80 """Setup config variable based on values specified in the ini file"""
83 'host': config
['infoex']['host'],
84 'uuid': config
['infoex']['uuid'],
85 'api_key': config
['infoex']['api_key'],
86 'csv_filename': config
['infoex']['csv_filename'],
87 'location_uuid': config
['infoex']['location_uuid'],
88 'wx_data': {}, # placeholder key, values to come later
92 station
['provider'] = config
['station']['type']
94 if station
['provider'] not in ['nrcs', 'mesowest', 'python']:
95 print("Please specify either nrcs or mesowest as the station type.")
98 if station
['provider'] == 'nrcs':
99 station
['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
100 station
['station_id'] = config
['station']['station_id']
101 station
['desired_data'] = config
['station']['desired_data'].split(',')
102 station
['units'] = config
['station']['units']
104 if station
['provider'] == 'mesowest':
105 station
['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
106 station
['station_id'] = config
['station']['station_id']
107 station
['units'] = config
['station']['units']
108 station
['desired_data'] = config
['station']['desired_data']
110 # construct full API URL (sans start/end time, added later)
111 station
['source'] = station
['source'] + '?token=' + \
112 config
['station']['token'] + \
113 '&within=60&units=' + station
['units'] + \
114 '&stid=' + station
['station_id'] + \
115 '&vars=' + station
['desired_data']
117 if station
['provider'] == 'python':
118 station
['path'] = config
['station']['path']
120 tz
= 'America/Los_Angeles'
122 if 'tz' in config
['station']:
123 tz
= config
['station']['tz']
126 station
['tz'] = pytz
.timezone(tz
)
127 except pytz
.exceptions
.UnknownTimeZoneError
:
128 LOG
.critical("%s is not a valid timezone", tz
)
132 if 'hn24' in config
['station']:
133 if config
['station']['hn24'] not in ['true', 'false']:
134 raise ValueError("hn24 must be either 'true' or 'false'")
136 if config
['station']['hn24'] == "true":
137 station
['hn24'] = True
139 station
['hn24'] = False
142 station
['hn24'] = False
145 if 'wind_mode' in config
['station']:
146 if config
['station']['wind_mode'] not in ['normal', 'average']:
147 raise ValueError("wind_mode must be either 'normal' or 'average'")
149 station
['wind_mode'] = config
['station']['wind_mode']
152 station
['wind_mode'] = "normal"
154 except KeyError as err
:
155 LOG
.critical("%s not defined in configuration file", err
)
157 except ValueError as err
:
158 LOG
.critical("%s", err
)
161 # all sections/values present in config file, final sanity check
163 for key
in config
.sections():
164 for subkey
in config
[key
]:
165 if not config
[key
][subkey
]:
168 LOG
.critical("Config value '%s.%s' is empty", key
, subkey
)
171 return (infoex
, station
)
173 def setup_logging(log_level
):
174 """Setup our logging infrastructure"""
176 from systemd
.journal
import JournalHandler
177 LOG
.addHandler(JournalHandler())
179 ## fallback to syslog
180 #import logging.handlers
181 #LOG.addHandler(logging.handlers.SysLogHandler())
183 handler
= logging
.StreamHandler(sys
.stdout
)
184 formatter
= logging
.Formatter('%(asctime)s.%(msecs)03d '
185 '%(levelname)s %(module)s - '
186 '%(funcName)s: %(message)s',
188 handler
.setFormatter(formatter
)
189 LOG
.addHandler(handler
)
192 if log_level
in [None, 'debug', 'info', 'warning']:
193 if log_level
== 'debug':
194 LOG
.setLevel(logging
.DEBUG
)
195 elif log_level
== 'info':
196 LOG
.setLevel(logging
.INFO
)
197 elif log_level
== 'warning':
198 LOG
.setLevel(logging
.WARNING
)
200 LOG
.setLevel(logging
.NOTSET
)
207 """Main routine: sort through args, decide what to do, then do it"""
208 parser
= get_parser()
209 options
= parser
.parse_args()
211 config
= configparser
.ConfigParser(allow_no_value
=False)
213 if not options
.config
:
215 print("\nPlease specify a configuration file via --config.")
218 config
.read(options
.config
)
220 if not setup_logging(options
.log_level
):
222 print("\nPlease select an appropriate log level or remove the switch (--log-level).")
225 (infoex
, station
) = setup_config(config
)
227 LOG
.debug('Config parsed, starting up')
230 (fmap
, final_data
) = setup_infoex_fields_mapping(infoex
['location_uuid'])
231 iemap
= setup_infoex_counterparts_mapping(station
['provider'])
233 # override units if user selected metric
234 if station
['provider'] != 'python' and station
['units'] == 'metric':
235 final_data
= switch_units_to_metric(final_data
, fmap
)
237 (begin_date
, end_date
) = setup_time_values(station
)
239 if station
['provider'] == 'python':
240 LOG
.debug("Getting custom data from external Python program")
242 LOG
.debug("Getting %s data from %s to %s (%s)",
243 str(station
['desired_data']),
244 str(begin_date
), str(end_date
), end_date
.tzinfo
.zone
)
246 time_all_elements
= time
.time()
249 if station
['provider'] == 'nrcs':
250 infoex
['wx_data'] = get_nrcs_data(begin_date
, end_date
, station
)
251 elif station
['provider'] == 'mesowest':
252 infoex
['wx_data'] = get_mesowest_data(begin_date
, end_date
,
254 elif station
['provider'] == 'python':
256 spec
= importlib
.util
.spec_from_file_location('custom_wx',
258 mod
= importlib
.util
.module_from_spec(spec
)
259 spec
.loader
.exec_module(mod
)
263 infoex
['wx_data'] = mod
.get_custom_data()
265 if infoex
['wx_data'] is None:
266 infoex
['wx_data'] = []
267 except Exception as exc
:
268 LOG
.error("Python program for custom Wx data failed in "
269 "execution: %s", str(exc
))
272 LOG
.info("Successfully executed external Python program")
274 LOG
.error("Please upgrade to Python 3.3 or later")
276 except FileNotFoundError
:
277 LOG
.error("Specified Python program for custom Wx data "
280 except Exception as exc
:
281 LOG
.error("A problem was encountered when attempting to "
282 "load your custom Wx program: %s", str(exc
))
285 LOG
.info("Time taken to get all data : %.3f sec", time
.time() -
288 LOG
.debug("infoex[wx_data]: %s", str(infoex
['wx_data']))
291 final_end_date
= end_date
.astimezone(station
['tz'])
293 # Now we only need to add in what we want to change thanks to that
294 # abomination of a variable declaration earlier
295 final_data
[fmap
['Location UUID']] = infoex
['location_uuid']
296 final_data
[fmap
['obDate']] = final_end_date
.strftime('%m/%d/%Y')
297 final_data
[fmap
['obTime']] = final_end_date
.strftime('%H:%M')
298 final_data
[fmap
['timeZone']] = station
['tz'].zone
300 for element_cd
in infoex
['wx_data']:
301 if element_cd
not in iemap
:
302 LOG
.warning("BAD KEY wx_data['%s']", element_cd
)
305 if infoex
['wx_data'][element_cd
] is None:
308 # do the conversion before the rounding
309 if station
['provider'] == 'nrcs' and station
['units'] == 'metric':
310 infoex
['wx_data'][element_cd
] = convert_nrcs_units_to_metric(element_cd
, infoex
['wx_data'][element_cd
])
312 # Massage precision of certain values to fit InfoEx's
315 # 0 decimal places: relative humidity, wind speed, wind
316 # direction, wind gust, snow depth
317 # 1 decimal place: air temp, baro
318 # Avoid transforming None values
319 if element_cd
in ['wind_speed', 'WSPD', 'wind_direction',
320 'RHUM', 'relative_humidity', 'WDIR',
321 'wind_gust', 'SNWD', 'snow_depth']:
322 infoex
['wx_data'][element_cd
] = round(infoex
['wx_data'][element_cd
])
323 elif element_cd
in ['TOBS', 'air_temp', 'PRES', 'pressure']:
324 infoex
['wx_data'][element_cd
] = round(infoex
['wx_data'][element_cd
], 1)
325 elif element_cd
in ['PREC', 'precip_accum']:
326 infoex
['wx_data'][element_cd
] = round(infoex
['wx_data'][element_cd
], 2)
328 # CONSIDER: Casting every value to Float() -- need to investigate if
329 # any possible elementCds we may want are any other data
332 # Another possibility is to query the API with
333 # getStationElements and temporarily store the
334 # storedUnitCd. But that's pretty network-intensive and
335 # may not even be worth it if there's only e.g. one or two
336 # exceptions to any otherwise uniformly Float value set.
337 final_data
[fmap
[iemap
[element_cd
]]] = infoex
['wx_data'][element_cd
]
339 LOG
.debug("final_data: %s", str(final_data
))
341 if infoex
['wx_data']:
342 if not write_local_csv(infoex
['csv_filename'], final_data
):
343 LOG
.warning('Could not write local CSV file: %s',
344 infoex
['csv_filename'])
347 if not options
.dry_run
:
348 upload_csv(infoex
['csv_filename'], infoex
)
353 # data structure operations
354 def setup_infoex_fields_mapping(location_uuid
):
356 Create a mapping of InfoEx fields to the local data's indexing scheme.
360 This won't earn style points in Python, but here we establish a couple
361 of helpful mappings variables. The reason this is helpful is that the
362 end result is simply an ordered set, the CSV file. But we still may
363 want to manipulate the values arbitrarily before writing that file.
365 Also note that the current Auto Wx InfoEx documentation shows these
366 keys in a graphical table with the "index" beginning at 1, but here we
367 sanely index beginning at 0.
369 # pylint: disable=too-many-statements,multiple-statements,bad-whitespace
370 fmap
= {} ; final_data
= [None] * 29
371 fmap
['Location UUID'] = 0 ; final_data
[0] = location_uuid
372 fmap
['obDate'] = 1 ; final_data
[1] = None
373 fmap
['obTime'] = 2 ; final_data
[2] = None
374 fmap
['timeZone'] = 3 ; final_data
[3] = 'Pacific'
375 fmap
['tempMaxHour'] = 4 ; final_data
[4] = None
376 fmap
['tempMaxHourUnit'] = 5 ; final_data
[5] = 'F'
377 fmap
['tempMinHour'] = 6 ; final_data
[6] = None
378 fmap
['tempMinHourUnit'] = 7 ; final_data
[7] = 'F'
379 fmap
['tempPres'] = 8 ; final_data
[8] = None
380 fmap
['tempPresUnit'] = 9 ; final_data
[9] = 'F'
381 fmap
['precipitationGauge'] = 10 ; final_data
[10] = None
382 fmap
['precipitationGaugeUnit'] = 11 ; final_data
[11] = 'in'
383 fmap
['windSpeedNum'] = 12 ; final_data
[12] = None
384 fmap
['windSpeedUnit'] = 13 ; final_data
[13] = 'mph'
385 fmap
['windDirectionNum'] = 14 ; final_data
[14] = None
386 fmap
['hS'] = 15 ; final_data
[15] = None
387 fmap
['hsUnit'] = 16 ; final_data
[16] = 'in'
388 fmap
['baro'] = 17 ; final_data
[17] = None
389 fmap
['baroUnit'] = 18 ; final_data
[18] = 'inHg'
390 fmap
['rH'] = 19 ; final_data
[19] = None
391 fmap
['windGustSpeedNum'] = 20 ; final_data
[20] = None
392 fmap
['windGustSpeedNumUnit'] = 21 ; final_data
[21] = 'mph'
393 fmap
['windGustDirNum'] = 22 ; final_data
[22] = None
394 fmap
['dewPoint'] = 23 ; final_data
[23] = None
395 fmap
['dewPointUnit'] = 24 ; final_data
[24] = 'F'
396 fmap
['hn24Auto'] = 25 ; final_data
[25] = None
397 fmap
['hn24AutoUnit'] = 26 ; final_data
[26] = 'in'
398 fmap
['hstAuto'] = 27 ; final_data
[27] = None
399 fmap
['hstAutoUnit'] = 28 ; final_data
[28] = 'in'
401 return (fmap
, final_data
)
403 def setup_infoex_counterparts_mapping(provider
):
405 Create a mapping of the NRCS/MesoWest fields that this program supports to
406 their InfoEx counterparts
410 if provider
== 'nrcs':
411 iemap
['PREC'] = 'precipitationGauge'
412 iemap
['TOBS'] = 'tempPres'
413 iemap
['TMAX'] = 'tempMaxHour'
414 iemap
['TMIN'] = 'tempMinHour'
416 iemap
['PRES'] = 'baro'
418 iemap
['WSPD'] = 'windSpeedNum'
419 iemap
['WDIR'] = 'windDirectionNum'
420 # unsupported by NRCS:
422 elif provider
== 'mesowest':
423 iemap
['precip_accum'] = 'precipitationGauge'
424 iemap
['air_temp'] = 'tempPres'
425 iemap
['air_temp_high_24_hour'] = 'tempMaxHour'
426 iemap
['air_temp_low_24_hour'] = 'tempMinHour'
427 iemap
['snow_depth'] = 'hS'
428 iemap
['pressure'] = 'baro'
429 iemap
['relative_humidity'] = 'rH'
430 iemap
['wind_speed'] = 'windSpeedNum'
431 iemap
['wind_direction'] = 'windDirectionNum'
432 iemap
['wind_gust'] = 'windGustSpeedNum'
433 elif provider
== 'python':
434 # we expect Python programs to use the InfoEx data type names
435 iemap
['precipitationGauge'] = 'precipitationGauge'
436 iemap
['tempPres'] = 'tempPres'
437 iemap
['tempMaxHour'] = 'tempMaxHour'
438 iemap
['tempMinHour'] = 'tempMinHour'
440 iemap
['baro'] = 'baro'
442 iemap
['windSpeedNum'] = 'windSpeedNum'
443 iemap
['windDirectionNum'] = 'windDirectionNum'
444 iemap
['windGustSpeedNum'] = 'windGustSpeedNum'
448 # provider-specific operations
449 def get_nrcs_data(begin
, end
, station
):
450 """get the data we're after from the NRCS WSDL"""
451 transport
= zeep
.transports
.Transport(cache
=zeep
.cache
.SqliteCache())
452 transport
.session
.verify
= False
453 client
= zeep
.Client(wsdl
=station
['source'], transport
=transport
)
456 # massage begin/end date format
457 begin_date_str
= begin
.strftime('%Y-%m-%d %H:%M:00')
458 end_date_str
= end
.strftime('%Y-%m-%d %H:%M:00')
460 for element_cd
in station
['desired_data']:
461 time_element
= time
.time()
463 # get the last three hours of data for this elementCd/element_cd
464 tmp
= client
.service
.getHourlyData(
465 stationTriplets
=[station
['station_id']],
466 elementCd
=element_cd
,
468 beginDate
=begin_date_str
,
469 endDate
=end_date_str
)
471 LOG
.info("Time to get NRCS elementCd '%s': %.3f sec", element_cd
,
472 time
.time() - time_element
)
474 values
= tmp
[0]['values']
476 # sort and isolate the most recent
478 # NOTE: we do this because sometimes there are gaps in hourly data
479 # in NRCS; yes, we may end up with slightly inaccurate data,
480 # so perhaps this decision will be re-evaluated in the future
482 ordered
= sorted(values
, key
=lambda t
: t
['dateTime'], reverse
=True)
483 remote_data
[element_cd
] = ordered
[0]['value']
485 remote_data
[element_cd
] = None
489 def get_mesowest_data(begin
, end
, station
):
490 """get the data we're after from the MesoWest/Synoptic API"""
493 # massage begin/end date format
494 begin_date_str
= begin
.strftime('%Y%m%d%H%M')
495 end_date_str
= end
.strftime('%Y%m%d%H%M')
497 # construct final, completed API URL
498 api_req_url
= station
['source'] + '&start=' + begin_date_str
+ '&end=' + end_date_str
501 req
= requests
.get(api_req_url
)
502 except requests
.exceptions
.ConnectionError
:
503 LOG
.error("Could not connect to '%s'", api_req_url
)
509 LOG
.error("Bad JSON in MesoWest response")
513 observations
= json
['STATION'][0]['OBSERVATIONS']
514 except KeyError as exc
:
515 LOG
.error("Unexpected JSON in MesoWest response: '%s'", exc
)
517 except IndexError as exc
:
518 LOG
.error("Unexpected JSON in MesoWest response: '%s'", exc
)
520 LOG
.error("Detailed MesoWest response: '%s'",
521 json
['SUMMARY']['RESPONSE_MESSAGE'])
525 except ValueError as exc
:
526 LOG
.error("Bad JSON in MesoWest response: '%s'", exc
)
529 pos
= len(observations
['date_time']) - 1
531 for element_cd
in station
['desired_data'].split(','):
532 # sort and isolate the most recent, see note above in NRCS for how and
535 # NOTE: Unlike in the NRCS case, the MesoWest API response contains all
536 # data (whereas with NRCS, we have to make a separate request for
537 # each element we want). This is nice for network efficiency but
538 # it means we have to handle this part differently for each.
540 # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
541 # provides hourly data, but MesoWest can often provide data every
542 # 10 minutes -- though this provides more opportunity for
545 # we may not have the data at all
546 key_name
= element_cd
+ '_set_1'
548 if key_name
in observations
:
549 if observations
[key_name
][pos
]:
550 remote_data
[element_cd
] = observations
[key_name
][pos
]
552 # mesowest by default provides wind_speed in m/s, but
553 # we specify 'english' units in the request; either way,
555 if element_cd
in ('wind_speed', 'wind_gust'):
556 remote_data
[element_cd
] = kn_to_mph(remote_data
[element_cd
])
558 # mesowest provides HS in mm, not cm; we want cm
559 if element_cd
== 'snow_depth' and station
['units'] == 'metric':
560 remote_data
[element_cd
] = mm_to_cm(remote_data
[element_cd
])
562 remote_data
[element_cd
] = None
564 remote_data
[element_cd
] = None
568 def switch_units_to_metric(data_map
, mapping
):
569 """replace units with metric counterparts"""
571 # NOTE: to update this, use the fmap<->final_data mapping laid out
572 # in setup_infoex_fields_mapping ()
573 data_map
[mapping
['tempMaxHourUnit']] = 'C'
574 data_map
[mapping
['tempMinHourUnit']] = 'C'
575 data_map
[mapping
['tempPresUnit']] = 'C'
576 data_map
[mapping
['precipitationGaugeUnit']] = 'mm'
577 data_map
[mapping
['hsUnit']] = 'cm'
578 data_map
[mapping
['windSpeedUnit']] = 'm/s'
579 data_map
[mapping
['windGustSpeedNumUnit']] = 'm/s'
580 data_map
[mapping
['dewPointUnit']] = 'C'
581 data_map
[mapping
['hn24AutoUnit']] = 'cm'
582 data_map
[mapping
['hstAutoUnit']] = 'cm'
586 def convert_nrcs_units_to_metric(element_cd
, value
):
587 """convert NRCS values from English to metric"""
588 if element_cd
== 'TOBS':
589 value
= f_to_c(value
)
590 elif element_cd
== 'SNWD':
591 value
= in_to_cm(value
)
592 elif element_cd
== 'PREC':
593 value
= in_to_mm(value
)
597 def write_local_csv(path_to_file
, data
):
598 """Write the specified CSV file to disk"""
599 with
open(path_to_file
, 'w') as file_object
:
600 # The requirement is that empty values are represented in the CSV
601 # file as "", csv.QUOTE_NONNUMERIC achieves that
602 LOG
.debug("writing CSV file '%s'", path_to_file
)
603 writer
= csv
.writer(file_object
, quoting
=csv
.QUOTE_NONNUMERIC
)
604 writer
.writerow(data
)
608 def upload_csv(path_to_file
, infoex_data
):
609 """Upload the specified CSV file to InfoEx FTP and remove the file"""
610 with
open(path_to_file
, 'rb') as file_object
:
611 LOG
.debug("uploading FTP file '%s'", infoex_data
['host'])
612 ftp
= FTP(infoex_data
['host'], infoex_data
['uuid'],
613 infoex_data
['api_key'])
614 ftp
.storlines('STOR ' + path_to_file
, file_object
)
617 os
.remove(path_to_file
)
619 # other miscellaneous routines
620 def setup_time_values(station
):
621 """establish time bounds of data request(s)"""
623 # default timezone to UTC (for MesoWest)
626 # but for NRCS, use the config-specified timezone
627 if station
['provider'] == 'nrcs':
630 # floor time to nearest hour
631 date_time
= datetime
.datetime
.now(tz
=tz
)
632 end_date
= date_time
- datetime
.timedelta(minutes
=date_time
.minute
% 60,
633 seconds
=date_time
.second
,
634 microseconds
=date_time
.microsecond
)
635 begin_date
= end_date
- datetime
.timedelta(hours
=3)
636 return (begin_date
, end_date
)
639 """convert Fahrenheit to Celsius"""
640 return (float(f
) - 32) * 5.0/9.0
642 def in_to_cm(inches
):
643 """convert inches to centimetrs"""
644 return float(inches
) * 2.54
646 def in_to_mm(inches
):
647 """convert inches to millimeters"""
648 return (float(inches
) * 2.54) * 10.0
651 """convert meters per second to miles per hour"""
655 """convert knots to miles per hour"""
659 """convert millimeters to centimetrs"""
662 if __name__
== "__main__":