Sprache auswählen

Corona ist in aller Munde, daher kann es nicht schaden, sich seine eigene Visualisierung zu basteln, um das Virus im Blick zu halten. Die folgende Visu ist so gemacht, dass sie auf dem 7" Display meines RaspberryPi ansehbar und bedienbar ist. Wer einen größeren Bildschirm hat, kann natürlich noch mehr Informationen anzeigen. Das Kochrezept ist aber immer das selbe.

 

Voraussetzungen

Damit die App funktioniert solltet Ihr Python, Kivy und die Matplotlib installiert haben.

Die App / Das Widget

Das folgende Programm kann entweder standalone als eigenständige App betrieben werden oder in eine andere App als Widget eingebunden werden. Am Ende des Eintrags findet Ihr den Link zu Github, wo Ihr euch den vollständig kommentierten Quellcode runterladen könnt.

Daten besorgen

Die Daten zur Visualisierung finden wir ganz einfach bei bspw. dem European Center for Disease Control. Kurz ECDC. Unter anderem kann man sich hier die täglichen Infektionszahlen in allen Ländern der Welt herunterladen. Ebenso steht einem der Zugang zu den aktuellen Fortschritten der Impfungen zur Verfügung. Es gibt sogar verschiedene Formate zur Auswahl. Ich entscheide mich hier für JSON. Solltet Ihr eine eigene App mit diesen Daten veröffentliche, so beachtet bitte die Copyright Hinweise des ECDC.

Das kv File

Wie in Kivy üblich, erstelle ich das Layout im kv File.

Zunächst definieren wir ein Layout, das die Darstellung aller weiteren Elemente übernimmt. Später im py File werdet Ihr sehen, dass es sich hierbei um ein Boxlayout handelt. Das sorgt dafür, dass meine Darstellung immer das gesamte Fenster einnimmt. Das Boxlayout stellt nur ein einziges Element, nämlich das CoronaWidget dar.

<CoronaTestLayout>:
    orientation: 'vertical'
    CoronaWidget:
        id: wdgt1

Das eigentliche Widget besteht im wesentlichen aus zwei, sogenannten Spinnern. Das sind DropDown Felder. Der erste Spinner zeigt die Kontinente an und der zweite die Länder des jeweiligen Kontinents. Das Updaten der Inhalte der Spinner läuft hier automatisch, denn die Eigenschaft values ist mit den jeweiligen Variablen in der zugehörigen Klasse im py File gebunden.

Gleiches mache ich mit den beiden Labels, die das Datum des letzten verfügbaren Eintrags, sowie die dazugehörige Zahl an Neuinfektionen anzeigen.

Der Plot wird ebenso mit den täglichen und kummulierten Infektionszahlen verbunden. Die Darstellung des Plots kann man über die Vorgabe der Linienfarben und Beschriftungen selbst noch etwas anpassen. Die verfügbaren Farben sind in der Doku von Matplotlib nachzulesen.

<CoronaWidget>:
    Spinner:
        id: spn1
        values: root.continents
        size_hint: .25, .15
        pos_hint: {'top':1, 'left':1}
        on_text:
            root.activeContinent = self.text
            root.countries = root.continentsAndCountries[self.text]
    Spinner:
        id: spn2
        values: root.countries
        size_hint: .25, .15
        pos_hint: {'top':1, 'x':.25}
        on_text:
            root.update_active_country_infection(self.text)
            root.update_active_country_vaccination(self.text)
    Label:
        id: lbl1
        text: 'Date'
        font_size: 15
        size_hint: .25, .05
        pos_hint: {'top':1, 'x':.5}
    Label:
        id: lbl2
        text: root.newInfectionsDate
        font_size: 25
        size_hint: .25, .1
        pos_hint: {'top':.95, 'x':.5}
    Label:
        id: lbl3
        text: 'Infections'
        font_size: 15
        size_hint: .25, .05
        pos_hint: {'top':1, 'x':.75}
    Label:
        id: lbl4
        text: root.newInfections
        font_size: 25
        size_hint: .25, .1
        pos_hint: {'top':.95, 'x':.75}
    ScrollView:
        id: scroll1
        size_hint: 1, .85
        pos_hint: {'top': .85, 'left':1}
        GridLayout:
            id: grid1
            size_hint: 1, None
            height: self.minimum_height
            cols: 1
            row_force_default: True
            row_default_height: root.height * 0.85
            TwoPlotsSharedXWidget:
                id: plt1
                sourceX: root.datesOfCases
                sourceY: [root.casesWeekly, root.cumulativeCases]
                units : ['','']
                titles : ['Cases / week','Cumulative Cases']
                ax_colors : ['r','b']
                formatter : '%d-%b'
            TwoPlotsSharedXWidget:
                id: plt2
                size_hint:1,1
                #pos_hint: {'top': .85, 'left':1}
                sourceX: root.datesOfVaccination
                sourceY: [root.vaccinationWeekly,[]]
                units : ['']
                titles : ['Vaccinations / week']
                ax_colors : ['r']
                formatter : '%d-%b'

Das Python File

Zunächst binden wir die notwendingen Module ein.

# encoding: utf-8

from kivy.app import App
from kivy.uix.relativelayout import RelativeLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.properties import DictProperty, ObjectProperty, StringProperty, ListProperty
from kivy.uix.spinner import Spinner
from kivy.garden.matplotlib.backend_kivyagg import FigureCanvasKivyAgg
import matplotlib.pyplot as plt
import matplotlib.dates as dt
from datetime import datetime
from time import mktime, strptime, strftime
import os
from kivy.network.urlrequest import UrlRequest
from TwoPlotsSharedXWidgetApp import TwoPlotsSharedXWidget as TwoPlotsSharedXWidget
import pandas as pd
from kivy.resources import resource_add_path
import os


Anschließend bauen wir das CoronaWidget auf.

Zunächsteinmal definieren wir alle Eigenschaften, die über Ihren Namen and die jeweiligen Variablen im kv File gebunden werden. Wo es Sinn macht, vergebe ich hier schon Standardwerte, damit die Anzeigen nicht so leer sind.

Die Funktionen download_data und get_http_to_json beschaffen sich die, auf der Homepage des ECDC vorhandenen Daten und speichern diese in ein JSON File mit dem Namen covid.json. 

Die Funktion update_dataset wird aufgerufen, wenn ein neuer Datensatz heruntergeladen wurde. Alle Informationen werden dann aus der covid.json Datei in die Variable dataset gespeichert.

Wenn ein neues Land ausgewählt wird, holt sich die Funktion update_active_country alle Daten und befüllt die Variablen für die täglichen Fälle, kummulierten Fälle und die aktuellen Fälle.

Die Funktion update_continent_spinner wird benötigt, um die Spinner initial mit allen Kontinenten und Ländern zu befüllen. Als Standardwert wähle ich hier Deutschland aus.

class CoronaWidget(RelativeLayout):
    '''Shows infections for the current date and plots for daily and cumulative infections.
        Attributes:
        The attributes (excluding dataset[]) are bound by name to propertis in the kv file. Updating them will automatically update the displayed data in the visualisation
            continentsAndCountries (DictProperty):
                contains continents and correspondant countries
                {'europe': ['germany', 'france', ...], 'asia': ['china', 'singapore', ...]}
            continents (ListProperty):
                list holding all continents. Retrieved from continentsAndCountries.
            countries (ListProperty):
                list holding all countries of the selected continent.
                Retrieved from continentsAndCountries
            activeCountry (StringProperty, str):
                currently active country
            activeContinent (StringProperty, str):
                currently active continent
            newInfections (StringProperty, str):
                Infections which were reported for the latest date within activeCountry
            newInfectionsDate (StringProperty, str):
                Date at which the newInfections were reported
            weeklyCases (ListProperty):
                List holding all infections per week in the order from latest to oldest
            cumulativeCases (ListProperty):
                List holding all added up infections in the order from latest to oldest
            datesOfCases (ListProperty):
                List holding all dates for the dailyCases and cumulativeCases
            notification  (StringProperty, str):
                Error string. Shows exceptions, like no data available.
                Initially set to --.
            dataset (list):
                list holding all the downloaded data
    '''

    continentsAndCountries = DictProperty({})
    continents = ListProperty([])
    countries = ListProperty([])
    activeCountry = StringProperty('Germany')
    activeContinent = StringProperty('Europe')
    newInfections = StringProperty('--')
    newInfectionsDate = StringProperty('--')
    casesWeekly = ListProperty([])
    cumulativeCases = ListProperty([])
    datesOfCases = ListProperty([])
    notification = StringProperty('')
    dataset = []

    dataset_vaccination = []
    vaccinationWeekly = ListProperty([])
    datesOfVaccination = ListProperty([])
    vaccines = {
        'AZ' : 'AstraZeneca',
        'JANSS' : 'Janssen',
        'MOD' : 'Moderna',
        'COM' : 'Pfizer / BioNTech',
        'SIN' : 'Sinovac',
        'SPU' : 'Sputnik V',
        'CN' : 'CNBG',
        'UNK' : 'Unknown'
    }


    def __init__(self, **kwargs):
        super(CoronaWidget, self).__init__(**kwargs)

    def download_data_infection(self, *args, **kwargs):
        '''download the current data from the ECDC. Infections and deaths by week from
        https://www.ecdc.europa.eu/en/publications-data/data-national-14-day-notification-rate-covid-19
        '''
        url = 'https://opendata.ecdc.europa.eu/covid19/nationalcasedeath/json/'

        UrlRequest(url=url, on_success=self.update_dataset_infection, on_error=self.download_error, on_progress=self.progress,
                   chunk_size=40960)

    def download_data_vaccination(self, *args, **kwargs):
        '''download the current data from the ECDC. Vaccination data from
        https://www.ecdc.europa.eu/en/publications-data/data-covid-19-vaccination-eu-eea
        '''
        url = 'https://opendata.ecdc.europa.eu/covid19/vaccine_tracker/json/'

        UrlRequest(url=url, on_success=self.update_dataset_vaccination, on_error=self.download_error, on_progress=self.progress,
                   chunk_size=40960)

    def update_dataset_infection(self, request, result):
        '''write result of request into variable self.dataset'''
        self.dataset = result  # data was json. Therefore directly decoded by the UrlRequest
        self.update_continent_spinner()
        self.update_active_country_infection(country=self.activeCountry)
        self.notification = ''

    def update_dataset_vaccination(self, request, result):
        self.dataset_vaccination = result['records']
        self.update_active_country_vaccination(country=self.activeCountry)

    def download_error(self, request, error):
        '''notify on error'''
        self.notification = 'data could not be downloaded'

    def progress(self, request, current_size, total_size):
        '''show progress to the user'''

        #modify. Two notifications necessary since two downloads in prallel
        self.notification = ('Downloading data: {} bytes of {} bytes'.format(current_size, total_size))

    def update_active_country_infection(self, country, *args, **kwargs):
        '''update all infection data for a new selected country'''

        self.activeCountry = country

        # update plot data for infections
        self.casesWeekly.clear()
        self.cumulativeCases.clear()
        self.datesOfCases.clear()

        for element in self.dataset:
            if element['country'] == country and element['indicator'] == 'cases':
                self.casesWeekly.append(element['weekly_count'])
                self.cumulativeCases.append(element['cumulative_count'])
                self.datesOfCases.append(
                    datetime.fromtimestamp(mktime(strptime(element['year_week'] + '-1', '%Y-%W-%w'))))

        try:
            self.ids['plt1'].update_plot()
        except:
            print('Infetions could not be updated. Check data')

        # update last week labels
        indexLastDate = self.datesOfCases.index(max(self.datesOfCases))
        self.newInfections = str(self.casesWeekly[indexLastDate])
        self.newInfectionsDate = self.datesOfCases[indexLastDate].strftime('%Y-%W')

    def update_active_country_vaccination(self, country, *args, **kwargs):
        '''update all vaccination data for a new selected country'''

        if not self.dataset_vaccination:
            return

        country_code = self.get_country_code(country_name=country, code='Alpha-2 code')

        #data sorted by week and all available vaccines
        self.vaccinationWeekly.clear()
        self.datesOfVaccination.clear()

        dates = []
        doses = []
        vaccines = []

        dates.clear()
        doses.clear()
        vaccines.clear()

        for element in self.dataset_vaccination:
            if element['ReportingCountry'] == country_code and element['TargetGroup'] == 'ALL':
                dates.append(datetime.fromtimestamp(mktime(strptime(element['YearWeekISO'].replace('W','') + '-1', '%Y-%W-%w'))))
                doses.append(element['FirstDose'])
                vaccines.append(self.vaccines[element['Vaccine']])

        #re-sort data by week and sum of all vaccines by week
        dates_unique = []
        doses_sum = []

        dates_unique.clear()
        doses_sum.clear()

        for date, dose in zip(dates, doses):
            if not date in dates_unique:
                dates_unique.append(date)
                doses_sum.append(dose)
            else:
                doses_sum[-1] = doses_sum[-1] + dose

        self.datesOfVaccination = dates_unique
        self.vaccinationWeekly = doses_sum

        try:
            self.ids['plt2'].update_plot()
        except Exception as e:
            print('Vaccination could not be updated')
            print(e)

    def update_continent_spinner(self, *args, **kwargs):
        '''Update the spinners with all available continents and countries.
           Use preset values from self.activeContinent and self.activeCountry'''

        continentsAndCountries = {}

        for element in self.dataset:
            try:
                if not element.get('continent') in continentsAndCountries.keys():
                    continentsAndCountries[element['continent']] = []
                if not element.get('country') in continentsAndCountries[element['continent']]:
                    continentsAndCountries[element['continent']].append(element['country'])
            except:
                self.notification = ('Unknown error in : {}').format(element)

        self.continentsAndCountries = continentsAndCountries
        self.continents = continentsAndCountries.keys()

        if self.activeContinent in self.continents:
            self.ids['spn1'].text = self.activeContinent
            if self.activeCountry in self.countries:
                self.ids['spn2'].text = self.activeCountry

    def get_country_code(self, country_name, code):
        xlsx = pd.ExcelFile('CountryCodes.xlsx')
        df = pd.read_excel(xlsx, sheet_name = 'Tabelle1', index_col = 0, usecols = 'A:D', engine = 'openpyxl')
        two_digit_code = df.loc[country_name, code]

        return two_digit_code

class CoronaTestLayout(BoxLayout):
    def __init__(self, **kwargs):
        super(CoronaTestLayout, self).__init__(**kwargs)
        self.ids['wdgt1'].download_data_infection()
        self.ids['wdgt1'].download_data_vaccination()

class CoronaWidgetApp(App):
    def build(self):
        return CoronaTestLayout()

if __name__ == '__main__':

    #add the parent directories of your referenced kv files or they will not be found
    resource_add_path(r'C:\Users\49172\PycharmProjects')
    CoronaWidgetApp().run()

Die Klasse TwoPlotsSharedXWidget ist ein separates Widget, das hier eingebunden und genutzt wird. Eine detaillierte Beschreibung gibt der Artikel: Matplotlib Widget in Kivy mit zwei Plots erstellen

Zu guter Letzt benötigen wir noch die Klasse für das initiale Layout. Sobald das instanziert wird, laden wir die Daten runter, aktualisieren das dataset und befüllen die Spinner mit den vorhandenen Werten. Durch das Binden der Eigenschaften, auch innerhalb des Plots, werden so auch die Plots aktualisiert.

class CoronaTestLayout(BoxLayout):
    def __init__(self, **kwargs):
        super(CoronaTestLayout, self).__init__(**kwargs)
        self.ids['wdgt1'].download_data_infection()
        self.ids['wdgt1'].download_data_vaccination()

class CoronaWidgetApp(App):
    def build(self):
        return CoronaTestLayout()

if __name__ == '__main__':

    #add the parent directories of your referenced kv files or they will not be found
    resource_add_path(r'C:\Users\49172\PycharmProjects')
    CoronaWidgetApp().run()

Quellcode

Der komplette Code ist auf Github zum Download verfügbar: https://github.com/HaikoKrais/CoronaWidget