import%20marimo%0A%0A__generated_with%20%3D%20%220.23.13%22%0Aapp%20%3D%20marimo.App()%0A%0A%0A%40app.cell%0Adef%20_()%3A%0A%20%20%20%20import%20marimo%20as%20mo%0A%0A%20%20%20%20return%20(mo%2C)%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Routing%20an%20Explicit%20OD%20Matrix%0A%0A%20%20%20%20When%20you%20have%20observed%20trips%20between%20zones%2C%20from%20a%20travel%20survey%2C%20ticketing%20records%2C%20or%20mobile%20traces%2C%20you%20already%20know%20the%20weight%20for%20each%20origin-destination%20pair.%20%5B%60build_od_matrix%60%5D(https%3A%2F%2Fcityseer.benchmarkurbanism.com%2Fapi%2Fnetwork%23build_od_matrix)%20turns%20a%20flow%20table%20and%20a%20set%20of%20zones%20into%20a%20matrix%2C%20snapping%20each%20zone%20centroid%20to%20the%20nearest%20network%20node%2C%20and%20%5B%60betweenness_od%60%5D(https%3A%2F%2Fcityseer.benchmarkurbanism.com%2Fapi%2Fnetwork%23betweenness_od)%20routes%20it%3A%20each%20pair's%20trips%20are%20accumulated%20along%20the%20shortest%20path%20between%20its%20zones.%20See%20the%20%5BOrigin-Destination%20Flows%20guide%5D(https%3A%2F%2Fcityseer.benchmarkurbanism.com%2Fguide%2Fflows)%20for%20the%20background.%0A%0A%20%20%20%20No%20observed-trip%20dataset%20is%20bundled%20here%2C%20so%20we%20synthesise%20one%20with%20a%20gravity%20model%3A%20trips%20between%20two%20zones%20grow%20with%20the%20population%20of%20each%20and%20fall%20away%20with%20the%20distance%20between%20them.%20This%20is%20a%20standard%20way%20to%20estimate%20a%20plausible%20trip%20table%20when%20only%20zone%20populations%20are%20known.%20We%20build%20two%20demand%20scenarios%20on%20the%20same%20network%2C%20route%20each%20at%20a%202%20km%20threshold%2C%20and%20compare%20the%20resulting%20flow%20maps.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_()%3A%0A%20%20%20%20import%20geopandas%20as%20gpd%0A%20%20%20%20import%20matplotlib.pyplot%20as%20plt%0A%20%20%20%20import%20numpy%20as%20np%0A%20%20%20%20import%20pandas%20as%20pd%0A%20%20%20%20from%20cityseer.network%20import%20CityNetwork%0A%0A%20%20%20%20return%20CityNetwork%2C%20gpd%2C%20np%2C%20pd%2C%20plt%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Network%2C%20zones%2C%20and%20population%0A%0A%20%20%20%20A%20central-Madrid%20network%2C%20with%20the%20city's%20*barrios*%20(neighbourhoods)%20as%20origin-destination%20zones.%20Each%20zone's%20total%20population%20is%20summed%20from%20the%20Eurostat%20census%20grid.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(CityNetwork%2C%20gpd%2C%20mo)%3A%0A%20%20%20%20data_dir%20%3D%20(mo.notebook_dir()%20%2F%20%22..%22%20%2F%20%22..%22%20%2F%20%22data%22).resolve()%0A%20%20%20%20streets%20%3D%20gpd.read_file(data_dir%20%2F%20%22madrid_streets%22%20%2F%20%22street_network.gpkg%22)%0A%20%20%20%20streets%20%3D%20streets.explode(ignore_index%3DTrue).drop_duplicates(subset%3D%22geometry%22)%0A%20%20%20%20centre%20%3D%20gpd.GeoSeries.from_xy(%5B440300%5D%2C%20%5B4474300%5D%2C%20crs%3Dstreets.crs)%0A%20%20%20%20study_poly%20%3D%20centre.buffer(3000).iloc%5B0%5D%0A%20%20%20%20buffered_poly%20%3D%20centre.buffer(5500).iloc%5B0%5D%20%20%23%20network%20buffered%20past%20the%202%20km%20threshold%0A%20%20%20%20cn%20%3D%20CityNetwork.from_geopandas(streets%5Bstreets.intersects(buffered_poly)%5D%2C%20boundary%3Dstudy_poly)%0A%0A%20%20%20%20zones%20%3D%20gpd.read_file(data_dir%20%2F%20%22madrid_nbhds%22%20%2F%20%22madrid_nbhds.gpkg%22).to_crs(streets.crs)%0A%20%20%20%20zones%20%3D%20zones%5Bzones.intersects(study_poly)%5D.reset_index(drop%3DTrue)%0A%20%20%20%20zones%5B%22zone_id%22%5D%20%3D%20zones%5B%22COD_DISB%22%5D%20%20%23%20a%20unique%20district-barrio%20code%0A%0A%20%20%20%20%23%20total%20population%20per%20zone%2C%20summed%20from%20the%20Eurostat%20census%20grid%20cells%20within%20it%0A%20%20%20%20census%20%3D%20gpd.read_file(data_dir%20%2F%20%22madrid_census%22%20%2F%20%22eu_stat_clipped.gpkg%22).to_crs(streets.crs)%0A%20%20%20%20census%5B%22geometry%22%5D%20%3D%20census.geometry.centroid%0A%20%20%20%20counts%20%3D%20gpd.sjoin(census%5B%5B%22T%22%2C%20%22geometry%22%5D%5D%2C%20zones%5B%5B%22zone_id%22%2C%20%22geometry%22%5D%5D%2C%20predicate%3D%22within%22)%0A%20%20%20%20zones%5B%22pop%22%5D%20%3D%20zones%5B%22zone_id%22%5D.map(counts.groupby(%22zone_id%22)%5B%22T%22%5D.sum()).fillna(0.0)%0A%20%20%20%20print(f%22%7Blen(zones)%7D%20zones%3B%20total%20population%20%7Bzones%5B'pop'%5D.sum()%3A.0f%7D%22)%0A%20%20%20%20return%20cn%2C%20study_poly%2C%20zones%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Two%20demand%20scenarios%0A%0A%20%20%20%20A%20flow%20table%20needs%20three%20columns%3A%20origin%20zone%2C%20destination%20zone%2C%20and%20a%20trip%20weight.%20We%20build%20the%20weight%20for%20every%20pair%20of%20zones%20with%20the%20same%20gravity%20rule%2C%20%60population_origin%20*%20population_destination%20*%20exp(-beta%20*%20distance)%60%2C%20and%20change%20only%20where%20the%20trips%20are%20allowed%20to%20go.%0A%0A%20%20%20%20-%20**Commute%20to%20the%20centre**%3A%20trips%20run%20to%20the%20central%20barrios%20only%2C%20decaying%20gently%20with%20distance%20(a%20monocentric%20commute).%0A%20%20%20%20-%20**Local%20trips%20everywhere**%3A%20trips%20run%20between%20all%20zones%2C%20but%20a%20steeper%20decay%20means%20short%20trips%20dominate%20(dispersed%2C%20everyday%20mobility).%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(np%2C%20study_poly%2C%20zones)%3A%0A%20%20%20%20cx%2C%20cy%20%3D%20study_poly.centroid.x%2C%20study_poly.centroid.y%0A%20%20%20%20zones%5B%22px%22%5D%20%3D%20zones.geometry.centroid.x%0A%20%20%20%20zones%5B%22py%22%5D%20%3D%20zones.geometry.centroid.y%0A%20%20%20%20zones%5B%22central%22%5D%20%3D%20np.hypot(zones.px%20-%20cx%2C%20zones.py%20-%20cy)%20%3C%201200%20%20%23%20the%20inner%20ring%20of%20barrios%0A%0A%20%20%20%20%23%20every%20ordered%20pair%20of%20zones%2C%20with%20the%20straight-line%20distance%20between%20their%20centroids%0A%20%20%20%20left%20%3D%20zones%5B%5B%22zone_id%22%2C%20%22px%22%2C%20%22py%22%2C%20%22pop%22%5D%5D%0A%20%20%20%20right%20%3D%20zones%5B%5B%22zone_id%22%2C%20%22px%22%2C%20%22py%22%2C%20%22pop%22%2C%20%22central%22%5D%5D%0A%20%20%20%20pairs%20%3D%20left.merge(right%2C%20how%3D%22cross%22%2C%20suffixes%3D(%22_o%22%2C%20%22_d%22))%0A%20%20%20%20pairs%20%3D%20pairs%5Bpairs.zone_id_o%20!%3D%20pairs.zone_id_d%5D.copy()%0A%20%20%20%20pairs%5B%22dist%22%5D%20%3D%20np.hypot(pairs.px_o%20-%20pairs.px_d%2C%20pairs.py_o%20-%20pairs.py_d)%0A%0A%20%20%20%20%23%20same%20gravity%20rule%2C%20different%20destinations%20and%20decay%0A%20%20%20%20commute%20%3D%20pairs%5Bpairs.central%5D.assign(trips%3Dlambda%20p%3A%20p.pop_o%20*%20p.pop_d%20*%20np.exp(-0.0008%20*%20p.dist))%0A%20%20%20%20local%20%3D%20pairs.assign(trips%3Dlambda%20p%3A%20p.pop_o%20*%20p.pop_d%20*%20np.exp(-0.0025%20*%20p.dist))%0A%0A%20%20%20%20print(f%22%7Blen(commute)%7D%20commute%20pairs%2C%20%7Blen(local)%7D%20local%20pairs%22)%0A%20%20%20%20return%20commute%2C%20local%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Access%20points%20within%20each%20zone%0A%0A%20%20%20%20A%20zonal%20trip%20table%20records%20how%20many%20trips%20run%20between%20two%20zones%2C%20not%20where%20within%20each%20zone%20they%20start%20and%20end.%20Passing%20the%20zone%20polygons%20straight%20to%20%60build_od_matrix%60%20snaps%20each%20zone%20to%20the%20single%20node%20nearest%20its%20centroid%2C%20so%20every%20pair's%20trips%20load%20onto%20one%20shortest%20path%20and%20the%20map%20reduces%20to%20a%20sparse%20set%20of%20centroid-to-centroid%20lines.%20Transport%20models%20handle%20this%20by%20connecting%20each%20zone%20to%20the%20network%20at%20several%20points%20(centroid%20connectors).%20We%20do%20the%20same%20here%3A%20a%20350%20m%20point%20grid%20within%20each%20zone%2C%20clipped%20to%20the%20study%20area%2C%20with%20the%20zone's%20trips%20divided%20evenly%20among%20its%20points.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(gpd%2C%20np%2C%20pd%2C%20study_poly%2C%20zones)%3A%0A%20%20%20%20clipped%20%3D%20zones%5B%5B%22zone_id%22%2C%20%22geometry%22%5D%5D.copy()%0A%20%20%20%20clipped%5B%22geometry%22%5D%20%3D%20clipped.geometry.intersection(study_poly)%0A%0A%20%20%20%20%23%20a%20350m%20point%20grid%2C%20each%20point%20assigned%20to%20the%20zone%20that%20contains%20it%0A%20%20%20%20minx%2C%20miny%2C%20maxx%2C%20maxy%20%3D%20clipped.total_bounds%0A%20%20%20%20gx%2C%20gy%20%3D%20np.meshgrid(np.arange(minx%2C%20maxx%2C%20350.0)%2C%20np.arange(miny%2C%20maxy%2C%20350.0))%0A%20%20%20%20grid%20%3D%20gpd.GeoDataFrame(geometry%3Dgpd.points_from_xy(gx.ravel()%2C%20gy.ravel())%2C%20crs%3Dzones.crs)%0A%20%20%20%20sites%20%3D%20gpd.sjoin(grid%2C%20clipped%2C%20predicate%3D%22within%22)%5B%5B%22zone_id%22%2C%20%22geometry%22%5D%5D%0A%0A%20%20%20%20%23%20a%20zone%20too%20small%20to%20catch%20a%20grid%20point%20falls%20back%20to%20a%20single%20interior%20point%0A%20%20%20%20missing%20%3D%20clipped%5B~clipped.zone_id.isin(sites.zone_id)%5D%0A%20%20%20%20if%20len(missing)%3A%0A%20%20%20%20%20%20%20%20fallback%20%3D%20missing.assign(geometry%3Dmissing.geometry.representative_point())%0A%20%20%20%20%20%20%20%20sites%20%3D%20pd.concat(%5Bsites%2C%20fallback%5D%2C%20ignore_index%3DTrue)%0A%0A%20%20%20%20sites%20%3D%20sites.reset_index(drop%3DTrue)%0A%20%20%20%20sites%5B%22site_id%22%5D%20%3D%20sites.index%0A%20%20%20%20n_sites%20%3D%20sites.groupby(%22zone_id%22).size()%0A%20%20%20%20print(f%22%7Blen(sites)%7D%20access%20points%20across%20%7Blen(n_sites)%7D%20zones%22)%0A%20%20%20%20return%20n_sites%2C%20sites%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Route%20each%20scenario%20and%20snapshot%20the%20result%0A%0A%20%20%20%20Each%20zone-to-zone%20flow%20is%20split%20evenly%20across%20its%20origin%20and%20destination%20access%20points%2C%20then%20%60build_od_matrix%60%20snaps%20the%20points%20to%20network%20nodes%20and%20assembles%20the%20sparse%20matrix%2C%20and%20%60betweenness_od%60%20routes%20it%20at%20the%202%20km%20threshold.%20We%20snapshot%20each%20result%20with%20%60to_geopandas%60%20before%20running%20the%20next%2C%20because%20both%20write%20to%20the%20same%20%60cc_betweenness_2000%60%20column.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(cn%2C%20commute%2C%20local%2C%20n_sites%2C%20sites)%3A%0A%20%20%20%20def%20expand(od)%3A%0A%20%20%20%20%20%20%20%20e%20%3D%20od.merge(sites%5B%5B%22zone_id%22%2C%20%22site_id%22%5D%5D%2C%20left_on%3D%22zone_id_o%22%2C%20right_on%3D%22zone_id%22)%0A%20%20%20%20%20%20%20%20e%20%3D%20e.rename(columns%3D%7B%22site_id%22%3A%20%22site_o%22%7D).drop(columns%3D%22zone_id%22)%0A%20%20%20%20%20%20%20%20e%20%3D%20e.merge(sites%5B%5B%22zone_id%22%2C%20%22site_id%22%5D%5D%2C%20left_on%3D%22zone_id_d%22%2C%20right_on%3D%22zone_id%22)%0A%20%20%20%20%20%20%20%20e%20%3D%20e.rename(columns%3D%7B%22site_id%22%3A%20%22site_d%22%7D).drop(columns%3D%22zone_id%22)%0A%20%20%20%20%20%20%20%20e%5B%22trips%22%5D%20%3D%20e%5B%22trips%22%5D%20%2F%20(e%5B%22zone_id_o%22%5D.map(n_sites)%20*%20e%5B%22zone_id_d%22%5D.map(n_sites))%0A%20%20%20%20%20%20%20%20return%20e%0A%0A%20%20%20%20def%20route(od)%3A%0A%20%20%20%20%20%20%20%20matrix%20%3D%20cn.build_od_matrix(%0A%20%20%20%20%20%20%20%20%20%20%20%20expand(od)%2C%20sites%2C%20origin_col%3D%22site_o%22%2C%20destination_col%3D%22site_d%22%2C%20weight_col%3D%22trips%22%2C%20zone_id_col%3D%22site_id%22%0A%20%20%20%20%20%20%20%20)%0A%20%20%20%20%20%20%20%20cn.betweenness_od(matrix%2C%20distances%3D%5B2000%5D)%0A%20%20%20%20%20%20%20%20return%20cn.to_geopandas()%0A%0A%20%20%20%20gdf_commute%20%3D%20route(commute)%0A%20%20%20%20gdf_local%20%3D%20route(local)%0A%20%20%20%20return%20gdf_commute%2C%20gdf_local%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Mapping%20the%20two%20flow%20patterns%0A%0A%20%20%20%20Line%20width%20and%20orange-red%20colour%20both%20scale%20with%20a%20percentile%20rank%20of%20the%20flow%2C%20so%20the%20map%20reads%20without%20a%20colour%20bar.%20The%20same%20styling%20is%20used%20across%20the%20flow%20recipes.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(gdf_commute%2C%20gdf_local%2C%20plt)%3A%0A%20%20%20%20fig%2C%20axes%20%3D%20plt.subplots(1%2C%202%2C%20figsize%3D(13%2C%206.5))%0A%20%20%20%20for%20ax%2C%20gdf%2C%20title%20in%20%5B%0A%20%20%20%20%20%20%20%20(axes%5B0%5D%2C%20gdf_commute%2C%20%22Commute%20to%20the%20centre%22)%2C%0A%20%20%20%20%20%20%20%20(axes%5B1%5D%2C%20gdf_local%2C%20%22Local%20trips%20everywhere%22)%2C%0A%20%20%20%20%5D%3A%0A%20%20%20%20%20%20%20%20live%20%3D%20gdf%5Bgdf.live%5D.copy()%0A%20%20%20%20%20%20%20%20live%5B%22rank%22%5D%20%3D%20live%5B%22cc_betweenness_2000%22%5D.rank(pct%3DTrue)%0A%20%20%20%20%20%20%20%20live%20%3D%20live.sort_values(%22rank%22)%20%20%23%20strongest%20drawn%20last%0A%20%20%20%20%20%20%20%20live.plot(ax%3Dax%2C%20color%3Dplt.get_cmap(%22OrRd%22)(live%5B%22rank%22%5D)%2C%20linewidth%3D0.15%20%2B%202.25%20*%20live%5B%22rank%22%5D)%0A%20%20%20%20%20%20%20%20ax.set_title(title%2C%20loc%3D%22left%22)%0A%20%20%20%20%20%20%20%20ax.set_axis_off()%0A%20%20%20%20%20%20%20%20ax.set_aspect(%22equal%22)%0A%20%20%20%20fig.tight_layout()%0A%20%20%20%20fig%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Interpretation%0A%0A%20%20%20%20The%20network%2C%20threshold%2C%20and%20routing%20are%20identical%3B%20only%20the%20demand%20differs.%20The%20commute%20scenario%20concentrates%20flow%20on%20the%20streets%20converging%20on%20the%20central%20barrios%2C%20fading%20with%20distance%20from%20the%20centre.%20The%20local%20scenario%20distributes%20flow%20across%20the%20densely%20populated%20fabric%2C%20with%20cross-town%20streets%20carrying%20more%20than%20the%20centre%20itself.%20With%20real%20survey%20or%20ticketing%20data%20in%20place%20of%20the%20gravity%20estimate%2C%20%60betweenness_od%60%20maps%20where%20actual%20trips%20load%20the%20network%2C%20instead%20of%20the%20uniform-demand%20assumption%20of%20standard%20betweenness.%0A%0A%20%20%20%20When%20you%20have%20weighted%20origins%20and%20destinations%20but%20no%20trip%20table%20at%20all%2C%20model%20the%20demand%20directly%20with%20%5B%60betweenness_demand%60%5D(https%3A%2F%2Fcityseer.benchmarkurbanism.com%2Fexamples%2Fflows%2Fdemand-flows).%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20app.run()%0A
fe8f6d00559837ec5f91da96c9bae804