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