From d815f46046c4538b31a02c79d0b683dc5f9177af Mon Sep 17 00:00:00 2001 From: Manuel-Knepper Date: Thu, 22 Oct 2026 09:26:50 +0200 Subject: [PATCH] added osm import and made sortbikenet.py an example how to run the algorithm --- sortbikenet/growth.py | 160 ++++++++++++++++++++++++++++++++++++- sortbikenet/sortbikenet.py | 146 ++++++++++++++++++++++++++++++++- 2 files changed, 302 insertions(+), 4 deletions(-) diff --git a/sortbikenet/growth.py b/sortbikenet/growth.py index 0717cfd..6aee194 100644 --- a/sortbikenet/growth.py +++ b/sortbikenet/growth.py @@ -6,11 +6,12 @@ import numpy as np import networkx as nx import momepy as mp +import osmnx as ox import shapely from metrics import directness -class Orderbike: +class SortBike: def __init__( self, G, @@ -65,6 +66,120 @@ def __init__( self.grown = False self.keep_searching = True + @staticmethod + def download_osmnx(city_query, network_type="all_public", simplify=True, ): + """ Download a street network from OpenStreetMap using OSMnx. + Parameters + ---------- + city_query : str Place name understood by OSMnx, e.g. "Copenhagen, Denmark". + network_type : str OSMnx network type, e.g. "bike", "walk", or "drive". + simplify : bool Whether OSMnx should simplify the downloaded graph. + + Returns + ------- + nx.MultiDiGraph The raw OSMnx graph. """ + + for custom_tag in ["highway", "cycleway", "bicycle", "cycleway:right", "cycleway:left", "cycleway:both", + "cyclestreet", "access", "area", "service", "motor_vehicle", + "motorcar"]: # This list should contain all tags used in any custom filters + if custom_tag not in ox.settings.useful_tags_way: + ox.settings.useful_tags_way.extend(custom_tag) + + custom_filter = ['["highway"~"cycleway|living_street"]', + '["highway"~"path|pedestrian|footway"]["bicycle"~"designated|yes|permissive"]["access"!~"private"]', + '["cyclestreet"~"yes"]', + '["bicycle_road"~"yes"]', + '["cycleway"~"track"]', + '["cycleway:right"~"track|opposite_track"]', # opposite_track is deprecated, but could still exist + '["cycleway:left"~"track|opposite_track"]', # opposite_track is deprecated, but could still exist + '["cycleway:both"~"track|opposite_track"]', # opposite_track is deprecated, but could still exist +] + + g = ox.graph_from_place( + city_query, network_type=network_type, custom_filter=custom_filter, retain_all=False, simplify=simplify + ) + + return g + + @staticmethod + def _prepare_osmnx_graph(G): + """ Prepare an OSMnx graph for use with Orderbike. The graph is converted to an undirected graph and the edge attributes required by Orderbike are added. + Parameters + ---------- + G : nx.MultiDiGraph Graph downloaded with OSMnx. + + Returns + ------- + nx.MultiGraph Prepared graph. """ + G = ox.projection.project_graph(G) + G = ox.convert.to_undirected(G) + + gdf_nodes, gdf_edges = ox.convert.graph_to_gdfs( G, nodes=True, edges=True, fill_edge_geometry=True, ) + + if "length" not in gdf_edges.columns: gdf_edges["length"] = gdf_edges.geometry.length + + # Sortbike expects a "built" attribute. + # By default, none of the downloaded network is considered + # initially built. + gdf_edges["built"] = 0 + + # This is a provisional mapping for road hierarchies. + hierarchy_map = {"primary": 4, + "secondary": 3, + "tertiary": 2, + "residential": 1, + "living_street": 1, + "service": 1, + "unclassified": 1, } + def get_highway_value(value): + if isinstance(value, list): + return value[0] + return value + + if "highway" in gdf_edges.columns: + gdf_edges["highway"] = gdf_edges["highway"].apply( + get_highway_value + ) + gdf_edges["hierarchy"] = ( + gdf_edges["highway"] + .map(hierarchy_map) + .fillna(1) + .astype(int) + ) + else: + # Fall back to the lowest hierarchy level if OSM data does not contain highway information. + gdf_edges["hierarchy"] = 1 + + # Reconstruct the NetworkX graph with the modified edge attributes. + G = ox.convert.graph_from_gdfs( + gdf_nodes, + gdf_edges, + graph_attrs=G.graph, + ) + # make Graph undirected again + G = nx.MultiGraph(G) + + return G + + @classmethod + def from_osmnx( + cls, + city_query, + network_type="public_all", + preset="random", + buff_size=200, + keep_connected=True, + seed=None, + simplify=True, + ): + """ Download and prepare an OSMnx network for Orderbike. + This is method combines download_osmnx() + and _prepare_osmnx_graph(). """ + + G = cls.download_osmnx(city_query=city_query, network_type=network_type, simplify=simplify, ) + G = cls._prepare_osmnx_graph(G) + return cls(G, preset=preset, buff_size=buff_size, built=False, keep_connected=keep_connected, seed=seed, ) + def _add_preset_dict(self): return { "directness": { @@ -464,3 +579,46 @@ def main_random(self): edgelist = list(self.G.edges) self.random_generator.shuffle(edgelist) return {key: -idx for idx, key in enumerate(edgelist)} + + def to_geodataframe(self): + """ + Export the final grown network as a GeoDataFrame + + Parameters + ---------- + + Returns + ------- + geopandas.GeoDataFrame + Edges of the grown network, including their attributes + and growth step. + """ + + # Convert the final grown graph to an edge GeoDataFrame. + gdf = ox.convert.graph_to_gdfs( + self.G_actual, + nodes=False, + edges=True, + fill_edge_geometry=True, + ) + + # Record the order in which edges were added. + # Step 0 represents the initial edge; subsequent steps follow + # the order recorded by the growth algorithm. + growth_steps = { + frozenset(edge[:2]): step + for step, edge in enumerate(self.growth_order, start=1) + } + + gdf["growth_step"] = [ + growth_steps.get(frozenset((u, v)), 0) + for u, v in zip( + gdf.index.get_level_values("u"), + gdf.index.get_level_values("v"), + ) + ] + + # Reset the index so u, v, and key are ordinary columns. + gdf = gdf.reset_index() + + return gdf diff --git a/sortbikenet/sortbikenet.py b/sortbikenet/sortbikenet.py index 225e9a8..c24058c 100644 --- a/sortbikenet/sortbikenet.py +++ b/sortbikenet/sortbikenet.py @@ -1,3 +1,143 @@ -def sortbikenet(): - 'Placeholder for sortbikenet function' - print("sortbikenet") \ No newline at end of file + +""" +Run the Sortbike growth process on an OpenStreetMap network. + +Edit the CONFIGURATION section below before running. The script is deliberately +non-interactive: it does not prompt for input. + +Requirements: + pip install osmnx geopandas networkx numpy momepy shapely + +Project files expected beside this script: + growth.py + metrics.py + +Run: + python sortbikenet.py +""" + +from pathlib import Path +import sys + +from growth import SortBike + +# ============================================================================= +# CONFIGURATION — edit these values before running +# ============================================================================= + +# Place query understood by OpenStreetMap / OSMnx. +CITY_QUERY = "Paris" + +# OSMnx network type" +NETWORK_TYPE = "all_public" + +# Must be one of the presets defined in SortBike._add_preset_dict(): +# "random", "directness", "coverage", "betweenness", "dual_betweenness", +# "closeness", "dual_closeness", "hierarchy", "hierarchy_coverage", +# "hierarchy_directness" +PRESET = "betweenness" + +# Buffer distance in metres. The graph is projected before SortBike uses it. +BUFFER_SIZE = 200 + +# Whether to constrain growth to edges adjacent to the current grown network. +KEEP_CONNECTED = True + +# Integer for reproducible random choices, or None for a non-fixed seed. +RANDOM_SEED = 42 + +# Simplify the downloaded OSM network. +SIMPLIFY = True + +# Output files. Use None to skip the corresponding output. +GEOJSON_PATH = "grown_network.geojson" + +# Print the first N rows of the resulting GeoDataFrame. +PREVIEW_ROWS = 5 + +# ============================================================================= +# RUN +# ============================================================================= + +VALID_PRESETS = { + "random", + "directness", + "coverage", + "betweenness", + "dual_betweenness", + "closeness", + "dual_closeness", + "hierarchy", + "hierarchy_coverage", + "hierarchy_directness", +} + + +def main() -> int: + if PRESET not in VALID_PRESETS: + raise ValueError( + f"Unknown PRESET={PRESET!r}. Choose one of: " + + ", ".join(sorted(VALID_PRESETS)) + ) + + try: + # from_osmnx uses the download and graph-preparation methods already + # defined in the uploaded growth.py. + sortbike = SortBike.from_osmnx( + city_query=CITY_QUERY, + network_type=NETWORK_TYPE, + preset=PRESET, + buff_size=BUFFER_SIZE, + keep_connected=KEEP_CONNECTED, + seed=RANDOM_SEED, + simplify=SIMPLIFY, + ) + + print( + "Input graph prepared: " + f"{sortbike.G.number_of_nodes():,} nodes, " + f"{sortbike.G.number_of_edges():,} edges" + ) + + print("Running growth process...") + sortbike.grow() + + # growth.py's current to_geodataframe() returns the GeoDataFrame. + # GeoJSON is saved here because that method currently takes no path. + gdf_result = sortbike.to_geodataframe() + + print("\nGrowth complete.") + print(f"Output edges: {len(gdf_result):,}") + print(f"CRS: {gdf_result.crs}") + print("\nGeoDataFrame preview:") + print(gdf_result.head(PREVIEW_ROWS).to_string(index=False)) + + if GEOJSON_PATH: + geojson_path = Path(GEOJSON_PATH) + geojson_path.parent.mkdir(parents=True, exist_ok=True) + # GeoJSON should use WGS84 longitude/latitude coordinates. + gdf_result.to_crs("EPSG:4326").to_file( + geojson_path, driver="GeoJSON" + ) + print(f"GeoJSON saved to: {geojson_path.resolve()}") + + print("\nFinal metrics:") + for metric, values in sortbike.get_metrics_dict().items(): + if values: + print(f" {metric}: {values[-1]}") + + return 0 + + except Exception as exc: + print(f"\nERROR: {exc}", file=sys.stderr) + print( + "\nCheck the place/network settings, installed dependencies, and " + "that growth.py and metrics.py are importable.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) +