From 430f269a22999ee2e1450a0e63c76af663ec5e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Tue, 2 Dec 2025 14:28:46 +0100 Subject: [PATCH 1/7] Add drop_detours opteration --- src/dhnx/gistools/connect_points.py | 2 ++ src/dhnx/gistools/geometry_operations.py | 33 +++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/dhnx/gistools/connect_points.py b/src/dhnx/gistools/connect_points.py index 41348329..b4332d43 100644 --- a/src/dhnx/gistools/connect_points.py +++ b/src/dhnx/gistools/connect_points.py @@ -724,6 +724,8 @@ def process_geometry( # Convert all MultiLineStrings to LineStrings check_geometry_type(lines_all, types=["LineString"]) + go.drop_detours(lines_all) + # ## check for near points go.check_double_points(points_all, id_column="id_full") diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py index d7736c95..c318b413 100644 --- a/src/dhnx/gistools/geometry_operations.py +++ b/src/dhnx/gistools/geometry_operations.py @@ -15,10 +15,11 @@ try: import geopandas as gpd - except ImportError: print("Need to install geopandas to process geometry data.") +import networkx as nx + try: import shapely from shapely import wkt @@ -629,6 +630,36 @@ def drop_parallel_lines(gdf): return gdf +def drop_detours(lines_all): + """Keep only lines that are the shortest connections between two points. + """ + graph = nx.Graph() + graph.add_edges_from([ + (a, b, {"weight": length}) + for a,b,length + in zip( + lines_all["from_node"], + lines_all["to_node"], + lines_all["length"], + ) + ]) + + # identify rows contaning "detour" lines + lines_to_drop = [] + for row, line in lines_all.iterrows(): + if line["length"] > nx.shortest_path_length( + graph, + source=line["from_node"], + target=line["to_node"], + weight="weight", + ): + lines_to_drop.append(row) + + lines_all.drop(lines_to_drop, inplace=True) + + return lines_all + + def check_crs(gdf, crs=4647, force_2d=True): """Convert CRS to EPSG:4647 - ETRS89 / UTM zone 32N (zE-N). From 581149b33c020064a8068dc7f834ec9ad5f8af67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Fri, 5 Dec 2025 21:17:25 +0100 Subject: [PATCH 2/7] Add pure networkx based simplification functions --- src/dhnx/gistools/geometry_operations.py | 37 +++++++++++++++++++++++- tests/test_gistools.py | 29 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py index c318b413..59279c4e 100644 --- a/src/dhnx/gistools/geometry_operations.py +++ b/src/dhnx/gistools/geometry_operations.py @@ -636,7 +636,7 @@ def drop_detours(lines_all): graph = nx.Graph() graph.add_edges_from([ (a, b, {"weight": length}) - for a,b,length + for a, b, length in zip( lines_all["from_node"], lines_all["to_node"], @@ -660,6 +660,41 @@ def drop_detours(lines_all): return lines_all +def _drop_detours( + graph: nx.Graph, +) -> nx.Graph: + for (source, target) in list(graph.edges()): + edge_weight = graph[source][target]["weight"] + if edge_weight > nx.shortest_path_length( + graph, + source=source, + target=target, + weight="weight", + ): + graph.remove_edge(source, target) + + return graph + + +def _weld_edges( + graph: nx.Graph, +) -> nx.Graph: + for node in list(graph.nodes()): + if graph.degree(node) == 2: + edges = list(graph.edges(node)) + edge_weight = ( + graph[edges[0][0]][edges[0][1]]["weight"] + + graph[edges[1][0]][edges[1][1]]["weight"] + ) + graph.add_edge( + edges[0][1], + edges[1][1], + weight=edge_weight, + ) + graph.remove_node(node) + return graph + + def check_crs(gdf, crs=4647, force_2d=True): """Convert CRS to EPSG:4647 - ETRS89 / UTM zone 32N (zE-N). diff --git a/tests/test_gistools.py b/tests/test_gistools.py index 908b5558..d45e0848 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -15,6 +15,8 @@ from shapely.geometry import MultiLineString from shapely.geometry import Point +import networkx as nx + from dhnx.gistools import connect_points as cp from dhnx.gistools import geometry_operations as go @@ -35,3 +37,30 @@ def test_split_linestring(): results = go.split_multilinestr_to_linestr(gdf_line) assert gdf_line.geometry.length.sum() == results.length.sum() assert len(results.index) == 7 + +def test_drop_detours(): + edgelist = [ + (0, 1, {"weight": 5}), + (1, 2, {"weight": 2}), + (2, 0, {"weight": 2}), + ] + graph = nx.Graph(edgelist) + + go._drop_detours(graph) + + # longer connection with direct edge has been dropped + assert list(graph.edges()) == [(0, 2), (1, 2)] + + +def test_weld_edges(): + edgelist = [ + (0, 1, {"weight": 5}), + (1, 2, {"weight": 2}), + (2, 3, {"weight": 2}), + ] + graph = nx.Graph(edgelist) + go._weld_edges(graph) + + # connection has been merged + assert list(graph.edges()) == [(0, 3)] + assert graph[0][3]["weight"] == 5 + 2 + 2 From 812761199783ed209df6b77eba94a361b607a2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Mon, 8 Dec 2025 10:47:53 +0100 Subject: [PATCH 3/7] Improve graph simplification functions The newly introduced functions now return if the graph was updated. The change from _weld_edges to _remove_useless_forks also removes dead ends. Note that they require an attribute "type" to make the funtion work. --- src/dhnx/gistools/geometry_operations.py | 66 ++++++++++++++++++------ tests/test_gistools.py | 26 +++++++--- 2 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py index 59279c4e..d14f737b 100644 --- a/src/dhnx/gistools/geometry_operations.py +++ b/src/dhnx/gistools/geometry_operations.py @@ -660,9 +660,29 @@ def drop_detours(lines_all): return lines_all +def simplify_graph( + graph: nx.Graph, +) -> bool: + graph_was_updated = False + graph_needs_iteration = True + while graph_needs_iteration: + graph_needs_iteration = False + detours_dropped = _drop_detours(graph) + forks_removed = _remove_useless_forks(graph) + + # if something changed, we need a new iteration + graph_needs_iteration = detours_dropped or forks_removed + + # graph was updated if new iteration is needed or it was updated before + graph_was_updated = graph_needs_iteration or graph_was_updated + + return graph_was_updated + + def _drop_detours( graph: nx.Graph, -) -> nx.Graph: +) -> bool: + graph_was_updated = False for (source, target) in list(graph.edges()): edge_weight = graph[source][target]["weight"] if edge_weight > nx.shortest_path_length( @@ -672,27 +692,39 @@ def _drop_detours( weight="weight", ): graph.remove_edge(source, target) + graph_was_updated = True - return graph + return graph_was_updated -def _weld_edges( +def _remove_useless_forks( graph: nx.Graph, -) -> nx.Graph: +) -> bool: + """Removes forks that only connect two lines as well as dead ends. + + You need to iterate to also remove forks + that connected dead ends to meaningful lines. + """ + graph_was_updated = False for node in list(graph.nodes()): - if graph.degree(node) == 2: - edges = list(graph.edges(node)) - edge_weight = ( - graph[edges[0][0]][edges[0][1]]["weight"] - + graph[edges[1][0]][edges[1][1]]["weight"] - ) - graph.add_edge( - edges[0][1], - edges[1][1], - weight=edge_weight, - ) - graph.remove_node(node) - return graph + if graph.nodes[node]['type'] == "fork": + if graph.degree(node) == 1: + graph.remove_node(node) + graph_was_updated = True + elif graph.degree(node) == 2: + edges = list(graph.edges(node)) + edge_weight = ( + graph[edges[0][0]][edges[0][1]]["weight"] + + graph[edges[1][0]][edges[1][1]]["weight"] + ) + graph.add_edge( + edges[0][1], + edges[1][1], + weight=edge_weight, + ) + graph.remove_node(node) + graph_was_updated = True + return graph_was_updated def check_crs(gdf, crs=4647, force_2d=True): diff --git a/tests/test_gistools.py b/tests/test_gistools.py index d45e0848..77392a42 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -52,15 +52,29 @@ def test_drop_detours(): assert list(graph.edges()) == [(0, 2), (1, 2)] -def test_weld_edges(): +def test_remove_useless_forks(): + nodelist = [ + (0, {"type": "fork"}), + (1, {"type": "fork"}), + (2, {"type": "fork"}), + (3, {"type": "fork"}), + (4, {"type": "fork"}), + ("s1", {"type": "sink"}), + ("s2", {"type": "sink"}), + ] edgelist = [ + ("s1", 0, {"weight": 1}), (0, 1, {"weight": 5}), (1, 2, {"weight": 2}), + (2, 4, {"weight": 7}), (2, 3, {"weight": 2}), + ("s2", 3, {"weight": 1}), ] - graph = nx.Graph(edgelist) - go._weld_edges(graph) + graph = nx.Graph() + graph.add_nodes_from(nodelist) + graph.add_edges_from(edgelist) + graph_was_updated = go.simplify_graph(graph) - # connection has been merged - assert list(graph.edges()) == [(0, 3)] - assert graph[0][3]["weight"] == 5 + 2 + 2 + assert graph_was_updated + assert list(graph.edges()) == [("s1", "s2")] + assert graph["s1"]["s2"]["weight"] == 5 + 2 + 2 + 1 + 1 From 4b7c171d9ff98ff3fc5efdb6e445bb180ecef8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Mon, 8 Dec 2025 13:38:11 +0100 Subject: [PATCH 4/7] Add missing blank line (Black) --- tests/test_gistools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_gistools.py b/tests/test_gistools.py index 77392a42..72c6595f 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -38,6 +38,7 @@ def test_split_linestring(): assert gdf_line.geometry.length.sum() == results.length.sum() assert len(results.index) == 7 + def test_drop_detours(): edgelist = [ (0, 1, {"weight": 5}), From ab8eda3de2302363fdee5eec78f7bfd37010fb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Mon, 8 Dec 2025 13:40:54 +0100 Subject: [PATCH 5/7] Fix import order in test_gistools --- tests/test_gistools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_gistools.py b/tests/test_gistools.py index 72c6595f..500ea9f5 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -11,12 +11,11 @@ """ import geopandas as gpd +import networkx as nx from shapely.geometry import LineString from shapely.geometry import MultiLineString from shapely.geometry import Point -import networkx as nx - from dhnx.gistools import connect_points as cp from dhnx.gistools import geometry_operations as go From 12086f867eb61abccd49233cd647e1438c1d496c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Mon, 15 Dec 2025 20:33:29 +0100 Subject: [PATCH 6/7] Add via bookkeeping for merging edges This will help to reconstruct the original paths, e.g. for plotting. --- src/dhnx/gistools/geometry_operations.py | 6 ++++++ tests/test_gistools.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py index d14f737b..65dd547e 100644 --- a/src/dhnx/gistools/geometry_operations.py +++ b/src/dhnx/gistools/geometry_operations.py @@ -717,10 +717,16 @@ def _remove_useless_forks( graph[edges[0][0]][edges[0][1]]["weight"] + graph[edges[1][0]][edges[1][1]]["weight"] ) + via = ( + graph[edges[0][0]][edges[0][1]].get("via", [edges[0]]) + + graph[edges[1][0]][edges[1][1]].get("via", [edges[1]]) + ) + graph.add_edge( edges[0][1], edges[1][1], weight=edge_weight, + via=via, ) graph.remove_node(node) graph_was_updated = True diff --git a/tests/test_gistools.py b/tests/test_gistools.py index 500ea9f5..a5f2ad61 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -77,4 +77,5 @@ def test_remove_useless_forks(): assert graph_was_updated assert list(graph.edges()) == [("s1", "s2")] - assert graph["s1"]["s2"]["weight"] == 5 + 2 + 2 + 1 + 1 + assert len(graph["s1"]["s2"]["via"]) == 5 + assert graph["s1"]["s2"]["weight"] == 1 + 5 + 2 + 2 + 1 From 38d96f43e80f236a7e86b6707549c94c8cb18165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Sch=C3=B6nfeldt?= Date: Tue, 3 Feb 2026 14:04:28 +0100 Subject: [PATCH 7/7] Add distance calcuation in gistools --- src/dhnx/gistools/geometry_operations.py | 47 ++++++++++++++++++ tests/test_gistools.py | 63 +++++++++++++++++------- 2 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py index d14f737b..817749b3 100644 --- a/src/dhnx/gistools/geometry_operations.py +++ b/src/dhnx/gistools/geometry_operations.py @@ -13,6 +13,8 @@ SPDX-License-Identifier: MIT """ +import math + try: import geopandas as gpd except ImportError: @@ -679,6 +681,51 @@ def simplify_graph( return graph_was_updated +def annotate_distance( + graph: nx.Graph, +) -> None: + """Inefficient algorithm that doesthe job.""" + for source in list(graph.nodes()): + source_type = graph.nodes[source]["type"] + for target in list(graph.nodes()): + target_type = graph.nodes[target]["type"] + if source_type != target_type: + source_distance = graph.nodes[source].get("distance", math.inf) + target_distance = graph.nodes[target].get("distance", math.inf) + path_length = nx.shortest_path_length( + graph, + source=source, + target=target, + weight="weight", + ) + graph.nodes[source]["distance"] = min( + path_length, source_distance + ) + graph.nodes[target]["distance"] = min( + path_length, target_distance + ) + + +def longest_distance( + graph: nx.Graph, +) -> float: + _longest_distance = 0.0 + for source in list(graph.nodes()): + if graph.nodes[source]['type'] != "fork": + for target in list(graph.nodes()): + if graph.nodes[target]['type'] != "fork": + _longest_distance = max( + _longest_distance, + nx.shortest_path_length( + graph, + source=source, + target=target, + weight="weight", + ), + ) + return _longest_distance + + def _drop_detours( graph: nx.Graph, ) -> bool: diff --git a/tests/test_gistools.py b/tests/test_gistools.py index 500ea9f5..0f3d8c22 100644 --- a/tests/test_gistools.py +++ b/tests/test_gistools.py @@ -51,25 +51,54 @@ def test_drop_detours(): # longer connection with direct edge has been dropped assert list(graph.edges()) == [(0, 2), (1, 2)] +nodelist = [ + (0, {"type": "fork"}), + (1, {"type": "fork"}), + (2, {"type": "fork"}), + (3, {"type": "fork"}), + (4, {"type": "fork"}), + ("s1", {"type": "sink"}), + ("s2", {"type": "sink"}), +] +edgelist = [ + ("s1", 0, {"weight": 1}), + (0, 1, {"weight": 5}), + (1, 2, {"weight": 2}), + (2, 4, {"weight": 7}), + (2, 3, {"weight": 2}), + ("s2", 3, {"weight": 1}), +] + + +def test_annotate_distance(): + graph = nx.Graph() + graph.add_nodes_from(nodelist) + graph.add_edges_from(edgelist) + go.annotate_distance(graph) + + distances = { + 0: 1, + 1: 5, + 2: 3, + 3: 1, + 4: 10, + "s1": 1, + "s2": 1, + } + node_list = list(graph.nodes()) + for node in node_list: + assert graph.nodes[node]["distance"] == distances[node] + + +def test_longest_distance(): + graph = nx.Graph() + graph.add_nodes_from(nodelist) + graph.add_edges_from(edgelist) + + assert go.longest_distance(graph) == 5 + 2 + 2 + 1 + 1 + def test_remove_useless_forks(): - nodelist = [ - (0, {"type": "fork"}), - (1, {"type": "fork"}), - (2, {"type": "fork"}), - (3, {"type": "fork"}), - (4, {"type": "fork"}), - ("s1", {"type": "sink"}), - ("s2", {"type": "sink"}), - ] - edgelist = [ - ("s1", 0, {"weight": 1}), - (0, 1, {"weight": 5}), - (1, 2, {"weight": 2}), - (2, 4, {"weight": 7}), - (2, 3, {"weight": 2}), - ("s2", 3, {"weight": 1}), - ] graph = nx.Graph() graph.add_nodes_from(nodelist) graph.add_edges_from(edgelist)