diff --git a/examples/data/damages_analysis_OSdamage/network.ini b/examples/data/damages_analysis_OSdamage/network.ini index 3fed67ff6..cf19bbe17 100644 --- a/examples/data/damages_analysis_OSdamage/network.ini +++ b/examples/data/damages_analysis_OSdamage/network.ini @@ -31,7 +31,7 @@ hazard_crs = EPSG:32736 [cleanup] snapping_threshold = None -segmentation_length = None +segmentation_length = 100 merge_lines = True merge_on_id = False cut_at_intersections = False \ No newline at end of file diff --git a/examples/data/damages_analysis_huizinga/network.ini b/examples/data/damages_analysis_huizinga/network.ini index 3fed67ff6..cf19bbe17 100644 --- a/examples/data/damages_analysis_huizinga/network.ini +++ b/examples/data/damages_analysis_huizinga/network.ini @@ -31,7 +31,7 @@ hazard_crs = EPSG:32736 [cleanup] snapping_threshold = None -segmentation_length = None +segmentation_length = 100 merge_lines = True merge_on_id = False cut_at_intersections = False \ No newline at end of file diff --git a/examples/data/damages_analysis_manual/network.ini b/examples/data/damages_analysis_manual/network.ini index 3fed67ff6..cf19bbe17 100644 --- a/examples/data/damages_analysis_manual/network.ini +++ b/examples/data/damages_analysis_manual/network.ini @@ -31,7 +31,7 @@ hazard_crs = EPSG:32736 [cleanup] snapping_threshold = None -segmentation_length = None +segmentation_length = 100 merge_lines = True merge_on_id = False cut_at_intersections = False \ No newline at end of file diff --git a/examples/example_damages_OSdamage.ipynb b/examples/example_damages_OSdamage.ipynb index dc730b854..39d626b53 100644 --- a/examples/example_damages_OSdamage.ipynb +++ b/examples/example_damages_OSdamage.ipynb @@ -112,7 +112,7 @@ "name = beira
\n", "
\n", "[network]
\n", - "**directed = False
\n", + "directed = False
\n", "source = OSM download
\n", "primary_file = None
\n", "diversion_file = None
\n", @@ -123,28 +123,28 @@ "save_gpkg = True\n", "
\n", "[origins_destinations]
\n", - "**origins = None
\n", + "origins = None
\n", "destinations = None
\n", "origins_names = None
\n", "destinations_names = None
\n", "id_name_origin_destination = None
\n", "origin_count = None
\n", "origin_out_fraction = None
\n", - "category = category**
\n", + "category = categorybr>\n", "
\n", "[hazard]
\n", - "**hazard_map = max_flood_depth.tif
\n", + "hazard_map = max_flood_depth.tif
\n", "hazard_id = None
\n", "hazard_field_name = waterdepth
\n", "aggregate_wl = max
\n", - "hazard_crs = EPSG:32736**
\n", + "hazard_crs = EPSG:32736br>\n", "
\n", - "*[cleanup]
\n", + "[cleanup]
\n", "snapping_threshold = None
\n", - "segmentation_length = None
\n", + "segmentation_length = 100
\n", "merge_lines = True
\n", "merge_on_id = False
\n", - "cut_at_intersections = False
*" + "cut_at_intersections = False
" ] }, { diff --git a/examples/example_damages_huizinga.ipynb b/examples/example_damages_huizinga.ipynb index b7a754ac9..e811d3580 100644 --- a/examples/example_damages_huizinga.ipynb +++ b/examples/example_damages_huizinga.ipynb @@ -132,7 +132,7 @@ "name = beira
\n", "
\n", "[network]
\n", - "**directed = False
\n", + "directed = False
\n", "source = OSM download
\n", "primary_file = None
\n", "diversion_file = None
\n", @@ -143,28 +143,28 @@ "save_gpkg = True\n", "
\n", "[origins_destinations]
\n", - "**origins = None
\n", + "origins = None
\n", "destinations = None
\n", "origins_names = None
\n", "destinations_names = None
\n", "id_name_origin_destination = None
\n", "origin_count = None
\n", "origin_out_fraction = None
\n", - "category = category**
\n", + "category = categorybr>\n", "
\n", "[hazard]
\n", - "**hazard_map = max_flood_depth.tif
\n", + "hazard_map = max_flood_depth.tif
\n", "hazard_id = None
\n", "hazard_field_name = waterdepth
\n", "aggregate_wl = max
\n", - "hazard_crs = EPSG:32736**
\n", + "hazard_crs = EPSG:32736br>\n", "
\n", - "*[cleanup]
\n", + "[cleanup]
\n", "snapping_threshold = None
\n", - "segmentation_length = None
\n", + "segmentation_length = 100
\n", "merge_lines = True
\n", "merge_on_id = False
\n", - "cut_at_intersections = False
*" + "cut_at_intersections = False
" ] }, { diff --git a/examples/example_damages_manual.ipynb b/examples/example_damages_manual.ipynb index d047f011f..d743464d1 100644 --- a/examples/example_damages_manual.ipynb +++ b/examples/example_damages_manual.ipynb @@ -112,7 +112,7 @@ "name = beira
\n", "
\n", "[network]
\n", - "**directed = False
\n", + "directed = False
\n", "source = OSM download
\n", "primary_file = None
\n", "diversion_file = None
\n", @@ -123,25 +123,25 @@ "save_gpkg = True\n", "
\n", "[origins_destinations]
\n", - "**origins = None
\n", + "origins = None
\n", "destinations = None
\n", "origins_names = None
\n", "destinations_names = None
\n", "id_name_origin_destination = None
\n", "origin_count = None
\n", "origin_out_fraction = None
\n", - "category = category**
\n", + "category = category
\n", "
\n", "[hazard]
\n", - "**hazard_map = max_flood_depth.tif
\n", + "hazard_map = max_flood_depth.tif
\n", "hazard_id = None
\n", "hazard_field_name = waterdepth
\n", "aggregate_wl = max
\n", - "hazard_crs = EPSG:32736**
\n", + "hazard_crs = EPSG:32736
\n", "
\n", "*[cleanup]
\n", "snapping_threshold = None
\n", - "segmentation_length = None
\n", + "segmentation_length = 100
\n", "merge_lines = True
\n", "merge_on_id = False
\n", "cut_at_intersections = False
*" diff --git a/ra2ce/network/network_wrappers/network_wrapper_protocol.py b/ra2ce/network/network_wrappers/network_wrapper_protocol.py index b6159869d..1018cc9bd 100644 --- a/ra2ce/network/network_wrappers/network_wrapper_protocol.py +++ b/ra2ce/network/network_wrappers/network_wrapper_protocol.py @@ -18,14 +18,55 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -from typing import Protocol, runtime_checkable +import logging +import math +from typing import Protocol, runtime_checkable, Union, Optional from geopandas import GeoDataFrame from networkx import MultiGraph +from ra2ce.network.segmentation import Segmentation + @runtime_checkable class NetworkWrapperProtocol(Protocol): + def segment_graph( + self, + edges: GeoDataFrame, + export_link_table: bool, + link_tables: Optional[tuple] = None, + ) -> Union[GeoDataFrame, tuple[GeoDataFrame, tuple]]: + """ + Segments a complex graph based on the given segmentation length. + + Args: + - segmentation_length (Optional[float]): The length to segment the graph edges. If None, no segmentation is applied. + - edges_complex (gpd.GeoDataFrame): The GeoDataFrame containing the complex graph edges. + - crs (str): The coordinate reference system to apply if the CRS is missing after segmentation. + + Returns: + - gpd.GeoDataFrame: The segmented edges_complex GeoDataFrame. + """ + if not math.isnan(self.segmentation_length): + segmentation = Segmentation(edges, self.segmentation_length) + segmented_edges = segmentation.apply_segmentation() + if segmented_edges.crs is None: # The CRS might have disappeared. + segmented_edges.crs = self.crs # set the right CRS + + if export_link_table: + updated_link_tables = segmentation.generate_link_tables() + segmented_edges.drop(columns=["rfid_c"], inplace=True) + segmented_edges.rename(columns={"splt_id": "rfid_c"}, inplace=True) + return segmented_edges, updated_link_tables + return segmented_edges + elif export_link_table and link_tables: + return edges, link_tables + elif export_link_table and not link_tables: + logging.warning("empty link_tables is passed") + return edges, tuple() + else: + return edges + def get_network(self) -> tuple[MultiGraph, GeoDataFrame]: """ Gets a network built within this wrapper instance. No arguments are accepted, the `__init__` method is meant to assign all required attributes for a wrapper. diff --git a/ra2ce/network/network_wrappers/osm_network_wrapper/osm_network_wrapper.py b/ra2ce/network/network_wrappers/osm_network_wrapper/osm_network_wrapper.py index 432e4002d..bf6d8a5c2 100644 --- a/ra2ce/network/network_wrappers/osm_network_wrapper/osm_network_wrapper.py +++ b/ra2ce/network/network_wrappers/osm_network_wrapper/osm_network_wrapper.py @@ -59,7 +59,7 @@ def __init__(self, config_data: NetworkConfigData) -> None: config_data.network.attributes_to_exclude_in_simplification ) self.output_graph_dir = config_data.output_graph_dir - self.graph_crs = config_data.crs + self.crs = config_data.crs # Network self.network_type = config_data.network.network_type @@ -67,6 +67,9 @@ def __init__(self, config_data: NetworkConfigData) -> None: self.polygon_graph = self._get_clean_graph_from_osm(config_data.network.polygon) self.is_directed = config_data.network.directed + # Cleanup + self.segmentation_length = config_data.cleanup.segmentation_length + @classmethod def with_polygon( cls, config_data: NetworkConfigData, polygon: BaseGeometry @@ -153,6 +156,11 @@ def get_network(self) -> tuple[MultiGraph, GeoDataFrame]: edges_complex, _ = nut.graph_to_gdf(graph_complex) logging.info("Finished converting the graph to a geodataframe") + # Segment the complex graph + edges_complex, link_tables = self.segment_graph( + edges_complex, export_link_table=True, link_tables=link_tables + ) + # Save the link tables linking complex and simple IDs self._export_linking_tables(link_tables) @@ -250,7 +258,7 @@ def _download_clean_graph_from_osm( ) ) if "crs" not in _complex_graph.graph.keys(): - _complex_graph.graph["crs"] = self.graph_crs + _complex_graph.graph["crs"] = self.crs self.get_clean_graph(_complex_graph) return _complex_graph diff --git a/ra2ce/network/network_wrappers/shp_network_wrapper.py b/ra2ce/network/network_wrappers/shp_network_wrapper.py index 9e1b1f5af..3d05d201e 100644 --- a/ra2ce/network/network_wrappers/shp_network_wrapper.py +++ b/ra2ce/network/network_wrappers/shp_network_wrapper.py @@ -20,7 +20,6 @@ """ import logging -import math import geopandas as gpd import networkx as nx @@ -32,7 +31,6 @@ from ra2ce.network.network_wrappers.network_wrapper_protocol import ( NetworkWrapperProtocol, ) -from ra2ce.network.segmentation import Segmentation class ShpNetworkWrapper(NetworkWrapperProtocol): @@ -190,9 +188,6 @@ def get_network( graph_complex, edges_complex = self._get_complex_graph_and_edges(edges, id_name) - if not math.isnan(self.segmentation_length): - edges_complex = Segmentation(edges_complex, self.segmentation_length) - edges_complex = edges_complex.apply_segmentation() - if edges_complex.crs is None: # The CRS might have dissapeared. - edges_complex.crs = self.crs # set the right CRS + edges_complex = self.segment_graph(edges_complex, export_link_table=False) + return graph_complex, edges_complex diff --git a/ra2ce/network/network_wrappers/trails_network_wrapper.py b/ra2ce/network/network_wrappers/trails_network_wrapper.py index 16aefe1e2..bb3245421 100644 --- a/ra2ce/network/network_wrappers/trails_network_wrapper.py +++ b/ra2ce/network/network_wrappers/trails_network_wrapper.py @@ -30,7 +30,6 @@ NetworkWrapperProtocol, ) from ra2ce.network.networks_utils import graph_from_gdf -from ra2ce.network.segmentation import Segmentation class TrailsNetworkWrapper(NetworkWrapperProtocol): @@ -90,14 +89,8 @@ def get_network(self) -> tuple[MultiGraph, GeoDataFrame]: "RA2CE will not clean-up your graph, assuming that it is already done in TRAILS" ) - edges_complex = edges - if self.segmentation_length: - logging.info("TRAILS importer: start segmentating graph") - to_segment = Segmentation(edges, self.segmentation_length) - edges_simple_segmented = to_segment.apply_segmentation() - if edges_simple_segmented.crs is None: # The CRS might have disappeared. - edges_simple_segmented.crs = edges.crs # set the right CRS - edges_complex = edges_simple_segmented + # Segment the complex graph + edges_complex = self.segment_graph(edges, export_link_table=False) graph_complex = graph_simple # NOTE THAT DIFFERENCE # BETWEEN SIMPLE AND COMPLEX DOES NOT EXIST WHEN IMPORTING WITH TRAILS diff --git a/ra2ce/network/network_wrappers/vector_network_wrapper.py b/ra2ce/network/network_wrappers/vector_network_wrapper.py index e4ecb0d4f..c8b7fb5b7 100644 --- a/ra2ce/network/network_wrappers/vector_network_wrapper.py +++ b/ra2ce/network/network_wrappers/vector_network_wrapper.py @@ -68,6 +68,7 @@ def __init__( self.output_graph_dir = config_data.output_graph_dir # Cleanup + self.segmentation_length = config_data.cleanup.segmentation_length self.delete_duplicate_nodes = config_data.cleanup.delete_duplicate_nodes def get_network( @@ -111,6 +112,11 @@ def get_network( edges_complex, _ = nut.graph_to_gdf(graph_complex) logging.info("Finished converting the graph to a geodataframe") + # Segment the complex graph + edges_complex, link_tables = self.segment_graph( + edges_complex, export_link_table=True, link_tables=link_tables + ) + # Save the link tables linking complex and simple IDs self._export_linking_tables(link_tables) diff --git a/ra2ce/network/segmentation.py b/ra2ce/network/segmentation.py index b10500e07..9c146bde0 100644 --- a/ra2ce/network/segmentation.py +++ b/ra2ce/network/segmentation.py @@ -21,11 +21,13 @@ import logging from decimal import Decimal +from typing import Union import geopandas as gpd +from geopy import distance from shapely.geometry import LineString, MultiLineString, Point -from ra2ce.network.networks_utils import cut as network_cut +from ra2ce.network.networks_utils import cut as network_cut, line_length class Segmentation: # Todo: more naturally, this would be METHOD of the network class. @@ -34,7 +36,7 @@ class Segmentation: # Todo: more naturally, this would be METHOD of the network Variables: *self.edges_input* (Geopandas DataFrame) : the edges that are to be segmented - *self.segmentation_length* (float) : segmentation lenght in degrees #Todo also in meters? + *self.segmentation_length* (float) : segmentation length in metres *self.save_files* (Boolean) : save segmented graph? Result: @@ -43,16 +45,53 @@ class Segmentation: # Todo: more naturally, this would be METHOD of the network """ - def __init__(self, edges_input, segmentation_length, save_files: bool = False): + def __init__( + self, + edges_input: gpd.GeoDataFrame, + segmentation_length: float, + save_files: bool = False, + ): # General self.edges_input = edges_input # Edges GeoDataFrame self.edges_segmented = ( None # This is where the result will be saved Edges GeoDataframe ) - self.segmentation_length = segmentation_length + self.link_tables = ( + None # will include dictionaries simple_ids to complex and vice-versa + ) + self._get_segmentation_length_from_metre(segmentation_length) self.save_files = save_files # Todo not implemented yet - def apply_segmentation(self): + def _get_segmentation_length_from_metre(self, segmentation_length_in_metre: float): + """ + Convert a length in meters to degrees at a given latitude. + + Args: + - segmentation_length_in_metre: Length in meters to be converted. + """ + + origin = ( + 0, + 0, + ) # reference at the equator. The conversion is more accurate at the equator and changes as you move towards the poles. + + # Calculate the destination point segmentation_length_in_metre north (latitude direction) + try: + distance.geodesic.ELLIPSOID = self.edges_input.crs.ellipsoid.name.replace( + " ", "-" + ) + except: + distance.geodesic.ELLIPSOID = "WGS-84" + + destination_point_lat = distance.geodesic( + meters=segmentation_length_in_metre + ).destination(origin, bearing=0) + + self.segmentation_length = ( + destination_point_lat.latitude - origin[0] + ) # length in degrees + + def apply_segmentation(self) -> gpd.GeoDataFrame: self.cut_gdf() logging.info( "Finished segmenting the geodataframe with split length: {} degree".format( @@ -127,7 +166,7 @@ def split_linestring(self, linestring: LineString, split_length: float): split_length (float): Length by which to split the linestring into equal segments. Returns: - result_list (list): List of LineString objects that all have the same length split_lenght. + result_list (list): list of LineString objects that all have the same length split_lenght. """ n_segments = self.number_of_segments(linestring, split_length) @@ -173,7 +212,7 @@ def cut_gdf(self): for column in columns: data[column] = [] - count = 0 + count = 1 for _, row in gdf.iterrows(): geom = row["geometry"] assert type(geom) == LineString or type(geom) == MultiLineString @@ -183,8 +222,61 @@ def cut_gdf(self): for key, value in row.items(): if key == "geometry": data[key].append(linestring) + elif key == "length": + data[key].append(line_length(linestring, self.edges_input.crs)) + elif key == "time": + data[key].append( + round((row["length"] / row["avgspeed"]) / 1000, 5) + ) else: data[key].append(value) data["splt_id"].append(count) count += 1 self.edges_segmented = gpd.GeoDataFrame(data) + + def generate_link_tables( + self, + ) -> tuple[dict[int, Union[int, list[int]]], dict[int, int]]: + """ + Generate mappings from a GeoDataFrame and two dictionaries. + + Args: + - dicts: Tuple of two dictionaries. + - First dictionary maps rfid to rfid_c (integer or list of integers). + - Second dictionary maps rfid_c to rfid (integer). + + Returns: + - A tuple containing two dictionaries: + - The first dictionary maps rfid to splt_id or list of splt_id. + - The second dictionary maps splt_id to rfid. + """ + # Initialize result dictionaries + splt_id_to_rfid = {} + rfid_to_splt_id = {} + + # Create a mapping from splt_id to rfid + for _, row in self.edges_segmented.iterrows(): + splt_id = row["splt_id"] + rfid = row["rfid"] + splt_id_to_rfid[splt_id] = rfid + + # Create a mapping from rfid to splt_id + for _, row in self.edges_segmented.iterrows(): + splt_id = row["splt_id"] + rfid = row["rfid"] + rfid_c = row["rfid_c"] + + # Determine the expected rfid from rfid_c + if rfid in rfid_to_splt_id: + if isinstance(rfid_to_splt_id[rfid], list): + rfid_to_splt_id[rfid].append(splt_id) + else: + rfid_to_splt_id[rfid] = [rfid_to_splt_id[rfid], splt_id] + else: + rfid_to_splt_id[rfid] = splt_id + + # Sort the dictionaries by their keys + splt_id_to_rfid_sorted = dict(sorted(splt_id_to_rfid.items())) + rfid_to_splt_id_sorted = dict(sorted(rfid_to_splt_id.items())) + self.link_tables = (rfid_to_splt_id_sorted, splt_id_to_rfid_sorted) + return self.link_tables diff --git a/tests/network/network_wrappers/test_osm_network_wrapper.py b/tests/network/network_wrappers/test_osm_network_wrapper.py index ec8b7a388..cba6aac93 100644 --- a/tests/network/network_wrappers/test_osm_network_wrapper.py +++ b/tests/network/network_wrappers/test_osm_network_wrapper.py @@ -40,7 +40,7 @@ def test_initialize_without_graph_crs(self): # 3. Verify final expectations. assert isinstance(_wrapper, OsmNetworkWrapper) assert isinstance(_wrapper, NetworkWrapperProtocol) - assert _wrapper.graph_crs.to_epsg() == 4326 + assert _wrapper.crs.to_epsg() == 4326 @staticmethod def _get_dummy_network_config_data() -> NetworkConfigData: