Last active
April 14, 2020 02:47
-
-
Save akira-okumura/6a675255c0efe71a7e61fd5c9df1121d to your computer and use it in GitHub Desktop.
COVID-19 route map
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import graphviz | |
import datetime | |
import json | |
from urllib import request | |
cases = {} | |
def register_cases(pref): | |
response = request.urlopen('https://www.ctv.co.jp/covid-19/data/%s.csv' % pref) | |
lines = response.readlines() | |
lines.reverse() | |
for line in lines: | |
idx, date, description, note = line.decode('utf-8').split(',')[:4] | |
idx = int(idx.replace('例目', '')) # drop '例目’ | |
m, d = map(int, date.replace('月', '-').replace('日', '').split('-')) | |
date = datetime.date.fromisoformat('2020-%02d-%02d' % (m, d)) | |
note = note[:-2] | |
node_name = '%s%d' % (pref, idx) | |
source_idx = [] | |
if note.find('愛知県内') >= 0: # cluster | |
sources = note.split('愛知県内')[1].split('県内')[0].split('例目') | |
for source in sources: | |
try: | |
n = int(source.replace('・', '、').split('、')[-1].translate(str.maketrans({chr(0xFF01 + i): chr(0x21 + i) for i in range(94)}))) | |
except: | |
pass | |
else: | |
source_idx.append('aichi%d' % n) | |
if note.find('岐阜県内') >= 0: # cluster | |
sources = note.split('岐阜県内')[1].split('県内')[0].split('例目') | |
for source in sources: | |
try: | |
n = int(source.split('、')[-1].translate(str.maketrans({chr(0xFF01 + i): chr(0x21 + i) for i in range(94)}))) | |
except: | |
pass | |
else: | |
source_idx.append('gifu%d' % n) | |
if note == '※岐阜県内31例目女性と同じ職場': | |
charme = True | |
elif note == '※岐阜大学医学部付属病院の精神科医': | |
# see https://www.hosp.gifu-u.ac.jp/oshirase/2020/04/04/post_176.html | |
# 「当該医師は、30代2名、20代1名であり、接触があった人については、概ね特定されております。」 | |
charme = True | |
source_idx.append('gifu44') | |
elif note in ('※岐阜市内の飲食店(シャルム)の従業員', '※岐阜市内の飲食店(シャルム)の従業員 '): | |
charme = True | |
#source_idx.append('gifu31') | |
#source_idx.append('gifu35') | |
#source_idx.append('gifu36') | |
elif note in ('※岐阜市内の飲食店(シャルム)を利用', '※岐阜市内の飲食店(シャルム)を利用', '※岐阜市内の飲食店(シャルム)の従業員 ', | |
'※岐阜市内の飲食店(シャルム)の従業員 の濃厚接触者 ', '※岐阜市内の飲食店(シャルム)の従業員の濃厚接触者', | |
'※岐阜市内の飲食店(シャルム)利用者の濃厚接触者'): | |
charme = True | |
#for i in range(51, 59): | |
# source_idx.append('gifu%d' % i) | |
elif note in ('※集団感染が発生した合唱団に参加', '※岐阜県で集団感染が発生した合唱団に所属'): | |
charme = False | |
source_idx.append('gifu5') | |
elif note in ('※岐阜市内の肉料理店(潜龍)の従業員',): | |
source_idx.append('gifu79') | |
elif note in ('※岐阜市内の肉料理店(潜龍)の従業員の家族(濃厚接触者)', | |
'※岐阜市内の肉料理店(潜龍)の利用者'): | |
source_idx.append('gifu81') | |
else: | |
charme = False | |
cases['%s%d' % (pref, idx)] = {'node_name':node_name, 'date':date, 'note':note, | |
'source_idx':source_idx, | |
'description':description, 'linked':False, 'charme':charme} | |
def register_sources(): | |
for case in cases.values(): | |
for source_idx in case['source_idx']: | |
cases[source_idx]['linked'] = True | |
def make_date_ranks(): | |
date_ranks = {} | |
for case in sorted(cases.values(), key=lambda x:x['date']): | |
date = case['date'] | |
if date not in date_ranks.keys(): | |
date_ranks[date] = [case,] | |
else: | |
date_ranks[date].append(case) | |
# add an empty date | |
if str(date) == '2020-03-01': | |
date_ranks[datetime.date.fromisoformat('2020-03-02')] = [] | |
elif str(date) == '2020-03-14': | |
date_ranks[datetime.date.fromisoformat('2020-03-15')] = [] | |
elif str(date) == '2020-02-23': | |
date_ranks[datetime.date.fromisoformat('2020-02-24')] = [] | |
elif str(date) == '2020-01-26': | |
date_ranks[datetime.date.fromisoformat('2020-01-27')] = [] | |
elif str(date) == '2020-01-28': | |
date_ranks[datetime.date.fromisoformat('2020-01-29')] = [] | |
return date_ranks | |
def register_dummy_cases(): | |
for case in sorted(cases.values(), key=lambda x:x['date']): | |
date = case['date'] | |
node_name = 'dummy%s' % str(date) | |
cases[node_name] = {'node_name':node_name, 'date':date, 'note':'', | |
'source_idx':[], | |
'description':'', 'linked':False, 'charme':False} | |
def make_date_nodes(date_ranks): | |
date_nodes = [] | |
for date in date_ranks.keys(): | |
if str(date) == '2020-01-26': | |
with graph.subgraph() as s: | |
s.attr('node', fixedsize='1', width='4', fontsize='36') | |
s.node('date', label='陽性発表日', shape='plaintext', fontsize='36') | |
s.node('NB', label=' ' * 47 + '※1 陽性確定日の順に並べているため、各クラスターの先頭が感染源であったことを必ずしも意味しません。\n' \ | |
+ ' ' * 42 + '※2 間違いが混入している可能性がありますので、一次情報は自治体の発表をあたってください。\n' \ | |
+ ' ' * 45 + '※3 岐阜県飲食店シャルムと潜龍での集団感染は、作図が困難なため線の繋がりを一部省略しています。\n' \ | |
+ ' ' * 33 + '※4 愛知県の合計感染者数は延べ人数です。再陽性となった 1 人を含みます。\n' \ | |
+ ' ' * 16 + '※5 印刷・再配布などご自由にどうぞ。', shape='plaintext', fontsize='40', labeljust='l', height='4') | |
s.node('author', label=' ' * 25 + 'データ出典:https://www.ctv.co.jp/covid-19/person.html\n' + ' ' * 28 + '作成:@AkiraOkumura(名古屋大学 宇宙地球環境研究所 奥村曉)', shape='plaintext', fontsize='40', height='2.5') | |
response = request.urlopen('https://www.ctv.co.jp/covid-19/person.txt') | |
json_data = json.loads(response.read())[0] | |
update = json_data['update'] | |
json_data = json_data['pref3'] | |
for i in range(len(json_data) - 1, -1, -1): | |
deaths = int(json_data[i]['content2']) | |
total = int(json_data[i]['content']) | |
if json_data[i]['class'] == 'aichi': | |
s.node('total_aichi', label='' + ' ' * 16 + ('愛知 合計感染者数:%3d 合計死者数:%2d' % (total, deaths)), shape='plaintext', fontsize='48', height='1.15') | |
elif json_data[i]['class'] == 'gifu': | |
s.node('total_gifu', label='' + ' ' * 16 + ('岐阜 合計感染者数:%3d 合計死者数:%3d' % (total, deaths)), shape='plaintext', fontsize='48', height='0.3') | |
#m, d = map(int, [x for x in cases.keys() if cases[x]['node_name'].find('dummy') == 0][-1].split('-')[1:]) | |
#today = '%d/%d %s' % (m, d, t) | |
m, d, t = update.replace('月', ' ').replace('日', '').split(' ') | |
today = '%s/%s %s' % (m, d, t) | |
s.node('title', label=' ' * 20 + '新型コロナウイルス感染経路図(%s 現在)' % today, shape='plaintext', fontsize='80') | |
s.node('border', label='\n愛知県↑\n岐阜県↓', shape='plaintext', height='8', fontsize='40') | |
s.node('arrow_exp', label='\n濃厚接触もしくは\n健康観察対象者', shape='plaintext', fontcolor='black', height='2') | |
s.node('arrows', label='\n\n←→', shape='plaintext', fontcolor='black') | |
s.node('nosex', label='紫:性別不明', shape='plaintext', fontcolor='purple') | |
s.node('male', label='青:男性 ', shape='plaintext', fontcolor='blue') | |
s.node('female', label='赤:女性 ', shape='plaintext', fontcolor='red') | |
s.node('blank10', style='invis') | |
s.node('cap1', label='岐阜県飲食店\nシャルム関連', shape='doublecircle') | |
s.node('cap2', label='帰国者 or 外国籍', shape='circle') | |
s.node('cap3', label='感染経路不明\nor 未確定\nor 他地域流入?', style='filled', shape='circle', color='#000000AA', fontcolor='white') | |
s.node('cap4', label='接触者\n※リンク非表示\nは他県との接触', shape='square') | |
s.node('cap5', label='図の読み方', shape='plaintext') | |
with graph.subgraph() as s: | |
s.attr(rank='same') | |
# default not to be seen | |
s.attr('node', fixedsize='1', width='0.5') | |
m, d = map(int, str(date).split('-')[1:]) | |
if (m, d) == (1, 26) or (m, d) == (2, 14) or d == 1: | |
label = '%d/%d' % (m, d) | |
elif (m, d) == (1, 29): | |
label = '……' | |
else: | |
label = '%d' % d | |
if d == 1: | |
s.node('date%s' % date, label=label, shape='plaintext', fontsize='24') | |
else: | |
s.node('date%s' % date, label=label, shape='plaintext', fontsize='20') | |
date_nodes.append('date%s' % date) | |
tmp1 = sorted([x for x in date_ranks[date] if x['node_name'].find('aichi') >= 0], key=lambda x:x['linked'], reverse=True) | |
tmp2 = [x for x in date_ranks[date] if x['node_name'].find('dummy') >= 0] | |
tmp3 = sorted([x for x in date_ranks[date] if x['node_name'].find('gifu') >= 0], key=lambda x:x['linked'], reverse=False) | |
date_ranks[date] = tmp1 + tmp2 + tmp3 | |
#date_ranks[date] = sorted(date_ranks[date], key=lambda x:x['linked'], reverse=True) | |
for case in date_ranks[date]: | |
s.attr('node', shape='octagon', color='black', style='diagonals', fontcolor='black') | |
if case['description'].find('男性') >= 0: | |
color = '#0000ff' # blue | |
elif case['description'].find('女性') >= 0: | |
color = '#ff0000' # red | |
elif case['description'].find('名古屋市在住の方(性別・年代非公表)') >= 0 or case['description'] == '': | |
color = '#ff00ff' # purple | |
else: | |
color='#000000' # black | |
if case['note'].find('帰国') >= 0 or \ | |
case['description'].find('中国籍') >= 0 or \ | |
case['description'].find('帰国') >= 0 or \ | |
case['note'].find('渡航歴') >= 0: | |
s.attr('node', shape='circle', style='', color=color, fontcolor='black') | |
elif case['note'].find('感染経路不明') >= 0 or case['node_name'] == 'gifu79': | |
s.attr('node', shape='circle', style='filled', color=color+'AA', fontcolor='white') | |
elif case['charme']: | |
s.attr('node', shape='doublecircle', style='', color=color, fontcolor='black') | |
elif case['note'].find('例目') >= 0 or \ | |
case['note'].find('岐阜県で集団感染が発生した合唱団に所属') >= 0 or \ | |
case['note'].find('※集団感染が発生した合唱団に参加') >= 0 or \ | |
case['note'].find('大阪市内のライブハウスの利用者') >= 0 or \ | |
case['note'].find('岐阜市内の肉料理店(潜龍)') >= 0 or \ | |
case['note'].find('スポーツジムの利用者') >= 0 or \ | |
case['note'].find('※高齢者施設の送迎ドライバーとして勤務') >= 0 or \ | |
case['note'].find('※死亡後に感染を確認<br>※名古屋市緑区のデイサービスを利用') >= 0 or \ | |
case['note'].find('※3月5日に感染確認。3月24日に検査で陰性だったため退院。4月2日に陽性と再度確認') >= 0 or \ | |
case['note'].find('※愛知県陽性患者の濃厚接触者') >= 0: | |
s.attr('node', shape='square', style='', color=color, fontcolor='black') | |
else: | |
# probably OK to be categorized into '感染経路不明' | |
s.attr('node', shape='circle', style='filled', color=color+'AA', fontcolor='white') | |
print(case['description'], case['note']) | |
#s.attr('node', shape='square', style='diagonals', color=color) | |
if case['node_name'].find('dummy') == 0: | |
s.node(case['node_name'], style='invis', width='0', height='3', shape='box') | |
else: | |
s.node(case['node_name'], label=case['node_name'].replace('aichi', 'A').replace('gifu', 'G'), fontname='Myriad Pro') | |
graph.edge('date', date_nodes[0], style='invis') | |
graph.edge('border', 'dummy2020-01-26', style='invis') | |
return date_nodes | |
def link_date_nodes(): | |
for i in range(len(date_nodes) - 1): | |
graph.edge(date_nodes[i], date_nodes[i + 1], style='invis') | |
def link_dummy_nodes(): | |
dummy_cases = [y for y in sorted(cases.values(), key=lambda x:x['node_name']) if y['node_name'].find('dummy') == 0] | |
for i in range(len(dummy_cases) - 1): | |
graph.edge(dummy_cases[i]['node_name'], dummy_cases[i + 1]['node_name'], style='dashed', color='black', dir='') | |
gifu_cases = [y for y in sorted(cases.values(), key=lambda x:x['node_name']) if y['node_name'].find('gifu') == 0] | |
for i in range(len(gifu_cases)): | |
case = gifu_cases[i] | |
dummy_case = cases['dummy%s' % str(case['date'])] | |
if case['node_name'] not in ('gifu5') or \ | |
case['node_name'] not in ('gifu9', 'gifu10', 'gifu11') or \ | |
case['node_name'] not in ('gifu16') or \ | |
case['node_name'] not in ('gifu23', 'gifu24', 'gifu25', 'gifu26') or \ | |
case['node_name'] not in ('gifu27', 'gifu28', 'gifu29', 'gifu30', 'gifu31') or \ | |
case['node_name'] not in ('gifu48', 'gifu52', 'gifu54', 'gifu55', 'gifu56', 'gifu57', 'gifu58'): | |
graph.edge(dummy_case['node_name'], case['node_name'], style='invis') | |
graph = graphviz.Graph(engine='dot') | |
graph.attr('node', fontname='Hiragino UD Sans F StdN', fontsize='14') | |
graph.attr('edge', arrowhead='vee', arrowsize='0.5', dir='both') | |
graph.attr(nodesep='0.1', ranksep='0.12') | |
register_cases('gifu') | |
register_cases('aichi') | |
register_dummy_cases() | |
register_sources() | |
date_ranks = make_date_ranks() | |
date_nodes = make_date_nodes(date_ranks) | |
for case in cases.values(): | |
if len(case['source_idx']) > 0: | |
source_idx = case['source_idx'] | |
node_name = case['node_name'] | |
for source in source_idx: | |
source_name = cases[source]['node_name'] | |
graph.edge(source_name, node_name) | |
link_date_nodes() | |
link_dummy_nodes() | |
graph.edge('aichi166', 'aichi165', style='invis') | |
graph.edge('aichi64', 'aichi69', style='invis') | |
graph.edge('aichi132', 'aichi131', style='invis') | |
graph.edge('aichi131', 'aichi134', style='invis') | |
graph.edge('aichi160', 'aichi158', style='invis') | |
graph.edge('aichi163', 'aichi162', style='invis') | |
graph.edge('aichi98', 'aichi90', style='invis') | |
graph.edge('aichi188', 'aichi189', style='invis') | |
# Police cluster | |
graph.edge('aichi181', 'aichi182', style='invis') | |
graph.edge('aichi193', 'aichi192', style='invis') | |
graph.edge('aichi214', 'aichi217', style='invis') | |
graph.edge('aichi21', 'aichi23', style='invis') | |
#graph.edge('aichi22', 'aichi23', style='invis') | |
# place Aichi nodes in Aichi | |
graph.edge('aichi134', 'dummy2020-03-19', style='invis') | |
graph.edge('aichi145', 'dummy2020-03-23', style='invis') | |
graph.edge('aichi151', 'aichi152', style='invis') | |
graph.edge('aichi155', 'aichi152', style='invis') | |
graph.edge('aichi155', 'aichi151', style='invis') | |
graph.edge('aichi155', 'dummy2020-03-26', style='invis') | |
graph.edge('aichi158', 'aichi159', style='invis') | |
graph.edge('aichi159', 'dummy2020-03-27', style='invis') | |
graph.edge('aichi162', 'dummy2020-03-28', style='invis') | |
graph.edge('aichi165', 'dummy2020-03-29', style='invis') | |
graph.edge('aichi172', 'dummy2020-03-31', style='invis') | |
graph.edge('aichi179', 'dummy2020-04-01', style='invis') | |
graph.edge('aichi181', 'dummy2020-04-01', style='invis') | |
graph.edge('aichi182', 'dummy2020-04-01', style='invis') | |
graph.edge('aichi187', 'aichi186', style='invis') | |
graph.edge('aichi186', 'dummy2020-04-02', style='invis') | |
graph.edge('aichi221', 'aichi217', style='invis') | |
graph.edge('aichi217', 'aichi207', style='invis') | |
graph.edge('aichi207', 'dummy2020-04-04', style='invis') | |
graph.edge('aichi227', 'dummy2020-04-05', style='invis') | |
graph.edge('aichi253', 'aichi244', style='invis') | |
graph.edge('aichi245', 'aichi248', style='invis') | |
graph.edge('aichi249', 'aichi250', style='invis') | |
graph.edge('aichi251', 'aichi257', style='invis') | |
graph.edge('aichi264', 'aichi261', style='invis') | |
graph.edge('aichi270', 'aichi280', style='invis') | |
graph.edge('aichi280', 'aichi277', style='invis') | |
graph.edge('aichi267', 'aichi275', style='invis') | |
graph.edge('aichi275', 'dummy2020-04-08', style='invis') | |
graph.edge('aichi282', 'aichi285', style='invis') | |
graph.edge('aichi267', 'aichi275', style='invis') | |
graph.edge('aichi309', 'aichi304', style='invis') | |
graph.edge('dummy2020-03-21', 'gifu5', style='invis') | |
graph.edge('gifu5', 'gifu4', style='invis') | |
graph.edge('gifu8', 'gifu7', style='invis') | |
graph.edge('gifu7', 'gifu6', style='invis') | |
graph.edge('dummy2020-03-24', 'gifu10', style='invis') | |
graph.edge('gifu9', 'gifu10', style='invis') | |
graph.edge('gifu10', 'gifu11', style='invis') | |
graph.edge('gifu8', 'gifu10', style='invis') | |
graph.edge('gifu7', 'gifu11', style='invis') | |
graph.edge('dummy2020-03-27', 'gifu16', style='invis') | |
graph.edge('gifu16', 'gifu17', style='invis') | |
graph.edge('aichi161', 'aichi163', style='invis') | |
graph.edge('dummy2020-03-31', 'gifu26', style='invis') | |
graph.edge('gifu26', 'gifu25', style='invis') | |
graph.edge('gifu25', 'gifu23', style='invis') | |
graph.edge('gifu23', 'gifu24', style='invis') | |
graph.edge('dummy2020-04-01', 'gifu27', style='invis') | |
graph.edge('gifu27', 'gifu29', style='invis') | |
graph.edge('gifu29', 'gifu28', style='invis') | |
graph.edge('gifu30', 'gifu31', style='invis') | |
graph.edge('gifu28', 'gifu30', style='invis') | |
graph.edge('gifu24', 'gifu31', style='invis') | |
graph.edge('gifu28', 'gifu31', style='invis') | |
graph.edge('gifu32', 'gifu36', style='invis') | |
graph.edge('gifu36', 'gifu35', style='invis') | |
graph.edge('gifu35', 'gifu34', style='invis') | |
graph.edge('gifu34', 'gifu33', style='invis') | |
graph.edge('gifu40', 'gifu38', style='invis') | |
graph.edge('gifu38', 'gifu39', style='invis') | |
graph.edge('dummy2020-04-04', 'gifu45', style='invis') | |
graph.edge('gifu45', 'gifu46', style='invis') | |
graph.edge('gifu46', 'gifu47', style='invis') | |
graph.edge('gifu47', 'gifu41', style='invis') | |
graph.edge('gifu41', 'gifu42', style='invis') | |
graph.edge('gifu42', 'gifu43', style='invis') | |
graph.edge('dummy2020-04-05', 'gifu48', style='invis') | |
graph.edge('gifu48', 'gifu52', style='invis') | |
graph.edge('gifu52', 'gifu54', style='invis') | |
graph.edge('gifu54', 'gifu55', style='invis') | |
graph.edge('gifu55', 'gifu56', style='invis') | |
graph.edge('gifu56', 'gifu57', style='invis') | |
graph.edge('gifu57', 'gifu58', style='invis') | |
graph.edge('gifu59', 'gifu50', style='invis') | |
graph.graph_attr['rankdir'] = 'LR' | |
graph.view() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment