diff --git a/GrowBikeMVP.ipynb b/GrowBikeMVP.ipynb deleted file mode 100644 index 3826f0e..0000000 --- a/GrowBikeMVP.ipynb +++ /dev/null @@ -1,450 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "0", - "metadata": {}, - "source": [ - "## MVP for GrowBikeNet implementation" - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, - "source": [ - "This is a notebook only for testing and development purposes." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "# import libraries\n", - "import osmnx as ox\n", - "import networkx as nx\n", - "import geopandas as gpd\n", - "import os\n", - "import matplotlib.pyplot as plt\n", - "\n", - "# import functions\n", - "from growbikenet.functions import (\n", - " get_principal_bearing,\n", - " get_grid_seed_points,\n", - " snap_seed_points,\n", - " filter_seed_points,\n", - " create_delaunay_edges,\n", - " add_path_to_df,\n", - " create_gdf_with_geoms,\n", - " node_to_edge_attributes,\n", - " df_from_graph,\n", - " rank_df,\n", - ")\n", - "\n", - "# import visualization\n", - "from growbikenet.visualizations import make_video, create_plots" - ] - }, - { - "cell_type": "markdown", - "id": "3", - "metadata": {}, - "source": [ - "### User Input:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "city_name = \"Bath\"\n", - "seed_point_spacing = 1707 # distance between seed points, in meters\n", - "crs_projected = \"3857\"\n", - "seed_point_snap_distance = (\n", - " 500 # maximal distance between seed point and actual point in OSM data, in meters\n", - ")\n", - "method = \"betweenness_centrality\" # specify method via user input/config file later. Each method will need meta-information whether it is a node or an edge method." - ] - }, - { - "cell_type": "markdown", - "id": "5", - "metadata": {}, - "source": [ - "### Data from OSM" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "# fetch street network data from osmnx\n", - "g = ox.graph_from_place(city_name, network_type=\"all\")\n", - "g_undir = g.to_undirected().copy() # convert to undirected (dropping OSMnx keys!)\n", - "\n", - "# # plausibility check\n", - "# ox.plot_graph(g, figsize=(10, 10))" - ] - }, - { - "cell_type": "markdown", - "id": "7", - "metadata": {}, - "source": [ - "### Street network data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "# export osmnx data to gdfs\n", - "nodes, edges = ox.graph_to_gdfs(\n", - " g_undir, nodes=True, edges=True, node_geometry=True, fill_edge_geometry=True\n", - ")\n", - "\n", - "# save \"original\" graph data (in orig_crs)\n", - "nodes.to_file(\"nodes.gpkg\", driver=\"GPKG\")\n", - "edges.to_file(\"edges.gpkg\", driver=\"GPKG\")\n", - "\n", - "# replace after dropping edges with key = 1\n", - "edges = edges.loc[:, :, 0].copy()\n", - "# this also means we are dropping the \"key\" level from edge index (u,v,key becomes: u,v)\n", - "\n", - "# project geometries of nodes, edges, seed points\n", - "edges = edges.to_crs(crs_projected)\n", - "nodes = nodes.to_crs(crs_projected)\n", - "\n", - "# add osm ID as column to node gdf\n", - "nodes[\"osmid\"] = nodes.index" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "### Seed points" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "# Bearings work on unprojected graph\n", - "ox.bearing.add_edge_bearings(g_undir)\n", - "principal_bearing = get_principal_bearing(g_undir)\n", - "\n", - "# But this is on the projected edges now\n", - "seed_points = get_grid_seed_points(edges, seed_point_spacing, principal_bearing)" - ] - }, - { - "cell_type": "markdown", - "id": "11", - "metadata": {}, - "source": [ - "### Snap Seed points to OSM nodes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "seed_points_snapped = snap_seed_points(seed_points, nodes)\n", - "seed_points_snapped = filter_seed_points(seed_points_snapped, seed_point_snap_distance)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "# plausibility check\n", - "seed_points_snapped.plot();" - ] - }, - { - "cell_type": "markdown", - "id": "14", - "metadata": {}, - "source": [ - "### Greedy triangulation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "# create df with delaunay edges\n", - "df = create_delaunay_edges(seed_points_snapped)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "# map each abstract edge to a merged geometry of corresponding osmnx edges (routed on g_undir)\n", - "df = add_path_to_df(df, edges, g_undir)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "# get \"routed\" geometry (LineString) for each abstract edge (row)\n", - "gdf = create_gdf_with_geoms(df, edges)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "# add distances between source and target from geometry\n", - "gdf[\"dist\"] = gdf[\"geometry\"].length" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "edge_list = gdf[\"pair\"]\n", - "dist_list = gdf[\"dist\"]\n", - "dist_dict = dict(zip(edge_list, dist_list))\n", - "geom_dict = dict(zip(edge_list, gdf[\"geometry\"].tolist()))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "# make graph object from edge list\n", - "A = nx.Graph()\n", - "A.add_nodes_from(seed_points_snapped.index)\n", - "A.add_edges_from(edge_list)\n", - "nx.set_edge_attributes(A, dist_dict, \"distance\")\n", - "nx.set_edge_attributes(A, geom_dict, \"geometry\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "# add betweenness attributes to edges\n", - "bc_values = nx.edge_betweenness_centrality(A, weight=\"distance\", normalized=True)\n", - "nx.set_edge_attributes(A, bc_values, name=\"betweenness_centrality\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "22", - "metadata": {}, - "outputs": [], - "source": [ - "# step 5: add closeness attributes to nodes and edges\n", - "cc_values_nodes = nx.closeness_centrality(A, distance=\"distance\")\n", - "nx.set_node_attributes(A, cc_values_nodes, name=\"closeness_centrality\")\n", - "\n", - "cc_values = node_to_edge_attributes(cc_values_nodes, A.edges)\n", - "nx.set_edge_attributes(A, cc_values, name=\"closeness_centrality\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "# step 6: export attributes to gdfs\n", - "\n", - "# create dataframe and add method as edge attribute\n", - "edges_ranked = df_from_graph(A, method)\n", - "\n", - "# rank edges by specified method\n", - "edges_ranked = rank_df(edges_ranked, method)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "edges_ranked = gpd.GeoDataFrame(edges_ranked, crs=edges.crs, geometry=\"geometry\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "# plausibility check: now edges_ranked contains all of our results we wanted to get,\n", - "# will be saving edges_ranked to file, then plotting.\n", - "edges_ranked.head()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26", - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = plt.subplots(1, 1, figsize=(10, 10))\n", - "edges_ranked.plot(ax=ax, column=\"ordering\", cmap=\"Blues_r\")\n", - "# seed_points_snapped.plot(ax=ax, color=\"grey\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27", - "metadata": {}, - "outputs": [], - "source": [ - "# save to file\n", - "edges_ranked.to_file(\"edges_ranked.gpkg\", driver=\"GPKG\")" - ] - }, - { - "cell_type": "markdown", - "id": "28", - "metadata": {}, - "source": [ - "### Visualization" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "29", - "metadata": {}, - "outputs": [], - "source": [ - "# create directories\n", - "os.makedirs(\"./results/\", exist_ok=True)\n", - "os.makedirs(\"./results/plots/\", exist_ok=True)\n", - "os.makedirs(\"./results/plots/video/\", exist_ok=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30", - "metadata": {}, - "outputs": [], - "source": [ - "# read in file to plot\n", - "routed_edges_gdf = gpd.read_file(\"edges_ranked.gpkg\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "31", - "metadata": {}, - "outputs": [], - "source": [ - "# viz/plot settings (move to config file later)\n", - "\n", - "# define color palette (from Michael's project: https://github.com/mszell/bikenwgrowth/blob/main/parameters/parameters.py)\n", - "streetcolor = \"#999999\"\n", - "edgecolor = \"#0EB6D2\"\n", - "seedcolor = \"#ff7338\"\n", - "\n", - "# define linewidths\n", - "\n", - "lws = {\"street\": 0.75, \"bike\": 2}" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "32", - "metadata": {}, - "outputs": [], - "source": [ - "create_plots(\n", - " routed_edges_gdf, seed_points_snapped, streetcolor, edgecolor, seedcolor, lws\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "33", - "metadata": {}, - "outputs": [], - "source": [ - "make_video(img_folder_name=\"./results/plots/\", fps=1)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/source/reference_developer.rst b/docs/source/reference_developer.rst index 71aaf49..8d6adcf 100644 --- a/docs/source/reference_developer.rst +++ b/docs/source/reference_developer.rst @@ -9,6 +9,13 @@ growbikenet.growbikenet .. automodule:: growbikenet.growbikenet :members: +growbikenet.constants +--------------------- + +.. automodule:: growbikenet.constants + :members: + :private-members: + growbikenet.functions --------------------- @@ -16,4 +23,16 @@ growbikenet.functions :members: :private-members: - \ No newline at end of file +growbikenet.settings +-------------------- + +.. automodule:: growbikenet.settings + :members: + :private-members: + +growbikenet.visualization +------------------------- + +.. automodule:: growbikenet.visualization + :members: + :private-members: \ No newline at end of file diff --git a/docs/source/reference_user.rst b/docs/source/reference_user.rst index 27bbeb6..08c0306 100644 --- a/docs/source/reference_user.rst +++ b/docs/source/reference_user.rst @@ -5,5 +5,14 @@ This is the user reference for the GrowBikeNet package. If you are looking for a The standard way to import the GrowBikeNet package is via ``import growbikenet as gbn``. The main ``growbikenet()`` function below is then called via ``gbn.growbikenet()``, see the :doc:`mwe`. +growbikenet.growbikenet +----------------------- + .. automodule:: growbikenet.growbikenet + :members: + +growbikenet.settings +-------------------- + +.. automodule:: growbikenet.settings :members: \ No newline at end of file diff --git a/docs/source/usage_02_network_growth.ipynb b/docs/source/usage_02_network_growth.ipynb index 04cd58d..4100c8a 100644 --- a/docs/source/usage_02_network_growth.ipynb +++ b/docs/source/usage_02_network_growth.ipynb @@ -14,7 +14,7 @@ "id": "2b9fb396-327e-43dc-8ef2-2794675b63b1", "metadata": {}, "source": [ - "**Parameters covered**: `ranking`, `allow_edge_overlaps`, `crs_projected`" + "**Parameters covered**: `ranking`, `allow_edge_overlaps`" ] }, { @@ -128,7 +128,7 @@ "source": [ "They are: \n", "- **betweenness_centrality**: The metric to rank edges by.\n", - "- **geometry**: The geometries of the edges connecting source and target seed points, projected in the coordinate references system (crs) given by the parameter `crs_projected`, rounded to meters. The coordinates correspond to the easting and northing in the crs. These geometries are typically a mix between linestrings and multilinestrings. \n", + "- **geometry**: The geometries of the edges connecting source and target seed points, projected in the coordinate references system (crs) given by the setting `settings.crs_projected`, rounded to meters. The coordinates correspond to the easting and northing in the crs. These geometries are typically a mix between linestrings and multilinestrings. \n", "- **source**, **target**: The OSM IDs of source and target seed points. These are nodes that can be looked up on OSM, for example for OSM ID 11037313412: https://www.openstreetmap.org/node/11037313412\n", "- **rank**: The rank which orders the edge by betweenness centrality. These are increasing integers, but not necessarily consecutive, due to potentially empty pieces in-between that are removed due to edge overlaps, see end of this notebook.\n", "- **length**: Length of the current edge, rounded to whole meters.\n", diff --git a/docs/source/usage_04_data_export_and_visualization.ipynb b/docs/source/usage_04_data_export_and_visualization.ipynb index 1f9e3e7..4792ef3 100644 --- a/docs/source/usage_04_data_export_and_visualization.ipynb +++ b/docs/source/usage_04_data_export_and_visualization.ipynb @@ -14,7 +14,7 @@ "id": "2540d64c-d30f-4f7a-bd7b-0366358401e6", "metadata": {}, "source": [ - "**Parameters covered**: `export_data`, `export_file_format`, `city_name`, `export_data_slug`, `export_plots`, `export_video`, `crs_projected`" + "**Parameters covered**: `export_data`, `export_file_format`, `city_name`, `export_data_slug`, `export_plots`, `export_video`" ] }, { diff --git a/growbikenet/constants.py b/growbikenet/constants.py index f0fdbd3..c6f061b 100644 --- a/growbikenet/constants.py +++ b/growbikenet/constants.py @@ -1,7 +1,31 @@ -'''Constants for growbikenet.''' +"""Global constants for growbikenet that can be tweaked during development, but should not be changed later by the user. +PBI_CUSTOM_FILTER : list[str] + Custom filter for protected bicycle infrastructure (pbi) +PRESET_TAGS : dict + Pre-defined tags to select tags as seed points +PHI_LIMITS : list[float] + Two orientation order limits between street networks with: + 1) negligible grid elements, 2) some grid elements, 3) grid. + We aimed to use the tercile limits from the paper [3]_ (Fig 2), but the values here are lower for unknown reasons, also with the unweighted version. Also, it was aimed to have Barcelona in the grid category. For these reasons, the limits were lowered. +EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH : int + Minimum length a bike network component needs to have for seed points to snap, in meters +SEED_POINT_SNAP_DISTANCE_FACTOR : float + Factor to multiply seed_point_grid_spacing with, to determine auto value of seed_point_snap_distance +EXISTING_NETWORK_SPACING_FACTOR : float + Factor to multiply seed_point_grid_spacing with, to determine auto value of existing_network_spacing +GRID_SPACING_TRIANGULATE : int + Grid spacing in meters for grid triangulation that ensures that any point in the city is always within buffer distance b=500m of the network (if seed points snap perfectly). +GRID_SPACING_QUADRANGULATE : int + Grid spacing in meters for quadrangulation that ensures that any point in the city is always within buffer distance b=500m of the network (if seed points snap perfectly). +GRID_SPACING_TRIANGLE : int + Grid spacing in meters for triangle grid that ensures that any point in the city is always within buffer distance b=500m of the network (if seed points snap perfectly). +BUFFER_SEED_POINTS_EXNW_FACTOR : float + Factor to multiply existing_network_spacing with, to determine which previously determined seed points (grid or rail) to drop that are too close to the extra existing network points +BEARING_BINS : int + Number of bins to determine bearing. e.g. 72 will create 5 degrees bins +""" -# Custom filter for protected bicycle infrastructure (pbi) PBI_CUSTOM_FILTER = ['["cycleway"~"track"]', '["highway"~"cycleway"]', '["highway"~"path"]["bicycle"~"designated"]', @@ -20,21 +44,24 @@ if custom_tag not in ox.settings.useful_tags_way: ox.settings.useful_tags_way.extend(custom_tag) - -# Pre-defined tags to select tags as seed points PRESET_TAGS = { "rail": {"railway": ["station", "halt"]}, "school": {"amenity": ["kindergarten", "school", "college", "university"]}, "park": {"leisure": ["park", "garden", "nature_reserve", "bathing_place"]}, } - -# Orientation order limits between street networks with: -# 1) negligible grid elements, 2) some grid elements, 3) grid. -# We aimed to use the tercile limits from the paper [3]_ (Fig 2), but the values -# here are lower for unknown reasons, also with the unweighted version. Also, it was aimed to have Barcelona in the grid category. For these reasons, the limits were lowered. PHI_LIMITS = [0.02, 0.08] # Tercile limits in the paper: 0.033, 0.161 - -# Minimum length a bike network component needs to have for seed points to snap EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH = 100 + +SEED_POINT_SNAP_DISTANCE_FACTOR = 0.25 + +EXISTING_NETWORK_SPACING_FACTOR = 0.5 + +GRID_SPACING_TRIANGULATE = 1707 # a=2b/(2-sqrt(2)) +GRID_SPACING_QUADRANGULATE = 1000 # a=2b +GRID_SPACING_TRIANGLE = 1154 # h/2=b=a*sqrt(3)/4 -> a=4b/sqrt(3) + +BUFFER_SEED_POINTS_EXNW_FACTOR = 0.5 + +BEARING_BINS = 72 diff --git a/growbikenet/functions.py b/growbikenet/functions.py index aef5a95..2bdc4bc 100644 --- a/growbikenet/functions.py +++ b/growbikenet/functions.py @@ -1,6 +1,7 @@ """Utility functions for growbikenet.""" -from growbikenet.constants import * +from . import constants +from . import settings import os import re import numpy as np @@ -16,17 +17,38 @@ from tqdm import tqdm +def validate_settings(): + """ Check if user settings input is valid. If not, raise an exception or warning + + Parameters + ---------- + See settings + + Returns + ------- + True + """ + + if type(settings.crs_projected) is not str: + raise TypeError("settings.crs_projected must be a string") + if settings.export_file_format != "geojson" and settings.export_file_format != "gpkg": + raise ValueError("settings.export_file_format must be 'geojson' or 'gpkg'") + # To do: check export_path + if type(settings.seed_point_snap_distance) is not int and settings.seed_point_snap_distance != 'auto': + raise TypeError("settings.seed_point_snap_distance must be 'auto' or an integer") + if type(settings.seed_point_snap_distance) is int and settings.seed_point_snap_distance <= 0: + raise ValueError("settings.seed_point_snap_distance must be a positive integer") + return True + + def validate_parameters( city_name, - crs_projected, ranking, seed_point_type, seed_point_grid_spacing, - seed_point_snap_distance, seed_point_linking, existing_network_spacing, export_data, - export_file_format, export_data_slug, export_plots, # export_video, @@ -40,7 +62,7 @@ def validate_parameters( ---------- Same as growbikenet.growbikenet() Additionally: - PRESET_TAGS : dict + constants.PRESET_TAGS : dict Dictionary of preset seed point tags. Returns @@ -50,8 +72,6 @@ def validate_parameters( if type(city_name) is not str: raise TypeError("city_name must be a string") - if type(crs_projected) is not str: - raise TypeError("crs_projected must be a string") if type(ranking) is not str: raise TypeError("ranking must be a string") if ranking not in ["betweenness_centrality", "closeness_centrality", "random"]: @@ -68,10 +88,6 @@ def validate_parameters( raise ValueError("With seed_point_type 'file', a seed_point must be provided") if seed_point_type == 'tags' and type(seed_point) is None: raise ValueError("With seed_point_type 'tags', seed_point_tags must be provided") - if type(seed_point_snap_distance) is not int and seed_point_snap_distance != 'auto': - raise TypeError("seed_point_snap_distance must be 'auto' or an integer") - if type(seed_point_snap_distance) is int and seed_point_snap_distance <= 0: - raise ValueError("seed_point_snap_distance must be a positive integer") if seed_point_linking not in ['auto', 'triangulate_delaunay', 'quadrangulate']: raise ValueError("seed_point_linking must be 'auto' or 'triangulate_delaunay' or 'quadrangulate'") if seed_point_linking == 'quadrangulate' and (seed_point_type != 'grid_square' or existing_network_spacing is not None): @@ -92,8 +108,6 @@ def validate_parameters( raise ValueError( "export_data_slug must contain at least one non-special character" ) - if export_file_format != "geojson" and export_file_format != "gpkg": - raise ValueError("export_file_format must be 'geojson' or 'gpkg'") if type(export_plots) is not bool: raise TypeError("export_plots must be a boolean") # if type(export_video) is not bool: @@ -104,11 +118,11 @@ def validate_parameters( raise TypeError("bike_network must be a dict with possible keys 'city_boundary','street_network','bike_network','seed_points'") if import_files['city_boundary'] is not None and type(import_files['city_boundary']) is not str: raise TypeError("city_boundary must be None or a string") - if type(import_files['city_boundary']) is str and not os.path.isfile(import_files['city_boundary']): + if type(import_files['city_boundary']) is str and not os.path.isfile(settings.import_path+import_files['city_boundary']): raise FileNotFoundError("city_boundary not found") - if type(import_files['street_network']) is str and not os.path.isfile(import_files['street_network']): + if type(import_files['street_network']) is str and not os.path.isfile(settings.import_path+import_files['street_network']): raise FileNotFoundError("street_network not found") - if type(import_files['seed_points']) is str and not os.path.isfile(import_files['seed_points']): + if type(import_files['seed_points']) is str and not os.path.isfile(settings.import_path+import_files['seed_points']): raise FileNotFoundError("seed_points not found") if seed_point_tags is not None and type(seed_point_tags) is not dict: @@ -143,7 +157,6 @@ def slugify(s): def resolve_auto_parameters( seed_point_type, seed_point_grid_spacing, - seed_point_snap_distance, seed_point_linking, existing_network_spacing, phi, @@ -169,18 +182,18 @@ def resolve_auto_parameters( seed_point_linking = 'triangulate_delaunay' if seed_point_type == 'auto': - if phi>PHI_LIMITS[1]: # Case grid. For example, Barcelona, Manhattan + if phi>constants.PHI_LIMITS[1]: # Case grid. For example, Barcelona, Manhattan seed_point_type = 'grid_square' if seed_point_linking == 'auto': seed_point_linking = 'quadrangulate' if existing_network_spacing is not None: # Case incompatible with existing_network_spacing not None existing_network_spacing = None warnings.warn("Automatically chosen seed_point_linking 'quadrangulate' is incompatible with existing_network_spacing not set to None. Changing existing_network_spacing to None.") - elif phi<=PHI_LIMITS[1] and phi>PHI_LIMITS[0]: # Case contains some grid elements. For example, Prague, Budapest + elif phi<=constants.PHI_LIMITS[1] and phi>constants.PHI_LIMITS[0]: # Case contains some grid elements. For example, Prague, Budapest seed_point_type = 'grid_square' if seed_point_linking == 'auto': seed_point_linking = 'triangulate_delaunay' - elif phi<=PHI_LIMITS[0]: # Case negligible grid elements. For example, Berlin, London + elif phi<=constants.PHI_LIMITS[0]: # Case negligible grid elements. For example, Berlin, London seed_point_type = 'grid_triangle' if seed_point_linking == 'auto': seed_point_linking = 'triangulate_delaunay' @@ -192,35 +205,35 @@ def resolve_auto_parameters( if seed_point_type != 'grid_square': # Everything is triangulated, but the grid could also be quadrangulated seed_point_linking = 'triangulate_delaunay' else: - if phi>PHI_LIMITS[1]: # Case grid. For example, Barcelona, Manhattan + if phi>constants.PHI_LIMITS[1]: # Case grid. For example, Barcelona, Manhattan seed_point_linking = 'quadrangulate' if existing_network_spacing is not None: # Case incompatible with existing_network_spacing not None existing_network_spacing = None warnings.warn("Automatically chosen seed_point_linking 'quadrangulate' is incompatible with existing_network_spacing not set to None. Changing existing_network_spacing to None.") - elif phi<=PHI_LIMITS[1] and phi>PHI_LIMITS[0]: # Case contains some grid elements. For example, Prague, Budapest + elif phi<=constants.PHI_LIMITS[1] and phi>constants.PHI_LIMITS[0]: # Case contains some grid elements. For example, Prague, Budapest seed_point_linking = 'triangulate_delaunay' if seed_point_grid_spacing == 'auto': # These values ensure that any point in the city is always within b=500m of the network (if seed points snap perfectly). # In comments, general equations for arbitrary buffer distance b if seed_point_type == 'grid_square' and seed_point_linking == 'triangulate_delaunay': - seed_point_grid_spacing = 1707 # a=2b/(2-sqrt(2)) + seed_point_grid_spacing = constants.GRID_SPACING_TRIANGULATE # a=2b/(2-sqrt(2)) elif seed_point_type == 'grid_square' and seed_point_linking == 'quadrangulate': - seed_point_grid_spacing = 1000 # a=2b + seed_point_grid_spacing = constants.GRID_SPACING_QUADRANGULATE # a=2b elif seed_point_type == 'grid_triangle': - seed_point_grid_spacing = 1154 # h/2=b=a*sqrt(3)/4 -> a=4b/sqrt(3) + seed_point_grid_spacing = constants.GRID_SPACING_TRIANGLE # h/2=b=a*sqrt(3)/4 -> a=4b/sqrt(3) else: - seed_point_grid_spacing = 1707 + seed_point_grid_spacing = constants.GRID_SPACING_TRIANGULATE - if seed_point_snap_distance == 'auto': - seed_point_snap_distance = int(np.ceil(seed_point_grid_spacing/4)) + if settings.seed_point_snap_distance == 'auto': + settings.seed_point_snap_distance = int(np.ceil(seed_point_grid_spacing*constants.SEED_POINT_SNAP_DISTANCE_FACTOR)) if existing_network_spacing == 'auto': - existing_network_spacing = int(np.ceil(seed_point_grid_spacing/2)) + existing_network_spacing = int(np.ceil(seed_point_grid_spacing*constants.EXISTING_NETWORK_SPACING_FACTOR)) - return seed_point_type, seed_point_grid_spacing, seed_point_snap_distance, seed_point_linking, existing_network_spacing + return seed_point_type, seed_point_grid_spacing, seed_point_linking, existing_network_spacing -def import_network(street_network, crs_projected): +def import_network(street_network): """Import and project a street network from gpkg file Parameters @@ -229,8 +242,6 @@ def import_network(street_network, crs_projected): The street network will be loaded from this file. Must be a gpkg file in unprojected crs EPSG:4326 with layers nodes and edges, with the structure that a osmnx street network g has after saved its undirected version via ox.io.save_graph_geopackage(). For example: >>> g = ox.graph_from_place("Barcelona", network_type='drive') >>> ox.io.save_graph_geopackage(g, "Barcelona_streets.gpkg") - crs_projected : str - Coordinate reference system that is used to project osm data. Returns ------- @@ -244,8 +255,8 @@ def import_network(street_network, crs_projected): Convex hull of the street network """ - nodes = gpd.read_file(street_network, layer='nodes') - edges = gpd.read_file(street_network, layer='edges') + nodes = gpd.read_file(settings.import_path+street_network, layer='nodes') + edges = gpd.read_file(settings.import_path+street_network, layer='edges') # Set indices as required by osmnx.convert.graph_from_gdfs # See: https://osmnx.readthedocs.io/en/stable/user-reference.html#osmnx.utils_graph.graph_from_gdfs @@ -256,9 +267,9 @@ def import_network(street_network, crs_projected): g_undir = g.to_undirected().copy() # convert to undirected (dropping OSMnx keys!) city_boundary_gdf = gpd.GeoDataFrame(gpd.GeoSeries(nodes.union_all().convex_hull), geometry=0, crs=nodes.crs) # We do this before the projection of nodes below - # To do: To be super-correct, the hull should be buffered by seed_point_snap_distance (in degrees due to being unprojected) + # To do: To be super-correct, the hull should be buffered by settings.seed_point_snap_distance (in degrees due to being unprojected) - nodes, edges = prepare_nodes_edges(nodes, edges, crs_projected) + nodes, edges = prepare_nodes_edges(nodes, edges) return nodes, edges, g_undir, city_boundary_gdf @@ -290,7 +301,7 @@ def orientation_order(g_undir): return phi -def prepare_nodes_edges(nodes, edges, crs_projected): +def prepare_nodes_edges(nodes, edges): """Project and prepare nodes and edges for further use Parameters @@ -299,8 +310,6 @@ def prepare_nodes_edges(nodes, edges, crs_projected): OSM nodes, unprojected edges : geopandas.geodataframe.GeoDataFrame OSM edges, unprojected - crs_projected : str - Coordinate reference system that is used to project osm data. Returns ------- @@ -316,26 +325,24 @@ def prepare_nodes_edges(nodes, edges, crs_projected): # This also means we are dropping the "key" level from edge index (u,v,key becomes: u,v) # Project geometries of nodes, edges - edges = edges.to_crs(crs_projected) - nodes = nodes.to_crs(crs_projected) + edges = edges.to_crs(settings.crs_projected) + nodes = nodes.to_crs(settings.crs_projected) # Add osm ID as column to node gdf nodes["osmid"] = nodes.index return nodes, edges -def download_network(city_name, crs_projected, network_type='drive', custom_filter=None, retain_all=True, city_boundary_geometry=None): +def download_network(city_name, network_type='drive', custom_filter=None, retain_all=True, city_boundary_geometry=None): """Download and prepare a street network from OSM via OSMnx Downloads a network with a given network_type and custom_filter using ox.graph_from_place. - Then, stores the undirected OSM data in gdfs and projects using crs_projected. + Then, stores the undirected OSM data in gdfs and projects using settings.crs_projected. Parameters ---------- city_name : str Name of the city that the analysis should be performed on. Overruled (for data fetching) if city_boundary or street_network is set. - crs_projected : str - Coordinate reference system that is used to project osm data. network_type : {'all', 'all_public', 'bike', 'drive', 'drive_service', 'walk'} What type of street network to retrieve if custom_filter is None. custom_filter : (str | list[str] | None) @@ -368,19 +375,17 @@ def download_network(city_name, crs_projected, network_type='drive', custom_filt g_undir = g.to_undirected().copy() # convert to undirected (dropping OSMnx keys!) # Export osmnx data to gdfs - nodes, edges = nx_to_nodes_edges(g_undir, crs_projected) + nodes, edges = nx_to_nodes_edges(g_undir) return nodes, edges, g_undir -def nx_to_nodes_edges(G, crs_projected): +def nx_to_nodes_edges(G): """Get nodes and projected edges from networkX graph Parameters ---------- G : networkx.classes.multigraph.MultiGraph networkX graph, undirected - crs_projected : str - Coordinate reference system that is used to project osm data. Returns ------- @@ -397,7 +402,7 @@ def nx_to_nodes_edges(G, crs_projected): fill_edge_geometry=True ) - nodes, edges = prepare_nodes_edges(nodes, edges, crs_projected) + nodes, edges = prepare_nodes_edges(nodes, edges) return nodes, edges @@ -465,7 +470,7 @@ def get_existing_network_seed_points(nodes_exnw, existing_network_spacing): return seed_points_exnw -def update_with_existing_bike_network(city_name, crs_projected, g_undir, import_files, city_boundary_geometry=None): +def update_with_existing_bike_network(city_name, g_undir, import_files, city_boundary_geometry=None): """Update street network with existing bike network Downloads a network of protected bike infrastructure from OSM (retaining all connected components) and merges it to a given street network graph g_undir, or imports it from a local file. @@ -474,8 +479,6 @@ def update_with_existing_bike_network(city_name, crs_projected, g_undir, import_ ---------- city_name : str Name of the city that the analysis should be performed on. Overruled (for data fetching) if city_boundary_geometry is set. - crs_projected : str - Coordinate reference system that is used to project osm data. g_undir : networkx.classes.multigraph.MultiGraph Street network networkX graph, undirected import_files : dict @@ -498,16 +501,16 @@ def update_with_existing_bike_network(city_name, crs_projected, g_undir, import_ """ if import_files['bike_network'] is not None: # Import and preprocess data from file - nodes_exnw, edges_exnw, g_undir_exnw, _ = import_network(import_files['bike_network'], crs_projected) + nodes_exnw, edges_exnw, g_undir_exnw, _ = import_network(import_files['bike_network']) else: # Fetch protected bike network data from osmnx # Due to retain_all=True, this fetches all the connected components - nodes_exnw, edges_exnw, g_undir_exnw = download_network(city_name, crs_projected, custom_filter=PBI_CUSTOM_FILTER, retain_all=True, city_boundary_geometry=city_boundary_geometry) + nodes_exnw, edges_exnw, g_undir_exnw = download_network(city_name, custom_filter=constants.PBI_CUSTOM_FILTER, retain_all=True, city_boundary_geometry=city_boundary_geometry) g_undir = nx.compose(g_undir_exnw, g_undir) # Merge to be sure we have everything from both # Intermezzo: Get filtered existing network by component length - nodes_exnw_filtered, _, _ = filter_network_by_component_length(g_undir_exnw, crs_projected) + nodes_exnw_filtered, _, _ = filter_network_by_component_length(g_undir_exnw) # Now we could have some leftover bike infra that is disconnected from the street network and thus not routable. # We delete those parts next: @@ -521,22 +524,20 @@ def update_with_existing_bike_network(city_name, crs_projected, g_undir, import_ # edges_exnw has a MultiIndex ('u','v'), so we must use get_level_values, see https://stackoverflow.com/a/18835121 edges_exnw = edges_exnw.iloc[edges_exnw.index.get_level_values('u').isin(valid_node_osmids)] edges_exnw = edges_exnw.iloc[edges_exnw.index.get_level_values('v').isin(valid_node_osmids)] - nodes, edges = nx_to_nodes_edges(g_undir, crs_projected) + nodes, edges = nx_to_nodes_edges(g_undir) return nodes, edges, g_undir, nodes_exnw, edges_exnw, g_undir_exnw, nodes_exnw_filtered -def filter_network_by_component_length(g_undir, crs_projected): +def filter_network_by_component_length(g_undir): """Filter a network to remove too short components - The application is that g_undir is all the components of the existing bicycle network, but we do not snap seed points to components shorter than EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH. So we create a new set of nodes where the nodes from the too small components are removed. + The application is that g_undir is all the components of the existing bicycle network, but we do not snap seed points to components shorter than constants.EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH. So we create a new set of nodes where the nodes from the too small components are removed. Parameters ---------- g_undir : networkx.classes.multigraph.MultiGraph Street network networkX graph, undirected - crs_projected : str - Coordinate reference system that is used to project osm data. Returns ------- @@ -551,7 +552,7 @@ def filter_network_by_component_length(g_undir, crs_projected): g_undir_filtered = nx.MultiGraph() components_by_length = [g_undir.subgraph(c).copy() for c in sorted(nx.connected_components(g_undir), key=lambda c: sum([l[-1] for l in g_undir.subgraph(c).copy().edges.data('length')]), reverse=True)] for c in components_by_length: # Create the union of long enough components. Probably there is a way to do this faster/vectorized. - if c.number_of_edges() and sum([l[-1] for l in c.edges.data('length')]) >= EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH: # no matter the min length, remove isolated nodes + if c.number_of_edges() and sum([l[-1] for l in c.edges.data('length')]) >= constants.EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH: # no matter the min length, remove isolated nodes g_undir_filtered = nx.union(g_undir_filtered, c) else: break @@ -563,11 +564,11 @@ def filter_network_by_component_length(g_undir, crs_projected): node_geometry=True, fill_edge_geometry=True ) - nodes_filtered, _ = prepare_nodes_edges(nodes_filtered, gpd.GeoDataFrame(), crs_projected) + nodes_filtered, _ = prepare_nodes_edges(nodes_filtered, gpd.GeoDataFrame()) return nodes_filtered, edges_filtered, g_undir_filtered -def update_seed_points_with_existing_bike_network(seed_points_snapped, nodes_exnw, existing_network_spacing, crs_projected): +def update_seed_points_with_existing_bike_network(seed_points_snapped, nodes_exnw, existing_network_spacing): """Update seed points with existing bike network Updates given snapped seed points by incorporating seed points from an existing bike network. @@ -577,11 +578,9 @@ def update_seed_points_with_existing_bike_network(seed_points_snapped, nodes_exn seed_points_snapped : geopandas.geodataframe.GeoDataFrame Snapped seed points on the street network, constructed with seed_point_grid_spacing nodes_exnw : geopandas.geodataframe.GeoDataFrame - Nodes of the existing bike network, after shortest components below EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH have been filtered out + Nodes of the existing bike network, after shortest components below constants.EXISTING_NETWORK_MINIMUM_COMPONENT_LENGTH have been filtered out existing_network_spacing : int Positive integer denoting spacing between seed points, in meters, only on the existing bicycle network. - crs_projected : str - Coordinate reference system that is used to project osm data. Returns ------- @@ -591,12 +590,12 @@ def update_seed_points_with_existing_bike_network(seed_points_snapped, nodes_exn # If the existing bicycle network is used, create extra seed points on it. They are by construction already snapped. seed_points_exnw = get_existing_network_seed_points(nodes_exnw, existing_network_spacing) - seed_points_exnw.to_crs(crs_projected, inplace=True) + seed_points_exnw.to_crs(settings.crs_projected, inplace=True) # Afterwards, drop all previously determined seed points (grid or rail) that are now too close to these extra points. - buffer_seed_points_exnw = gpd.GeoDataFrame(seed_points_exnw.buffer(existing_network_spacing/2)) # To do: Think more about this factor + buffer_seed_points_exnw = gpd.GeoDataFrame(seed_points_exnw.buffer(existing_network_spacing*constants.BUFFER_SEED_POINTS_EXNW_FACTOR)) buffer_seed_points_exnw = buffer_seed_points_exnw.rename(columns={0:'geometry'}).set_geometry('geometry') # https://gis.stackexchange.com/questions/266098/how-to-convert-a-geoseries-to-a-geodataframe-with-geopandas - buffer_seed_points_exnw.to_crs(crs_projected, inplace=True) + buffer_seed_points_exnw.to_crs(settings.crs_projected, inplace=True) # Delete the seed points that are too close to seed_points_exnw via its buffer seed_points_snapped = seed_points_snapped.overlay(buffer_seed_points_exnw, how='difference') @@ -696,15 +695,13 @@ def get_grid_seed_points(edges, seed_point_spacing, principal_bearing, seed_poin return seed_points, seed_network -def prepare_seed_points(seed_points, crs_projected): +def prepare_seed_points(seed_points): """Project and prepare seed points for further use Parameters ---------- seed_points: geopandas.geodataframe.GeoDataFrame Unprojected seed points - crs_projected : str - Coordinate reference system that is used to project the seed points. Returns ------- @@ -712,20 +709,18 @@ def prepare_seed_points(seed_points, crs_projected): Projected and prepared seed points. """ seed_points = seed_points[seed_points["geometry"].type == "Point"] - seed_points.to_crs(crs_projected, inplace=True) + seed_points.to_crs(settings.crs_projected, inplace=True) # To do optional: merge closeby seed points return seed_points -def get_tags_seed_points(city_name, crs_projected, tags, city_boundary_geometry=None): +def get_tags_seed_points(city_name, tags, city_boundary_geometry=None): """Get tags seed points for a city Parameters ---------- city_name : str Name of the city that the analysis should be performed on. This is the query string used to fetch the data from nominatim. Overruled (for data fetching) if city_boundary_geometry is set. - crs_projected : str - Coordinate reference system that is used to project osm data. Default is '3857' (WGS 84 / Pseudo-Mercator). If this web mercator projection is not needed, then for Europe '3035' (LAEA) and globally '54035' (Equal Earth) is better. tags : None | dict[str, bool | str | list[str]], default None Geocodable tags, see [3]_. For example, tags={"railway": ["station", "halt"]} will retrieve exactly the same as seed_point_type='rail'. city_boundary_geometry : (shapely Polygon | shapely MultiPolygon | None), default None @@ -749,7 +744,7 @@ def get_tags_seed_points(city_name, crs_projected, tags, city_boundary_geometry= seed_points = ox.features_from_place( city_name, tags ) - seed_points = prepare_seed_points(seed_points, crs_projected) + seed_points = prepare_seed_points(seed_points) return seed_points @@ -770,10 +765,6 @@ def get_principal_bearing(G): The principal bearing, precise to 5 degrees. """ - bearingbins = ( - 72 # number of bins to determine bearing. e.g. 72 will create 5 degrees bins - ) - bearings = {} # weight bearings by length (meters) city_bearings = [] @@ -784,8 +775,8 @@ def get_principal_bearing(G): pass # Bearings cannot be calculated in rare edge cases. b = pd.Series(city_bearings) bearings = pd.concat([b, b.map(reverse_bearing)]).reset_index(drop="True") - bins = np.arange(bearingbins + 1) * 360 / bearingbins - count = count_and_merge(bearingbins, bearings) + bins = np.arange(constants.BEARING_BINS + 1) * 360 / constants.BEARING_BINS + count = count_and_merge(constants.BEARING_BINS, bearings) principal_bearing = bins[np.where(count == max(count))][0] return principal_bearing @@ -884,19 +875,17 @@ def snap_seed_points(seed_points, nodes): return seed_points_snapped -def filter_seed_points(seed_points_snapped, seed_point_snap_distance): +def filter_seed_points(seed_points_snapped): """Remove seed_points that are further than the snap distance away from an actual osm node Parameters ---------- seed_points_snapped: geopandas.geodataframe.GeoDataFrame seed_points with additional information about geometries of osm nodes that seed nodes were snapped to - seed_point_snap_distance: int - maximum distance a seed_point may be removed from an actual osm node Returns ------- - seed_points_snapped: geopandas.geodataframe.GeoDataFrame + seed_points_snapped_filtered: geopandas.geodataframe.GeoDataFrame seed_points within snap distance away from an actual osm node, only columns are osmid and the associated osm geometry """ gdf = seed_points_snapped.copy() @@ -905,7 +894,7 @@ def filter_seed_points(seed_points_snapped, seed_point_snap_distance): gdf["snap_dist"] = gdf.geometry_generated.distance(gdf.geometry_osm) # Filter by threshold - gdf = gdf[gdf["snap_dist"] <= seed_point_snap_distance].copy() + gdf = gdf[gdf["snap_dist"] <= settings.seed_point_snap_distance].copy() # Drop duplicates: one row per osmid gdf = gdf.sort_values("snap_dist").drop_duplicates("osmid") @@ -917,9 +906,9 @@ def filter_seed_points(seed_points_snapped, seed_point_snap_distance): gdf = gdf.set_geometry("geometry") gdf = gdf.set_index("osmid", drop=False) - seed_points_snapped = gdf.copy() + seed_points_snapped_filtered = gdf.copy() - return seed_points_snapped + return seed_points_snapped_filtered def create_delaunay_edges(nodes_gdf): diff --git a/growbikenet/growbikenet.py b/growbikenet/growbikenet.py index af939eb..4dfefe0 100644 --- a/growbikenet/growbikenet.py +++ b/growbikenet/growbikenet.py @@ -1,4 +1,5 @@ -from growbikenet.constants import * +from . import constants +from . import settings import os import numpy as np import networkx as nx @@ -10,6 +11,7 @@ import time import datetime from growbikenet.functions import ( + validate_settings, validate_parameters, orientation_order, resolve_auto_parameters, @@ -37,15 +39,12 @@ def growbikenet( city_name, - crs_projected='3857', ranking='betweenness_centrality', seed_point_type='auto', seed_point_grid_spacing='auto', - seed_point_snap_distance='auto', seed_point_linking='auto', existing_network_spacing=None, export_data=True, - export_file_format='geojson', export_data_slug=None, export_plots=False, # export_video=False, @@ -61,8 +60,6 @@ def growbikenet( ---------- city_name : str Name of the city that the analysis should be performed on. This is the query string used to fetch the data from nominatim. Overruled for data fetching if city_boundary or street_network is set. - crs_projected : str, default '3857' - EPSG code of the coordinate reference system that is used to project osm data. Default is '3857' (WGS 84 / Pseudo-Mercator). If this web mercator projection is not needed, then for Europe '3035' (LAEA) and globally '54035' (Equal Earth) is better. ranking : str, default 'betweenness_centrality' Method used to rank edges. Must be 'betweenness_centrality' (default), 'closeness_centrality', or 'random'. seed_point_type : str ('auto' | 'grid_square' | 'grid_triangle' | 'rail' | 'school' | 'park' | 'file' | 'tags'), default 'auto' @@ -81,9 +78,6 @@ def growbikenet( Auto-value for seed_point_type 'grid_triangle': 1154 Auto-value otherwise: 1707 These values ensure that any point in the city is always within 500m of the network (under perfect conditions). For case 1707, see [1]_. - seed_point_snap_distance : 'auto' | int, default 'auto' - Maximum distance between raw seed points and osm nodes for snapping, in meters. - Auto-value is round(seed_point_grid_spacing/4). If integer, must be positive. seed_point_linking : str ('auto' | 'triangulate_delaunay' | 'quadrangulate'), default 'auto' The algorithm for linking up the seed points into an unrouted, abstract network. If set to 'auto', selects 'triangulate_delaunay' or 'quadrangulate' automatically depending on the street network's orientation entropy, see [3]_. @@ -92,9 +86,7 @@ def growbikenet( existing_network_spacing : None | 'auto' | int, default None Spacing between seed points, in meters, only on the existing bicycle network. If not set to a positive integer, the existing network is ignored. existing_network_spacing is recommended to be smaller than seed_point_grid_spacing, ideally around 50%, to ensure that the existing bicycle network is built first. Option 'auto' sets existing_network_spacing to 50% of the seed_point_grid_spacing. export_data : bool, default True - If set to True, data is saved to a file. The filename is [slug]-[ranking]-[seed_point_type].[export_file_format], where slug is a string id made out of city_name. - export_file_format : str ('geojson' | 'gpkg'), default 'geojson' - File format for the data export, relevant if export_data set to True. Default 'geojson', also possible 'gpkg'. If exporting as geojson, generates extra files for seed points and city boundary. If exporting as gkpg, these are added all in one file as extra layers. + If set to True, data is saved to a file. The filename is [slug]-[ranking]-[seed_point_type].[settings.export_file_format], where slug is a string id made out of city_name. export_data_slug : str | None, default None If not set to None, the city_name will be slugified and used as the slug in the filename of the data export. export_plots : bool, default False @@ -155,17 +147,15 @@ def growbikenet( if k not in import_files: import_files[k] = None + validate_settings() validate_parameters( city_name, - crs_projected, ranking, seed_point_type, seed_point_grid_spacing, - seed_point_snap_distance, seed_point_linking, existing_network_spacing, export_data, - export_file_format, export_data_slug, export_plots, allow_edge_overlaps, @@ -173,7 +163,7 @@ def growbikenet( seed_point_tags, ) - np.random.seed(42) # Set random number generator seed for reproducibility + np.random.seed(settings.random_seed) # Set random number generator seed for reproducibility print("==============================================") print("RUNNING GROWBIKENET FOR CITY: " + city_name) @@ -189,7 +179,7 @@ def growbikenet( unit="network", bar_format='{l_bar}{bar:16}{r_bar}', ) - nodes, edges, g_undir, city_boundary_gdf = import_network(import_files['street_network'], crs_projected) + nodes, edges, g_undir, city_boundary_gdf = import_network(import_files['street_network']) city_boundary_geometry = city_boundary_gdf.geometry[0] progress_bar.update(1) else: @@ -203,18 +193,18 @@ def growbikenet( ) # Get city boundary if import_files['city_boundary']: - city_boundary_shp = gpd.read_file(import_files['city_boundary']) + city_boundary_shp = gpd.read_file(settings.import_path+import_files['city_boundary']) city_boundary_gdf = city_boundary_shp.iloc[[0]] else: city_boundary_gdf = ox.geocoder.geocode_to_gdf(city_name) city_boundary_geometry = city_boundary_gdf.geometry[0] # Fetch street network data from osmnx # Due to retain_all=False, this fetches the largest connected component - nodes, edges, g_undir = download_network(city_name, crs_projected, network_type='drive', retain_all=False, city_boundary_geometry=city_boundary_geometry) + nodes, edges, g_undir = download_network(city_name, network_type='drive', retain_all=False, city_boundary_geometry=city_boundary_geometry) progress_bar.update(1) if existing_network_spacing is not None: # update g_undir: add the existing bike network - nodes, edges, g_undir, nodes_exnw, edges_exnw, g_undir_exnw, nodes_exnw_filtered = update_with_existing_bike_network(city_name, crs_projected, g_undir, import_files=import_files, city_boundary_geometry=city_boundary_geometry) + nodes, edges, g_undir, nodes_exnw, edges_exnw, g_undir_exnw, nodes_exnw_filtered = update_with_existing_bike_network(city_name, g_undir, import_files=import_files, city_boundary_geometry=city_boundary_geometry) progress_bar.update(1) progress_bar.close() @@ -222,10 +212,9 @@ def growbikenet( # Now that the graph is ready, decide auto values ox.bearing.add_edge_bearings(g_undir) phi = orientation_order(g_undir) - seed_point_type, seed_point_grid_spacing, seed_point_snap_distance, seed_point_linking, existing_network_spacing = resolve_auto_parameters( + seed_point_type, seed_point_grid_spacing, seed_point_linking, existing_network_spacing = resolve_auto_parameters( seed_point_type, seed_point_grid_spacing, - seed_point_snap_distance, seed_point_linking, existing_network_spacing, phi, @@ -250,14 +239,14 @@ def growbikenet( seed_points, seed_network = get_grid_seed_points( edges, seed_point_grid_spacing, principal_bearing, seed_point_type ) # The seed_network is only relevant for quadrangulation - elif seed_point_type in PRESET_TAGS: - seed_point_tags = PRESET_TAGS[seed_point_type] + elif seed_point_type in constants.PRESET_TAGS: + seed_point_tags = constants.PRESET_TAGS[seed_point_type] elif seed_point_type == 'file': - seed_points = gpd.read_file(import_files['seed_points']) - seed_points = prepare_seed_points(seed_points, crs_projected) + seed_points = gpd.read_file(settings.import_path+import_files['seed_points']) + seed_points = prepare_seed_points(seed_points) - if seed_point_type == 'tags' or seed_point_type in PRESET_TAGS: - seed_points = get_tags_seed_points(city_name, crs_projected=crs_projected, tags=seed_point_tags, city_boundary_geometry=city_boundary_geometry) + if seed_point_type == 'tags' or seed_point_type in constants.PRESET_TAGS: + seed_points = get_tags_seed_points(city_name, tags=seed_point_tags, city_boundary_geometry=city_boundary_geometry) progress_bar.update(1) # Snap seed points to OSM nodes @@ -266,7 +255,7 @@ def growbikenet( mapping = {row.geometry_generated: row.osmid for row in seed_points_snapped.itertuples()} nx.relabel_nodes(seed_network, mapping, copy=False) progress_bar.update(1) - seed_points_snapped_filtered = filter_seed_points(seed_points_snapped, seed_point_snap_distance) + seed_points_snapped_filtered = filter_seed_points(seed_points_snapped) if seed_point_linking == 'quadrangulate': # Remove all filtered out nodes filtered_nodes = set(seed_points_snapped.osmid) - set(seed_points_snapped_filtered.osmid) seed_network.remove_nodes_from(filtered_nodes) @@ -274,7 +263,7 @@ def growbikenet( progress_bar.update(1) if existing_network_spacing is not None: - seed_points_snapped_filtered = update_seed_points_with_existing_bike_network(seed_points_snapped_filtered, nodes_exnw_filtered, existing_network_spacing, crs_projected) + seed_points_snapped_filtered = update_seed_points_with_existing_bike_network(seed_points_snapped_filtered, nodes_exnw_filtered, existing_network_spacing) progress_bar.update(1) progress_bar.close() @@ -372,16 +361,16 @@ def growbikenet( # Rank edges by specified method edges_ranked = rank_df(edges_ranked, ranking) - edges_ranked = gpd.GeoDataFrame(edges_ranked, crs=crs_projected, geometry="geometry") + edges_ranked = gpd.GeoDataFrame(edges_ranked, crs=settings.crs_projected, geometry="geometry") # Add existing bike network on top, https://stackoverflow.com/a/43408736 if existing_network_spacing: - existing_bikenet = gpd.GeoDataFrame({c: None for c in edges_ranked.columns}, index=[-1], crs=crs_projected) + existing_bikenet = gpd.GeoDataFrame({c: None for c in edges_ranked.columns}, index=[-1], crs=settings.crs_projected) existing_bikenet.loc[-1, 'geometry'] = gpd.GeoSeries(edges_exnw.geometry).union_all() edges_ranked.loc[-1] = existing_bikenet.loc[-1] edges_ranked.index = edges_ranked.index+1 edges_ranked.sort_index(inplace=True) - edges_ranked.crs = crs_projected + edges_ranked.crs = settings.crs_projected progress_bar.update(1) progress_bar.close() @@ -399,7 +388,7 @@ def growbikenet( # Generate export data filename if export_data or export_plots:# or export_video: - os.makedirs("./results/", exist_ok=True) + os.makedirs(settings.export_path['results'], exist_ok=True) if export_data_slug is None: city_string = city_name else: @@ -409,7 +398,7 @@ def growbikenet( else: exnw_string = "" export_data_filename = ( - slugify(city_string) + "-" + ranking + "-" + seed_point_type + overlap_string + exnw_string + "." + export_file_format + slugify(city_string) + "-" + ranking + "-" + seed_point_type + overlap_string + exnw_string + "." + settings.export_file_format ) ### Export data @@ -424,22 +413,22 @@ def growbikenet( # We have meter precision, so rounding to integers is fine. Better would be to # change dtypes to int, but this does not seem possible without manual looping. if city_boundary_exists: - city_boundary_gdf.to_crs(epsg=crs_projected, inplace=True) + city_boundary_gdf.to_crs(epsg=settings.crs_projected, inplace=True) city_boundary_gdf.geometry = city_boundary_gdf.geometry.set_precision(grid_size=1) seed_points_snapped_filtered.geometry = seed_points_snapped_filtered.geometry.set_precision(grid_size=1) edges_ranked.geometry = edges_ranked.geometry.set_precision(grid_size=1) - if export_file_format == "geojson": - edges_ranked.to_file("./results/"+export_data_filename, driver="GeoJSON") - seed_points_snapped_filtered.to_file("./results/"+slugify(city_string)+"-"+seed_point_type+exnw_string+".geojson", driver="GeoJSON") - if city_boundary_exists: city_boundary_gdf.to_file("./results/"+slugify(city_string)+"-city_boundary.geojson", driver="GeoJSON") - elif export_file_format == "gpkg": + if settings.export_file_format == "geojson": + edges_ranked.to_file(settings.export_path['results']+export_data_filename, driver="GeoJSON") + seed_points_snapped_filtered.to_file(settings.export_path['results']+slugify(city_string)+"-"+seed_point_type+exnw_string+".geojson", driver="GeoJSON") + if city_boundary_exists: city_boundary_gdf.to_file(settings.export_path['results']+slugify(city_string)+"-city_boundary.geojson", driver="GeoJSON") + elif settings.export_file_format == "gpkg": if existing_network_spacing: - edges_ranked.iloc[[0]].to_file("./results/"+export_data_filename, driver="GPKG", layer="Existing bike network") - edges_ranked.iloc[1:-1].to_file("./results/"+export_data_filename, driver="GPKG", layer="Grown bike network", append=True) + edges_ranked.iloc[[0]].to_file(settings.export_path['results']+export_data_filename, driver="GPKG", layer="Existing bike network") + edges_ranked.iloc[1:-1].to_file(settings.export_path['results']+export_data_filename, driver="GPKG", layer="Grown bike network", append=True) else: - edges_ranked.to_file("./results/"+export_data_filename, driver="GPKG", layer="Grown bike network") - seed_points_snapped_filtered.to_file("./results/"+export_data_filename, driver="GPKG", layer="Seed points", append=True) - if city_boundary_exists: city_boundary_gdf.to_file("./results/"+export_data_filename, driver="GPKG", layer="City boundary", append=True) + edges_ranked.to_file(settings.export_path['results']+export_data_filename, driver="GPKG", layer="Grown bike network") + seed_points_snapped_filtered.to_file(settings.export_path['results']+export_data_filename, driver="GPKG", layer="Seed points", append=True) + if city_boundary_exists: city_boundary_gdf.to_file(settings.export_path['results']+export_data_filename, driver="GPKG", layer="City boundary", append=True) progress_bar.update(1) progress_bar.close() @@ -447,38 +436,26 @@ def growbikenet( ### Visualize # Read in file to plot - routed_edges_gdf = gpd.read_file("./results/"+export_data_filename, layer="Grown bike network") + routed_edges_gdf = gpd.read_file(settings.export_path['results']+export_data_filename, layer="Grown bike network") - # Viz/plot settings (move to config file later) - # Define color palette (from Michael's project: https://github.com/mszell/bikenwgrowth/blob/main/parameters/parameters.py) - streetcolor = "#999999" - edgecolor = "#0EB6D2" - seedcolor = "#ff7338" - # Define linewidths - lws = {"street": 0.75, "bike": 2} - - os.makedirs("./results/plots/ordering_"+ranking+"/", exist_ok=True) + os.makedirs(settings.export_path['plots']+"ordering_"+ranking+"/", exist_ok=True) create_plots( routed_edges_gdf, seed_points_snapped_filtered, - streetcolor, - edgecolor, - seedcolor, - lws, ranking, ) # if export_video: - # os.makedirs("./results/plots/ordering_"+ranking+"/video/", exist_ok=True) - # make_video(img_folder_name="./results/plots/ordering_"+ranking+"/", fps=5) + # os.makedirs(settings.export_path['videos']+"/ordering_"+ranking+"/", exist_ok=True) + # make_video(img_folder_name=settings.export_path['videos']+"ordering_"+ranking+"/", fps=5) print("----------------------------------------------╯") if export_data: - print("Data exported to results/") + print("Data exported to "+settings.export_path['results']) if export_plots: - print("Plots exported to results/plots/") + print("Plots exported to "+settings.export_path['plots']) # if export_video: - # print("Video exported to results/plots/") + # print("Video exported to "+settings.export_path['videos']) if export_data or export_plots:# or export_video: print("----------------------------------------------") diff --git a/growbikenet/settings.py b/growbikenet/settings.py new file mode 100644 index 0000000..fdedf2f --- /dev/null +++ b/growbikenet/settings.py @@ -0,0 +1,42 @@ +"""Global settings for growbikenet that can be configured by the user. + +export_path : dict(str | Path) + Paths to results, plots, and video folders to save data, plots, and videos. +import_path : str | Path + Path to import files (as defined in growbikenet's import_files parameter). +crs_projected : str, default '3857' + EPSG code of the coordinate reference system that is used to project osm data. Default is '3857' (WGS 84 / Pseudo-Mercator). If this web mercator projection is not needed, then for Europe '3035' (LAEA) and globally '54035' (Equal Earth) is better. +export_file_format : str ('geojson' | 'gpkg'), default 'gpkg' + File format for the data export, relevant if export_data set to True. If exporting as geojson, generates extra files for seed points and city boundary. If exporting as gkpg, these are added all in one file as extra layers. +seed_point_snap_distance : 'auto' | int, default 'auto' + Maximum distance between raw seed points and osm nodes for snapping, in meters. + Auto-value is ceil(seed_point_grid_spacing*constants.SEED_POINT_SNAP_DISTANCE_FACTOR). If integer, must be positive. +random_seed : int + Random number generator seed for reproducibility +viz : dict + Dictionary of visualization settings +""" + +export_path = { + "results":"./results/", + "plots":"./results/plots/", + "videos":"./results/videos/", +} +import_path = "./" +crs_projected = '3857' +export_file_format = 'gpkg' +seed_point_snap_distance = 'auto' +random_seed = 42 + +# Viz/plot settings +viz = { + "color":{ + "street":"#999999", + "edge":"#0EB6D2", + "seed_point":"#FF7338" + }, + "line_width":{ + "street": 0.75, + "bike": 2 + }, +} diff --git a/growbikenet/visualization.py b/growbikenet/visualization.py index 9bc8046..30c6112 100644 --- a/growbikenet/visualization.py +++ b/growbikenet/visualization.py @@ -1,3 +1,7 @@ +"""Visualization functions for growbikenet.""" + +from . import constants +from . import settings import os import glob import re @@ -8,7 +12,7 @@ def create_plots( - routed_edges_gdf, seed_points_snapped, streetcolor, edgecolor, seedcolor, lws, ranking + routed_edges_gdf, seed_points_snapped, ranking ): for ordering in tqdm( @@ -22,20 +26,20 @@ def create_plots( fig, ax = plt.subplots(1, 1, figsize=(10, 10)) # first, plot street network as "base line" - routed_edges_gdf.plot(ax=ax, color=streetcolor, lw=lws["street"], zorder=0) + routed_edges_gdf.plot(ax=ax, color=settings.viz.color['street'], lw=settings.viz.line_width['street'], zorder=0) # plot all edges up to current rank routed_edges_gdf[routed_edges_gdf["ordering_"+ranking] <= ordering].plot( - ax=ax, color=edgecolor, lw=lws["bike"], zorder=1 + ax=ax, color=settings.viz.color['edge'], lw=settings.viz.line_width['bike'], zorder=1 ) - seed_points_snapped.plot(ax=ax, color=seedcolor, zorder=2) + seed_points_snapped.plot(ax=ax, color=settings.viz.color['seed_point'], zorder=2) ax.set_axis_off() plot_id = "{:03d}".format(int(ordering)) # format plot ID with leading zeros - fig.savefig(f"./results/plots/ordering_{ranking}/{plot_id}.png", dpi=150, bbox_inches='tight') + fig.savefig(settings.export_path['plots']+f"ordering_{ranking}/{plot_id}.png", dpi=150, bbox_inches='tight') plt.close() diff --git a/tests/test_functions.py b/tests/test_functions.py index 3c2e34f..683a2ca 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,3 +1,5 @@ +from growbikenet import constants +from growbikenet import settings import pytest import osmnx as ox import pandas as pd @@ -57,11 +59,6 @@ def test_rank_df(test_data_rank, method, validation_data_rank): ) -@pytest.fixture -def seed_point_snap_distance(): - return 500 - - @pytest.fixture def snapped_seed_points(): d = { @@ -86,10 +83,12 @@ def filtered_seed_points(): def test_filter_seed_points( - snapped_seed_points, filtered_seed_points, seed_point_snap_distance + snapped_seed_points, filtered_seed_points ): + settings.seed_point_snap_distance = 500 + constants.SEED_POINT_GRID_SPACING_FACTOR = 0.25 assert_frame_equal( - filter_seed_points(snapped_seed_points, seed_point_snap_distance), + filter_seed_points(snapped_seed_points), filtered_seed_points, check_dtype=False, ) diff --git a/tests/test_main.py b/tests/test_main.py index 4f58a50..c4a92fe 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -20,7 +20,6 @@ def test_growbikenet_case_success_online(create_validation_gdf_oelde): create_validation_gdf_oelde.equals( growbikenet( city_name="Oelde", - crs_projected="3857", ranking="betweenness_centrality", export_data=False, ) @@ -32,7 +31,6 @@ def test_growbikenet_case_success_offline1(create_validation_gdf_oelde): create_validation_gdf_oelde.equals( growbikenet( city_name="Oelde", - crs_projected="3857", ranking="betweenness_centrality", export_data=False, import_files={"street_network":"./tests/test_data/oelde_street_network.gpkg"}, @@ -45,7 +43,6 @@ def test_growbikenet_case_success_offline2(create_validation_gdf_athens): create_validation_gdf_athens.equals( growbikenet( city_name="Municipality of Athens", - crs_projected="3857", ranking="betweenness_centrality", export_data=False, existing_network_spacing='auto',