Update of autogenerated documentation.
[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
311     * ``begin=<datetime>``, optional, format like ``2018-05-19T21:07:53``
312     * ``end=<datetime>``, optional, format like ``2018-05-19T21:07:53``
313     * ``mode=<full|consolidated>``, optional. return all rows (default) or with lower resolution (for charts)
314     * ``format=<default|c3>``, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
315     """
316     result = sensordata(sensor_id=sensor_id)
317     return jsonify(result)
318
319
320 @app.route('/api/<version>/sensor/type/<sensor_type>')
321 def sensortype(version, sensor_type):
322     """Return all data for a specific sensor type
323
324     URL parameters:
325
326     * ``begin=<datetime>``, optional, format like ``2018-05-19T21:07:53``
327     * ``end=<datetime>``, optional, format like ``2018-05-19T21:07:53``
328     * ``mode=<full|consolidated>``, optional. return all rows (default) or with lower resolution (for charts)
329     * ``format=<default|c3>``, optional. return result as returned by sqlalchemy (default) or formatted for c3.js
330     """
331     result = sensordata(sensor_type=sensor_type)
332     return jsonify(result)
333
334
335 @app.route('/api/<version>/openweathermap/cities')
336 def openweathermap_cities(version):
337     """List all city IDs found in the database"""
338     result = db.session.query(OpenWeatherMap.cityid).distinct().all()
339     return jsonify(result)
340
341
342 @app.route('/api/<version>/openweathermap/city/<cityid>')
343 def openweathermap_city(version, cityid):
344     """List all data found for a city"""
345     result = openweathermapdata(cityid=cityid)
346     return jsonify(result)
347
348
349 @app.route('/api/<version>/currentairtemperature')
350 def currentair(version):
351     value, timestamp = currentairtemperature(cityid)
352     return jsonify({"value": value, "timestamp": timestamp})
353
354
355 @app.route('/api/<version>/currentwatertemperature')
356 def currentwater(version):
357     value, timestamp = currentwatertemperature(mainsensor)
358     return jsonify({"value": value, "timestamp": timestamp})
359
360
361 @app.route('/report/<int:year>/<int:month>')
362 def report(year, month):
363     """Report for given year and month
364     """
365     paper_size = (29.7 / 2.54, 21. / 2.54)  # A4
366
367     begin = datetime.datetime(year, month, 1)
368     end = add_month(begin)
369
370     water_data = sensordata_to_xy(select_sensordata(mainsensor, 'Wassertemperatur', begin, end))
371     air_data = openweatherdata_to_xy(select_openweatherdata(cityid, begin, end))
372     swimmer_data = swimmerdata_to_xy(select_swimmerdata(begin, end))
373
374     report_times = [datetime.time(10), datetime.time(15)]
375     report_data = {'Wasser': water_data, 'Luft': air_data}
376
377     days_datetime = []
378     d = begin
379     while d < end:
380         days_datetime.append(d)
381         d = d + datetime.timedelta(1)
382
383     binary_pdf = io.BytesIO()
384     with PdfPages(binary_pdf) as pdf:
385         title = 'Seepark Obsteig {} {}'.format(MONTH_DE[begin.month-1], begin.year)
386
387         # graphic
388         plt.figure(figsize=paper_size)
389         report_colors = []
390         for label, data in sorted(report_data.items(), reverse=True):
391             x, y = data
392             lines = plt.plot(x, y, label=label)
393             report_colors.append(lines[0].get_color())
394         plt.xticks(days_datetime, [''] * len(days_datetime))
395         plt.ylabel('Temperatur in °C')
396         plt.axis(xmin=begin, xmax=end)
397         plt.legend()
398         plt.grid()
399         plt.title(title)
400
401         # table
402         columns = []
403         for d in days_datetime:
404             columns.append('{}.'.format(d.day))
405         rows = []
406         for label in sorted(report_data.keys(), reverse=True):
407             for t in report_times:
408                 rows.append('{:02d}:{:02d} {} °C'.format(t.hour, t.minute, label))
409         for t in report_times:
410             rows.append('{:02d}:{:02d} Badende'.format(t.hour, t.minute))
411         cells = []
412         for label, data in sorted(report_data.items(), reverse=True):
413             for t in report_times:
414                 row_cells = []
415                 x, y = data
416                 for d in days_datetime:
417                     report_datetime = datetime.datetime.combine(d.date(), t)
418                     closest_index = np.argmin(np.abs(x - report_datetime))
419                     if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
420                         cell = 'N/A'
421                     else:
422                         value = y[closest_index]
423                         cell = '{:.1f}'.format(value)
424                     row_cells.append(cell)
425                 cells.append(row_cells)
426         for t in report_times:
427             row_cells = []
428             x, y = swimmer_data
429             for d in days_datetime:
430                 report_datetime = datetime.datetime.combine(d.date(), t)
431                 closest_index = np.argmin(np.abs(x - report_datetime))
432                 if abs(x[closest_index] - report_datetime) > datetime.timedelta(hours=1):
433                     cell = 'N/A'
434                 else:
435                     cell = y[closest_index]
436                 row_cells.append(cell)
437             cells.append(row_cells)
438         row_colors = list(ntimes(report_colors + ['w'], len(report_times)))
439         table = plt.table(cellText=cells, colLabels=columns, rowLabels=rows, rowColours=row_colors, loc='bottom')
440         table.scale(xscale=1, yscale=2)
441         plt.title(title)
442         plt.subplots_adjust(left=0.15, right=0.97, bottom=0.3)  # do not cut row labels
443         pdf.savefig()
444
445         pdf_info = pdf.infodict()
446         pdf_info['Title'] = title
447         pdf_info['Author'] = 'Chrisu Jähnl'
448         pdf_info['Subject'] = 'Temperaturen'
449         pdf_info['Keywords'] = 'Seepark Obsteig'
450         pdf_info['CreationDate'] = datetime.datetime.now()
451         pdf_info['ModDate'] = datetime.datetime.today()
452
453     response = make_response(binary_pdf.getvalue())
454     response.headers['Content-Type'] = 'application/pdf'
455     response.headers['Content-Disposition'] = 'attachment; filename=seepark_{:04d}-{:02d}.pdf'.format(year, month)
456     return response
457
458
459 @app.route("/")
460 def index():
461     airvalue, airtime     = currentairtemperature(cityid)
462     watervalue, watertime = currentwatertemperature(mainsensor)
463
464     return render_template(
465         'seepark_web.html',
466         apikey=apikey,
467         watervalue=watervalue,
468         watertime=watertime,
469         airvalue=airvalue,
470         airtime=airtime,
471     )