diff --git a/bin/coronaflask b/bin/coronaflask new file mode 100755 index 0000000..019e892 --- /dev/null +++ b/bin/coronaflask @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from coronaflask.app import app + +if __name__ == '__main__': + app.run() diff --git a/coronaflask.wsgi b/coronaflask.wsgi new file mode 100644 index 0000000..c369aba --- /dev/null +++ b/coronaflask.wsgi @@ -0,0 +1 @@ +from coronaflask.app import app as application diff --git a/coronaflask/__init__.py b/coronaflask/__init__.py new file mode 100644 index 0000000..b7e069f --- /dev/null +++ b/coronaflask/__init__.py @@ -0,0 +1,2 @@ +from . import app +from . import corona \ No newline at end of file diff --git a/coronaflask/app.py b/coronaflask/app.py new file mode 100644 index 0000000..8f7ce10 --- /dev/null +++ b/coronaflask/app.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +from flask import Flask, render_template +from markupsafe import escape + +if __package__ is None: + import corona +else: + from . import corona + +app = Flask(__name__) +Cor = corona.Corona() + + +@app.route("/") +def world(): + return cor('World') + + +@app.route('/') +def cor(place): + return render_template('index.html', place=place, figure=Cor.plot_cases(escape(place)), regions=Cor.regions) + + +def main(): + app.run() + + +if __name__ == '__main__': + main() diff --git a/coronaflask/corona.py b/coronaflask/corona.py new file mode 100644 index 0000000..0a96056 --- /dev/null +++ b/coronaflask/corona.py @@ -0,0 +1,181 @@ +import os +import re +import datetime +import pandas +import numpy as np +from matplotlib.figure import Figure +import matplotlib.dates as mdates +import mpld3 +import requests +import io + + +class Plots: + @staticmethod + def get_date(date_s): + date_d = {k: int(i) + (2000 if k == 'year' else 0) for i, k in zip(date_s.split('/'), ('month', 'day', 'year'))} + date_o = datetime.datetime(**date_d) + return int(mdates.date2num(date_o) + 0.5), date_s, date_o + + @staticmethod + def fmt_axis(ax): + now = int(mdates.date2num(datetime.datetime.now()) + 0.5) + ax.set_xlim(now - 100, now) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d')) + ax.xaxis.set_major_locator(mdates.DayLocator(interval=5)) + xy = [a.get_xydata() for a in ax.get_children() if hasattr(a, 'get_xydata')] + if len(xy): + xy = np.vstack(xy) + ax.set_ylim(Plots.getylim(xy[:, 1], xy[:, 0], ax.get_xlim(), log=ax.get_xscale() == 'log')) + + @staticmethod + def getylim(y, x=None, xlim=None, margin=0.05, log=False): + """ get limits for plots according to data + copied from matplotlib.axes._base.autoscale_view + + y: the y data + optional, for when xlim is set manually on the plot + x: corresponding x data + xlim: limits on the x-axis in the plot, example: xlim=(0, 100) + margin: what fraction of white-space to have at all borders + y and x can be lists or tuples of different data in the same plot + + wp@tl20191220 + """ + y = np.array(y).flatten() + if log: + y = np.log(y) + if x is not None and xlim is not None: + x = np.array(x).flatten() + y = y[(np.nanmin(xlim) < x)*(x < np.nanmax(xlim))*(np.abs(x) > 0)] + if not np.any(np.isfinite(y)): + return 0, 1 + if len(y) == 0: + return -margin, margin + y0t, y1t = np.nanmin(y), np.nanmax(y) + if np.isfinite(y1t) and np.isfinite(y0t): + delta = (y1t - y0t) * margin + if y0t == y1t: + delta = 0.5 + else: # If at least one bound isn't finite, set margin to zero + delta = 0 + if log: + return np.exp(y0t - delta), np.exp(y1t + delta) + else: + return y0t - delta, y1t + delta + + @staticmethod + def transform(a, b, x, xlim): + return np.polyval(np.polyfit(Plots.getylim(a, x, xlim), Plots.getylim(b, x, xlim), 1), a) + + +class Corona: + def __init__(self): + self.home = os.path.dirname(__file__) + self.update() + + def update(self): + self.last_update = datetime.datetime.now() + self.confirmed_global = self.get_data( + 'https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_confirmed_global.csv') + self.deaths_global = self.get_data( + 'https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_deaths_global.csv') + self.regions = sorted(self.confirmed_global['Province/State'].tolist()) + + @staticmethod + def get_data(url): + with requests.get(url, allow_redirects=True) as r: + with io.BytesIO(r.content) as b: + p = pandas.read_csv(b) + + null = p['Province/State'].isnull() + p.loc[null, 'Province/State'] = p.loc[null, 'Country/Region'] + + # make series searchable by region + countries = p['Country/Region'].unique() + states = p['Province/State'].unique() + for country in countries: + if country not in states: + ca = p.query('`Country/Region`=="{}"'.format(country)) + cb = ca.sum(axis=0) + cb['Province/State'] = country + cb['Country/Region'] = country + cb['Lat'] /= len(ca) + cb['Long'] /= len(ca) + p.loc[p.index.max() + 1] = cb + + # make World entry by summing + w = np.sum(p) + w.name = p.index.max() + 1 + w['Province/State'] = 'World' + w['Country/Region'] = 'World' + w['Lat'] = 0 + w['Long'] = 0 + return p.append(w) + + @staticmethod + def plot_series(fig, ax, dates_number, dates_obj, cum, llabel=None, rlabel=None): + ax.plot(dates_obj, cum, 'ro-') + Plots.fmt_axis(ax) + ax.set_ylim(Plots.getylim(cum, dates_number, ax.get_xlim())) + + dcum = cum - np.interp(dates_number, dates_number + 1, cum) + bx = ax.twinx() + p, = bx.plot(dates_obj, Plots.transform(cum, dcum, dates_number, ax.get_xlim()), 'o', color='none') + mpld3.plugins.connect(fig, mpld3.plugins.PointLabelTooltip(p, labels=[f'{n:.2g}' for n in cum])) + p, = bx.plot(dates_obj, dcum, 'ko-') + bx.patch.set_alpha(0.0) + mpld3.plugins.connect(fig, mpld3.plugins.PointLabelTooltip(p, labels=[f'{n:.2g}' for n in dcum])) + Plots.fmt_axis(bx) + if llabel is not None: + ax.set_ylabel(llabel, color='r') + if rlabel is not None: + bx.set_ylabel(rlabel, color='k') + + def plot_cases(self, place, sicktime=14): + if datetime.datetime.now() - self.last_update > datetime.timedelta(hours=1): + self.update() + + n = self.confirmed_global.query(f'`Province/State`=="{place}"') + d = self.deaths_global.query(f'`Province/State`=="{place}"') + if n.empty: + n = self.confirmed_global.query(f'`Country/Region`=="{place}"') + d = self.deaths_global.query(f'`Country/Region`=="{place}"') + n = pandas.DataFrame(n.sum()).T + d = pandas.DataFrame(d.sum()).T + + dates = [Plots.get_date(c) for c in n.columns if re.match('[\d/]+', c) is not None] + + dates_number, dates_str, dates_obj = zip(*sorted(dates)) + sick_cum = np.array([int(n[date]) for date in dates_str]) + death_cum = np.array([int(d[date]) for date in dates_str]) + dates_number = np.array(dates_number) + sick_now = sick_cum - np.interp(dates_number, dates_number + sicktime, sick_cum) + + fig = Figure(figsize=(16, 8), dpi=100) + ax = fig.add_subplot(4, 1, 1) + self.plot_series(fig, ax, dates_number, dates_obj, sick_cum, 'sick cumulative', 'daily new cases') + ax.set_title('cumulative and new daily cases') + + ax = fig.add_subplot(4, 1, 2) + self.plot_series(fig, ax, dates_number, dates_obj, sick_now, 'sick now', 'daily change') + ax.set_title('current sick and daily change') + + ax = fig.add_subplot(4, 1, 3) + reproduction_rate = sick_now / np.clip(np.interp(dates_number, dates_number + 4, sick_now), 1e-15, np.inf) + p, = ax.plot(dates_obj, np.clip(reproduction_rate, 0, 3), 'ro-') + ax.plot((np.nanmin(dates_number), np.nanmax(dates_number)), (1, 1), '--k') + mpld3.plugins.connect(fig, mpld3.plugins.PointLabelTooltip(p, labels=[f'{n:.2f}' for n in reproduction_rate])) + Plots.fmt_axis(ax) + ax.set_ylim(0.5, 1.5) + ax.set_title('reproduction rate') + ax.set_ylabel('reproduction rate', color='r') + + ax = fig.add_subplot(4, 1, 4) + self.plot_series(fig, ax, dates_number, dates_obj, death_cum, 'diseased cumulative', 'daily diseased') + ax.set_title('cumulative and new deaths') + + fig.autofmt_xdate() + fig.tight_layout(h_pad=2) + + return mpld3.fig_to_html(fig) diff --git a/coronaflask/templates/index.html b/coronaflask/templates/index.html new file mode 100644 index 0000000..7047510 --- /dev/null +++ b/coronaflask/templates/index.html @@ -0,0 +1,13 @@ + + + + {{place}} + + + {{figure|safe}} +
+ {% for region in regions %} + {{region}} + {% endfor %} + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2bd04d6 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +import os +import setuptools + +with open('README.md', 'r') as fh: + long_description = fh.read() + +setuptools.setup( + name='coronaflask', + version='2022.1.0', + author='Wim Pomp', + author_email='wimpomp@gmail', + description='Flask for a corona graph plotting website.', + long_description=long_description, + long_description_content_type='text/markdown', + url='', + packages=setuptools.find_packages(), + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Operating System :: OS Independent', + ], + python_requires='>=3.8', + install_requires=['waitress', 'flask', 'markupsafe', 'pandas', 'numpy', 'matplotlib', 'requests', 'mpld3'], + scripts=[os.path.join('bin', script) for script in + os.listdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bin'))], +)