From 84c1dfd64cc07e9d5889820ed468fdfa58ea421f Mon Sep 17 00:00:00 2001 From: Tom Pike Date: Mon, 12 Aug 2024 20:06:30 -0400 Subject: [PATCH] Update tutorial and viz - Update tutorial to be SIR model - Update Viz to allow for different markers; default agent to circle -Update tests --- .../data/TorontoNeighbourhoods.geojson | 16 + docs/tutorials/intro_tutorial.ipynb | 1090 +++++++++++++++-- mesa_geo/visualization/geojupyter_viz.py | 213 ++-- mesa_geo/visualization/leaflet_viz.py | 70 +- tests/test_GeoJupyterViz.py | 12 +- tests/test_MapModule.py | 28 +- 6 files changed, 1177 insertions(+), 252 deletions(-) create mode 100644 docs/tutorials/data/TorontoNeighbourhoods.geojson diff --git a/docs/tutorials/data/TorontoNeighbourhoods.geojson b/docs/tutorials/data/TorontoNeighbourhoods.geojson new file mode 100644 index 00000000..86372c68 --- /dev/null +++ b/docs/tutorials/data/TorontoNeighbourhoods.geojson @@ -0,0 +1,16 @@ +{ +"type": "FeatureCollection", +"name": "Toronto", +"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, +"features": [ +{ "type": "Feature", "properties": { "DAUID": "35202325", "PRUID": "35", "CSDUID": "3520005", "HOODNUM": 103, "HOOD": "Lawrence Park South", "FULLHOOD": "Lawrence Park South (103)" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -79.388979232123205, 43.721308240235665 ], [ -79.388380839335355, 43.719084895560265 ], [ -79.400414151289169, 43.716606883656979 ], [ -79.399785136452493, 43.71365383536812 ], [ -79.411443630677539, 43.711205418207157 ], [ -79.410371345426896, 43.708570333516498 ], [ -79.409628804285433, 43.705776710535716 ], [ -79.408831308223895, 43.705196244441318 ], [ -79.408453245228017, 43.704600007566697 ], [ -79.415941882637327, 43.703011377959236 ], [ -79.41707403747948, 43.707150739141944 ], [ -79.416112961118941, 43.707353564739442 ], [ -79.417607119695376, 43.710399611197914 ], [ -79.419477212009085, 43.710011211194569 ], [ -79.420729510434242, 43.71308114779189 ], [ -79.421980808359393, 43.716958974699047 ], [ -79.418553394317328, 43.717912807448563 ], [ -79.416928509014198, 43.717975118090983 ], [ -79.41550157335908, 43.718337751349502 ], [ -79.414519654720095, 43.718306675263293 ], [ -79.414566433540244, 43.718524359318849 ], [ -79.413731860292174, 43.718907245674309 ], [ -79.414297870931321, 43.722526740794237 ], [ -79.39021430303211, 43.72764188778973 ], [ -79.389682327756503, 43.725649034859309 ], [ -79.389603329162398, 43.724588312367089 ], [ -79.389250653387577, 43.723689098542472 ], [ -79.388979232123205, 43.721308240235665 ] ] ] } } +, +{ "type": "Feature", "properties": { "DAUID": "35202344", "PRUID": "35", "CSDUID": "3520005", "HOODNUM": 105, "HOOD": "Lawrence Park North", "FULLHOOD": "Lawrence Park North (105)" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -79.390882155425913, 43.729987722680477 ], [ -79.390358823303401, 43.72870713259811 ], [ -79.39021430303211, 43.72764188778973 ], [ -79.414297870931321, 43.722526740794237 ], [ -79.414681008399029, 43.724945096314627 ], [ -79.41568553405213, 43.727394836840794 ], [ -79.417027760781565, 43.733199737110688 ], [ -79.416349964450035, 43.733504965530749 ], [ -79.404880337349709, 43.73590303370672 ], [ -79.404576298630928, 43.734417896154561 ], [ -79.404184850850044, 43.734355721237094 ], [ -79.392820225983897, 43.736804633178963 ], [ -79.390494544890146, 43.730059232478482 ], [ -79.390882155425913, 43.729987722680477 ] ] ] } } +, +{ "type": "Feature", "properties": { "DAUID": "35200370", "PRUID": "35", "CSDUID": "3520005", "HOODNUM": 40, "HOOD": "St.Andrew-Windfields", "FULLHOOD": "St.Andrew-Windfields (40)" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -79.359952422301689, 43.752962799374949 ], [ -79.363196880408907, 43.752933952400944 ], [ -79.365497961172338, 43.75253298731878 ], [ -79.380650002918912, 43.749372301888187 ], [ -79.38257070950452, 43.748911862433872 ], [ -79.383546890659389, 43.748241259716103 ], [ -79.384150548937981, 43.748062285151335 ], [ -79.402014050256057, 43.744234544958964 ], [ -79.402833109260186, 43.744139588534502 ], [ -79.404086379463067, 43.744272763733228 ], [ -79.404331652757108, 43.743786847168067 ], [ -79.406521590880033, 43.743340113546608 ], [ -79.40808285799568, 43.750141688214953 ], [ -79.408068178986881, 43.752466744533621 ], [ -79.408326166010113, 43.753504105570059 ], [ -79.397855741898553, 43.760064744092737 ], [ -79.394524566662042, 43.761499979525055 ], [ -79.390428434237492, 43.76262350965461 ], [ -79.376582688623657, 43.765387975458253 ], [ -79.371750460231326, 43.765846398646666 ], [ -79.359648969676826, 43.766414966764366 ], [ -79.359468999049071, 43.765731038733712 ], [ -79.357844970875504, 43.764858042841581 ], [ -79.357536982066691, 43.764728043328745 ], [ -79.356124028332303, 43.764814045616617 ], [ -79.354928991462003, 43.764618979881867 ], [ -79.354177036164401, 43.763668999757357 ], [ -79.353539050614643, 43.763614984248001 ], [ -79.352658971599013, 43.76383797276771 ], [ -79.351974980780341, 43.763534025940722 ], [ -79.351359050202504, 43.76354999007215 ], [ -79.351141011043737, 43.763972990326515 ], [ -79.350742005209383, 43.764113963917836 ], [ -79.349585011072847, 43.763572011945783 ], [ -79.349254027394792, 43.763165033035655 ], [ -79.349606986650699, 43.762362038086927 ], [ -79.349810000713902, 43.761042965082325 ], [ -79.34896896600948, 43.760696045060449 ], [ -79.348683006090937, 43.760190964845584 ], [ -79.348285031538154, 43.759930014616515 ], [ -79.346503038317792, 43.759534010413809 ], [ -79.346030049794081, 43.758589991060049 ], [ -79.344600996136975, 43.757710985296946 ], [ -79.34383504335355, 43.756489959471843 ], [ -79.359952422301689, 43.752962799374949 ] ] ] } } +, +{ "type": "Feature", "properties": { "DAUID": "35202608", "PRUID": "35", "CSDUID": "3520005", "HOODNUM": 41, "HOOD": "Bridle Path-Sunnybrook-York Mills", "FULLHOOD": "Bridle Path-Sunnybrook-York Mills (41)" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -79.361585608453339, 43.719794166619586 ], [ -79.365769670599292, 43.719268029451932 ], [ -79.378066348250684, 43.716724032604894 ], [ -79.377430659846183, 43.713140546205558 ], [ -79.381863865668834, 43.712017108032072 ], [ -79.385911596028663, 43.711341751088881 ], [ -79.388979232123205, 43.721308240235665 ], [ -79.389250653387577, 43.723689098542472 ], [ -79.389603329162398, 43.724588312367089 ], [ -79.390358823303401, 43.72870713259811 ], [ -79.390882155425913, 43.729987722680477 ], [ -79.390494544890146, 43.730059232478482 ], [ -79.392820225983897, 43.736804633178963 ], [ -79.404184850850044, 43.734355721237094 ], [ -79.404576298630928, 43.734417896154561 ], [ -79.406521590880033, 43.743340113546608 ], [ -79.404331652757108, 43.743786847168067 ], [ -79.404086379463067, 43.744272763733228 ], [ -79.402833109260186, 43.744139588534502 ], [ -79.402014050256057, 43.744234544958964 ], [ -79.384150548937981, 43.748062285151335 ], [ -79.383546890659389, 43.748241259716103 ], [ -79.382661286359394, 43.748849590924543 ], [ -79.382458494459101, 43.748632658332888 ], [ -79.381315718557502, 43.748175304131657 ], [ -79.38116282820458, 43.747398801712571 ], [ -79.380260350184912, 43.747241899743379 ], [ -79.379921264116319, 43.746922030960896 ], [ -79.379915325294789, 43.746678807284987 ], [ -79.379124160622382, 43.746082383829162 ], [ -79.378773679530312, 43.746185436250371 ], [ -79.378379513906211, 43.746062732098665 ], [ -79.37852409015818, 43.745308538589974 ], [ -79.378811070972702, 43.745258597477168 ], [ -79.378807180602294, 43.744943388044959 ], [ -79.37836142302389, 43.744432873244186 ], [ -79.378158583329679, 43.74366465971017 ], [ -79.377940551169004, 43.743463436004362 ], [ -79.376020424829221, 43.742364778095542 ], [ -79.375389943425859, 43.742256710178403 ], [ -79.374897018213673, 43.741655493011926 ], [ -79.37440067041473, 43.741639434644441 ], [ -79.373285887221044, 43.741074375754444 ], [ -79.37301670522784, 43.740467241915901 ], [ -79.372291887524057, 43.740636961434419 ], [ -79.372021940332075, 43.740516093803897 ], [ -79.371987315337194, 43.740875764394538 ], [ -79.371761410188725, 43.740962565643493 ], [ -79.371667475923545, 43.740763187285545 ], [ -79.371382705303603, 43.740732125423953 ], [ -79.371328961979074, 43.740425207203387 ], [ -79.370948993437594, 43.740698850626003 ], [ -79.370625986420222, 43.740703249393043 ], [ -79.370625206800213, 43.740271059900948 ], [ -79.370998834456202, 43.740231375063942 ], [ -79.371144899507101, 43.739882384404154 ], [ -79.371025223640075, 43.739718556079922 ], [ -79.370677590998639, 43.739713602275017 ], [ -79.370597999428668, 43.739442412670634 ], [ -79.371045655866567, 43.739421786412272 ], [ -79.371076384443796, 43.739206089998333 ], [ -79.370419463811302, 43.739160720844097 ], [ -79.370199721281764, 43.738563286759707 ], [ -79.369770801033468, 43.738350128947928 ], [ -79.369543682254445, 43.738481917766393 ], [ -79.369604902309717, 43.738059614158018 ], [ -79.369235680803214, 43.737937324118349 ], [ -79.368918971408277, 43.738166854012114 ], [ -79.368573301524194, 43.738089907063092 ], [ -79.368062230746546, 43.737704445448252 ], [ -79.368008748505758, 43.737388617438242 ], [ -79.368198120944655, 43.737274206560961 ], [ -79.367881483467343, 43.737044640528381 ], [ -79.367426591978685, 43.736876022323308 ], [ -79.367210879561014, 43.737044066817653 ], [ -79.366751690055011, 43.737037507777586 ], [ -79.366642603799747, 43.736936839006901 ], [ -79.366824233407087, 43.736651374729597 ], [ -79.365980907623651, 43.736612318902004 ], [ -79.365489896397349, 43.736857351642456 ], [ -79.364420280429641, 43.736463886805566 ], [ -79.364335437164371, 43.735931474041863 ], [ -79.363770497450574, 43.736157528016392 ], [ -79.363398363213591, 43.736143109543377 ], [ -79.363218541456831, 43.735906487200943 ], [ -79.363640182331864, 43.735471342498592 ], [ -79.363263022662139, 43.73472761199691 ], [ -79.362116738475834, 43.73441404450697 ], [ -79.360880385183918, 43.733306921463814 ], [ -79.36010257598295, 43.733142645671364 ], [ -79.3591938415275, 43.733228627418171 ], [ -79.359084176713679, 43.732695854293119 ], [ -79.359376162787072, 43.732006722256081 ], [ -79.358817036188711, 43.731566518283394 ], [ -79.357682235327815, 43.731289168501654 ], [ -79.357551211760622, 43.731089155473782 ], [ -79.357694873201609, 43.730830165560384 ], [ -79.35702606394625, 43.730766454927732 ], [ -79.357207382327204, 43.7300398203827 ], [ -79.356975262357309, 43.729901366897643 ], [ -79.356168454175702, 43.729889769063924 ], [ -79.35587603418621, 43.729687523347074 ], [ -79.354646266260147, 43.729705838792022 ], [ -79.35446280467761, 43.729604177908158 ], [ -79.354405709208137, 43.729423228996957 ], [ -79.354671556760664, 43.729238016334619 ], [ -79.354089098059362, 43.728743442174718 ], [ -79.353967596970676, 43.728192399680538 ], [ -79.353586085194976, 43.728069882014587 ], [ -79.353344881661528, 43.727355081477917 ], [ -79.352895526779747, 43.726988444158088 ], [ -79.35271571493567, 43.726301622234594 ], [ -79.352169374056871, 43.726302750749873 ], [ -79.351670254588001, 43.725944393134888 ], [ -79.350739362286006, 43.72593096792221 ], [ -79.350070924075553, 43.725408127139758 ], [ -79.350291927537327, 43.724591967122038 ], [ -79.350748362432583, 43.724247389905941 ], [ -79.349941100934998, 43.723803562272487 ], [ -79.349898875389343, 43.72353280695814 ], [ -79.350135399557544, 43.723509214943142 ], [ -79.350176598997393, 43.723365779699392 ], [ -79.349867651633701, 43.723316311604023 ], [ -79.349870863156283, 43.723199243729169 ], [ -79.350247604246405, 43.723042647275378 ], [ -79.350282490209082, 43.72267398428167 ], [ -79.351174601891046, 43.722740865050348 ], [ -79.351465440683484, 43.72254701818332 ], [ -79.351911833636279, 43.722112272730662 ], [ -79.351933935583332, 43.721761428602576 ], [ -79.352484917586594, 43.721589332604289 ], [ -79.352723737894834, 43.721025476318651 ], [ -79.353047624255211, 43.720985133239701 ], [ -79.35353454671197, 43.720433940878912 ], [ -79.353668427484735, 43.721059428174065 ], [ -79.361585608453339, 43.719794166619586 ] ] ] } } +, +{ "type": "Feature", "properties": { "DAUID": "35200376", "PRUID": "35", "CSDUID": "3520005", "HOODNUM": 42, "HOOD": "Banbury-Don Mills", "FULLHOOD": "Banbury-Don Mills (42)" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -79.330149781248252, 43.723153876628579 ], [ -79.352670283743592, 43.716210776284626 ], [ -79.353094553700217, 43.716555989299103 ], [ -79.353606181865388, 43.72041615859866 ], [ -79.353047624255211, 43.720985133239701 ], [ -79.352723737894834, 43.721025476318651 ], [ -79.352484917586594, 43.721589332604289 ], [ -79.351933935583332, 43.721761428602576 ], [ -79.351911833636279, 43.722112272730662 ], [ -79.351465440683484, 43.72254701818332 ], [ -79.351174601891046, 43.722740865050348 ], [ -79.350282490209082, 43.72267398428167 ], [ -79.350247604246405, 43.723042647275378 ], [ -79.349870863156283, 43.723199243729169 ], [ -79.349867651633701, 43.723316311604023 ], [ -79.350176598997393, 43.723365779699392 ], [ -79.350135399557544, 43.723509214943142 ], [ -79.349898875389343, 43.72353280695814 ], [ -79.349941100934998, 43.723803562272487 ], [ -79.350748362432583, 43.724247389905941 ], [ -79.350291927537327, 43.724591967122038 ], [ -79.350067923274835, 43.725065923156066 ], [ -79.350155095549056, 43.725508362304296 ], [ -79.350739362286006, 43.72593096792221 ], [ -79.351670254588001, 43.725944393134888 ], [ -79.352169374056871, 43.726302750749873 ], [ -79.35271571493567, 43.726301622234594 ], [ -79.352895526779747, 43.726988444158088 ], [ -79.353344881661528, 43.727355081477917 ], [ -79.353586085194976, 43.728069882014587 ], [ -79.353967596970676, 43.728192399680538 ], [ -79.354089098059362, 43.728743442174718 ], [ -79.354671556760664, 43.729238016334619 ], [ -79.354405709208137, 43.729423228996957 ], [ -79.35446280467761, 43.729604177908158 ], [ -79.354646266260147, 43.729705838792022 ], [ -79.35587603418621, 43.729687523347074 ], [ -79.356168454175702, 43.729889769063924 ], [ -79.356975262357309, 43.729901366897643 ], [ -79.357207382327204, 43.7300398203827 ], [ -79.35702606394625, 43.730766454927732 ], [ -79.357694873201609, 43.730830165560384 ], [ -79.357551211760622, 43.731089155473782 ], [ -79.357682235327815, 43.731289168501654 ], [ -79.358817036188711, 43.731566518283394 ], [ -79.359376162787072, 43.732006722256081 ], [ -79.359084176713679, 43.732695854293119 ], [ -79.3591938415275, 43.733228627418171 ], [ -79.36010257598295, 43.733142645671364 ], [ -79.360880385183918, 43.733306921463814 ], [ -79.362116738475834, 43.73441404450697 ], [ -79.363263022662139, 43.73472761199691 ], [ -79.363640182331864, 43.735471342498592 ], [ -79.363218541456831, 43.735906487200943 ], [ -79.363398363213591, 43.736143109543377 ], [ -79.363770497450574, 43.736157528016392 ], [ -79.364335437164371, 43.735931474041863 ], [ -79.364420280429641, 43.736463886805566 ], [ -79.365489896397349, 43.736857351642456 ], [ -79.365980907623651, 43.736612318902004 ], [ -79.366824233407087, 43.736651374729597 ], [ -79.366642603799747, 43.736936839006901 ], [ -79.366751690055011, 43.737037507777586 ], [ -79.367210879561014, 43.737044066817653 ], [ -79.367426591978685, 43.736876022323308 ], [ -79.367881483467343, 43.737044640528381 ], [ -79.368198120944655, 43.737274206560961 ], [ -79.368008748505758, 43.737388617438242 ], [ -79.368062230746546, 43.737704445448252 ], [ -79.368573301524194, 43.738089907063092 ], [ -79.368918971408277, 43.738166854012114 ], [ -79.369235680803214, 43.737937324118349 ], [ -79.369604902309717, 43.738059614158018 ], [ -79.369543682254445, 43.738481917766393 ], [ -79.369770801033468, 43.738350128947928 ], [ -79.370199721281764, 43.738563286759707 ], [ -79.370419463811302, 43.739160720844097 ], [ -79.371076384443796, 43.739206089998333 ], [ -79.371045655866567, 43.739421786412272 ], [ -79.370597999428668, 43.739442412670634 ], [ -79.370677590998639, 43.739713602275017 ], [ -79.371025223640075, 43.739718556079922 ], [ -79.371144899507101, 43.739882384404154 ], [ -79.370998834456202, 43.740231375063942 ], [ -79.370625206800213, 43.740271059900948 ], [ -79.370625986420222, 43.740703249393043 ], [ -79.370948993437594, 43.740698850626003 ], [ -79.371328961979074, 43.740425207203387 ], [ -79.371382705303603, 43.740732125423953 ], [ -79.371667475923545, 43.740763187285545 ], [ -79.371761410188725, 43.740962565643493 ], [ -79.371987315337194, 43.740875764394538 ], [ -79.372021940332075, 43.740516093803897 ], [ -79.372291887524057, 43.740636961434419 ], [ -79.37301670522784, 43.740467241915901 ], [ -79.373285887221044, 43.741074375754444 ], [ -79.37440067041473, 43.741639434644441 ], [ -79.374897018213673, 43.741655493011926 ], [ -79.375389943425859, 43.742256710178403 ], [ -79.376020424829221, 43.742364778095542 ], [ -79.377940551169004, 43.743463436004362 ], [ -79.378158583329679, 43.74366465971017 ], [ -79.37836142302389, 43.744432873244186 ], [ -79.378807180602294, 43.744943388044959 ], [ -79.378811070972702, 43.745258597477168 ], [ -79.37852409015818, 43.745308538589974 ], [ -79.378379513906211, 43.746062732098665 ], [ -79.378773679530312, 43.746185436250371 ], [ -79.379124160622382, 43.746082383829162 ], [ -79.379915325294789, 43.746678807284987 ], [ -79.379921264116319, 43.746922030960896 ], [ -79.380260350184912, 43.747241899743379 ], [ -79.38116282820458, 43.747398801712571 ], [ -79.381315718557502, 43.748175304131657 ], [ -79.382458494459101, 43.748632658332888 ], [ -79.382661286359394, 43.748849590924543 ], [ -79.365497961172338, 43.75253298731878 ], [ -79.363196880408907, 43.752933952400944 ], [ -79.359952422301689, 43.752962799374949 ], [ -79.34383504335355, 43.756489959471843 ], [ -79.343608975835579, 43.755181962950978 ], [ -79.34382003884491, 43.755009035569024 ], [ -79.344833969245428, 43.754802005511678 ], [ -79.345570971425573, 43.754302991399008 ], [ -79.345571046958966, 43.753678987322608 ], [ -79.345270024488642, 43.752848984906798 ], [ -79.346389997434628, 43.751518969813468 ], [ -79.345247970896438, 43.751008966062741 ], [ -79.343098033387122, 43.750932997578929 ], [ -79.342541966797896, 43.750780998044867 ], [ -79.342572032434717, 43.749832012730195 ], [ -79.34217403375861, 43.74898502157977 ], [ -79.342392041438558, 43.748166046223368 ], [ -79.342248990800741, 43.747634034558644 ], [ -79.340534963186755, 43.746961035685281 ], [ -79.33933195892871, 43.746727973185948 ], [ -79.339144050571932, 43.746538005453289 ], [ -79.339790990095125, 43.745675035229937 ], [ -79.340167042152132, 43.744856018266866 ], [ -79.340129001982248, 43.744535000848352 ], [ -79.339211957061622, 43.743569018763878 ], [ -79.338039952639079, 43.743498966013235 ], [ -79.337107011812222, 43.743846012907618 ], [ -79.336445985179751, 43.743492976127307 ], [ -79.336206044584983, 43.742896983916999 ], [ -79.336596035912692, 43.7424080083672 ], [ -79.336565972488302, 43.742077023670028 ], [ -79.335603958615778, 43.740855966208485 ], [ -79.334883021169532, 43.740681994682305 ], [ -79.333521991146014, 43.740801961561367 ], [ -79.333123985518327, 43.740959002310888 ], [ -79.332785954744153, 43.741372028258816 ], [ -79.332177051143844, 43.741534001504498 ], [ -79.331168986549287, 43.741539994845702 ], [ -79.330605997915598, 43.741269011700389 ], [ -79.33047795735159, 43.740823981699542 ], [ -79.330839019894796, 43.740172030100275 ], [ -79.330154963813683, 43.739272029152069 ], [ -79.330079967689059, 43.738783034223907 ], [ -79.330312965168659, 43.738251008183987 ], [ -79.331559991405172, 43.737181972291225 ], [ -79.331402024856061, 43.736807980887356 ], [ -79.330672968030456, 43.736368039929403 ], [ -79.331184005120505, 43.735483958623178 ], [ -79.331064045005732, 43.734843976916991 ], [ -79.329974015331885, 43.734099990052414 ], [ -79.32949298641941, 43.733948032541583 ], [ -79.329117032066449, 43.733953970769406 ], [ -79.328583977457427, 43.734285008051614 ], [ -79.328312967011115, 43.734236036321555 ], [ -79.327795004734242, 43.733817977253324 ], [ -79.327591967761393, 43.733015013453894 ], [ -79.326937978456371, 43.73287896862734 ], [ -79.326057977599874, 43.732174025104726 ], [ -79.324772964427225, 43.731685036139758 ], [ -79.32459304599351, 43.731105021208052 ], [ -79.325261968086792, 43.729715035068942 ], [ -79.325103989653769, 43.729519991148557 ], [ -79.323104025240411, 43.729444043637955 ], [ -79.320218045809654, 43.731565989506535 ], [ -79.319744041746731, 43.731592960794821 ], [ -79.319609036287616, 43.731430023502384 ], [ -79.319608979321814, 43.730996043726982 ], [ -79.320119985074712, 43.730524025348174 ], [ -79.320376039524575, 43.729644976462886 ], [ -79.320112988249733, 43.729477007767066 ], [ -79.318993025842772, 43.729324977532301 ], [ -79.318737033848009, 43.72920499929382 ], [ -79.318707033081367, 43.728945034821223 ], [ -79.320120045989015, 43.728461973054351 ], [ -79.320457993959238, 43.72800100267191 ], [ -79.323149003094542, 43.727170025951686 ], [ -79.323127037476695, 43.726231980835465 ], [ -79.322390018403084, 43.725618043909961 ], [ -79.322299951367427, 43.725205967626373 ], [ -79.330149781248252, 43.723153876628579 ] ] ] } } +] +} diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb index 21362d52..32945151 100644 --- a/docs/tutorials/intro_tutorial.ipynb +++ b/docs/tutorials/intro_tutorial.ipynb @@ -2,223 +2,1118 @@ "cells": [ { "cell_type": "markdown", - "id": "670cf30d", + "id": "4d52735e-0198-403b-bd9d-56cb12d4dde2", "metadata": {}, "source": [ - "# Introductory Tutorial" + "# Mesa-Geo Introductory Model \n", + "\n", + "To overview the critical parts of Mesa-Geo this tutorial uses the pandemic modelling approach known as a S(usceptible), I(infected) and R(ecovered) or SIR model. \n", + "\n", + "Components of the model are:\n", + "\n", + "**Agents:** Each agent in the model represents an individual in the population. Agents have states of susceptible, infected, recovered, or dead. The Agents are point agents, randomly placed into the environment.\n", + "\n", + "**Environment:** The environment is a set of polygons of a few Toronto neighborhoods. \n", + "\n", + "**Interaction Rules:** Susceptible agents can become infected with a certain probability, if they come into contact with infected agents. Infected agents then recover after a certain period or perish based on a probability.\n", + "\n", + "**Parameters:** \n", + "- Population Size (number of human agents in the model)\n", + "- Initial Infection (percent of the population initial infected)\n", + "- Exposure Distance (proximity suscpetible agents must be to infected agents to possibly get infected)\n", + "- Infection Risk (probability of becoming infected)\n", + "- Recovery Rate (time infection lasts)\n", + "- Mobility (distance agent moves)\n", + "\n", + "### The tutorial then proceeds in three parts: \n", + "- Part 1 Create the Basic Model \n", + "- Part 2 Add Agent Behaviors and Model Complexity \n", + "- Part 3 Add Visualizations and Interface\n", + "\n", + " (You can use the table of contents button on the left side of the interface to skip to any specific part)\n", + "\n", + "\n", + "Users can use Google Colab (Please ensure you run the Colab dependency import cell) \n", + "\n", + "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/projectmesa/mesa-geo/blob/main/docs/tutorials/intro_tutorial.ipynb)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0c702b3-c307-4f00-9530-468e29f53184", + "metadata": {}, + "outputs": [], + "source": [ + "#Run this if in colab\n", + "!pip install mesa\n", + "!pip install mesa-geo\n", + "!mkdir -p data\n", + "!wget -P data https://raw.githubusercontent.com/projectmesa/mesa-geo/tree/main/docs/tutorials/data/TorontoNeighbourhoods.geojson" ] }, { "cell_type": "markdown", - "id": "1482bfa9", + "id": "3f1b8324-9849-4992-b13a-db9a94372d0a", "metadata": {}, "source": [ - "## Getting started\n", + "## Part 1 Create the Basic Model\n", + "This portion initializes the human agents, the neighborhood agents, and the model class that manages the model dynamics. \n", + "\n", + "First we import our dependencies" + ] + }, + { + "cell_type": "markdown", + "id": "431d4b10-a0ee-4c03-9197-1c1266165169", + "metadata": { + "explanatory": true, + "has_explanation": false + }, + "source": [ + "\n", + "\n", + "This cell imports the specific libraries we need to create our model. \n", "\n", - "You should be familiar with how [Mesa](https://github.com/projectmesa/mesa) works.\n", + "- [Shapley](https://shapely.readthedocs.io/en/stable/) a library GIS library for object in the cartesian plane. From Shapely we specifically need the Point class to create our human agents\n", + "- [Mesa](https://mesa.readthedocs.io/en/stable/) the parent ABM library to Mesa-Geo\n", "\n", - "So let's get started with some geometries! We will work with [records of US states](http://eric.clst.org/Stuff/USGeoJSON). We use the `requests` library to retrieve the data, but of course you can work with local data." + "Then of course mesa-geo which although not strictly necessary we also specifically import the visualization part of the library \n", + "so we do not have to write out mesa-geo.visualization modules when we call them. " ] }, { "cell_type": "code", "execution_count": null, - "id": "6c026625", + "id": "c7d43c11-a846-45e1-bbcf-77de58ff5033", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.247846Z", - "start_time": "2022-10-17T18:18:29.927694Z" - }, - "has_explanation": false + "has_explanation": true }, "outputs": [], "source": [ - "import warnings\n", - "\n", - "warnings.filterwarnings(\"ignore\")\n", + "from shapely.geometry import Point\n", "\n", "import mesa\n", "import mesa_geo as mg\n", - "import requests\n", - "\n", + "import mesa_geo.visualization as mgv\n" + ] + }, + { + "cell_type": "markdown", + "id": "040912e4-e07b-4ffa-97fa-5aeae014c844", + "metadata": {}, + "source": [ + "### Create the person agent class\n", "\n", - "url = \"http://eric.clst.org/assets/wiki/uploads/Stuff/gz_2010_us_040_00_20m.json\"\n", - "r = requests.get(url)\n", - "geojson_states = r.json()" + "The person in this model represents one human being and we initilaize each person agent with two key parts: \n", + "1. The agent attributes, such as recovery rate and death risk\n", + "2. The step function, actions the agent will take each model step " ] }, { "cell_type": "markdown", - "id": "c5599433", + "id": "0d0bd901-c038-40d5-8017-df3fdedbd838", "metadata": { - "ExecuteTime": { - "end_time": "2022-08-31T13:29:13.715839Z", - "start_time": "2022-08-31T13:29:13.710995Z" - } + "explanatory": true, + "has_explanation": false }, "source": [ - "First we create a `State` Agent and a `GeoModel`. Both should look familiar if you have worked with Mesa before." + "\n", + "The first thing we are going to do is create the person agent class. This class has several attributes necessary to make a more holistic model. \n", + "\n", + "First, there are the required attributes for any GeoAgent in Mesa-Geo\n", + "\n", + "- **unique_id**: Some unique identifier, often an int, this ensure Mesa can keep track of each agent without confusion\n", + "- **model**: Model object class that we will build later, this is a pointer to the model instance so the agent can get information from the model as it behaves\n", + "- **geometry**: GIS geometric object in this case a GIS Point\n", + "- **crs**: A string describing the coordinate reference system the agent is using\n", + "\n", + "As you can see these are inherited from the mesa-geo librarary through the \"mg.GeoAgent\" in the class instantiation. \n", + "\n", + "Second, the variable attributes these are unique to our SIR model:\n", + "\n", + "- **agent_type**: A string which describes the agent state (susceptible, infected, recovered, or dead) \n", + "- **mobility_range**: Distance the agent can move in meters\n", + "- **infection risk**: A float from 0.0 to 1.0 that determines the risk of the agent being infected if exposed. \n", + "- **recovery_rate**: A float from 0.0 to 1.0 that determine how long the agents takes to recover\n", + "- **death_risk**: A float from 0.0 to 1.0 that determines the probability the agent will die\n", + "\n", + "The **`__repr__`** function is a Python primitive that will print out information as directed by the code. In this case we will print out the agent ID\n", + "\n", + "The **step** function is a Mesa primitive that the scheduler looks for and describes what action the agent takes each step" ] }, { "cell_type": "code", "execution_count": null, - "id": "c07e7a9a", + "id": "b39b18eb-16e9-4db0-8739-a050cc74d40a", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.256884Z", - "start_time": "2022-10-17T18:18:56.251192Z" - }, - "has_explanation": false + "has_explanation": true }, "outputs": [], "source": [ - "class State(mg.GeoAgent):\n", - " def __init__(self, unique_id, model, geometry, crs):\n", + "class PersonAgent(mg.GeoAgent):\n", + " \"\"\"Person Agent.\"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " unique_id,\n", + " model,\n", + " geometry,\n", + " crs,\n", + " agent_type,\n", + " mobility_range,\n", + " infection_risk,\n", + " recovery_rate,\n", + " death_risk\n", + " ):\n", " super().__init__(unique_id, model, geometry, crs)\n", + " # Agent attributes\n", + " self.atype = agent_type\n", + " self.mobility_range = mobility_range\n", + " self.infection_risk=infection_risk,\n", + " self.recovery_rate = recovery_rate\n", + " self.death_risk = death_risk\n", + "\n", + " def __repr__(self):\n", + " return \"Person \" + str(self.unique_id)\n", + "\n", + " def step(self): \n", + " print (repr(self))\n", + " print(self.atype, self.death_risk, self.recovery_rate)" + ] + }, + { + "cell_type": "markdown", + "id": "ce98080f-209d-4476-b50c-88a232ff0bbb", + "metadata": {}, + "source": [ + "### Create the neighborhood agent \n", "\n", + "The neighborhood in this model represents one geographical area as defined by the geojson file we uploaded. \n", "\n", - "class GeoModel(mesa.Model):\n", - " def __init__(self):\n", - " self.space = mg.GeoSpace()\n", + "Similar to the person agent, we initialize each neighborhood agent with the same two key parts. \n", "\n", - " ac = mg.AgentCreator(agent_class=State, model=self)\n", - " agents = ac.from_GeoJSON(GeoJSON=geojson_states, unique_id=\"NAME\")\n", - " self.space.add_agents(agents)" + "1. The agent attributes, such as geometry and state of neighborhood\n", + "2. The step function, behaviors the agent will take during each model step. " ] }, { "cell_type": "markdown", - "id": "c806fc00", + "id": "b44f5943-ae82-4329-9255-9ec8458311aa", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "Similar to the person agent for the neighborhood agent there are two types of attributes. \n", + "\n", + "The required attributes for any GeoAgent in Mesa-Geo:\n", + "\n", + "- **unique_id**: For geographic agents such as a neighborhood mesa-geo will assign a very large integer as the agent id, if desired users can specify their own. \n", + "- **model**: Model object class that we will build later, this is a pointer to the model instance so the agent can get information from the model as it behaves\n", + "- **geometry**: GIS geometric object in this case a polygon form the geojson defining the perimeter of the neighborhood\n", + "- **crs**: A string describing the coordinate reference system the agent is using\n", + "\n", + "Similar to the person agent, \"mg.GeoAgent\" is inherited from mesa-geo. \n", + "\n", + "Next are the variable attributes:\n", + "\n", + "- **agent_type**: A string which describes the state of the neighborhood which will be either safe or hot spot \n", + "- **hotspot_threshold**: An integer that is the number of infected people in a neighborhood to call it a hotspot \n", + "\n", + "We will also use the **`__repr__`** function to print out the agent ID\n", + "\n", + "Then the **step** function, which is a primitive that the Mesa scheduler looks for and describes what action the agent takes each step" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da9c0121-9f35-4c7b-ab6a-d8d970189ec6", + "metadata": { + "has_explanation": true + }, + "outputs": [], + "source": [ + "class NeighbourhoodAgent(mg.GeoAgent):\n", + " \"\"\"Neighbourhood agent. Changes color according to number of infected inside it.\"\"\"\n", + "\n", + " def __init__(\n", + " self, unique_id, model, geometry, crs, agent_type=\"safe\", hotspot_threshold=1\n", + " ):\n", + " super().__init__(unique_id, model, geometry, crs)\n", + " self.atype = agent_type\n", + " self.hotspot_threshold = (\n", + " hotspot_threshold # When a neighborhood is considered a hot-spot\n", + " )\n", + "\n", + " def __repr__(self):\n", + " return \"Neighbourhood \" + str(self.unique_id)\n", + " \n", + " def step(self):\n", + " \"\"\"Advance agent one step.\"\"\"\n", + " print(repr(self))\n", + " \n", + " " + ] + }, + { + "cell_type": "markdown", + "id": "efe370a7-a27e-4c76-830d-155a8853d81c", "metadata": {}, "source": [ - "In the `GeoModel` we first create an instance of AgentCreator, where we provide the Agent class (State) and its required arguments, except geometry and unique_id. We then use the `.from_GeoJSON` function to create our agents from the geometries in the GeoJSON file. We provide the feature \"name\" as the key from which the agents get their unique_ids.\n", - "Finally, we add the agents to the GeoSpace\n", + "### Create the Model Class\n", + "\n", + "The model class is the manager that instantiates the agents, then manages what is happening in the model through the step function, and collects data. \n", + "\n", + "We will create the model with parameters that will set the attributes of the agents as it instantiates them and a step function to call the agent step function. " + ] + }, + { + "cell_type": "markdown", + "id": "c44bdd40-6ee9-4579-90fa-502ba482f157", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "First, we name our class in this case GeoSIR and we inherit the model class from Mesa. We store the path to our GeoJSON file in the object geojson regions. As JSONs mirror Pythons dictionary structure, we store the key for the neighbourhood id (\"HOODNUM\") in the variable unique_id. \n", + "\n", + "Second, we set up the python initializer to initiate our model class. To do this we will, set up key word arguments or kwargs of the parameters we want for our model. In this case we will use: \n", + "- population size (pop_size): An integer that determines the number of person agents\n", + "- initial infection (init_infection): A float between 0.0 and 1.0 which determines what percentage of the population is infected as the model initiates\n", + "- exposure_distance (exposure_dist): An integer for the distance in meters a susceptible person agent must within to be infected by a person agent who is infected\n", + "- maximum infection risk (max_infection_risk): A float between 0.0 and 1.0 of which determines the highest suscpetibility rate in the population\n", + "\n", + "Third, we initialize our agents. Mesa-Geo has an AgentCreator class inside is geoagent.py file that can create GeoAgents from files, GeoDataFrames, GeoJSON or Shapely objects. \n", "\n", - "Let's instantiate our model and look at one of the agents:" + "**Creating the NeighbourhoodAgents**\n", + "\n", + "In this case we will use the `torontoneighbourhoods.geojson` file located in the data folder to to create the NeighbourhoodAgents. Next, we will add them to the environment with the space.add_agents function. Then we will iterate through each of the NeighbourhoodAgents to add them to the schedule. \n", + "\n", + "\n", + "**Creating the PersonAgents**\n", + "\n", + "We will use Mesa-Geo AgentCreator to create the person agents. To create a heterogeneous (diverse) population we will use the [random object created as part of Mesa's base class](https://github.com/projectmesa/mesa/blob/01477fc9624b70078fe1c82634d7c9e4938de942/mesa/model.py#L60) to help initialize the population's parameters. \n", + "\n", + "- death_risk: A float from 0 to 1\n", + "- agent_type: Compares the model parameter of initial infection of a random float between 0 and 1 and the initial infection parameter. If it is less than the initial infection parameter the agent is initialized as infected.\n", + "- recover: Is an integer between 1 and the recovery rate. This determines the number of steps it takes for the agent to recover.\n", + "- infection_risk: is a float between 0 and the parameter of max_infection_risk, which will then determine how likely a person is to get infected.\n", + "- death_risk: Is a random float between 0 and 1 that will determine how likely a person is to die when infected.\n", + "\n", + "By using Python's random library to create these attributes for each agent, we can now create a diverse agent population. \n", + "\n", + "Passing these parameters through the `AgentCreator` class we initialize our agent object. \n", + "\n", + "As Mesa-Geo is an GIS based ABM, we need assign each PersonAgent a Geometry and location. To do this we will use a helper function `find_home`. This helper function first identifies a NeighbourhoodAgent where the PersonAgent will start. Next it identifies the center of the neighborhood and its boundary and then randomly moving from the center point, put staying within the bounds, it a lat and long to aissgn the PersonAgent is starting location.\n", + "\n", + "**Step Function**\n", + "\n", + "The final piece is to initialize a step function. This function a Mesa primitive calls the RandomActiviationByType scheduler we set up and then iterates through each agent calling their step function. \n", + "\n", + "**The Model**\n", + "\n", + "We know have the pieces of our Model. A GIS layer of polygons that creates NeighbourhoodAgents from our GeoJSON file. A diverse population of GIS Point objects, with different infection, recovery and death risks. A model class that initializes these agents, a scheduler to call these agents, a GIS space and step function to execute the simulation\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "ebee624a", + "id": "d80d198b-6fa3-4be3-ad05-213d70e7a093", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.466576Z", - "start_time": "2022-10-17T18:18:56.260255Z" - }, - "has_explanation": false + "has_explanation": true }, "outputs": [], "source": [ - "m = GeoModel()\n", + "class GeoSIR(mesa.Model):\n", + " \"\"\"Model class for a simplistic infection model.\"\"\"\n", + "\n", + " # Geographical parameters for desired map\n", + " geojson_regions = \"data/TorontoNeighbourhoods.geojson\"\n", + " unique_id = \"HOODNUM\"\n", + "\n", + " def __init__(\n", + " self, pop_size=30, mobility_range=500, init_infection=0.2, exposure_dist=500, max_infection_risk=0.2,\n", + " max_recovery_time=5\n", + " ):\n", + " self.schedule = mesa.time.RandomActivationByType(self)\n", + " self.space = mg.GeoSpace(warn_crs_conversion=False)\n", + " \n", + " # SIR model parameters\n", + " self.pop_size = pop_size\n", + " self.mobility_range = mobility_range\n", + " self.initial_infection = init_infection\n", + " self.exposure_distance = exposure_dist\n", + " self.infection_risk = max_infection_risk\n", + " self.recovery_rate = max_recovery_time\n", + "\n", + " # Set up the Neighbourhood patches for every region in file\n", + " ac = mg.AgentCreator(NeighbourhoodAgent, model=self)\n", + " neighbourhood_agents = ac.from_file(\n", + " self.geojson_regions, unique_id=self.unique_id\n", + " )\n", + " \n", + " #Add neighbourhood agents to space\n", + " self.space.add_agents(neighbourhood_agents)\n", + " \n", + " #Add neighbourhood agents to scheduler \n", + " for agent in neighbourhood_agents:\n", + " self.schedule.add(agent)\n", + " \n", + " \n", + " # Generate random location, add agent to grid and scheduler\n", + " for i in range(pop_size):\n", + " #assess if they are infected\n", + " if self.random.random() < self.initial_infection: \n", + " agent_type = \"infected\"\n", + " else: \n", + " agent_type = \"susceptible\"\n", + " #determine movement range\n", + " mobility_range = self.random.randint(0,self.mobility_range) \n", + " #determine agent recovery rate\n", + " recover = self.random.randint(1,self.recovery_rate)\n", + " #determine agents infection risk\n", + " infection_risk = self.random.uniform(0,self.infection_risk)\n", + " #determine agent death probability \n", + " death_risk= self.random.random()\n", + "\n", + " # Generate PersonAgent population\n", + " unique_person = mg.AgentCreator(\n", + " PersonAgent,\n", + " model=self,\n", + " crs=self.space.crs,\n", + " agent_kwargs={\"agent_type\": agent_type, \n", + " \"mobility_range\":mobility_range,\n", + " \"recovery_rate\":recover,\n", + " \"infection_risk\": infection_risk,\n", + " \"death_risk\": death_risk\n", + " }\n", + " )\n", + " \n", + " \n", + " x_home, y_home = self.find_home(neighbourhood_agents)\n", + " \n", + " this_person = unique_person.create_agent(\n", + " Point(x_home, y_home), \"P\" + str(i), \n", + " )\n", + " self.space.add_agents(this_person)\n", + " self.schedule.add(this_person)\n", + " \n", + " \n", + " def find_home(self, neighbourhood_agents): \n", + " \"\"\" Find start location of agent \"\"\"\n", + "\n", + " #identify location\n", + " this_neighbourhood = self.random.randint(\n", + " 0, len(neighbourhood_agents) - 1\n", + " ) # Region where agent starts\n", + " center_x, center_y = neighbourhood_agents[\n", + " this_neighbourhood\n", + " ].geometry.centroid.coords.xy\n", + " this_bounds = neighbourhood_agents[this_neighbourhood].geometry.bounds\n", + " spread_x = int(\n", + " this_bounds[2] - this_bounds[0]\n", + " ) # Heuristic for agent spread in region\n", + " spread_y = int(this_bounds[3] - this_bounds[1])\n", + " this_x = center_x[0] + self.random.randint(0, spread_x) - spread_x / 2\n", + " this_y = center_y[0] + self.random.randint(0, spread_y) - spread_y / 2\n", + "\n", + " return this_x, this_y\n", "\n", - "agent = m.space.agents[0]\n", - "print(agent.unique_id)\n", - "agent.geometry" + " \n", + " def step(self):\n", + " \"\"\"Run one step of the model.\"\"\"\n", + " self.schedule.step()\n" ] }, { "cell_type": "markdown", - "id": "983dad91", + "id": "eb15ce7d-3537-4e23-84da-dca93ad7c3f9", "metadata": {}, "source": [ - "If you work in the Jupyter Notebook your output should give you the name of the state and a visual representation of the geometry.\n", + "### Run The Base Model" + ] + }, + { + "cell_type": "markdown", + "id": "36bdf628-b152-45ec-a568-03e6faa413e9", + "metadata": { + "explanatory": true + }, + "source": [ + "#explanatory\n", + "\n", + "This cell is fairly simple\n", + "\n", + "1 - We instantiate the SIR model by call the class name \"GeoSIR\" into the object `model`.\n", + "\n", + "2 - Then we call the step function to see if it prints out the Agent IDs, infection status, death_risk, and recovery rate as called in the PersonAgent class. \n", "\n", - "By default the AgentCreator also sets further agent attributes from the Feature properties." + "You can also see all the person agents are called and then the neighbourhood agents. This will become important later as we want to update the neighbourhood status later based on its PersonAgent status. \n", + "\n", + "If you are curious about the numbers for the neighbourhood agents, you can open up the GeoJSON in the data folder and see that each neighborhood gets a unique id identified by `HOODNUM` to ensure this number does not cause a conflict with our agent numbers, we add a \"P\" to their ID. \n", + "\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "0f7c0ee6", + "id": "c181dbf7-c175-47c7-8c20-2ec8fa758c60", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.473375Z", - "start_time": "2022-10-17T18:18:56.469477Z" - }, - "has_explanation": false + "has_explanation": true }, "outputs": [], "source": [ - "agent.CENSUSAREA" + "model = GeoSIR()\n", + "model.step()" + ] + }, + { + "cell_type": "markdown", + "id": "ceeed343-3cdb-45af-be8e-90816f542e4d", + "metadata": {}, + "source": [ + "## Part 2 Add Agent and Model Complexity " ] }, { "cell_type": "markdown", - "id": "6a843f67", + "id": "dfbc2440-a9a3-4e40-903e-c0d9ff98c548", "metadata": {}, "source": [ - "Let's start to do some spatial analysis. We can use usual Mesa function names to get neighboring states." + "### Increase PersonAgent Complexity\n", + "\n", + "In this section we add behaviors to the PersonAgent to build the necessary SIR dynamics. " + ] + }, + { + "cell_type": "markdown", + "id": "26967d31-9dab-4a8f-b970-17ab2e1c7990", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "To create the SIR dynamics we need the agents move, determine if they have been exposed and if they have process the probability of them being infected and possibly dying. \n", + "\n", + "To do this we will update our step function. The step function logic uses the agent's `atype` to determine what actions to process\n", + "\n", + "**Part 1**\n", + "\n", + "If the PersonAgent `atype` is susceptible, then we need to identify all PersonAgent's neighbors within the exposure distance. To do this, we will use Mesa-Geo's `get_neighbors_within_distance` function which takes 2 parameters, the agent, and a distance, which in this case is the model parameter for exposure distance in meters. This creates a list of PersonAgents within that distance. \n", + "\n", + "The `get_neighbors_within_distance` [function](https://github.com/projectmesa/mesa-geo/blob/56c598486e0f58f3626d9951796998d38bbab0b5/mesa_geo/geospace.py#L197) has two keyword arguments `center` and `relation`. `center` takes `True` or `False` on whether to include the center, it is set to `False` and measures as a buffer around the agent's geometry. If `True` it measures from the Center of the point. `relation` is defaulted to `intersects` but can take any common spatial relationship, such as `contains`, `within`, `touches`, `crosses`\n", + "\n", + "The step function then iterates through the list of neighbors to see if any agents are infected. If so it does a probabilistic comparison of a random float compared to the agents infection risk and if `True` the agent becomes infected and the iteration ends. \n", + "\n", + "**Part 2**\n", + "\n", + "If the agent `atype` is infected, then the step function does comparisons. First, it sees how many steps the agent has been infected. To track this the PersonAgent got a new attribute counter which is `steps_infected`. If the steps are greater than or equal to their recovery rate, the agent is recovered, if not then the function does a probabilistic comparison with the agents death risk to see if the agent dies. If neither of these things happen the `steps_infected` increases by one.\n", + "\n", + "**Part 3**\n", + "\n", + "The next part is if the agent `atype` is not dead then the agent moves. For this we randomly get an integer for the x any (lat and long) between their negative `mobility_range` and positive `mobility range`. We pass these two integers into the helper function `move_point` and then update the agents geometry with this new point. \n", + "\n", + "Finally, we update the counts of agent types. " ] }, { "cell_type": "code", "execution_count": null, - "id": "154e56b2", + "id": "0f6e8a2c-ec86-4cd8-8feb-86e3da6920cb", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.759515Z", - "start_time": "2022-10-17T18:18:56.475418Z" - }, - "has_explanation": false + "has_explanation": true }, "outputs": [], "source": [ - "neighbors = m.space.get_neighbors(agent)\n", - "print([a.unique_id for a in neighbors])" + "class PersonAgent(mg.GeoAgent):\n", + " \"\"\"Person Agent.\"\"\"\n", + "\n", + " def __init__(\n", + " self,\n", + " unique_id,\n", + " model,\n", + " geometry,\n", + " crs,\n", + " agent_type,\n", + " mobility_range,\n", + " infection_risk,\n", + " recovery_rate,\n", + " death_risk\n", + " ):\n", + " super().__init__(unique_id, model, geometry, crs)\n", + " # Agent attributes\n", + " self.atype = agent_type\n", + " self.mobility_range = mobility_range\n", + " self.infection_risk=infection_risk,\n", + " self.recovery_rate = recovery_rate\n", + " self.death_risk = death_risk\n", + " self.steps_infected=0\n", + " self.steps_recovered = 0\n", + "\n", + " def __repr__(self):\n", + " return \"Person \" + str(self.unique_id)\n", + "\n", + " #Helper function for moving agent\n", + " def move_point(self, dx, dy):\n", + " \"\"\"\n", + " Move a point by creating a new one\n", + " :param dx: Distance to move in x-axis\n", + " :param dy: Distance to move in y-axis\n", + " \"\"\"\n", + " return Point(self.geometry.x + dx, self.geometry.y + dy)\n", + " \n", + " \n", + " def step(self): \n", + "\n", + " #Part 1 - find neighbors based on infection distance\n", + " if self.atype == \"susceptible\":\n", + " neighbors = self.model.space.get_neighbors_within_distance(\n", + " self, self.model.exposure_distance\n", + " )\n", + " for neighbor in neighbors:\n", + " if (\n", + " neighbor.atype == \"infected\"\n", + " and self.random.random() < self.model.infection_risk\n", + " ):\n", + " self.atype = \"infected\"\n", + " break #stop process if agent becomes infected\n", + "\n", + " #Part -2 If infected, check if agent recovers or agent dies\n", + " elif self.atype == \"infected\":\n", + " if self.steps_infected >= self.recovery_rate:\n", + " self.atype = \"recovered\"\n", + " self.steps_infected = 0\n", + " elif self.random.random() < self.death_risk:\n", + " self.atype = \"dead\"\n", + " else:\n", + " self.steps_infected += 1\n", + "\n", + " elif self.atype == \"recovered\":\n", + " self.steps_recovered+=1\n", + " if self.steps_recovered >=2: \n", + " self.atype= \"susceptible\"\n", + " self.steps_recovered = 0\n", + " \n", + " #Part 3 - If not dead, move\n", + " if self.atype != \"dead\":\n", + " move_x = self.random.randint(-self.mobility_range, self.mobility_range)\n", + " move_y = self.random.randint(-self.mobility_range, self.mobility_range)\n", + " self.geometry = self.move_point(move_x, move_y) # Reassign geometry\n", + "\n", + " self.model.counts[self.atype] += 1 # Count agent type" ] }, { "cell_type": "markdown", - "id": "6b5d5c9d", + "id": "c4823f40-2837-4176-8ee5-e09276888e45", "metadata": {}, "source": [ - "To get a list of all states within a certain distance you can use the following:" + "### Increase NeighbourhoodAgent Complexity" + ] + }, + { + "cell_type": "markdown", + "id": "e1af08cc-4f1e-45b7-8282-06d30f6dbe3e", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "For the NeighbourhoodAgent we want to change their color based on the number of infected PersonAgents in their neighbourhood. \n", + "\n", + "To do this we will create a helper function called `color_hotspot`. We will then use mesa-geo's `get_intersecting_agents` [function](https://github.com/projectmesa/mesa-geo/blob/56c598486e0f58f3626d9951796998d38bbab0b5/mesa_geo/geospace.py#L194). We will then iterate through that list to get the agents with `atype` infected if the list is longer than our `hotspot_threshold` equal to 1 (so if two agents in the neighborhood are infected) then the `atype` will change to `hotspot`. \n", + "\n", + "We then update our model counts. " ] }, { "cell_type": "code", "execution_count": null, - "id": "63b36a85", + "id": "50aab416-959f-4d72-abd9-844ed42c784d", "metadata": { - "ExecuteTime": { - "end_time": "2022-10-17T18:18:56.769281Z", - "start_time": "2022-10-17T18:18:56.761925Z" - }, - "has_explanation": false + "has_explanation": true, + "jupyter": { + "source_hidden": true + } + }, + "outputs": [], + "source": [ + "class NeighbourhoodAgent(mg.GeoAgent):\n", + " \"\"\"Neighbourhood agent. Changes color according to number of infected inside it.\"\"\"\n", + "\n", + " def __init__(\n", + " self, unique_id, model, geometry, crs, agent_type=\"safe\", hotspot_threshold=1\n", + " ):\n", + " super().__init__(unique_id, model, geometry, crs)\n", + " self.atype = agent_type\n", + " self.hotspot_threshold = (\n", + " hotspot_threshold # When a neighborhood is considered a hot-spot\n", + " )\n", + "\n", + " def __repr__(self):\n", + " return \"Neighbourhood \" + str(self.unique_id)\n", + " \n", + " def color_hotspot(self):\n", + " # Decide if this region agent is a hot-spot\n", + " # (if more than threshold person agents are infected)\n", + " neighbors = self.model.space.get_intersecting_agents(self)\n", + " infected_neighbors = [\n", + " neighbor for neighbor in neighbors if neighbor.atype == \"infected\"\n", + " ]\n", + " if len(infected_neighbors) > self.hotspot_threshold:\n", + " self.atype = \"hotspot\"\n", + " else:\n", + " self.atype = \"safe\"\n", + " \n", + " def step(self):\n", + " \"\"\"Advance agent one step.\"\"\"\n", + " self.color_hotspot()\n", + " self.model.counts[self.atype] += 1 # Count agent type" + ] + }, + { + "cell_type": "markdown", + "id": "ee5ba9f7-db79-43a9-8a76-37dfbf6b2bd5", + "metadata": {}, + "source": [ + "### Increase model complexity\n", + "\n", + "For this section will add data collection where we collect the status of the PersonAgents and the NeighbourhoodAgents but counting the different `atypes`.\n" + ] + }, + { + "cell_type": "markdown", + "id": "6ccbc3cb-083d-4fc1-9516-85a71fe36701", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "As we run our SIR model, we want to ensure we are collecting information about the status of the disease. \n", + "\n", + "To do this we will create helper functions that get this information. In this case we will put them in a separate cell, but depending on the developers preference they could also put them in the model class or collect the information in a handful of other ways. \n", + "\n", + "In this case, we set up an attribute in the model called counts and these functions just get the total number from Mesa's data collector of each of our statuses. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e93ff02-3d17-4663-a33a-5113e65c6aef", + "metadata": { + "has_explanation": true + }, + "outputs": [], + "source": [ + "# Functions needed for datacollector\n", + "def get_infected_count(model):\n", + " return model.counts[\"infected\"]\n", + "\n", + "\n", + "def get_susceptible_count(model):\n", + " return model.counts[\"susceptible\"]\n", + "\n", + "\n", + "def get_recovered_count(model):\n", + " return model.counts[\"recovered\"]\n", + "\n", + "\n", + "def get_dead_count(model):\n", + " return model.counts[\"dead\"]\n", + "\n", + "def get_hotspot_count(model): \n", + " return model.counts[\"hotspot\"]\n", + "\n", + "def get_safe_count(model): \n", + " return model.counts[\"safe\"]" + ] + }, + { + "cell_type": "markdown", + "id": "9ef38b13-a442-480d-ab23-0fa4366dd716", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "Now to finish the model so we can add the interface we add datacollection and a stop condition. As these updates are interspersed throughout the class. The comment `#added` is used to make the changes easier to identify.\n", + "\n", + "First, we add an attribute called `self.counts` which will track our the agent types (e.g. infected). We will initialize it as None. We then initialize the counts in our next line `self.reset_counts()`. This helper function located directly above the step function, resets the counts of each type of agent so it is always based on the current situation in the Model. \n", + "\n", + "We are then going to add the attribute self.running so we can input the stop condition. Next we set our our data collector that call our functions from the previous cell which collects our agent types\n", + "\n", + "With these added we can now call `self.reset_counts` and `self.datacollector.collect` in our step function so it collect our agent states each step. \n", + "\n", + "Finally we add a stop condition. If no PersonAgent is infected the pandemic is over and we stop the model. \n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d24aa5cb-61bc-4651-9792-3ab68ff3b0f6", + "metadata": { + "has_explanation": true + }, + "outputs": [], + "source": [ + "class GeoSIR(mesa.Model):\n", + " \"\"\"Model class for a simplistic infection model.\"\"\"\n", + "\n", + " # Geographical parameters for desired map\n", + " geojson_regions = \"data/TorontoNeighbourhoods.geojson\"\n", + " unique_id = \"HOODNUM\"\n", + "\n", + " def __init__(\n", + " self, pop_size=30, mobility_range=500, init_infection=0.2, exposure_dist=500, max_infection_risk=0.2,\n", + " max_recovery_time=5\n", + " ):\n", + " #Scheduler\n", + " self.schedule = mesa.time.RandomActivationByType(self)\n", + " #Space\n", + " self.space = mg.GeoSpace(warn_crs_conversion=False)\n", + " # Data Collection\n", + " self.counts = None #added\n", + " self.reset_counts() #added\n", + " \n", + " # SIR model parameters\n", + " self.pop_size = pop_size\n", + " self.mobility_range = mobility_range\n", + " self.initial_infection = init_infection\n", + " self.exposure_distance = exposure_dist\n", + " self.infection_risk = max_infection_risk\n", + " self.recovery_rate = max_recovery_time\n", + " self.running = True #added\n", + " #added\n", + " self.datacollector = mesa.DataCollector(\n", + " {\n", + " \"infected\": get_infected_count,\n", + " \"susceptible\": get_susceptible_count,\n", + " \"recovered\": get_recovered_count,\n", + " \"dead\": get_dead_count,\n", + " \"safe\": get_safe_count, \n", + " \"hotspot\": get_hotspot_count\n", + " }\n", + " )\n", + " \n", + " # Set up the Neighbourhood patches for every region in file\n", + " ac = mg.AgentCreator(NeighbourhoodAgent, model=self)\n", + " neighbourhood_agents = ac.from_file(\n", + " self.geojson_regions, unique_id=self.unique_id\n", + " )\n", + " \n", + " #Add neighbourhood agents to space\n", + " self.space.add_agents(neighbourhood_agents)\n", + " \n", + " #Add neighbourhood agents to scheduler \n", + " for agent in neighbourhood_agents:\n", + " self.schedule.add(agent)\n", + " \n", + " \n", + " # Generate random location, add agent to grid and scheduler\n", + " for i in range(pop_size):\n", + " #assess if they are infected\n", + " if self.random.random() < self.initial_infection: \n", + " agent_type = \"infected\"\n", + " else: \n", + " agent_type = \"susceptible\"\n", + " #determine movement range\n", + " mobility_range = self.random.randint(0,self.mobility_range) \n", + " #determine agent recovery rate\n", + " recover = self.random.randint(1,self.recovery_rate)\n", + " #determine agents infection risk\n", + " infection_risk = self.random.uniform(0,self.infection_risk)\n", + " #determine agent death probability \n", + " death_risk= self.random.uniform(0,0.05)\n", + "\n", + " # Generate PersonAgent population\n", + " unique_person = mg.AgentCreator(\n", + " PersonAgent,\n", + " model=self,\n", + " crs=self.space.crs,\n", + " agent_kwargs={\"agent_type\": agent_type, \n", + " \"mobility_range\":mobility_range,\n", + " \"recovery_rate\":recover,\n", + " \"infection_risk\": infection_risk,\n", + " \"death_risk\": death_risk\n", + " }\n", + " )\n", + " \n", + " \n", + " x_home, y_home = self.find_home(neighbourhood_agents)\n", + " \n", + " this_person = unique_person.create_agent(\n", + " Point(x_home, y_home), \"P\" + str(i), \n", + " )\n", + " self.space.add_agents(this_person)\n", + " self.schedule.add(this_person)\n", + " \n", + " \n", + " def find_home(self, neighbourhood_agents): \n", + " \"\"\" Find start location of agent \"\"\"\n", + "\n", + " #identify location\n", + " this_neighbourhood = self.random.randint(\n", + " 0, len(neighbourhood_agents) - 1\n", + " ) # Region where agent starts\n", + " center_x, center_y = neighbourhood_agents[\n", + " this_neighbourhood\n", + " ].geometry.centroid.coords.xy\n", + " this_bounds = neighbourhood_agents[this_neighbourhood].geometry.bounds\n", + " spread_x = int(\n", + " this_bounds[2] - this_bounds[0]\n", + " ) # Heuristic for agent spread in region\n", + " spread_y = int(this_bounds[3] - this_bounds[1])\n", + " this_x = center_x[0] + self.random.randint(0, spread_x) - spread_x / 2\n", + " this_y = center_y[0] + self.random.randint(0, spread_y) - spread_y / 2\n", + "\n", + " return this_x, this_y\n", + " \n", + " #added\n", + " def reset_counts(self):\n", + " self.counts = {\n", + " \"susceptible\": 0,\n", + " \"infected\": 0,\n", + " \"recovered\": 0,\n", + " \"dead\": 0,\n", + " \"safe\": 0,\n", + " \"hotspot\": 0,\n", + " }\n", + " \n", + " \n", + " def step(self):\n", + " \"\"\"Run one step of the model.\"\"\"\n", + " \n", + " self.reset_counts() #added\n", + " self.schedule.step()\n", + " self.datacollector.collect(self) #added\n", + "\n", + " # Run until no one is infected\n", + " if self.counts[\"infected\"] == 0 :\n", + " self.running = False\n" + ] + }, + { + "cell_type": "markdown", + "id": "ae5bf0be-e748-4850-9cec-ecbb5a0c2201", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "To test our code we will run the model through 5 steps and then call the model dataframe via data collector with `get_model_vars_dataframe()`. This will show a Pandas DataFrame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "027a8065-13d7-4bc2-96ba-9c556ce16b3c", + "metadata": { + "has_explanation": true }, "outputs": [], "source": [ - "[a.unique_id for a in m.space.get_neighbors_within_distance(agent, 600000)]" + "model = GeoSIR()\n", + "for i in range(5): \n", + " model.step()\n", + "\n", + "model.datacollector.get_model_vars_dataframe()" ] }, { "cell_type": "markdown", - "id": "78744f63", + "id": "f299f22b-9115-48a4-8277-ede19628707c", "metadata": {}, "source": [ - "The unit for the distance depends on the coordinate reference system (CRS) of the GeoSpace. Since we did not specify the CRS, Mesa-Geo defaults to the 'Web Mercator' projection (in meters). If you want to do some serious measurements you should always set an appropriate CRS, since the accuracy of Web Mercator declines with distance from the equator. We can achieve this by initializing the AgentCreator and the GeoSpace with the `crs` keyword `crs=\"epsg:2163\"`. Mesa-Geo then transforms all coordinates from the GeoJSON geographic coordinates into the set crs." + "## Part 3 - Add Interface" ] }, { "cell_type": "markdown", - "id": "d56743c5", + "id": "1035cc55-0169-4096-8b63-d99b8259c980", "metadata": {}, "source": [ - "## Going further\n", + "Adding the interface requires three steps: \n", + "\n", + "1. Define the agent portrayal\n", + "2. Set the sliders for the model parameters\n", + "3. Call the model through the Mesa-Geo visualization model" + ] + }, + { + "cell_type": "markdown", + "id": "b5924dd0-38e6-4b16-911e-4bd93d80976e", + "metadata": { + "explanatory": true + }, + "source": [ + "\n", + "Visualizing agents is done through a function that is is passed in as a parameter. By default agents they are Point geometries are rendered as circles. However, Mesa uses [ipyleaflet](https://ipyleaflet.readthedocs.io/en/latest/layers/marker.html) Users can pass through any Point geometry for their Agent (i.e. Marker, Circle, Icon, AwesomeIcon). To show this we will use different colors for the PersonAgent base don infection status and if they die, we will use the [Font Awesome Icons](https://fontawesome.com/v4/) and represent them with an x, in the traditional ipyleaflet marker. \n", + "\n", + "We will also change the color of the NeighbourhoodAgent based whether or not it is a hotspot\n", "\n", - "To get a deeper understanding of Mesa-Geo you should check out the [GeoSchelling](https://github.com/projectmesa/mesa-examples/tree/main/gis/geo_schelling) example. It implements a Leaflet visualization which is similar to use as the CanvasGridVisualization of Mesa.\n", + "Next we will build Sliders for each of our input parameters. These use the [Solara's input approach](https://solara.dev/documentation/components/input/slider). This is stored in a dictionary of dictionaries that is then passed through in the model instantiation. \n", "\n", - "To add further functionality, I need feedback on which functionality is desired by users. Please post a message at [Mesa-Geo discussions](https://github.com/projectmesa/mesa-geo/discussions) or open an [issue](https://github.com/projectmesa/mesa-geo/issues) if you have any ideas or recommendations." + "If you want the model to fill the entire screen you can hit the expand button in the upper right. " ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d19cb45-3499-47e2-b5b8-2096bc0fc268", + "metadata": { + "editable": true, + "has_explanation": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "def SIR_draw(agent):\n", + " \"\"\"\n", + " Portrayal Method for canvas\n", + " \"\"\"\n", + " portrayal = {}\n", + " if isinstance(agent, PersonAgent): \n", + " if agent.atype == \"susceptible\":\n", + " portrayal[\"color\"] = \"Green\"\n", + " elif agent.atype == \"infected\":\n", + " portrayal[\"color\"] = \"Red\"\n", + " elif agent.atype == \"recovered\":\n", + " portrayal[\"color\"] = \"Blue\"\n", + " else: \n", + " portrayal[\"marker_type\"] = \"AwesomeIcon\"\n", + " portrayal[\"name\"] = \"times\"\n", + " portrayal[\"icon_properties\"] = {\n", + " \"marker_color\": 'black',\n", + " \"icon_color\":'white'}\n", + " \n", + " if isinstance(agent, NeighbourhoodAgent):\n", + " if agent.atype == \"hotspot\":\n", + " portrayal[\"color\"] = \"Red\"\n", + " else: \n", + " portrayal[\"color\"] = \"Green\"\n", + " \n", + " return portrayal\n", + "\n", + "\n", + "model_params = {\n", + " \"pop_size\": {\n", + " \"type\": \"SliderInt\",\n", + " \"value\": 80,\n", + " \"label\": \"Population Size\",\n", + " \"min\": 0,\n", + " \"max\": 100, \n", + " \"step\": 1,\n", + " },\n", + " \"mobility_range\": {\n", + " \"type\": \"SliderInt\",\n", + " \"value\": 500,\n", + " \"label\": \"Max Possible Agent Movement\",\n", + " \"min\": 100,\n", + " \"max\": 1000, \n", + " \"step\": 50,\n", + " },\n", + " \"init_infection\": {\n", + " \"type\": \"SliderFloat\",\n", + " \"value\": 0.4,\n", + " \"label\": \"Initial Infection\",\n", + " \"min\": 0.0,\n", + " \"max\": 1.0,\n", + " \"step\": 0.1,\n", + " },\n", + " \"exposure_dist\": {\n", + " \"type\": \"SliderInt\",\n", + " \"value\": 800,\n", + " \"label\": \"Exposure Distance\",\n", + " \"min\": 100,\n", + " \"max\": 1000, \n", + " \"step\": 50,\n", + " },\n", + " \"max_infection_risk\": {\n", + " \"type\": \"SliderFloat\",\n", + " \"value\": 0.7,\n", + " \"label\": \"Maximum Infection Risk\",\n", + " \"min\": 0.0,\n", + " \"max\": 1.0,\n", + " \"step\": 0.1\n", + " },\n", + " \"max_recovery_time\": {\n", + " \"type\": \"SliderInt\",\n", + " \"value\": 7,\n", + " \"label\": \"Maximum Number of Steps to Recover\",\n", + " \"min\": 1,\n", + " \"max\": 10, \n", + " \"step\": 1, \n", + " }}" + ] + }, + { + "cell_type": "markdown", + "id": "ee5f4df8-f8af-40a1-a469-843b4c043866", + "metadata": { + "explanatory": true + }, + "source": [ + "To create the model with the interface we use [Mesa's GeoJupyterViz module](https://github.com/projectmesa/mesa-geo/tree/main/mesa_geo/visualization). First we pass in the model class and next the parameters. We then switch to key word arguments. First measures, in this case of list of lists, where the first list will be a chart of the PersonAgent statuses and the second chart will be the NeighbourhoodAgent statuses. We also pass in a name, our agent portrayal function a zoom level and in this case set the scroll wheel zoom to false. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "daf1d216-0001-45c6-9527-450036d42764", + "metadata": { + "editable": true, + "has_explanation": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "page = mgv.GeoJupyterViz(\n", + " GeoSIR,\n", + " model_params,\n", + " measures= [[\"infected\", \"susceptible\", \"recovered\", \"dead\"], [\"safe\", \"hotspot\"]],\n", + " name=\"GeoSIR\",\n", + " agent_portrayal=SIR_draw,\n", + " zoom=12,\n", + " scroll_wheel_zoom=False\n", + ")\n", + "# This is required to render the visualization in the Jupyter notebook\n", + "page" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f789c49b-ae8c-49eb-8bcf-8231ccb33454", + "metadata": { + "has_explanation": false + }, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da907e19-46de-4faa-8ba9-193c6530461c", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Xeus-Python (Python 3.10)", "language": "python", - "name": "python3" + "name": "mesa_geo" }, "language_info": { "codemirror_mode": { @@ -231,19 +1126,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.3" - }, - "toc": { - "base_numbering": 1, - "nav_menu": {}, - "number_sections": false, - "sideBar": true, - "skip_h1_title": false, - "title_cell": "Table of Contents", - "title_sidebar": "Contents", - "toc_cell": false, - "toc_position": {}, - "toc_section_display": true, - "toc_window_display": true } }, "nbformat": 4, diff --git a/mesa_geo/visualization/geojupyter_viz.py b/mesa_geo/visualization/geojupyter_viz.py index 5002165f..4040a683 100644 --- a/mesa_geo/visualization/geojupyter_viz.py +++ b/mesa_geo/visualization/geojupyter_viz.py @@ -1,5 +1,3 @@ -import sys - import matplotlib.pyplot as plt import mesa.experimental.components.matplotlib as components_matplotlib import solara @@ -30,16 +28,54 @@ def Card( map_drawer, center_default, zoom, + scroll_wheel_zoom, current_step, color, layout_type, ): + """ + + + Parameters + ---------- + model : Mesa Model Object + A pointer to the Mesa Model object this allows the visual to get get + model information, such as scheduler and space. + measures : List + Plots associated with model typically from datacollector that represent + critical information collected from the model. + agent_portrayal : Dictionary + Contains details of how visualization should plot key elements of the + such as agent color etc + map_drawer : Method + Function that generates map from GIS data of model + center_default : List + Latitude and Longitude of where center of map should be located + zoom : Int + Zoom level at which to intialize the map + scroll_wheel_zoom: Boolean + True of False on whether user can zoom on map with mouse scroll wheel + default is True + current_step : Int + Number on which step is the model + color : String + Background color for visual + layout_type : String + Type of layout Map or Measure + + Returns + ------- + main : Solara object + Visualization of model + + """ + with rv.Card( style_=f"background-color: {color}; width: 100%; height: 100%" ) as main: if "Map" in layout_type: rv.CardTitle(children=["Map"]) - leaflet_viz.map(model, map_drawer, zoom, center_default) + leaflet_viz.map(model, map_drawer, zoom, center_default, scroll_wheel_zoom) if "Measure" in layout_type: rv.CardTitle(children=["Measure"]) @@ -65,23 +101,50 @@ def GeoJupyterViz( # parameters for leaflet_viz view=None, zoom=None, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, center_point=None, # Due to projection challenges in calculation allow user to specify center point ): - """Initialize a component to visualize a model. - Args: - model_class: class of the model to instantiate - model_params: parameters for initializing the model - measures: list of callables or data attributes to plot - name: name for display - agent_portrayal: options for rendering agents (dictionary) - space_drawer: method to render the agent space for - the model; default implementation is the `SpaceMatplotlib` component; - simulations with no space to visualize should - specify `space_drawer=False` - play_interval: play interval (default: 150) - center_point: list of center coords """ + + + Parameters + ---------- + model_class : Mesa Model Object + A pointer to the Mesa Model object this allows the visual to get get + model information, such as scheduler and space. + model_params : Dictionary + Parameters of model with key being the paramter and values being the options + measures : List, optional + Plots associated with model typically from datacollector that represent + critical information collected from the model. The default is None. + name : String, optional + Name of simulation to appear on visual. The default is None. + agent_portrayal : Dictionary, optional + Dictionary of how the agent showed appear. The default is None. + play_interval : INT, optional + Rendering interval of model. The default is 150. + # parameters for leaflet_viz + view : List, optional + Bounds of map to be displayed; must be set with zoom. The default is None. + zoom : Int, optional + Zoom level of map on leaflet + scroll_wheel_zoom : Boolean, optional + True of False for whether or not to enable scroll wheel. The default is True. + Recommend False is in jupyter due to multiple scroll whell options + tiles : Data source for GIS data, optional + Data Source for GIS map data. The default is xyz.OpenStreetMap.Mapnik. + # Due to projection challenges in calculation allow user to specify + center_point : List, optional + Option to pass in center coordinates of map The default is None.. The default is None. + + + Returns + ------- + Provides information to Card to render model + + """ + if name is None: name = model_class.__name__ @@ -118,6 +181,7 @@ def handle_change_model_params(name: str, value: any): view=view, zoom=zoom, tiles=tiles, + scroll_wheel_zoom=scroll_wheel_zoom, ) layers = map_drawer.render(model) @@ -126,86 +190,47 @@ def handle_change_model_params(name: str, value: any): center_default = center_point else: bounds = layers["layers"]["total_bounds"] - center_default = list((bounds[2:] + bounds[:2]) / 2) + center_default = [ + (bounds[0][0] + bounds[1][0]) / 2, + (bounds[0][1] + bounds[1][1]) / 2, + ] + + # Build base data structure for layout + layout_types = [{"Map": "default"}] + + if measures: + layout_types += [{"Measure": elem} for elem in range(len(measures))] - def render_in_jupyter(): - # TODO: Build API to allow users to set rows and columns - # call in property of model layers geospace line; use 1 column to prevent map overlap + grid_layout_initial = jv.make_initial_grid_layout(layout_types=layout_types) + grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) - with solara.Row( - justify="space-between", style={"flex-grow": "1"} - ) and solara.GridFixed(columns=2): + with solara.Sidebar(): + with solara.Card("Controls", margin=1, elevation=2): jv.UserInputs(user_params, on_change=handle_change_model_params) jv.ModelController(model, play_interval, current_step, reset_counter) - solara.Markdown(md_text=f"###Step - {current_step}") - - # Builds Solara component of map - leaflet_viz.map_jupyter(model, map_drawer, zoom, center_default) - - # Place measurement in separate row - with solara.Row( - justify="space-between", - style={"flex-grow": "1"}, - ): - # 5. Plots - for measure in measures: - if callable(measure): - # Is a custom object - measure(model) - else: - components_matplotlib.PlotMatplotlib( - model, measure, dependencies=[current_step.value] - ) - - def render_in_browser(): - # determine center point - if center_point: - center_default = center_point - else: - bounds = layers["layers"]["total_bounds"] - center_default = list((bounds[2:] + bounds[:2]) / 2) - - # if space drawer is disabled, do not include it - layout_types = [{"Map": "default"}] - - if measures: - layout_types += [{"Measure": elem} for elem in range(len(measures))] - - grid_layout_initial = jv.make_initial_grid_layout(layout_types=layout_types) - grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) - - with solara.Sidebar(): - with solara.Card("Controls", margin=1, elevation=2): - jv.UserInputs(user_params, on_change=handle_change_model_params) - jv.ModelController(model, play_interval, current_step, reset_counter) - with solara.Card("Progress", margin=1, elevation=2): - solara.Markdown(md_text=f"####Step - {current_step}") - - items = [ - Card( - model, - measures, - agent_portrayal, - map_drawer, - center_default, - zoom, - current_step, - color="white", - layout_type=layout_types[i], - ) - for i in range(len(layout_types)) - ] - - solara.GridDraggable( - items=items, - grid_layout=grid_layout, - resizable=True, - draggable=True, - on_grid_layout=set_grid_layout, + with solara.Card("Progress", margin=1, elevation=2): + solara.Markdown(md_text=f"####Step - {current_step}") + + items = [ + Card( + model, + measures, + agent_portrayal, + map_drawer, + center_default, + zoom, + scroll_wheel_zoom, + current_step, + color="white", + layout_type=layout_types[i], ) - - if ("ipykernel" in sys.argv[0]) or ("colab_kernel_launcher.py" in sys.argv[0]): - # When in Jupyter or Google Colab - render_in_jupyter() - else: - render_in_browser() + for i in range(len(layout_types)) + ] + + solara.GridDraggable( + items=items, + grid_layout=grid_layout, + resizable=True, + draggable=True, + on_grid_layout=set_grid_layout, + ) diff --git a/mesa_geo/visualization/leaflet_viz.py b/mesa_geo/visualization/leaflet_viz.py index d86aa3b1..3c46b1a6 100644 --- a/mesa_geo/visualization/leaflet_viz.py +++ b/mesa_geo/visualization/leaflet_viz.py @@ -18,7 +18,7 @@ @solara.component -def map(model, map_drawer, zoom, center_default): +def map(model, map_drawer, zoom, center_default, scroll_wheel_zoom): # render map in browser zoom_map = solara.reactive(zoom) center = solara.reactive(center_default) @@ -29,7 +29,7 @@ def map(model, map_drawer, zoom, center_default): ipyleaflet.Map.element( zoom=zoom_map.value, center=center.value, - scroll_wheel_zoom=True, + scroll_wheel_zoom=scroll_wheel_zoom, layers=[ ipyleaflet.TileLayer.element(url=base_map["url"]), ipyleaflet.GeoJSON.element(data=layers["agents"][0]), @@ -38,28 +38,6 @@ def map(model, map_drawer, zoom, center_default): ) -@solara.component -def map_jupyter(model, map_drawer, zoom, center_default): - zoom_map = solara.reactive(zoom) - center = solara.reactive(center_default) - - base_map = map_drawer.tiles - layers = map_drawer.render(model) - - # prevents overlap of map with measures - with solara.Column(style={"isolation": "isolate"}): - ipyleaflet.Map.element( - zoom=zoom_map.value, - center=center.value, - scroll_wheel_zoom=True, - layers=[ - ipyleaflet.TileLayer.element(url=base_map["url"]), - ipyleaflet.GeoJSON.element(data=layers["agents"][0]), - *layers["agents"][1], - ], - ) - - @dataclass class LeafletViz: """A dataclass defining the portrayal of a GeoAgent in Leaflet map. @@ -89,6 +67,7 @@ def __init__( portrayal_method, view, zoom, + scroll_wheel_zoom, tiles, ): """ @@ -102,8 +81,7 @@ def __init__( :param zoom: The initial zoom level of the map. Must be set together with view. If both view and zoom are None, the map will be centered on the total bounds of the space. Default is None. - :param map_width: The width of the map in pixels. Default is 500. - :param map_height: The height of the map in pixels. Default is 500. + :param scroll_wheel_zoom: Boolean whether not user can scroll on map with mouse wheel :param tiles: An optional tile layer to use. Can be a :class:`RasterWebTile` or a :class:`xyzservices.TileProvider`. Default is `xyzservices.providers.OpenStreetMap.Mapnik`. @@ -217,8 +195,22 @@ def _get_marker(self, location, properties): return ipyleaflet.Circle(location=location, **properties) elif marker == "CircleMarker": return ipyleaflet.CircleMarker(location=location, **properties) - elif marker == "Marker" or marker == "Icon" or marker == "AwesomeIcon": + elif marker == "Marker": return ipyleaflet.Marker(location=location, **properties) + elif marker == "Icon": + icon_url = properties["icon_url"] + icon_size = properties.get("icon_size", [20, 20]) + icon_properties = properties.get("icon_properties", {}) + icon = ipyleaflet.Icon( + icon_url=icon_url, icon_size=icon_size, **icon_properties + ) + return ipyleaflet.Marker(location=location, icon=icon, **properties) + elif marker == "AwesomeIcon": + name = properties["name"] + icon_properties = properties.get("icon_properties", {}) + icon = ipyleaflet.AwesomeIcon(name=name, **icon_properties) + return ipyleaflet.Marker(location=location, icon=icon, **properties) + else: raise ValueError( f"Unsupported marker type:{marker}", @@ -245,16 +237,16 @@ def _render_agents(self, model): point_markers.append(self._get_marker(location, properties)) else: agent_portrayal.style = properties - agent_portrayal = dataclasses.asdict( - agent_portrayal, - dict_factory=lambda x: {k: v for (k, v) in x if v is not None}, - ) - - feature_collection["features"].append( - { - "type": "Feature", - "geometry": mapping(transformed_geometry), - "properties": agent_portrayal, - } - ) + agent_portrayal = dataclasses.asdict( + agent_portrayal, + dict_factory=lambda x: {k: v for (k, v) in x if v is not None}, + ) + + feature_collection["features"].append( + { + "type": "Feature", + "geometry": mapping(transformed_geometry), + "properties": agent_portrayal, + } + ) return [feature_collection, point_markers] diff --git a/tests/test_GeoJupyterViz.py b/tests/test_GeoJupyterViz.py index eb155ab1..1a646475 100644 --- a/tests/test_GeoJupyterViz.py +++ b/tests/test_GeoJupyterViz.py @@ -21,9 +21,10 @@ def test_card_function( model = MagicMock() measures = {"Measure1": lambda x: x} agent_portrayal = MagicMock() - map_drawer = MagicMock() - center_default = [0, 0] - zoom = 10 + map_drawer = (MagicMock(),) + zoom = (10,) + scroll_wheel_zoom = (True,) + center_default = ([0, 0],) current_step = MagicMock() current_step.value = 0 color = "white" @@ -39,6 +40,7 @@ def test_card_function( map_drawer, center_default, zoom, + scroll_wheel_zoom, current_step, color, layout_type, @@ -46,7 +48,9 @@ def test_card_function( mock_rv_card.assert_called_once() mock_CardTitle.assert_any_call(children=["Map"]) - mock_map.assert_called_once_with(model, map_drawer, zoom, center_default) + mock_map.assert_called_once_with( + model, map_drawer, zoom, center_default, scroll_wheel_zoom + ) # mock_PlotMatplotlib.assert_called_once() @patch("mesa_geo.visualization.geojupyter_viz.solara.GridDraggable") diff --git a/tests/test_MapModule.py b/tests/test_MapModule.py index 6700c35c..cc3010d3 100644 --- a/tests/test_MapModule.py +++ b/tests/test_MapModule.py @@ -47,6 +47,7 @@ def test_render_point_agents(self): portrayal_method=lambda x: {"color": "Green"}, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.point_agents) @@ -60,6 +61,7 @@ def test_render_point_agents(self): }, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.point_agents) @@ -69,9 +71,14 @@ def test_render_point_agents(self): # test Marker option map_module = mgv.leaflet_viz.MapModule( - portrayal_method=lambda x: {"marker_type": "Icon", "color": "Green"}, + portrayal_method=lambda x: { + "marker_type": "AwesomeIcon", + "name": "bus", + "color": "Green", + }, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.point_agents) @@ -86,23 +93,16 @@ def test_render_point_agents(self): }, view=None, zoom=3, + scroll_wheel_zoom=False, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.point_agents) + print(map_module.render(self.model).get("agents")[0]) self.assertDictEqual( map_module.render(self.model).get("agents")[0], { "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": {"type": "Point", "coordinates": (1.0, 1.0)}, - "properties": { - "popupProperties": "popupMsg", - }, - } - ] - * len(self.point_agents), + "features": [] * len(self.point_agents), }, ) @@ -111,6 +111,7 @@ def test_render_point_agents(self): portrayal_method=lambda x: {"marker_type": "Hexagon", "color": "Green"}, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.point_agents) @@ -122,6 +123,7 @@ def test_render_line_agents(self): portrayal_method=lambda x: {"color": "#3388ff", "weight": 7}, view=None, zoom=3, + scroll_wheel_zoom=False, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.line_agents) @@ -151,6 +153,7 @@ def test_render_line_agents(self): }, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.line_agents) @@ -182,6 +185,7 @@ def test_render_polygon_agents(self): portrayal_method=lambda x: {"fillColor": "#3388ff", "fillOpacity": 0.7}, view=None, zoom=3, + scroll_wheel_zoom=False, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.polygon_agents) @@ -215,6 +219,7 @@ def test_render_polygon_agents(self): }, view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_agents(self.polygon_agents) @@ -246,6 +251,7 @@ def test_render_raster_layers(self): portrayal_method=lambda x: (255, 255, 255, 0.5), view=None, zoom=3, + scroll_wheel_zoom=True, tiles=xyz.OpenStreetMap.Mapnik, ) self.model.space.add_layer(self.raster_layer)