2 # -*- coding: utf-8 -*-
5 InfoEx <-> NRCS/MesoWest Auto Wx implementation
7 Wylark Mountaineering LLC
11 This program fetches data from either an NRCS SNOTEL site or MesoWest
12 weather station and pushes it to InfoEx using the new automated weather
13 system implementation.
15 It is designed to be run hourly, and it asks for the last three hours
16 of data of each desired type, and selects the most recent one. This
17 lends some resiliency to the process and helps ensure that we have a
18 value to send, but it can lead to somewhat inconsistent/untruthful
19 data if e.g. the HS is from the last hour but the tempPres is from two
20 hours ago because the instrumentation had a hiccup. It's worth
21 considering if this is a bug or a feature.
23 For more information, see file: README
24 For licensing, see file: LICENSE
35 from collections
import OrderedDict
36 from ftplib
import FTP
37 from optparse
import OptionParser
43 import zeep
.transports
47 log
= logging
.getLogger(__name__
)
48 log
.setLevel(logging
.DEBUG
)
51 from systemd
.journal
import JournalHandler
52 log
.addHandler(JournalHandler())
55 #import logging.handlers
56 #log.addHandler(logging.handlers.SysLogHandler())
58 handler
= logging
.StreamHandler(sys
.stdout
)
59 log
.addHandler(handler
)
61 parser
= OptionParser()
63 parser
.add_option("--config",
66 help="location of config file")
68 parser
.add_option("--dry-run",
72 help="fetch data but don't upload to InfoEx")
74 parser
.add_option("--version",
78 help="show program version and exit")
80 (options
, args
) = parser
.parse_args()
82 config
= configparser
.ConfigParser(allow_no_value
=False)
84 if options
.show_version
:
85 print("%s - %s" % (os
.path
.basename(__file__
), __version__
))
88 if not options
.config
:
89 print("Please specify a configuration file via --config.")
92 config
.read(options
.config
)
94 log
.debug('STARTING UP')
98 'host': config
['infoex']['host'],
99 'uuid': config
['infoex']['uuid'],
100 'api_key': config
['infoex']['api_key'],
101 'csv_filename': config
['infoex']['csv_filename'],
102 'location_uuid': config
['infoex']['location_uuid'],
103 'wx_data': {}, # placeholder key, values to come later
107 data
['provider'] = config
['station']['type']
109 if data
['provider'] not in ['nrcs', 'mesowest']:
110 print("Please specify either nrcs or mesowest as the station type.")
113 if data
['provider'] == 'nrcs':
114 data
['source'] = 'https://www.wcc.nrcs.usda.gov/awdbWebService/services?WSDL'
115 data
['station_id'] = config
['station']['station_id']
118 desired_data
= config
['station']['desired_data'].split(',')
120 # desired_data malformed or missing, setting default
122 'TOBS', # AIR TEMPERATURE OBSERVED (degF)
123 'SNWD', # SNOW DEPTH (in)
124 'PREC' # PRECIPITATION ACCUMULATION (in)
127 # XXX: For NRCS, we're manually overriding units for now! Once
128 # unit conversion is supported for NRCS, REMOVE THIS!
129 if 'units' not in data
:
130 data
['units'] = 'imperial'
132 if data
['provider'] == 'mesowest':
133 data
['source'] = 'https://api.synopticdata.com/v2/stations/timeseries'
134 data
['station_id'] = config
['station']['station_id']
135 data
['units'] = config
['station']['units']
138 desired_data
= config
['station']['desired_data']
140 # desired_data malformed or missing, setting default
141 desired_data
= 'air_temp,snow_depth'
143 # construct full API URL (sans start/end time, added later)
144 data
['source'] = data
['source'] + '?token=' + config
['station']['token'] + '&within=60&units=' + data
['units'] + '&stid=' + data
['station_id'] + '&vars=' + desired_data
146 except KeyError as e
:
147 log
.critical("%s not defined in %s" % (e
, options
.config
))
149 except Exception as exc
:
150 log
.critical("Exception occurred in config parsing: '%s'" % (exc
))
153 # all sections/values present in config file, final sanity check
155 for key
in config
.sections():
156 for subkey
in config
[key
]:
157 if not len(config
[key
][subkey
]):
159 except ValueError as exc
:
160 log
.critical("Config value '%s.%s' is empty" % (key
, subkey
))
165 # This won't earn style points in Python, but here we establish a couple
166 # of helpful mappings variables. The reason this is helpful is that the
167 # end result is simply an ordered set, the CSV file. But we still may
168 # want to manipulate the values arbitrarily before writing that file.
170 # Also note that the current Auto Wx InfoEx documentation shows these
171 # keys in a graphical table with the "index" beginning at 1, but here we
172 # are sanely indexing beginning at 0.
173 fmap
= {} ; final_data
= [None] * 29
174 fmap
['Location UUID'] = 0 ; final_data
[0] = infoex
['location_uuid']
175 fmap
['obDate'] = 1 ; final_data
[1] = None
176 fmap
['obTime'] = 2 ; final_data
[2] = None
177 fmap
['timeZone'] = 3 ; final_data
[3] = 'Pacific'
178 fmap
['tempMaxHour'] = 4 ; final_data
[4] = None
179 fmap
['tempMaxHourUnit'] = 5 ; final_data
[5] = 'F'
180 fmap
['tempMinHour'] = 6 ; final_data
[6] = None
181 fmap
['tempMinHourUnit'] = 7 ; final_data
[7] = 'F'
182 fmap
['tempPres'] = 8 ; final_data
[8] = None
183 fmap
['tempPresUnit'] = 9 ; final_data
[9] = 'F'
184 fmap
['precipitationGauge'] = 10 ; final_data
[10] = None
185 fmap
['precipitationGaugeUnit'] = 11 ; final_data
[11] = 'in'
186 fmap
['windSpeedNum'] = 12 ; final_data
[12] = None
187 fmap
['windSpeedUnit'] = 13 ; final_data
[13] = 'mph'
188 fmap
['windDirectionNum'] = 14 ; final_data
[14] = None
189 fmap
['hS'] = 15 ; final_data
[15] = None
190 fmap
['hsUnit'] = 16 ; final_data
[16] = 'in'
191 fmap
['baro'] = 17 ; final_data
[17] = None
192 fmap
['baroUnit'] = 18 ; final_data
[18] = 'inHg'
193 fmap
['rH'] = 19 ; final_data
[19] = None
194 fmap
['windGustSpeedNum'] = 20 ; final_data
[20] = None
195 fmap
['windGustSpeedNumUnit'] = 21 ; final_data
[21] = 'mph'
196 fmap
['windGustDirNum'] = 22 ; final_data
[22] = None
197 fmap
['dewPoint'] = 23 ; final_data
[23] = None
198 fmap
['dewPointUnit'] = 24 ; final_data
[24] = 'F'
199 fmap
['hn24Auto'] = 25 ; final_data
[25] = None
200 fmap
['hn24AutoUnit'] = 26 ; final_data
[26] = 'in'
201 fmap
['hstAuto'] = 27 ; final_data
[27] = None
202 fmap
['hstAutoUnit'] = 28 ; final_data
[28] = 'in'
204 # one final mapping, the NRCS/MesoWest fields that this program supports to
205 # their InfoEx counterpart
208 if data
['provider'] == 'nrcs':
209 iemap
['PREC'] = 'precipitationGauge'
210 iemap
['TOBS'] = 'tempPres'
212 iemap
['PRES'] = 'baro'
214 iemap
['WSPD'] = 'windSpeedNum'
215 iemap
['WDIR'] = 'windDirectionNum'
216 # unsupported by NRCS:
218 elif data
['provider'] == 'mesowest':
219 iemap
['precip_accum'] = 'precipitationGauge'
220 iemap
['air_temp'] = 'tempPres'
221 iemap
['snow_depth'] = 'hS'
222 iemap
['pressure'] = 'baro'
223 iemap
['relative_humidity'] = 'rH'
224 iemap
['wind_speed'] = 'windSpeedNum'
225 iemap
['wind_direction'] = 'windDirectionNum'
226 iemap
['wind_gust'] = 'windGustSpeedNum'
228 # override units if user selected metric
230 # NOTE: to update this, use the fmap<->final_data mapping laid out above
232 # NOTE: this only 'works' with MesoWest for now, as the MesoWest API
233 # itself handles the unit conversion; in the future, we will also
234 # support NRCS unit conversion, but this must be done by this
236 if data
['units'] == 'metric':
237 final_data
[fmap
['tempPresUnit']] = 'C'
238 final_data
[fmap
['hsUnit']] = 'm'
239 final_data
[fmap
['windSpeedUnit']] = 'm/s'
240 final_data
[fmap
['windGustSpeedNumUnit']] = 'm/s'
242 # floor time to nearest hour
243 dt
= datetime
.datetime
.now()
244 end_date
= dt
- datetime
.timedelta(minutes
=dt
.minute
% 60,
246 microseconds
=dt
.microsecond
)
247 begin_date
= end_date
- datetime
.timedelta(hours
=3)
250 log
.debug("Getting %s data from %s to %s" % (str(desired_data
),
251 str(begin_date
), str(end_date
)))
253 time_all_elements
= time
.time()
256 if data
['provider'] == 'nrcs':
257 transport
= zeep
.transports
.Transport(cache
=zeep
.cache
.SqliteCache())
258 client
= zeep
.Client(wsdl
=data
['source'], transport
=transport
)
260 for elementCd
in desired_data
:
261 time_element
= time
.time()
263 # get the last three hours of data for this elementCd
264 tmp
= client
.service
.getHourlyData(
265 stationTriplets
=[data
['station_id']],
268 beginDate
=begin_date
,
271 log
.info("Time to get elementCd '%s': %.3f sec" % (elementCd
,
272 time
.time() - time_element
))
274 values
= tmp
[0]['values']
276 # sort and isolate the most recent
278 # NOTE: we do this because sometimes there are gaps in hourly data
279 # in NRCS; yes, we may end up with slightly inaccurate data,
280 # so perhaps this decision will be re-evaluated in the future
282 ordered
= sorted(values
, key
=lambda t
: t
['dateTime'], reverse
=True)
283 infoex
['wx_data'][elementCd
] = ordered
[0]['value']
285 infoex
['wx_data'][elementCd
] = None
287 # MesoWest-specific code
288 elif data
['provider'] == 'mesowest':
289 # massage begin/end date format
290 begin_date_str
= begin_date
.strftime('%Y%m%d%H%M')
291 end_date_str
= end_date
.strftime('%Y%m%d%H%M')
293 # construct final, completed API URL
294 api_req_url
= data
['source'] + '&start=' + begin_date_str
+ '&end=' + end_date_str
295 req
= requests
.get(api_req_url
)
300 log
.error("Bad JSON in MesoWest response")
304 observations
= json
['STATION'][0]['OBSERVATIONS']
306 log
.error("Bad JSON in MesoWest response")
309 pos
= len(observations
['date_time']) - 1
311 for elementCd
in desired_data
.split(','):
312 # sort and isolate the most recent, see note above in NRCS for how and
315 # NOTE: Unlike in the NRCS case, the MesoWest API respones contains all
316 # data (whereas with NRCS, we have to make a separate request for
317 # each element we want. This is nice for network efficiency but
318 # it means we have to handle this part differently for each.
320 # NOTE: Also unlike NRCS, MesoWest provides more granular data; NRCS
321 # provides hourly data, but MesoWest can often provide data every
322 # 10 minutes -- though this provides more opportunity for
325 # we may not have the data at all
326 key_name
= elementCd
+ '_set_1'
327 if key_name
in observations
:
328 if observations
[key_name
][pos
]:
329 infoex
['wx_data'][elementCd
] = observations
[key_name
][pos
]
331 infoex
['wx_data'][elementCd
] = None
333 infoex
['wx_data'][elementCd
] = None
335 log
.info("Time to get all data : %.3f sec" % (time
.time() -
338 log
.debug("infoex[wx_data]: %s", str(infoex
['wx_data']))
340 # Now we only need to add in what we want to change thanks to that
341 # abomination of a variable declaration earlier
342 final_data
[fmap
['Location UUID']] = infoex
['location_uuid']
343 final_data
[fmap
['obDate']] = end_date
.strftime('%m/%d/%Y')
344 final_data
[fmap
['obTime']] = end_date
.strftime('%H:%M')
346 for elementCd
in infoex
['wx_data']:
347 if elementCd
not in iemap
:
348 log
.warning("BAD KEY wx_data['%s']" % (elementCd
))
351 # CONSIDER: Casting every value to Float() -- need to investigate if
352 # any possible elementCds we may want are any other data
355 # Another possibility is to query the API with
356 # getStationElements and temporarily store the
357 # storedUnitCd. But that's pretty network-intensive and
358 # may not even be worth it if there's only e.g. one or two
359 # exceptions to any otherwise uniformly Float value set.
360 final_data
[fmap
[iemap
[elementCd
]]] = infoex
['wx_data'][elementCd
]
362 log
.debug("final_data: %s" % (str(final_data
)))
364 with
open(infoex
['csv_filename'], 'w') as f
:
365 # The requirement is that empty values are represented in the CSV
366 # file as "", csv.QUOTE_NONNUMERIC achieves that
367 log
.debug("writing CSV file '%s'" % (infoex
['csv_filename']))
368 writer
= csv
.writer(f
, quoting
=csv
.QUOTE_NONNUMERIC
)
369 writer
.writerow(final_data
)
372 if not options
.dry_run
:
374 with
open(infoex
['csv_filename'], 'rb') as f
:
375 log
.debug("uploading FTP file '%s'" % (infoex
['host']))
376 ftp
= FTP(infoex
['host'], infoex
['uuid'], infoex
['api_key'])
377 ftp
.storlines('STOR ' + infoex
['csv_filename'], f
)
380 os
.remove(infoex
['csv_filename'])