home · contact · privacy
Replace sticky header (and week-separator lines) with repeated header.
[berlin-corona-table] / enhance_table.py
1 #!//usr/bin/env python3
2
3 # District population numbers as per Wikipedia.
4 district_pops = {
5   'CW': 342332,
6   'FK': 289762,
7   'Li': 291452,
8   'MH': 268548,
9   'Mi': 384172,
10   'Ne': 329691,
11   'Pa': 407765,
12   'Re': 265225,
13   'Sp': 243977,
14   'SZ': 308697,
15   'TS': 351644,
16   'TK': 271153,
17   'sum': 3754418,
18 }
19
20 # Map abbreviations to full names.
21 translate = {
22   'CW': 'Charlottenburg-Wilmersdorf',
23   'FK': 'Friedrichshain-Kreuzberg',
24   'Li': 'Lichtenberg',
25   'MH': 'Marzahn-Hellersdorf',
26   'Mi': 'Mitte',
27   'Ne': 'Neukölln',
28   'Pa': 'Pankow',
29   'Re': 'Reinickendorf',
30   'Sp': 'Spandau',
31   'SZ': 'Steglitz-Zehlendorf',
32   'TS': 'Tempelhof-Schöneberg',
33   'TK': 'Treptow-Köpenick',
34   'sum': 'all of Berlin',
35   '+': 'new infections counted that day',
36   'Σ': 'sum of new infections for last 7 days',
37   'Ø': 'per-day average of new infections for last 7 days',
38   'i': 'incidence (x per 100k inhabitants) of new infections for last 7 days',
39 }
40
41 # Read infections table path and output type.
42 import sys
43 if len(sys.argv) != 3:
44     print('Expecting infections table file path and output type as only arguments.')
45     exit(1)
46 infections_table = sys.argv[1]
47 output_type = sys.argv[2]
48
49 # Read infections table file lines.
50 f = open(infections_table, 'r')
51 lines = f.readlines()
52 f.close()
53
54 # Basic input validation.
55 import datetime
56 header_elements = lines[0].split()
57 if set(header_elements) != district_pops.keys() or \
58        len(header_elements) != len(district_pops.keys()):
59     raise Exception('infections table: invalid header')
60 line_count = 0
61 for line in lines[1:]:
62     line_count += 1
63     fields = line.split()
64     if len(header_elements) != len(fields) - 1:
65         raise Exception('infections table: too many elements on line %s',
66                         line_count)
67     try:
68         datetime.date.fromisoformat(fields[0])
69     except ValueError:
70         raise Exception('infections table: bad ISO date on line %s',
71                         line_count)
72     for field in fields[1:]:
73         try:
74             int(field)
75         except ValueError:
76             raise Exception('infections table: bad value on line %s',
77                             line_count)
78
79 # Parse first table file line for the names and order of districts.
80 db = {}
81 sorted_districts = []
82 for header in lines[0].split():
83     sorted_districts += [header]
84     db[header] = {}
85
86 # Seed DB with daily new infections data per district, per date.
87 sorted_dates = []
88 for line in lines[1:]:
89     fields = line.split()
90     date = fields[0]
91     sorted_dates += [date]
92     for i in range(len(sorted_districts)):
93         district = sorted_districts[i]
94         district_data = fields[i + 1]
95         db[district][date] = {'new_infections': int(district_data)}
96 sorted_dates.sort()
97
98 # In LaGeSo's data, the last "district" is actually the sum of all districts /
99 # the whole of Berlin.  For our district order, move it in front of the other
100 # districts, as its numbers are the most interesting, so in the table views
101 # we want to see it first.
102 sum_district = sorted_districts.pop()
103 sorted_districts.insert(0, sum_district)
104
105 # Fail on any day where the "sum" district's new infections are not the proper
106 # sum of the individual districts new infections.  Yes, sometimes Lageso sends
107 # data that is troubled in this way.  It will then have to be fixed manually in
108 # the table file, since we should have a human look at what mistake was
109 # probably made.
110 for date in sorted_dates:
111     day_sum = 0
112     for district in [d for d in sorted_districts if not d==sum_district]:
113         day_sum += db[district][date]['new_infections']
114     if day_sum != db[sum_district][date]['new_infections']:
115         raise Exception('Questionable district infection sum in %s' % date)
116
117 # Enhance DB with data about weekly sums, averages, incidences per day.  Ignore
118 # days that have less than 6 predecessors (we can only know a weekly average if
119 # we have a whole week of data).
120 for i in range(len(sorted_dates)):
121     if i < 6:
122         continue
123     date = sorted_dates[i]
124     week_dates = []
125     for j in range(7):
126         week_dates += [sorted_dates[i - j]]
127     for district in sorted_districts:
128         district_pop = district_pops[district]
129         week_sum = 0
130         for week_date in week_dates:
131             week_sum += db[district][week_date]['new_infections']
132         db[district][date]['week_sum'] = week_sum
133         db[district][date]['week_average'] = week_sum / 7
134         db[district][date]['week_incidence'] = (week_sum / district_pop) * 100000
135
136 # Optimized for web browser viewing.
137 if output_type == 'html':
138     print("""<!DOCTYPE html>
139 <html>
140 <head>
141 <style>
142 th { text-align: left; vertical-align: bottom; }
143 .vertical_header { writing-mode: vertical-rl; transform: rotate(180deg); font-weight: normal; }
144 .repeated_head th { padding-top: 0.5em; border-bottom: 1px solid black; }
145 .bold { font-weight: bold }
146 </style>
147 <title>Berlin's Corona infection numbers, development by districts</title>
148 </head>
149 <a href="/">home</a> · <a href="/contact.html">contact</a> · <a href="/privacy.html">privacy</a>
150 <h1>Berlin's Corona infection numbers, development by districts</h1>
151 <p>Updated daily at 9pm based on data from the "Senatsverwaltung für Gesundheit, Pflege und Gleichstellung". <a href="https://plomlompom.com/repos/?p=berlin-corona-table">Source code</a>. <a href="berlin_corona.txt">Plain text view (optimized for terminal curl)</a>.</p>
152 <table>
153 <tr>
154 <th colspan=2></th>""")
155     sorted_dates.reverse()
156     for district in sorted_districts:
157         # Wrap in div because the vertical orientation otherwise fails
158         # in Chromium.
159         print('<th><div class="vertical_header">%s</div></th>' %
160               translate[district])
161     print('</tr>')
162     weekday_count = 0
163     for date in sorted_dates:
164         if weekday_count == 0:
165             print('<tr class="repeated_head">')
166             print('<th>date</th>')
167             print('<th><a href="#symbols">?</a></th>')
168             for district in sorted_districts:
169                 print('<th><abbr title="%s">%s</abbr></th>' %
170                       (translate[district], district))
171             print('</tr>')
172         print('<tr class="day_row">')
173         print('<td>%s</td>' % date)
174         print('<td><table>')
175         for abbr in ['+', 'Σ', 'Ø', 'i']:
176             print('<tr><th><abbr title="%s">%s</abbr></th></tr>' %
177                   (translate[abbr], abbr))
178         print('</table></td>')
179         for district in sorted_districts:
180             district_data = db[district][date]
181             week_sum = week_avg = week_inc = '?'
182             new_infections = district_data['new_infections']
183             if 'week_sum' in district_data:
184                 week_sum = '%s' % district_data['week_sum']
185             if 'week_average' in district_data:
186                 week_avg = '%.1f' % district_data['week_average']
187             if 'week_incidence' in district_data:
188                 week_inc = '%.1f' % district_data['week_incidence']
189             print('<td>')
190             print('<table>')
191             print('<tr><td class="bold">%s</td></tr>' % new_infections)
192             print('<tr><td>%s</td></tr>' % week_sum)
193             print('<tr><td>%s</td></tr>' % week_avg)
194             print('<tr><td>%s</td></tr>' % week_inc)
195             print('</table>')
196             print('</td>')
197         print('</tr>')
198         weekday_count += 1
199         if weekday_count != 7:
200             continue
201         weekday_count = 0
202     print('</table>')
203     print('<h3 id="symbols">Symbols</h3>')
204     print('<dl>')
205     for abbr in ['+', 'Σ', 'Ø', 'i']:
206         print('<dt>%s</dt><dd>%s</dd>' % (abbr, translate[abbr]))
207     print('</dl>')
208     print('</html>')
209
210 # Optimized for in-terminal curl.
211 elif output_type == 'txt':
212
213     # Explain what this is.
214     intro = \
215 """Table of Berlin's Corona infection number development by districts.
216 Updated daily at 9pm based on data from the "Senatsverwaltung für Gesundheit, Pflege und Gleichstellung".
217
218 Abbrevations/explanations:
219 """
220     for k in translate:
221         intro += "%s: %s\n" % (k, translate[k])
222     intro += """
223 Source code: https://plomlompom.com/repos/?p=berlin-corona-table
224 HTML view: https://plomlompom.com/berlin_corona.html"""
225     print(intro)
226
227     # Output table of enhanced daily infection data, newest on top,
228     # separated into 7-day units.
229     sorted_dates.reverse()
230     weekday_count = 0
231     for date in sorted_dates:
232
233         # Week table header.
234         if weekday_count == 0:
235             print()
236             print(' '*13, '   '.join(sorted_districts))
237             print('-'*77)
238
239         # Day table.
240         print(date)
241         new_infections = []
242         weekly_sums = []
243         weekly_avgs = []
244         weekly_incs = []
245         for district in sorted_districts:
246             district_day_data = db[district][date]
247             new_infections += [district_day_data['new_infections']]
248             if 'week_sum' in district_day_data:
249                 weekly_sums += [district_day_data['week_sum']]
250             if 'week_average' in district_day_data:
251                 weekly_avgs += [district_day_data['week_average']]
252             if 'week_incidence' in district_day_data:
253                 weekly_incs += [district_day_data['week_incidence']]
254         print('+', ' '*11, '  '.join(['%3s' % i for i in new_infections]))
255         print('Σ', ' '*10, ' '.join(['%4s' % wsum for wsum in weekly_sums]))
256         print('Ø', ' '*9, ''.join(['%5.1f' % wavg for wavg in weekly_avgs]))
257         print('i', ' '*9, ''.join(['%5.1f' % winc for winc in weekly_incs]))
258         weekday_count += 1
259         if weekday_count != 7:
260             continue
261         weekday_count = 0