diff --git a/src/topupopt/data/gis/modify.py b/src/topupopt/data/gis/modify.py index 6e39c278725ae15f478320106562dfab77487102..a00087bf4d0138969b95628b134740765e85ccdc 100644 --- a/src/topupopt/data/gis/modify.py +++ b/src/topupopt/data/gis/modify.py @@ -28,7 +28,7 @@ from .calculate import update_street_count, edge_lengths # ***************************************************************************** -def remove_self_loops(network: nx.MultiDiGraph): +def remove_self_loops(network: nx.MultiDiGraph) -> list: """ Removes self-loops from a directed graph defined in a MultiDiGraph object. @@ -39,11 +39,11 @@ def remove_self_loops(network: nx.MultiDiGraph): Returns ------- - generator-expression + list The keys to the nodes whose self-loops were removed. """ - + selflooping_nodes = list(gis_iden.find_self_loops(network)) for node in selflooping_nodes: while network.has_edge(u=node, v=node): @@ -526,12 +526,7 @@ def remove_longer_parallel_edges( A list of the edges removed. """ - - # redundancy: having more than one edge between two nodes - # solution: remove the longest one, leave the shortest one - - # for each node pair - + edges_removed = [] for node_one in network.nodes(): for node_two in network.nodes(): @@ -547,28 +542,24 @@ def remove_longer_parallel_edges( list_edges = gis_iden.get_edges_from_a_to_b( network, node_start=node_one, node_end=node_two ) - # if none exist, skip if len(list_edges) == 0: continue - - # otherwise, find out which is the shortest one + # otherwise, sort them by distance (lower distances first) sorted_edges = sorted( (network.edges[edge_key][osm.KEY_OSMNX_LENGTH], edge_key) for edge_key in list_edges ) - + # remove all but the shortest edge network.remove_edges_from(edge_tuple[1] for edge_tuple in sorted_edges[1:]) - + # update the list of edges removed edges_removed.extend(edge_tuple[1] for edge_tuple in sorted_edges[1:]) - + # return statement return edges_removed - # ***************************************************************************** # ***************************************************************************** - def merge_points_into_linestring( line: LineString, points: tuple or list, diff --git a/src/topupopt/data/gis/utils.py b/src/topupopt/data/gis/utils.py index dbc5c1b6bb861dd5db638a73e8a91ef1a98833ef..2ac442f3b434ec4485ac767e2ae68a7f06b3bf87 100644 --- a/src/topupopt/data/gis/utils.py +++ b/src/topupopt/data/gis/utils.py @@ -742,8 +742,10 @@ def simplify_network( network: MultiDiGraph, protected_nodes: list, dead_end_probing_depth: int = 5, - remove_opposite_parallel_edges: bool = False, + remove_opposite_parallel_edges: bool = True, update_street_count_per_node: bool = True, + transform_roundabouts: bool = False, + max_number_iterations: int = 5, **roundabout_conditions ): """ @@ -755,13 +757,18 @@ def simplify_network( The object describing the network. protected_nodes : list A list with the keys for the nodes that must be preserved. - dead_end_probing_depth: int - The maximum number of nodes a dead end can have to be detectable. + dead_end_probing_depth : int, optional + The maximum number of nodes a dead end can have to be detectable. The + default is 5. remove_opposite_parallel_edges : bool, optional If True, longer parallel edges in opposite directions are also removed. - The default is False. + The default is True. update_street_count_per_node : bool, optional If True, updates the street count on each node. The default is True. + transform_roundabouts : bool, optional + If True, roundabouts are to be transformed. The default is False. + max_number_iterations : int, optional + The maximum number of iterations. The default is 5. **roundabout_conditions : keyword and value pairs The conditions used to define which roundabouts are simplified. @@ -770,27 +777,45 @@ def simplify_network( None. """ - - # 1) remove dead ends (tends to create straight paths) - gis_mod.remove_dead_ends( - network, protected_nodes, max_iterations=dead_end_probing_depth - ) - # 2) remove longer parallel edges (tends to create straight paths) - gis_mod.remove_longer_parallel_edges( - network, ignore_edge_directions=remove_opposite_parallel_edges - ) - # 3) remove self loops (tends to create straight paths and dead ends) - gis_mod.remove_self_loops(network) - # 4) join segments (can create self-loops) - simplifiable_paths = gis_iden.find_simplifiable_paths(network, protected_nodes) - for path in simplifiable_paths: - gis_mod.replace_path(network, path) - # 4) remove self loops (tends to create straight paths and dead ends) - gis_mod.remove_self_loops(network) - # 5) transform roundabouts into crossroads (can create straight paths) - list_roundabout_nodes = gis_iden.find_roundabouts(network, **roundabout_conditions) - gis_mod.transform_roundabouts_into_crossroads(network, list_roundabout_nodes) - # 6) update street count + + iteration_counter = 0 + while iteration_counter < max_number_iterations: + # remove self loops (tends to create straight paths and dead ends) + looping_node_keys = gis_mod.remove_self_loops(network) + # remove longer parallel edges (can create dead ends and straight paths) + edge_keys = gis_mod.remove_longer_parallel_edges( + network, ignore_edge_directions=remove_opposite_parallel_edges + ) + # remove dead ends (tends to create straight paths) + node_keys = gis_mod.remove_dead_ends( + network, protected_nodes, max_iterations=dead_end_probing_depth + ) + # join segments (can create self-loops) + paths = gis_iden.find_simplifiable_paths( + network, + protected_nodes, + consider_reversed_edges=True) + for path in paths: + gis_mod.replace_path(network, path) + # update iteration counter + iteration_counter += 1 + # check if it makes sense to break out of the loop + if (len(looping_node_keys) == 0 and + len(edge_keys) == 0 and + len(paths) == 0 and + len(node_keys) == 0): + # no self-loops + # no edges were removed + # no paths were simplified + # no nodes were removed + break + + # transform roundabouts into crossroads (can create straight paths) + if transform_roundabouts: + list_roundabout_nodes = gis_iden.find_roundabouts(network, **roundabout_conditions) + gis_mod.transform_roundabouts_into_crossroads(network, list_roundabout_nodes) + + # update street count if update_street_count_per_node: gis_calc.update_street_count(network) diff --git a/tests/test_data_buildings_dk.py b/tests/test_data_buildings_dk.py index 280c990245eaca8f2b3766e9c88d911eccc26168..5f0e69375c36f2f61629978294dc73345c212ab1 100644 --- a/tests/test_data_buildings_dk.py +++ b/tests/test_data_buildings_dk.py @@ -58,7 +58,7 @@ class TestDataBuildingsDK: gdf_buildings=gdf_buildings, number_intervals=number_time_intervals, time_interval_durations=intraperiod_time_interval_duration, - bdg_min_to_max_ratio={ + bdg_ratio_min_max={ index: min_to_max_ratio for index in gdf_buildings.index }, bdg_specific_demand={ @@ -79,7 +79,7 @@ class TestDataBuildingsDK: gdf_buildings=gdf_buildings, number_intervals=number_time_intervals, time_interval_durations=intraperiod_time_interval_duration, - bdg_min_to_max_ratio={ + bdg_ratio_min_max={ index: min_to_max_ratio for index in gdf_buildings.index }, bdg_specific_demand={ diff --git a/tests/test_gis_modify.py b/tests/test_gis_modify.py index f9d9e3903da87cb0347b8fabd73f5b3558a3ca8a..aab6fa64e7dbddbe92fb879a415317250574f659 100644 --- a/tests/test_gis_modify.py +++ b/tests/test_gis_modify.py @@ -1170,16 +1170,34 @@ class TestGisModify: custom_filter='["highway"~"residential|tertiary|unclassified|service"]', truncate_by_edge=True, ) - + # copy the network _net = network.copy() + # identify all the dead end nodes + dead_end_nodes = tuple( + node_key + for node_key in _net.nodes() + if len(tuple(gis_iden.neighbours(network, node_key))) == 1 + ) + share_keeper_dead_end_nodes = 0.25 + keeper_dead_end_nodes = list(dead_end_nodes[0:round(len(dead_end_nodes)*share_keeper_dead_end_nodes)]) max_iterations = 5 nodes_removed = gis_mod.remove_dead_ends( - _net, keepers=[], max_iterations=max_iterations - ) - - # TODO: perform checks + _net, keepers=keeper_dead_end_nodes, max_iterations=max_iterations + ) + # the nodes removed cannot include keeper nodes + for node_key in nodes_removed: + assert node_key not in keeper_dead_end_nodes + + for node_key in _net.nodes(): + if node_key in keeper_dead_end_nodes: + # dead end node that was not meant to be removed + continue + # any other node cannot be a dead end node + assert node_key not in dead_end_nodes + # ensure that they have at least two neighbours + assert len(tuple(gis_iden.neighbours(_net, node_key))) >= 2 # ************************************************************************* # ************************************************************************* diff --git a/tests/test_gis_utils.py b/tests/test_gis_utils.py index 0fb9059f663f86fb4d40346d9378d4e2372b4410..ffd77d739b30e05c7d8e8e31d3b627899455e434 100644 --- a/tests/test_gis_utils.py +++ b/tests/test_gis_utils.py @@ -2115,7 +2115,361 @@ class TestGisUtils: # ************************************************************************* # ************************************************************************* - def test_simplifying_graph(self): + def test_simplify_network_manual(self): + + # seed number + seed_number = random.randint(0, int(1e5)) + random.seed(seed_number) + # define the network graph + network = nx.MultiDiGraph() + + network.add_edges_from([ + (1,2,0,{ + _osm.KEY_OSMNX_OSMID: 1, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (1,2,0,{ + _osm.KEY_OSMNX_OSMID: 2, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (2,3,0,{ + _osm.KEY_OSMNX_OSMID: 3, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (3,3,0,{ + _osm.KEY_OSMNX_OSMID: 4, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (3,4,0,{ + _osm.KEY_OSMNX_OSMID: 5, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (2,5,0,{ + _osm.KEY_OSMNX_OSMID: 6, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + + (5,6,0,{ + _osm.KEY_OSMNX_OSMID: 7, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (6,7,0,{ + _osm.KEY_OSMNX_OSMID: 8, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (5,8,0,{ + _osm.KEY_OSMNX_OSMID: 1, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + + (8,9,0,{ + _osm.KEY_OSMNX_OSMID: 9, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + + (9,10,0,{ + _osm.KEY_OSMNX_OSMID: 10, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + + (10,12,0,{ + _osm.KEY_OSMNX_OSMID: 11, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (12,11,0,{ + _osm.KEY_OSMNX_OSMID: 12, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (8,11,0,{ + _osm.KEY_OSMNX_OSMID: 13, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (8,13,0,{ + _osm.KEY_OSMNX_OSMID: 14, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (13,14,0,{ + _osm.KEY_OSMNX_OSMID: 15, + _osm.KEY_OSMNX_LENGTH: 3, + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (14,14,0,{ + _osm.KEY_OSMNX_OSMID: 16, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (14,15,0,{ + _osm.KEY_OSMNX_OSMID: 17, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (13,16,0,{ + _osm.KEY_OSMNX_OSMID: 18, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (16,17,0,{ + _osm.KEY_OSMNX_OSMID: 19, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (17,18,0,{ + _osm.KEY_OSMNX_OSMID: 20, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (16,19,0,{ + _osm.KEY_OSMNX_OSMID: 21, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (19,20,0,{ + _osm.KEY_OSMNX_OSMID: 22, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (20,22,0,{ + _osm.KEY_OSMNX_OSMID: 23, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (19,21,0,{ + _osm.KEY_OSMNX_OSMID: 24, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (21,22,0,{ + _osm.KEY_OSMNX_OSMID: 25, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (21,19,0,{ + _osm.KEY_OSMNX_OSMID: 26, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (21,19,1,{ + _osm.KEY_OSMNX_OSMID: 27, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (19,22,0,{ + _osm.KEY_OSMNX_OSMID: 28, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (19,23,0,{ + _osm.KEY_OSMNX_OSMID: 29, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (23,27,0,{ + _osm.KEY_OSMNX_OSMID: 30, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (27,24,0,{ + _osm.KEY_OSMNX_OSMID: 31, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (27,25,0,{ + _osm.KEY_OSMNX_OSMID: 32, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + + (27,26,0,{ + _osm.KEY_OSMNX_OSMID: 33, + _osm.KEY_OSMNX_LENGTH: random.random(), + _osm.KEY_OSMNX_REVERSED: False, + _osm.KEY_OSMNX_ONEWAY: False, + }, + ), + ]) + # add node data + for i, node_key in enumerate(network.nodes()): + network.nodes[node_key][_osm.KEY_OSMNX_X] = i+random.random() + network.nodes[node_key][_osm.KEY_OSMNX_Y] = i+1+random.random() + # define the keepers + protected_nodes = [1, 2, 5, 8, 13, 16, 19, 4, 18, 22, 23, 24, 25, 26] + # identify the original nodes + node_keys = tuple(network.nodes()) + # try simplifying it + gis_utils.simplify_network(network, protected_nodes) + # protected nodes must still exist + for node_key in protected_nodes: + assert network.has_node(node_key) + # there cannot be any self loop on the network + assert len(tuple(gis_iden.find_self_loops(network))) == 0 + # there cannot be any simplifiable path + assert len(gis_iden.find_simplifiable_paths(network, protected_nodes)) == 0 + # there cannot be any parallel arcs + for edge_key in network.edges(keys=True): + assert len(tuple(gis_iden.get_edges_between_two_nodes(network, *edge_key[0:2]))) == 1 + # there cannot be dead ends + for node_key in node_keys: + # nodes that no longer exist cannot be checked + if not network.has_node(node_key): + continue + # some nodes can be dead ends, if they are protected + if node_key in protected_nodes: + continue + # all other nodes cannot be dead ends + assert len(tuple(gis_iden.neighbours(network, node_key))) >= 1 + + # ********************************************************************* + # ********************************************************************* + + # final configuration + # there should be an edge between 2 and 4 + assert network.has_edge(2, 4) or network.has_edge(4, 2) + # node 3 should not exist + assert not network.has_node(3) + # nodes 6 and 7 should not exist + assert not network.has_node(6) and not network.has_node(7) + # nodes 9, 10, 11, and 12 should not exist either + assert ( + not network.has_node(9) and + not network.has_node(10) and + not network.has_node(11) and + not network.has_node(12) + ) + # nodes 14 and 15 should not exist + assert not network.has_node(14) and not network.has_node(15) + + # there should be an edge between 16 and 18 + assert network.has_edge(16, 18) or network.has_edge(18, 16) + # node 17 should not exist + assert not network.has_node(17) + + # there should be an edge between 19 and 22 + assert network.has_edge(19, 22) or network.has_edge(22, 19) + # node 20 should not exist + assert not network.has_node(20) + # node 21 should not exist + assert not network.has_node(21) + + # node 27 should exist + assert network.has_node(27) + + # ************************************************************************* + # ************************************************************************* + + def test_simplify_network_osmnx(self): # get a network network = ox.graph_from_point( (55.71654, 9.11728), @@ -2124,22 +2478,37 @@ class TestGisUtils: truncate_by_edge=True, ) # protect some nodes - number_nodes_protected = 4 node_keys = tuple(network.nodes()) + share_nodes_protected = 0.25 + number_nodes_protected = round(len(node_keys)*share_nodes_protected) protected_nodes = [ node_keys[random.randint(0, len(node_keys) - 1)] for i in range(number_nodes_protected) ] # try simplifying it gis_utils.simplify_network(network, protected_nodes) - # TODO: verify the changes - # confirm that the protected nodes still exist and have the same attr. - # for node_key in protected_nodes: - # assert network.has_node(node_key) - # TODO: check if [335762579, 335762585, 1785975921, 360252989, 335762632, 335762579] is a path - - # ************************************************************************* - # ************************************************************************* + # protected nodes must still exist + for node_key in protected_nodes: + assert network.has_node(node_key) + # there cannot be any self loop on the network + assert len(tuple(gis_iden.find_self_loops(network))) == 0 + # there cannot be any simplifiable path + assert len(gis_iden.find_simplifiable_paths(network, protected_nodes)) == 0 + # there cannot be any parallel arcs + for edge_key in network.edges(keys=True): + assert len(tuple(gis_iden.get_edges_between_two_nodes(network, *edge_key[0:2]))) == 1 + # there cannot be dead ends + for node_key in node_keys: + # nodes that no longer exist cannot be checked + if not network.has_node(node_key): + continue + # some nodes can be dead ends, if they are protected + if node_key in protected_nodes: + continue + # all other nodes cannot be dead ends + assert len(tuple(gis_iden.neighbours(network, node_key))) >= 1 + + # TODO: verify that the graph did not change in any meaningful way # *****************************************************************************