Skip to content

Instantly share code, notes, and snippets.

@akira-okumura
Last active April 14, 2020 02:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save akira-okumura/6a675255c0efe71a7e61fd5c9df1121d to your computer and use it in GitHub Desktop.
Save akira-okumura/6a675255c0efe71a7e61fd5c9df1121d to your computer and use it in GitHub Desktop.
COVID-19 route map
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