]> ToastFreeware Gitweb - chrisu/seepark.git/blob - web/seepark_web.py
586d296bdd65a8a0d25ba30214d60b5b01e16d30
[chrisu/seepark.git] / web / seepark_web.py
1 import collections
2 import datetime
3 import itertools
4 import time
5 import configparser
6 import os
7 import sys
8 from collections import defaultdict
9 import io
10 import numpy as np
11 import matplotlib
12 matplotlib.use('pdf')
13 import matplotlib.pyplot as plt
14 from matplotlib.backends.backend_pdf import PdfPages
15
16 from flask import Flask, render_template, jsonify, request, abort, Response, make_response
17 import flask.json
18 from flask_sqlalchemy import SQLAlchemy, inspect
19 from sqlalchemy import func
20
21 MONTH_DE = [
22     'Jänner',
23     'Februar',
24     'März',
25     'April',
26     'Mai',
27     'Juni',
28     'Juli',
29     'August',
30     'September',
31     'Oktober',
32     'November',
33     'Dezember']
34
35 DAY_OF_WEEK_DE = [
36     'Montag',
37     'Dienstag',
38     'Mittwoch',
39     'Donnerstag',
40     'Freitag',
41     'Samstag',
42     'Sonntag']
43
44
45 # https://stackoverflow.com/a/37350445
46 def sqlalchemy_model_to_dict(model):
47     return {c.key: getattr(model, c.key)
48         for c in inspect(model).mapper.column_attrs}
49
50
51 class JSONEncoder(flask.json.JSONEncoder):
52     def default(self, object):
53         if isinstance(object, datetime.datetime):
54             return object.isoformat()
55         elif isinstance(object, db.Model):
56             return sqlalchemy_model_to_dict(object)
57         return super().default(object)
58
59
60 def parse_datetime(date_str):
61     return datetime.datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
62
63
64 def ntimes(it, n):
65     for v in it:
66         yield from itertools.repeat(v, n)
67
68
69 def get_sqlalchemy_database_uri(config):
70     user = config.get('database', 'user')
71     pwd = config.get('database', 'password')
72     host = config.get('database', 'hostname')
73     db = config.get('database', 'database')
74     return 'mysql+mysqldb://{}:{}@{}/{}'.format(user, pwd, host, db)
75
76
77 config = configparser.ConfigParser()
78 config.read(os.environ['SEEPARKINI'])
79 apikey = config.get('openweathermap', 'apikey')
80 cityid = config.get('openweathermap', 'cityid')
81 mainsensor = config.get('webapp', 'mainsensor')
82
83 app = Flask(__name__)
84 app.json_encoder = JSONEncoder
85 app.config['SQLALCHEMY_DATABASE_URI'] = get_sqlalchemy_database_uri(config)
86 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
87 db = SQLAlchemy(app)
88 db.reflect(app=app)
89
90
91 class Sensors(db.Model):
92     __tablename__ = 'sensors'
93
94
95 class OpenWeatherMap(db.Model):
96     __tablename__ = 'openweathermap'
97
98
99 def calc_grouping_resolution(begin, end):
100     """How many data points should be between the timestamps begin and end?"""
101     # copied from munin/master/_bin/munin-cgi-graph.in
102     # except day: 300 -> 600
103     resolutions = dict(
104         day   =   600,
105         week  =  1800,
106         month =  7200,
107         year  = 86400,
108     )
109     duration = (end - begin).total_seconds()
110     day = 60 * 60 * 24
111     if duration <= day:
112         resolution = resolutions['day']
113     elif duration <= 7 * day:
114         resolution = resolutions['week']
115     elif duration <= 31 * day:
116         resolution = resolutions['month']
117     else:
118         resolution = resolutions['year']
119     return resolution
120
121
122 def select_sensordata(sensor_id, sensor_type, begin, end):
123     query = Sensors.query
124     if sensor_id is not None:
125         query = query.filter(Sensors.sensor_id == sensor_id)
126     if sensor_type is not None:
127         query = query.filter(Sensors.value_type == sensor_type)
128     if begin is not None:
129         query = query.filter(Sensors.timestamp >= begin)
130     if end is not None:
131         query = query.filter(Sensors.timestamp <= end)
132     return query.all()
133
134
135 def sensordata_to_xy(sensordata):
136     sensordata = list(sensordata)
137     x = np.array([d.timestamp for d in sensordata])
138     y = np.array([d.value for d in sensordata])
139     return x, y
140
141
142 def select_sensordata_grouped(sensor_id, sensor_type, begin, end):
143     # determine resolution (interval in seconds for data points)
144     resolution = calc_grouping_resolution(begin, end)
145
146     # Let the database do the grouping. Example in SQL (MySQL):
147     # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
148     query = db.session.query(func.to_seconds(Sensors.timestamp).op('div')(resolution).label('g'),
149             func.from_unixtime(func.avg(func.unix_timestamp(Sensors.timestamp))).label('timestamp'),
150             func.avg(Sensors.value).label('value'),
151             Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
152     if sensor_id is not None:
153         query = query.filter(Sensors.sensor_id == sensor_id)
154     if sensor_type is not None:
155         query = query.filter(Sensors.value_type == sensor_type)
156     query = query.filter(Sensors.timestamp >= begin)
157     query = query.filter(Sensors.timestamp <= end)
158     query = query.group_by('g', Sensors.sensor_id, Sensors.value_type, Sensors.sensor_name)
159     return query.all()
160
161
162 def select_openweatherdata(cityid, begin, end):
163     query = OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
164     if begin is not None:
165         query = query.filter(OpenWeatherMap.datetime >= begin)
166     if end is not None:
167         query = query.filter(OpenWeatherMap.datetime <= end)
168     return query.all()
169
170
171 def openweatherdata_to_xy(openweatherdata):
172     openweatherdata = list(openweatherdata)
173     x = np.array([d.datetime for d in openweatherdata])
174     y = np.array([d.temp for d in openweatherdata])
175     return x, y
176
177
178 def select_openweatherdata_grouped(cityid, begin, end):
179     # determine resolution (interval in seconds for data points)
180     resolution = calc_grouping_resolution(begin, end)
181
182     # Let the database do the grouping. Example in SQL (MySQL):
183     # select to_seconds(datetime) DIV (60*60*24) as interval_id, min(datetime), max(datetime), min(temp), avg(temp), max(temp), count(temp) from openweathermap group by interval_id order by interval_id;
184     query = db.session.query(func.to_seconds(OpenWeatherMap.datetime).op('div')(resolution).label('g'),
185             func.from_unixtime(func.avg(func.unix_timestamp(OpenWeatherMap.datetime))).label('datetime'),
186             func.avg(OpenWeatherMap.temp).label('temp'),
187             OpenWeatherMap.cityid)
188     OpenWeatherMap.query.filter(OpenWeatherMap.cityid == cityid)
189     query = query.filter(OpenWeatherMap.datetime >= begin)
190     query = query.filter(OpenWeatherMap.datetime <= end)
191     query = query.group_by('g', OpenWeatherMap.cityid)
192     return query.all()
193
194
195 def convert_to_c3(result, id, field_x, field_y):
196     c3result = defaultdict(list)
197     for row in result:
198         c3result[str(getattr(row, id))].append(getattr(row, field_y))
199         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
200         c3result[str(getattr(row, id)) + '_x'].append(dt)
201     return c3result
202
203
204 def request_arg(key, type, default=None):
205     """Returns the key from the request if available, otherwise the default value.
206     In case type is provided and the key is present, the value is converted by calling type.
207     In other words: Reimplement request.args.get but don't return default value if
208     type raises a ValueError."""
209     if key in request.args:
210         try:
211             return type(request.args[key])
212         except ValueError as e:
213             abort(Response(str(e), 400))
214     else:
215         return default
216
217
218 def sensordata(sensor_id=None, sensor_type=None):
219     begin = request_arg('begin', parse_datetime)
220     end = request_arg('end', parse_datetime)
221     mode = request.args.get('mode', 'full')
222     format = request.args.get('format', 'default')
223
224     if mode == 'full':
225         result = select_sensordata(sensor_id, sensor_type, begin, end)
226     elif mode == 'consolidated':
227         if begin is None or end is None:
228             abort(Response('begin and end have to be set for mode==consolidated', 400))
229         result = select_sensordata_grouped(sensor_id, sensor_type, begin, end)
230     else:
231         abort(Response('unknown value for mode', 400))
232
233     if format == 'c3':
234         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
235     return result
236
237
238 def openweathermapdata(cityid):
239     begin = request_arg('begin', parse_datetime)
240     end = request_arg('end', parse_datetime)
241     mode = request.args.get('mode', 'full')
242     format = request.args.get('format', 'default')
243
244     if mode == 'full':
245         result = select_openweatherdata(cityid, begin, end)
246     elif mode == 'consolidated':
247         if begin is None or end is None:
248             abort(Response('begin and end have to be set for mode==consolidated', 400))
249         result = select_openweatherdata_grouped(cityid, begin, end)
250     else:
251         abort(Response('unknown value for mode', 400))
252
253     if format == 'c3':
254         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
255     return result
256
257
258 def currentairtemperature(cityid):
259     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
260     return result.temp, result.datetime
261
262
263 def currentwatertemperature(sensorid):
264     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
265     return result.value, result.timestamp
266
267
268 def first_of_month(date, month):
269     date = date.replace(day=1)
270     if month == 0:
271         return date
272     if month == 1:
273         return (date + datetime.timedelta(days=42)).replace(day=1)
274     if month == -1:
275         return (date - datetime.timedelta(days=1)).replace(day=1)
276     assert False
277
278
279 @app.route('/api/<version>/sensors/')
280 def sensors(version):
281     """List all sensors found in the database"""
282     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
283     return jsonify(result)
284
285
286 @app.route('/api/<version>/sensor/id/<sensor_id>')
287 def sensorid(version, sensor_id):
288     """Return all data for a specific sensor
289
290     URL parameters:
291
292     * ``begin=<datetime>``, optional, format like ``2018-05-19T21:07:53``
293     * ``end=<datetime>``, optional, format like ``2018-05-19T21:07:53``
294     * ``mode=<full|consolidated>``, optional. return all rows (default) or with lower resolution (for charts)
295     * ``format=<default|c3>``, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
296     """
297     result = sensordata(sensor_id=sensor_id)
298     return jsonify(result)
299
300
301 @app.route('/api/<version>/sensor/type/<sensor_type>')
302 def sensortype(version, sensor_type):
303     """Return all data for a specific sensor type
304
305     URL parameters:
306
307     * ``begin=<datetime>``, optional, format like ``2018-05-19T21:07:53``
308     * ``end=<datetime>``, optional, format like ``2018-05-19T21:07:53``
309     * ``mode=<full|consolidated>``, optional. return all rows (default) or with lower resolution (for charts)
310     * ``format=<default|c3>``, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
311     """
312     result = sensordata(sensor_type=sensor_type)
313     return jsonify(result)
314
315
316 @app.route('/api/<version>/openweathermap/cities')
317 def openweathermap_cities(version):
318     """List all city IDs found in the database"""
319     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
320     return jsonify(result)
321
322
323 @app.route('/api/<version>/openweathermap/city/<cityid>')
324 def openweathermap_city(version, cityid):
325     """List all data found for a city"""
326     result = openweathermapdata(cityid=cityid)
327     return jsonify(result)
328
329
330 @app.route('/api/<version>/currentairtemperature')
331 def currentair(version):
332     value, timestamp = currentairtemperature(cityid)
333     return jsonify({"value": value, "timestamp": timestamp})
334
335
336 @app.route('/api/<version>/currentwatertemperature')
337 def currentwater(version):
338     value, timestamp = currentwatertemperature(mainsensor)
339     return jsonify({"value": value, "timestamp": timestamp})
340
341
342 @app.route('/report/<int(fixed_digits=4):year>/<int(fixed_digits=2):month>')
343 def report(year, month):
344     """Report for given year (4 digits) and month (2 digits)
345     """
346     paper_size = (29.7 / 2.54, 21. / 2.54)  # A4
347
348     begin = datetime.datetime(year, month, 1)
349     end = first_of_month(begin, 1)
350
351     water_data = sensordata_to_xy(select_sensordata(mainsensor, 'Wassertemperatur', begin, end))
352     air_data = openweatherdata_to_xy(select_openweatherdata(cityid, begin, end))
353
354     report_times = [datetime.time(10), datetime.time(15)]
355     report_data = {'Wasser': water_data, 'Luft': air_data}
356
357     days_datetime = []
358     d = begin
359     while d < end:
360         days_datetime.append(d)
361         d = d + datetime.timedelta(1)
362
363     binary_pdf = io.BytesIO()
364     with PdfPages(binary_pdf) as pdf:
365         title = 'Seepark Obsteig {} {}'.format(MONTH_DE[begin.month-1], begin.year)
366
367         # graphic
368         plt.figure(figsize=paper_size)
369         report_colors = []
370         for label, data in sorted(report_data.items(), reverse=True):
371             x, y = data
372             lines = plt.plot(x, y, label=label)
373             report_colors.append(lines[0].get_color())
374         plt.xticks(days_datetime, [''] * len(days_datetime))
375         plt.ylabel('Temperatur in °C')
376         plt.axis(xmin=begin, xmax=end)
377         plt.legend()
378         plt.grid()
379         plt.title(title)
380
381         # table
382         columns = []
383         for d in days_datetime:
384             columns.append('{}.'.format(d.day))
385         rows = []
386         for label in sorted(report_data.keys(), reverse=True):
387             for t in report_times:
388                 rows.append('{:02d}:{:02d} {} °C'.format(t.hour, t.minute, label))
389         cells = []
390         for label, data in sorted(report_data.items(), reverse=True):
391             for t in report_times:
392                 row_cells = []
393                 x, y = data
394                 for d in days_datetime:
395                     report_datetime = datetime.datetime.combine(d.date(), t)
396                     closest_index = np.argmin(np.abs(x - report_datetime))
397                     if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
398                         cell = 'N/A'
399                     else:
400                         value = y[closest_index]
401                         cell = '{:.1f}'.format(value)
402                     row_cells.append(cell)
403                 cells.append(row_cells)
404         row_colors = list(ntimes(report_colors + ['w'], len(report_times)))
405         table = plt.table(cellText=cells, colLabels=columns, rowLabels=rows, rowColours=row_colors, loc='bottom')
406         table.scale(xscale=1, yscale=2)
407         plt.title(title)
408         plt.subplots_adjust(left=0.15, right=0.97, bottom=0.3)  # do not cut row labels
409         pdf.savefig()
410
411         pdf_info = pdf.infodict()
412         pdf_info['Title'] = title
413         pdf_info['Author'] = 'Chrisu Jähnl'
414         pdf_info['Subject'] = 'Temperaturen'
415         pdf_info['Keywords'] = 'Seepark Obsteig'
416         pdf_info['CreationDate'] = datetime.datetime.now()
417         pdf_info['ModDate'] = datetime.datetime.today()
418
419     response = make_response(binary_pdf.getvalue())
420     response.headers['Content-Type'] = 'application/pdf'
421     response.headers['Content-Disposition'] = 'attachment; filename=seepark_{:04d}-{:02d}.pdf'.format(year, month)
422     return response
423
424
425 @app.route("/")
426 def index():
427     airvalue, airtime     = currentairtemperature(cityid)
428     watervalue, watertime = currentwatertemperature(mainsensor)
429     this_month = first_of_month(datetime.date.today(), 0)
430     last_month = first_of_month(this_month, -1)
431
432     return render_template(
433         'seepark_web.html',
434         apikey=apikey,
435         watervalue=watervalue,
436         watertime=watertime,
437         airvalue=airvalue,
438         airtime=airtime,
439         this_month=this_month,
440         last_month=last_month
441     )