Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create nodes at lines intersections #51

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[tool.pytest.ini_options]
pythonpath = [
"src",
]
133 changes: 127 additions & 6 deletions src/snkit/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
shape,
mapping,
)
from shapely.ops import split, linemerge
from shapely.ops import split, linemerge, unary_union

from collections import Counter

# optional progress bars
if "SNKIT_PROGRESS" in os.environ and os.environ["SNKIT_PROGRESS"] in ("1", "TRUE"):
Expand Down Expand Up @@ -290,12 +292,48 @@ def split_edges_at_nodes(network, tolerance=1e-9):

# combine dfs
edges = pandas.concat(split_edges, axis=0)
# reset index and drop
edges = edges.reset_index().drop("index", axis=1)
# return new network with split edges

return Network(nodes=network.nodes, edges=edges)


def split_edges_at_intersections(network, tolerance=1e-9):
"""Split network edges where they intersect line geometries"""
split_edges = []
split_points = []
for edge in tqdm(
network.edges.itertuples(index=False), desc="split", total=len(network.edges)
):
# note: the symmetry of intersection is not exploited here.
# (If A intersects B, then B intersects A)
# since edges are not modified within the loop, this has just
# potential performance consequences.

hits_points = edges_intersecting_points(edge.geometry, network.edges, tolerance)

# store the split edges and intersection points
split_points.extend(hits_points)
hits_points = MultiPoint(hits_points)
edges = split_edge_at_points(edge, hits_points, tolerance)
split_edges.append(edges)

# add the (potentially) split edges
edges = pandas.concat(split_edges, axis=0)
edges = edges.reset_index().drop("index", axis=1)

# combine the original nodes with the new intersection nodes
# dropping the duplicates.
# note: there are at least duplicates from above since intersections
# are checked twice
# note: intersection nodes are appended, and if any duplicates, the
# original counterparts are kept.
nodes = GeoDataFrame(geometry=split_points)
nodes = pandas.concat([network.nodes, nodes], axis=0).drop_duplicates()
nodes = nodes.reset_index().drop("index", axis=1)

return Network(nodes=nodes, edges=edges)


def link_nodes_to_edges_within(network, distance, condition=None, tolerance=1e-9):
"""Link nodes to all edges within some distance"""
new_node_geoms = []
Expand Down Expand Up @@ -542,6 +580,43 @@ def edges_within(point, edges, distance):
return d_within(point, edges, distance)


def edges_intersecting_points(line, edges, tolerance=1e-9):
"""Return intersection points of intersecting edges"""
hits = edges_intersecting(line, edges, tolerance)

hits_points = []
for hit in hits.geometry:
# first extract the actual intersections from the hits
# for being new geometrical objects, they are not in the sindex
intersection = line.intersection(hit)
# if the line is not simple, there is a self-crossing point
# (note that it will always interact with itself)
# note that __eq__ is used on purpose instead of equals()
# this is stricter: for geometries constructed in the same way
# it makes sense since the sindex is used here
if line == hit and not line.is_simple:
# there is not built-in way to find self-crossing points
# duplicated points after unary_union are the intersections
intersection = unary_union(line)
segments_coordinates = []
for seg in intersection.geoms:
segments_coordinates.extend(list(seg.coords))
intersection = [
Point(p) for p, c in Counter(segments_coordinates).items() if c > 1
]
intersection = MultiPoint(intersection)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might have sense to have a dedicated helper function here...


# then extract the intersection points
hits_points = intersection_endpoints(intersection, hits_points)

return hits_points


def edges_intersecting(line, edges, tolerance=1e-9):
"""Find edges intersecting line"""
return intersects(line, edges, tolerance)


def nodes_intersecting(line, nodes, tolerance=1e-9):
"""Find nodes intersecting line"""
return intersects(line, nodes, tolerance)
Expand Down Expand Up @@ -590,12 +665,45 @@ def line_endpoints(line):
return start, end


def intersection_endpoints(geom, output=None):
"""Return the points from an intersection geometry

It extracts the starting and ending points of intersection
geometries recursively and appends them to `output`.
This doesn't handle polygons or collections of polygons.
"""
if output is None:
output = []

geom_type = geom.geom_type
if geom.is_empty:
pass
elif geom_type == "Point":
output.append(geom)
elif geom_type == "LineString":
start = Point(geom.coords[0])
end = Point(geom.coords[-1])
output.append(start)
output.append(end)
# recursively for collections of geometries
# note that there is no shared inheritance relationship
elif (
geom_type == "MultiPoint"
or geom_type == "MultiLineString"
or geom_type == "GeometryCollection"
):
for geom_ in geom.geoms:
output = intersection_endpoints(geom_, output)

return output


def split_edge_at_points(edge, points, tolerance=1e-9):
"""Split edge at point/multipoint"""
try:
segments = split_line(edge.geometry, points, tolerance)
except ValueError:
# if splitting fails, e.g. becuase points is empty GeometryCollection
# if splitting fails, e.g. because points is empty GeometryCollection
segments = [edge.geometry]
edges = GeoDataFrame([edge] * len(segments))
edges.geometry = segments
Expand All @@ -605,6 +713,13 @@ def split_edge_at_points(edge, points, tolerance=1e-9):
def split_line(line, points, tolerance=1e-9):
"""Split line at point or multipoint, within some tolerance"""
to_split = snap_line(line, points, tolerance)
# when the splitter is a self-intersection point, shapely splits in
# two parts only in a semi-arbitrary way, see the related question:
# https://gis.stackexchange.com/questions/435879/python-shapely-split-a-complex-line-at-self-intersections?noredirect=1#comment711214_435879
# checking only that the line is complex might not be enough
# but the difference operation is useless in the worst case
if not to_split.is_simple:
to_split = to_split.difference(points)
return list(split(to_split, points).geoms)


Expand Down Expand Up @@ -696,14 +811,20 @@ def to_networkx(network, directed=False, weight_col=None):
G.add_nodes_from(network.nodes.id.to_list())

# add nodal positions from geom
for node_id, x, y in zip(network.nodes.id, network.nodes.geometry.x, network.nodes.geometry.y):
for node_id, x, y in zip(
network.nodes.id, network.nodes.geometry.x, network.nodes.geometry.y
):
G.nodes[node_id]["pos"] = (x, y)

# get edges from network data
if weight_col is None:
# default to geometry length
edges_as_list = list(
zip(network.edges.from_id, network.edges.to_id, network.edges.geometry.length)
zip(
network.edges.from_id,
network.edges.to_id,
network.edges.geometry.length,
)
)
else:
edges_as_list = list(
Expand Down
Loading