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