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