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/__init__.py +48 -0
- networkforge/__main__.py +7 -0
- networkforge/cli.py +681 -0
- networkforge/coverage.py +176 -0
- networkforge/edits.py +460 -0
- networkforge/errors.py +87 -0
- networkforge/export.py +1056 -0
- networkforge/inputs.py +256 -0
- networkforge/modes.py +336 -0
- networkforge/network.py +703 -0
- networkforge/osm.py +450 -0
- networkforge/paths.py +113 -0
- networkforge/points.py +290 -0
- networkforge/presets.py +50 -0
- networkforge/projection.py +81 -0
- networkforge/tags.py +132 -0
- networkforge/topology.py +1086 -0
- networkforge/traffic.py +905 -0
- networkforge/turns.py +308 -0
- networkforge/validation.py +642 -0
- networkforge-1.4.1.dist-info/METADATA +336 -0
- networkforge-1.4.1.dist-info/RECORD +25 -0
- networkforge-1.4.1.dist-info/WHEEL +4 -0
- networkforge-1.4.1.dist-info/entry_points.txt +2 -0
- networkforge-1.4.1.dist-info/licenses/LICENSE +674 -0
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"}
|