]> ToastFreeware Gitweb - chrisu/seepark.git/blob - web/seepark_web.py
Colorize row labels.
[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 estimate_swimmer_count(date):
196     return date.day
197
198
199 def select_swimmerdata(begin, end):
200     def report_times(begin, end):
201         d = begin
202         while d < end:
203             for t in [10, 15]:
204                 a = datetime.datetime.combine(d.date(), datetime.time(t))
205                 if a >= d:
206                     yield a
207             d += datetime.timedelta(days=1)
208     SwimmerData = collections.namedtuple('SwimmerData', ['datetime', 'count'])
209     for d in report_times(begin, end):
210         count = estimate_swimmer_count(d)
211         yield SwimmerData(d, count)
212
213
214 def swimmerdata_to_xy(swimmerdata):
215     swimmerdata = list(swimmerdata)
216     x = np.array([d.datetime for d in swimmerdata])
217     y = np.array([d.count for d in swimmerdata])
218     return x, y
219
220
221 def convert_to_c3(result, id, field_x, field_y):
222     c3result = defaultdict(list)
223     for row in result:
224         c3result[str(getattr(row, id))].append(getattr(row, field_y))
225         dt = getattr(row, field_x).strftime('%Y-%m-%d %H:%M:%S')
226         c3result[str(getattr(row, id)) + '_x'].append(dt)
227     return c3result
228
229
230 def request_arg(key, type, default=None):
231     """Returns the key from the request if available, otherwise the default value.
232     In case type is provided and the key is present, the value is converted by calling type.
233     In other words: Reimplement request.args.get but don't return default value if
234     type raises a ValueError."""
235     if key in request.args:
236         try:
237             return type(request.args[key])
238         except ValueError as e:
239             abort(Response(str(e), 400))
240     else:
241         return default
242
243
244 def sensordata(sensor_id=None, sensor_type=None):
245     begin = request_arg('begin', parse_datetime)
246     end = request_arg('end', parse_datetime)
247     mode = request.args.get('mode', 'full')
248     format = request.args.get('format', 'default')
249
250     if mode == 'full':
251         result = select_sensordata(sensor_id, sensor_type, begin, end)
252     elif mode == 'consolidated':
253         if begin is None or end is None:
254             abort(Response('begin and end have to be set for mode==consolidated', 400))
255         result = select_sensordata_grouped(sensor_id, sensor_type, begin, end)
256     else:
257         abort(Response('unknown value for mode', 400))
258
259     if format == 'c3':
260         return convert_to_c3(result, 'sensor_id', 'timestamp', 'value')
261     return result
262
263
264 def openweathermapdata(cityid):
265     begin = request_arg('begin', parse_datetime)
266     end = request_arg('end', parse_datetime)
267     mode = request.args.get('mode', 'full')
268     format = request.args.get('format', 'default')
269
270     if mode == 'full':
271         result = select_openweatherdata(cityid, begin, end)
272     elif mode == 'consolidated':
273         if begin is None or end is None:
274             abort(Response('begin and end have to be set for mode==consolidated', 400))
275         result = select_openweatherdata_grouped(cityid, begin, end)
276     else:
277         abort(Response('unknown value for mode', 400))
278
279     if format == 'c3':
280         return convert_to_c3(result, 'cityid', 'datetime', 'temp')
281     return result
282
283
284 def currentairtemperature(cityid):
285     result = OpenWeatherMap.query.filter_by(cityid=cityid).order_by(OpenWeatherMap.datetime.desc()).first()
286     return result.temp, result.datetime
287
288
289 def currentwatertemperature(sensorid):
290     result = Sensors.query.filter_by(sensor_id=sensorid).order_by(Sensors.timestamp.desc()).first()
291     return result.value, result.timestamp
292
293
294 def add_month(date):
295     return (date + datetime.timedelta(days=42)).replace(day=1)
296
297
298 @app.route('/api/<version>/sensors/')
299 def sensors(version):
300     """List all sensors found in the database"""
301     result = db.session.query(Sensors.sensor_id, Sensors.sensor_name, Sensors.value_type).distinct().all()
302     return jsonify(result)
303
304
305 @app.route('/api/<version>/sensor/id/<sensor_id>')
306 def sensorid(version, sensor_id):
307     """Return all data for a specific sensor
308
309     URL parameters:
310     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
311     end=<datetime>, optional, format like "2018-05-19T21:07:53"
312     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
313     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
314     """
315     result = sensordata(sensor_id=sensor_id)
316     return jsonify(result)
317
318
319 @app.route('/api/<version>/sensor/type/<sensor_type>')
320 def sensortype(version, sensor_type):
321     """Return all data for a specific sensor type
322
323     URL parameters:
324     begin=<datetime>, optional, format like "2018-05-19T21:07:53"
325     end=<datetime>, optional, format like "2018-05-19T21:07:53"
326     mode=<full|consolidated>, optional. return all rows (default) or with lower resolution (for charts)
327     format=<default|c3>, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
328     """
329     result = sensordata(sensor_type=sensor_type)
330     return jsonify(result)
331
332
333 @app.route('/api/<version>/openweathermap/cities')
334 def openweathermap_cities(version):
335     """List all city IDs found in the database"""
336     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
337     return jsonify(result)
338
339
340 @app.route('/api/<version>/openweathermap/city/<cityid>')
341 def openweathermap_city(version, cityid):
342     """List all data found for a city"""
343     result = openweathermapdata(cityid=cityid)
344     return jsonify(result)
345
346
347 @app.route('/api/<version>/currentairtemperature')
348 def currentair(version):
349     value, timestamp = currentairtemperature(cityid)
350     return jsonify({"value": value, "timestamp": timestamp})
351
352
353 @app.route('/api/<version>/currentwatertemperature')
354 def currentwater(version):
355     value, timestamp = currentwatertemperature(mainsensor)
356     return jsonify({"value": value, "timestamp": timestamp})
357
358
359 @app.route('/report/<int:year>-<int:month>')
360 def report(year, month):
361     paper_size = (29.7 / 2.54, 21. / 2.54)  # A4
362
363     begin = datetime.datetime(year, month, 1)
364     end = add_month(begin)
365
366     water_data = sensordata_to_xy(select_sensordata(mainsensor, 'Wassertemperatur', begin, end))
367     air_data = openweatherdata_to_xy(select_openweatherdata(cityid, begin, end))
368     swimmer_data = swimmerdata_to_xy(select_swimmerdata(begin, end))
369
370     report_times = [datetime.time(10), datetime.time(15)]
371     report_data = {'Wasser': water_data, 'Luft': air_data}
372
373     days_datetime = []
374     d = begin
375     while d < end:
376         days_datetime.append(d)
377         d = d + datetime.timedelta(1)
378
379     binary_pdf = io.BytesIO()
380     with PdfPages(binary_pdf) as pdf:
381         title = 'Seepark Obsteig {} {}'.format(MONTH_DE[begin.month-1], begin.year)
382
383         # graphic
384         plt.figure(figsize=paper_size)
385         report_colors = []
386         for label, data in sorted(report_data.items(), reverse=True):
387             x, y = data
388             lines = plt.plot(x, y, label=label)
389             report_colors.append(lines[0].get_color())
390         plt.xticks(days_datetime, [''] * len(days_datetime))
391         plt.ylabel('Temperatur in °C')
392         plt.axis(xmin=begin, xmax=end)
393         plt.legend()
394         plt.grid()
395         plt.title(title)
396
397         # table
398         columns = []
399         for d in days_datetime:
400             columns.append('{}.'.format(d.day))
401         rows = []
402         for label in sorted(report_data.keys(), reverse=True):
403             for t in report_times:
404                 rows.append('{:02d}:{:02d} {} °C'.format(t.hour, t.minute, label))
405         for t in report_times:
406             rows.append('{:02d}:{:02d} Badende'.format(t.hour, t.minute))
407         cells = []
408         for label, data in sorted(report_data.items(), reverse=True):
409             for t in report_times:
410                 row_cells = []
411                 x, y = data
412                 for d in days_datetime:
413                     report_datetime = datetime.datetime.combine(d.date(), t)
414                     closest_index = np.argmin(np.abs(x - report_datetime))
415                     if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
416                         cell = 'N/A'
417                     else:
418                         value = y[closest_index]
419                         cell = '{:.1f}'.format(value)
420                     row_cells.append(cell)
421                 cells.append(row_cells)
422         for t in report_times:
423             row_cells = []
424             x, y = swimmer_data
425             for d in days_datetime:
426                 report_datetime = datetime.datetime.combine(d.date(), t)
427                 closest_index = np.argmin(np.abs(x - report_datetime))
428                 if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
429                     cell = 'N/A'
430                 else:
431                     cell = y[closest_index]
432                 row_cells.append(cell)
433             cells.append(row_cells)
434         row_colors = list(ntimes(report_colors + ['w'], len(report_times)))
435         table = plt.table(cellText=cells, colLabels=columns, rowLabels=rows, rowColours=row_colors, loc='bottom')
436         table.scale(xscale=1, yscale=2)
437         plt.title(title)
438         plt.subplots_adjust(left=0.15, right=0.97, bottom=0.3)  # do not cut row labels
439         pdf.savefig()
440
441         pdf_info = pdf.infodict()
442         pdf_info['Title'] = title
443         pdf_info['Author'] = 'Chrisu Jähnl'
444         pdf_info['Subject'] = 'Temperaturen'
445         pdf_info['Keywords'] = 'Seepark Obsteig'
446         pdf_info['CreationDate'] = datetime.datetime.now()
447         pdf_info['ModDate'] = datetime.datetime.today()
448
449     response = make_response(binary_pdf.getvalue())
450     response.headers['Content-Type'] = 'application/pdf'
451     response.headers['Content-Disposition'] = 'attachment; filename=seepark_{:04d}-{:02d}.pdf'.format(year, month)
452     return response
453
454
455 @app.route("/")
456 def index():
457     airvalue, airtime     = currentairtemperature(cityid)
458     watervalue, watertime = currentwatertemperature(mainsensor)
459
460     return render_template(
461         'seepark_web.html',
462         apikey=apikey,
463         watervalue=watervalue,
464         watertime=watertime,
465         airvalue=airvalue,
466         airtime=airtime,
467     )