networkforge 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
networkforge/turns.py ADDED
@@ -0,0 +1,308 @@
1
+ """
2
+ Turn restrictions drawn in the custom layer.
3
+
4
+ A turn restriction is a short line drawn through a junction: it starts
5
+ on the street you arrive on, passes through the junction, and ends on
6
+ the street you leave on. Its `restriction` attribute holds the OSM value
7
+ (no_left_turn, only_straight_on, ...). For one kind of vehicle only, use
8
+ `restriction:hgv` (or :bus, :motorcar, ...) instead; `except` lists
9
+ vehicles the rule doesn't apply to (except=bicycle). These are the tags
10
+ of an OSM turn restriction relation, which is what the line becomes:
11
+
12
+ type=restriction, restriction=<value> [, except=<vehicles>]
13
+ members: from = the street arrived on, via = the junction,
14
+ to = the street left on
15
+
16
+ A line through TWO junctions joined by a street is a "via way"
17
+ restriction: from the street arrived on at the first junction, along
18
+ the street between them (via), onto the street left on at the second -
19
+ e.g. no_u_turn across a dual carriageway.
20
+
21
+ Turn lines are not part of the network themselves. They are matched to
22
+ the finished network (so they work between existing streets, new lines
23
+ and changed streets alike) by resolve_turns(); export writes the
24
+ relations, cutting the streets at the junction where OSM needs a way to
25
+ end there.
26
+ """
27
+
28
+ import logging
29
+ import math
30
+ from dataclasses import dataclass
31
+
32
+ import geopandas as gpd
33
+ import pandas as pd
34
+ import shapely
35
+
36
+ from .errors import InputError, InvalidTagsError
37
+ from .tags import CUSTOM_TAG
38
+ from .validation import _tag_value
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ # The tag that makes a feature a turn restriction, and the vehicle-only
43
+ # variants of it.
44
+ RESTRICTION_KEYS = (
45
+ "restriction", "restriction:hgv", "restriction:bus", "restriction:motorcar",
46
+ "restriction:bicycle",
47
+ )
48
+ EXCEPT_KEY = "except"
49
+ TURN_COLUMNS = (*RESTRICTION_KEYS, EXCEPT_KEY)
50
+
51
+ # OSM's turn restriction values for a from - junction - to restriction.
52
+ TURN_VALUES = {
53
+ "no_left_turn": "left", "no_right_turn": "right", "no_straight_on": "straight",
54
+ "no_u_turn": "u_turn", "only_left_turn": "left", "only_right_turn": "right",
55
+ "only_straight_on": "straight", "only_u_turn": "u_turn",
56
+ }
57
+ EXCEPT_VALUES = {
58
+ "psv", "bus", "bicycle", "hgv", "motorcar", "motorcycle", "moped", "emergency", "taxi",
59
+ }
60
+
61
+ # How far either side of the junction to look at the line to see which
62
+ # streets it comes from and goes to (metres; less if the line is short).
63
+ LOOK_AROUND_M = 3.0
64
+
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Turn:
69
+ """A turn restriction resolved on the network (node ids of the analysis tables)."""
70
+
71
+ feature: object
72
+ via: int # the junction; the first one of a via-way restriction
73
+ from_neighbour: int # the from street is the edge via - from_neighbour
74
+ to_neighbour: int # the to street is the edge via_path[-1] - to_neighbour
75
+ tags: dict
76
+ # The via way's nodes from the first junction to the second; (via,)
77
+ # for a restriction at one junction.
78
+ via_path: tuple[int, ...] = ()
79
+
80
+ @property
81
+ def path(self) -> tuple[int, ...]:
82
+ return self.via_path or (self.via,)
83
+
84
+ @property
85
+ def from_segment(self) -> frozenset:
86
+ return frozenset((self.path[0], self.from_neighbour))
87
+
88
+ @property
89
+ def to_segment(self) -> frozenset:
90
+ return frozenset((self.path[-1], self.to_neighbour))
91
+
92
+ @property
93
+ def via_segments(self) -> list[frozenset]:
94
+ return [frozenset(pair) for pair in zip(self.path, self.path[1:], strict=False)]
95
+
96
+
97
+ def turn_tags(row: pd.Series) -> dict[str, str]:
98
+ """The turn restriction tags a feature carries (empty for a line of the network)."""
99
+ tags = {}
100
+ for key in TURN_COLUMNS:
101
+ if key in row.index:
102
+ text = _tag_value(row[key])
103
+ if text is not None:
104
+ tags[key] = text
105
+ return tags if any(key in tags for key in RESTRICTION_KEYS) else {}
106
+
107
+
108
+ def split_turns(custom_gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
109
+ """
110
+ (lines of the network, turn restriction features) of the custom data.
111
+ The turn columns are taken off the lines: they mean nothing there.
112
+ """
113
+ present = [key for key in TURN_COLUMNS if key in custom_gdf.columns]
114
+ if not present:
115
+ return custom_gdf, custom_gdf.iloc[:0]
116
+ attributes = custom_gdf[present]
117
+ is_turn = attributes.apply(lambda row: bool(turn_tags(row)), axis=1).astype(bool)
118
+ return custom_gdf[~is_turn].drop(columns=present), custom_gdf[is_turn]
119
+
120
+
121
+ def check_turn_tags(turns_gdf: gpd.GeoDataFrame, strict: bool = True) -> None:
122
+ """Restriction values and exceptions must be OSM's; the line a single line."""
123
+ issues = []
124
+ for feature, row in turns_gdf.iterrows():
125
+ tags = turn_tags(row)
126
+ for key in RESTRICTION_KEYS:
127
+ value = tags.get(key)
128
+ if value is not None and value not in TURN_VALUES:
129
+ issues.append((feature, f"{key}={value!r} is not an OSM turn restriction "
130
+ f"(e.g. {', '.join(sorted(TURN_VALUES)[:3])})"))
131
+ exceptions = tags.get(EXCEPT_KEY)
132
+ if exceptions is not None:
133
+ unknown = [v for v in exceptions.split(";") if v.strip() not in EXCEPT_VALUES]
134
+ if unknown:
135
+ issues.append((feature, f"except={exceptions!r}: {', '.join(unknown)} is not "
136
+ f"one of {', '.join(sorted(EXCEPT_VALUES))}"))
137
+ if row.geometry is not None and row.geometry.geom_type != "LineString":
138
+ issues.append((feature, "a turn restriction must be one line, not "
139
+ f"{row.geometry.geom_type}"))
140
+ if issues:
141
+ error = InvalidTagsError(issues)
142
+ if strict:
143
+ raise error
144
+ log.warning("strict=False, continuing anyway: %s", error,
145
+ extra={"features": [i["feature"] for i in error.issues]})
146
+
147
+
148
+ def resolve_turns(
149
+ turns_gdf: gpd.GeoDataFrame,
150
+ nodes_gdf: gpd.GeoDataFrame,
151
+ edges_gdf: gpd.GeoDataFrame,
152
+ tolerance: float,
153
+ strict: bool = True,
154
+ ) -> list[Turn]:
155
+ """
156
+ Match each turn line to the network: the one junction it passes
157
+ through, the street it arrives on and the street it leaves on.
158
+
159
+ turns_gdf in the network's CRS. Raises (or, not strict, warns and
160
+ skips) for lines that pass through no junction, more than one, or
161
+ don't run along a street on either side of it.
162
+ """
163
+ if turns_gdf.empty:
164
+ return []
165
+
166
+ # Each node's neighbours, and the edge geometry towards each one.
167
+ neighbours: dict[int, dict[int, object]] = {}
168
+ for u, v, geometry in zip(edges_gdf["u"].astype("int64"), edges_gdf["v"].astype("int64"),
169
+ edges_gdf.geometry, strict=True):
170
+ neighbours.setdefault(int(u), {})[int(v)] = geometry
171
+ neighbours.setdefault(int(v), {})[int(u)] = geometry
172
+ node_ids = nodes_gdf.index.to_numpy()
173
+ tree = shapely.STRtree(nodes_gdf.geometry.to_numpy())
174
+
175
+ turns, issues = [], []
176
+ for feature, row in turns_gdf.iterrows():
177
+ line = row.geometry
178
+ if line is None or line.geom_type != "LineString":
179
+ continue # check_turn_tags reported it
180
+ start, end = shapely.get_point(line, 0), shapely.get_point(line, -1)
181
+
182
+ # Junctions on the line: nodes where three or more streets meet,
183
+ # between its two ends, in the order the line passes them.
184
+ near = node_ids[tree.query(line, predicate="dwithin", distance=tolerance)]
185
+ junctions = sorted(
186
+ (int(node) for node in near
187
+ if len(neighbours.get(int(node), {})) >= 3
188
+ and nodes_gdf.geometry.loc[node].distance(start) > tolerance
189
+ and nodes_gdf.geometry.loc[node].distance(end) > tolerance),
190
+ key=lambda node: line.project(nodes_gdf.geometry.loc[node]),
191
+ )
192
+ if not junctions:
193
+ issues.append((feature, "doesn't pass through a junction: draw it from the "
194
+ "street you arrive on, through the junction, onto the "
195
+ "street you leave on"))
196
+ continue
197
+ if len(junctions) > 2:
198
+ issues.append((feature, f"passes through {len(junctions)} junctions: draw it "
199
+ "through the one the restriction is at, or two joined "
200
+ "by a street (a via-way restriction)"))
201
+ continue
202
+ path = (junctions[0],)
203
+ if len(junctions) == 2:
204
+ path = _street_between(junctions[0], junctions[1], line, neighbours, nodes_gdf,
205
+ tolerance)
206
+ if path is None:
207
+ issues.append((feature, "passes through 2 junctions that no street along it "
208
+ "joins: draw it through one junction, or two joined "
209
+ "by a street (a via-way restriction)"))
210
+ continue
211
+ first, last = (nodes_gdf.geometry.loc[node] for node in (path[0], path[-1]))
212
+
213
+ # Which streets: the ones the line runs along just before the
214
+ # (first) junction and just after the (last) one.
215
+ position_in, position_out = line.project(first), line.project(last)
216
+ step = min(LOOK_AROUND_M, position_in / 2, (line.length - position_out) / 2)
217
+ before = line.interpolate(position_in - step)
218
+ after = line.interpolate(position_out + step)
219
+
220
+ def nearest(probe, node, leave_out=None):
221
+ streets = {other: geometry for other, geometry in neighbours[node].items()
222
+ if other != leave_out}
223
+ if not streets:
224
+ return None
225
+ other, geometry = min(streets.items(), key=lambda item: item[1].distance(probe))
226
+ return other if geometry.distance(probe) <= tolerance else None
227
+
228
+ via_way = len(path) > 1
229
+ came_from = nearest(before, path[0], path[1] if via_way else None)
230
+ goes_to = nearest(after, path[-1], path[-2] if via_way else None)
231
+ if came_from is None or goes_to is None:
232
+ issues.append((feature, "doesn't follow a street on both sides of the junction "
233
+ f"(within {tolerance} m): start and end it on the streets"))
234
+ continue
235
+
236
+ tags = turn_tags(row)
237
+ same_street = not via_way and came_from == goes_to
238
+ _warn_if_drawn_differently(feature, tags, before, first, last, after, same_street)
239
+ turns.append(Turn(feature, path[0], came_from, goes_to, tags,
240
+ via_path=path if via_way else ()))
241
+
242
+ if issues:
243
+ error = InputError(
244
+ "Some turn restrictions can't be placed - "
245
+ + "; ".join(f"feature {feature}: {message}" for feature, message in issues) + ".",
246
+ guide="turn-restrictions",
247
+ issues=[{"feature": feature, "message": message} for feature, message in issues],
248
+ )
249
+ if strict:
250
+ raise error
251
+ log.warning("strict=False, skipping them: %s", error,
252
+ extra={"features": [feature for feature, _ in issues]})
253
+
254
+ log.info("Turn restrictions: %d", len(turns))
255
+ return turns
256
+
257
+
258
+ def _street_between(a, b, line, neighbours, nodes_gdf, tolerance) -> tuple[int, ...] | None:
259
+ """
260
+ The nodes of the street from junction a to junction b along `line`
261
+ (edges whose middle lies on it, through no other junction), or None.
262
+ """
263
+ def along(node) -> float:
264
+ return line.project(nodes_gdf.geometry.loc[node])
265
+
266
+ path = [a]
267
+ while path[-1] != b:
268
+ node = path[-1]
269
+ onward = [other for other, geometry in neighbours[node].items()
270
+ if along(other) > along(node)
271
+ and geometry.interpolate(0.5, normalized=True).distance(line) <= tolerance]
272
+ if len(onward) != 1 or (onward[0] != b and len(neighbours[onward[0]]) >= 3):
273
+ return None
274
+ path.append(onward[0])
275
+ return tuple(path)
276
+
277
+
278
+ def _warn_if_drawn_differently(feature, tags, before, first, last, after, same_street) -> None:
279
+ """Warn when the drawn turn isn't the one the value names (no_left_turn drawn right)."""
280
+ value = next(tags[key] for key in RESTRICTION_KEYS if key in tags)
281
+ meant = TURN_VALUES.get(value)
282
+ drawn = "u_turn" if same_street else _turn_direction(before, first, last, after)
283
+ if meant is not None and drawn != meant:
284
+ log.warning("Turn restriction %s is drawn as a %s but tagged %s=%s - check the "
285
+ "line's direction (it goes from the street you arrive on to the one "
286
+ "you leave on).", feature, drawn.replace("_", "-"),
287
+ next(key for key in RESTRICTION_KEYS if key in tags), value,
288
+ extra={"features": [feature]})
289
+
290
+
291
+ def _turn_direction(before, first, last, after) -> str:
292
+ """
293
+ left / right / straight / u_turn, from the line's direction arriving
294
+ at the (first) junction and leaving the (last) one.
295
+ """
296
+ heading_in = math.atan2(first.y - before.y, first.x - before.x)
297
+ heading_out = math.atan2(after.y - last.y, after.x - last.x)
298
+ angle = math.degrees((heading_out - heading_in + math.pi) % (2 * math.pi) - math.pi)
299
+ if abs(angle) < 35:
300
+ return "straight"
301
+ if abs(angle) > 150:
302
+ return "u_turn"
303
+ return "left" if angle > 0 else "right"
304
+
305
+
306
+ def relation_tags(turn: Turn) -> dict[str, str]:
307
+ """The relation's tags: type=restriction plus the feature's own turn tags."""
308
+ return {"type": "restriction", **turn.tags, CUSTOM_TAG: "yes"}