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/points.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Points in the custom layer: barriers, traffic signals, crossings ...
|
|
3
|
+
|
|
4
|
+
A point feature with node tags (barrier=bollard, highway=traffic_signals,
|
|
5
|
+
crossing=zebra, ...) puts those tags on the network at that place, as an
|
|
6
|
+
OSM node of the street:
|
|
7
|
+
|
|
8
|
+
- within snap_tolerance of a node (a junction, or any node of an
|
|
9
|
+
existing street): that node gets the tags, e.g. signals at an
|
|
10
|
+
existing junction;
|
|
11
|
+
- otherwise on the nearest street within snap_tolerance - new line,
|
|
12
|
+
existing street or changed street alike: the street is cut there
|
|
13
|
+
and a new node carries the tags, e.g. a bollard mid-block.
|
|
14
|
+
|
|
15
|
+
`remove_tags` deletes tags from the node the point is on (an existing
|
|
16
|
+
bollard taken out: remove_tags=barrier). Points are placed on the
|
|
17
|
+
FINISHED network (place_points), like turn restrictions, and never touch
|
|
18
|
+
the "before" network.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
import geopandas as gpd
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
import shapely
|
|
27
|
+
import shapely.ops
|
|
28
|
+
|
|
29
|
+
from .errors import InputError, InvalidTagsError
|
|
30
|
+
from .tags import (
|
|
31
|
+
CUSTOM_COLUMN,
|
|
32
|
+
MODIFIED_COLUMN,
|
|
33
|
+
NODE_TAGS,
|
|
34
|
+
REMOVE_TAGS_COLUMN,
|
|
35
|
+
ROUTING_NODE_KEYS,
|
|
36
|
+
)
|
|
37
|
+
from .validation import ACCESS_KEYS, ACCESS_VALUES, _tag_value, warn_about_unknown_values
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
POINT_TYPES = {"Point", "MultiPoint"}
|
|
42
|
+
|
|
43
|
+
# Tags a point may put on a node: those routers read on nodes.
|
|
44
|
+
POINT_TAG_KEYS = tuple(dict.fromkeys((*ROUTING_NODE_KEYS, "traffic_calming", "kerb")))
|
|
45
|
+
|
|
46
|
+
# `highway` values for nodes (a way value such as "residential" on a
|
|
47
|
+
# point is a mistake), and the barriers routers know. Unknown barrier
|
|
48
|
+
# and crossing values only warn, as for way tags.
|
|
49
|
+
NODE_HIGHWAYS = {
|
|
50
|
+
"traffic_signals", "crossing", "stop", "give_way", "mini_roundabout", "turning_circle",
|
|
51
|
+
"turning_loop", "motorway_junction", "speed_camera", "elevator", "milestone",
|
|
52
|
+
"passing_place", "bus_stop", "street_lamp", "toll_gantry", "ford", "traffic_mirror",
|
|
53
|
+
}
|
|
54
|
+
NODE_KNOWN_VALUES = {
|
|
55
|
+
"barrier": {
|
|
56
|
+
"bollard", "gate", "lift_gate", "swing_gate", "sliding_gate", "wicket_gate", "block",
|
|
57
|
+
"cycle_barrier", "kissing_gate", "stile", "turnstile", "full-height_turnstile",
|
|
58
|
+
"chain", "rope", "bus_trap", "sump_buster", "toll_booth", "border_control", "entrance",
|
|
59
|
+
"height_restrictor", "jersey_barrier", "kerb", "spikes", "motorcycle_barrier", "debris",
|
|
60
|
+
"log", "hampshire_gate", "sally_port", "planter", "yes",
|
|
61
|
+
},
|
|
62
|
+
"crossing": {
|
|
63
|
+
"traffic_signals", "uncontrolled", "marked", "unmarked", "zebra", "no", "informal",
|
|
64
|
+
"island", "toucan", "pelican", "pegasus", "puffin",
|
|
65
|
+
},
|
|
66
|
+
"railway": {"level_crossing", "crossing"},
|
|
67
|
+
"traffic_calming": {
|
|
68
|
+
"bump", "hump", "table", "cushion", "chicane", "choker", "island", "rumble_strip",
|
|
69
|
+
"dip", "mini_bumps", "yes",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Two streets closer than this to a point (m) are both "under" it: a
|
|
74
|
+
# bridge over a street, say. `layer` on the point picks one.
|
|
75
|
+
SAME_PLACE = 0.01
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def split_points(custom_gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
79
|
+
"""(line features, point features, one row per point) of cleaned custom data."""
|
|
80
|
+
is_point = custom_gdf.geometry.geom_type.isin(POINT_TYPES).to_numpy()
|
|
81
|
+
if not is_point.any():
|
|
82
|
+
return custom_gdf, custom_gdf.iloc[:0]
|
|
83
|
+
points = custom_gdf[is_point].explode(index_parts=False)
|
|
84
|
+
return custom_gdf[~is_point], points
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def point_tags(row: pd.Series) -> dict[str, str]:
|
|
88
|
+
"""The node tags a point feature carries."""
|
|
89
|
+
tags = {}
|
|
90
|
+
for key in POINT_TAG_KEYS:
|
|
91
|
+
if key in row.index:
|
|
92
|
+
text = _tag_value(row[key])
|
|
93
|
+
if text is not None:
|
|
94
|
+
tags[key] = text
|
|
95
|
+
return tags
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def tags_to_remove(row: pd.Series) -> list[str]:
|
|
99
|
+
"""The keys a point's `remove_tags` lists (separated by ; or ,)."""
|
|
100
|
+
text = _tag_value(row.get(REMOVE_TAGS_COLUMN)) or ""
|
|
101
|
+
return list(dict.fromkeys(key.strip() for key in text.replace(",", ";").split(";")
|
|
102
|
+
if key.strip()))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_point_tags(points_gdf: gpd.GeoDataFrame, strict: bool = True) -> None:
|
|
106
|
+
"""Each point must set or delete node tags, with values OSM knows."""
|
|
107
|
+
issues, unknown = [], {}
|
|
108
|
+
for feature, row in points_gdf.drop(columns=points_gdf.geometry.name).iterrows():
|
|
109
|
+
tags, removing = point_tags(row), tags_to_remove(row)
|
|
110
|
+
if not tags and not removing:
|
|
111
|
+
issues.append((feature, "a point needs node tags, e.g. barrier=bollard or "
|
|
112
|
+
"highway=traffic_signals"))
|
|
113
|
+
highway = tags.get("highway")
|
|
114
|
+
if highway is not None and highway not in NODE_HIGHWAYS:
|
|
115
|
+
issues.append((feature, f"highway={highway!r} is not a value for a point "
|
|
116
|
+
"(e.g. traffic_signals, crossing, stop, give_way)"))
|
|
117
|
+
for key in ACCESS_KEYS:
|
|
118
|
+
value = tags.get(key)
|
|
119
|
+
if value is not None and value not in ACCESS_VALUES:
|
|
120
|
+
issues.append((feature, f"{key}={value!r} is not a valid OSM access value"))
|
|
121
|
+
if "barrier" not in tags and any(key in tags for key in ACCESS_KEYS):
|
|
122
|
+
log.warning("Point %s has access tags but no barrier: routers ignore access on a "
|
|
123
|
+
"node without one (add e.g. barrier=gate).", feature,
|
|
124
|
+
extra={"features": [feature]})
|
|
125
|
+
for key in sorted(set(removing) & tags.keys()):
|
|
126
|
+
issues.append((feature, f"{key} is both given a value ({tags[key]!r}) and listed "
|
|
127
|
+
f"in {REMOVE_TAGS_COLUMN}"))
|
|
128
|
+
for key, values in NODE_KNOWN_VALUES.items():
|
|
129
|
+
if key in tags and tags[key] not in values:
|
|
130
|
+
unknown.setdefault((key, tags[key]), []).append(feature)
|
|
131
|
+
|
|
132
|
+
warn_about_unknown_values(unknown, NODE_KNOWN_VALUES)
|
|
133
|
+
if issues:
|
|
134
|
+
error = InvalidTagsError(issues)
|
|
135
|
+
if strict:
|
|
136
|
+
raise error
|
|
137
|
+
log.warning("strict=False, continuing anyway: %s", error,
|
|
138
|
+
extra={"features": [i["feature"] for i in error.issues]})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def place_points(
|
|
142
|
+
points_gdf: gpd.GeoDataFrame,
|
|
143
|
+
nodes_gdf: gpd.GeoDataFrame,
|
|
144
|
+
edges_gdf: gpd.GeoDataFrame,
|
|
145
|
+
tolerance: float,
|
|
146
|
+
first_new_id: int,
|
|
147
|
+
existing_tags: dict[int, dict[str, str]],
|
|
148
|
+
strict: bool = True,
|
|
149
|
+
osm_nodes=(),
|
|
150
|
+
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, dict[int, dict[str, str | None]]]:
|
|
151
|
+
"""
|
|
152
|
+
Put each point's tags on the network (see the module docstring).
|
|
153
|
+
|
|
154
|
+
Returns (nodes, edges, changes): copies of the tables, with streets
|
|
155
|
+
cut where a point needed a node of its own, and {node id: {key:
|
|
156
|
+
value, or None to delete}}. existing_tags: the tags nodes already
|
|
157
|
+
have (OSM's), to tell deletions that change nothing. Each node a
|
|
158
|
+
point tags is marked in the nodes table, as edges are: `modified` =
|
|
159
|
+
yes on an OpenStreetMap node (one of `osm_nodes`), `custom` = yes on
|
|
160
|
+
a node of the user's own (a new one, or on a custom line).
|
|
161
|
+
points_gdf in the network's CRS.
|
|
162
|
+
"""
|
|
163
|
+
nodes, edges = nodes_gdf.copy(), edges_gdf.copy()
|
|
164
|
+
changes: dict[int, dict[str, str | None]] = {}
|
|
165
|
+
issues = []
|
|
166
|
+
next_id = first_new_id
|
|
167
|
+
|
|
168
|
+
for feature, row in points_gdf.iterrows():
|
|
169
|
+
point = row.geometry
|
|
170
|
+
tags, removing = point_tags(row), tags_to_remove(row)
|
|
171
|
+
|
|
172
|
+
# A node within reach: the nearest.
|
|
173
|
+
distances = nodes.geometry.distance(point)
|
|
174
|
+
node = None
|
|
175
|
+
if len(distances) and distances.min() <= tolerance:
|
|
176
|
+
node = int(distances.idxmin())
|
|
177
|
+
elif removing:
|
|
178
|
+
issues.append((feature, f"no node within {tolerance} m to delete tags from: put the "
|
|
179
|
+
"point on the node that has them"))
|
|
180
|
+
continue
|
|
181
|
+
else:
|
|
182
|
+
node, nodes, edges, problem = _cut_street(
|
|
183
|
+
point, row, nodes, edges, tolerance, next_id)
|
|
184
|
+
if problem:
|
|
185
|
+
issues.append((feature, problem))
|
|
186
|
+
continue
|
|
187
|
+
next_id += 1
|
|
188
|
+
|
|
189
|
+
change = changes.setdefault(node, {})
|
|
190
|
+
current = {**existing_tags.get(node, {}), **change}
|
|
191
|
+
for key, value in tags.items():
|
|
192
|
+
if key in change and change[key] != value:
|
|
193
|
+
issues.append((feature, f"another point sets {key}={change[key]!r} on the same "
|
|
194
|
+
f"node ({node})"))
|
|
195
|
+
change[key] = value
|
|
196
|
+
change |= {key: None for key in removing if key in current}
|
|
197
|
+
if not change:
|
|
198
|
+
log.warning("Point %s changes nothing: node %d has none of the tags in %s.",
|
|
199
|
+
feature, node, REMOVE_TAGS_COLUMN, extra={"features": [feature]})
|
|
200
|
+
changes.pop(node)
|
|
201
|
+
continue
|
|
202
|
+
for key, value in change.items():
|
|
203
|
+
if key in NODE_TAGS:
|
|
204
|
+
if key not in nodes.columns:
|
|
205
|
+
nodes[key] = None
|
|
206
|
+
nodes[key] = nodes[key].astype(object)
|
|
207
|
+
nodes.at[node, key] = value
|
|
208
|
+
|
|
209
|
+
if issues:
|
|
210
|
+
error = InputError(
|
|
211
|
+
"Some points can't be placed - "
|
|
212
|
+
+ "; ".join(f"feature {feature}: {message}" for feature, message in issues) + ".",
|
|
213
|
+
guide="points-barriers-signals-crossings",
|
|
214
|
+
issues=[{"feature": feature, "message": message} for feature, message in issues],
|
|
215
|
+
)
|
|
216
|
+
if strict:
|
|
217
|
+
raise error
|
|
218
|
+
log.warning("strict=False, skipping them: %s", error,
|
|
219
|
+
extra={"features": [feature for feature, _ in issues]})
|
|
220
|
+
|
|
221
|
+
osm_nodes = set(osm_nodes)
|
|
222
|
+
for node in changes:
|
|
223
|
+
column = MODIFIED_COLUMN if node in osm_nodes else CUSTOM_COLUMN
|
|
224
|
+
if column not in nodes.columns:
|
|
225
|
+
nodes[column] = None
|
|
226
|
+
nodes[column] = nodes[column].astype(object)
|
|
227
|
+
nodes.at[node, column] = "yes"
|
|
228
|
+
|
|
229
|
+
log.info("Points: %d node(s) tagged", len(changes))
|
|
230
|
+
return nodes, edges, changes
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _cut_street(point, row, nodes, edges, tolerance, node_id):
|
|
234
|
+
"""
|
|
235
|
+
Cut the street nearest `point` (within tolerance) there, with a new
|
|
236
|
+
node `node_id`. Returns (node id, nodes, edges, problem or None).
|
|
237
|
+
"""
|
|
238
|
+
geometries = edges.geometry.to_numpy()
|
|
239
|
+
distances = shapely.distance(geometries, point)
|
|
240
|
+
near = distances <= tolerance
|
|
241
|
+
if not near.any():
|
|
242
|
+
return None, nodes, edges, f"not within {tolerance} m of a street"
|
|
243
|
+
|
|
244
|
+
# The street(s) right under the point; `layer` on the point picks one.
|
|
245
|
+
under = near & (distances <= distances[near].min() + SAME_PLACE)
|
|
246
|
+
layer = _tag_value(row.get("layer"))
|
|
247
|
+
if layer is not None and "layer" in edges:
|
|
248
|
+
on_layer = (edges["layer"].map(_tag_value).fillna("0") == layer).to_numpy()
|
|
249
|
+
under &= on_layer
|
|
250
|
+
if not under.any():
|
|
251
|
+
return None, nodes, edges, f"no street on layer {layer} within {tolerance} m"
|
|
252
|
+
pairs = {frozenset((u, v)) for u, v in zip(edges["u"][under], edges["v"][under], strict=True)}
|
|
253
|
+
if len(pairs) > 1:
|
|
254
|
+
return None, nodes, edges, (f"lies on {len(pairs)} streets (a bridge over a street?): "
|
|
255
|
+
"give the point the street's layer")
|
|
256
|
+
|
|
257
|
+
# The street, and OSMnx's copy of it drawn the other way.
|
|
258
|
+
nearest = np.flatnonzero(under)[0]
|
|
259
|
+
street = geometries[nearest]
|
|
260
|
+
on_street = shapely.line_interpolate_point(street, street.project(point))
|
|
261
|
+
pair = frozenset((edges["u"].iloc[nearest], edges["v"].iloc[nearest]))
|
|
262
|
+
same = np.array([frozenset((u, v)) == pair and shapely.equals(g, street)
|
|
263
|
+
for u, v, g in zip(edges["u"], edges["v"], geometries, strict=True)])
|
|
264
|
+
|
|
265
|
+
pieces = []
|
|
266
|
+
for _, edge in edges[same].iterrows():
|
|
267
|
+
line = edge.geometry
|
|
268
|
+
at = line.project(on_street)
|
|
269
|
+
if at <= 0 or at >= line.length: # pragma: no cover - the ends are nodes, found first
|
|
270
|
+
return None, nodes, edges, "lies on a node's place"
|
|
271
|
+
first, second = edge.copy(), edge.copy()
|
|
272
|
+
first.geometry = shapely.ops.substring(line, 0, at)
|
|
273
|
+
second.geometry = shapely.ops.substring(line, at, line.length)
|
|
274
|
+
first["v"] = second["u"] = node_id
|
|
275
|
+
for piece in (first, second):
|
|
276
|
+
if "length" in piece and piece["length"] == piece["length"]:
|
|
277
|
+
piece["length"] = edge["length"] * piece.geometry.length / line.length
|
|
278
|
+
if "split" in edges:
|
|
279
|
+
piece["split"] = "yes"
|
|
280
|
+
pieces += [first, second]
|
|
281
|
+
|
|
282
|
+
edges = pd.concat([edges[~same], gpd.GeoDataFrame(pieces, crs=edges.crs)],
|
|
283
|
+
ignore_index=True)
|
|
284
|
+
edges = gpd.GeoDataFrame(edges, geometry="geometry", crs=nodes.crs)
|
|
285
|
+
new_node = gpd.GeoDataFrame(
|
|
286
|
+
geometry=[on_street], index=pd.Index([node_id], name=nodes.index.name), crs=nodes.crs)
|
|
287
|
+
if "street_count" in nodes:
|
|
288
|
+
new_node["street_count"] = 2
|
|
289
|
+
nodes = pd.concat([nodes, new_node])
|
|
290
|
+
return node_id, nodes, edges, None
|
networkforge/presets.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ready-made tag sets for common kinds of custom infrastructure.
|
|
3
|
+
|
|
4
|
+
A preset is the "blanket" option: pick one and every custom feature
|
|
5
|
+
gets its tags (filling gaps, or replacing values with overwrite=True).
|
|
6
|
+
Tags given alongside a preset (network_tags) take priority over it,
|
|
7
|
+
which is how you add a speed limit:
|
|
8
|
+
|
|
9
|
+
build_network(bbox, custom, preset="primary_road",
|
|
10
|
+
network_tags={"maxspeed": "40 mph"})
|
|
11
|
+
|
|
12
|
+
Presets deliberately don't set maxspeed: limits differ by country, and
|
|
13
|
+
without one the fixed DEFAULT_SPEEDS_KPH for the road type apply.
|
|
14
|
+
|
|
15
|
+
See docs/network-rules.md for what each preset means and who can use it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .errors import InputError
|
|
19
|
+
|
|
20
|
+
PRESETS: dict[str, dict[str, str]] = {
|
|
21
|
+
# Roads (cars, bikes and pedestrians). A single drawn line carries
|
|
22
|
+
# traffic both ways; add oneway=yes for one direction only.
|
|
23
|
+
"motorway": {"highway": "motorway", "oneway": "no"},
|
|
24
|
+
"trunk_road": {"highway": "trunk", "oneway": "no"},
|
|
25
|
+
"primary_road": {"highway": "primary", "oneway": "no"},
|
|
26
|
+
"secondary_road": {"highway": "secondary", "oneway": "no"},
|
|
27
|
+
"tertiary_road": {"highway": "tertiary", "oneway": "no"},
|
|
28
|
+
"residential_street": {"highway": "residential", "oneway": "no"},
|
|
29
|
+
"service_road": {"highway": "service", "oneway": "no"},
|
|
30
|
+
# Active travel.
|
|
31
|
+
"cycleway": {"highway": "cycleway", "oneway": "no"},
|
|
32
|
+
"footpath": {"highway": "footway"},
|
|
33
|
+
"shared_path": {"highway": "path", "bicycle": "designated", "foot": "designated"},
|
|
34
|
+
# Cycling on a pedestrian street has to be allowed explicitly; leave
|
|
35
|
+
# the preset out and tag highway=pedestrian for walking only.
|
|
36
|
+
"pedestrian_street": {"highway": "pedestrian", "bicycle": "yes"},
|
|
37
|
+
# A road closed to motor traffic (e.g. a filtered street or bus gate).
|
|
38
|
+
"car_free_street": {"highway": "residential", "motor_vehicle": "no", "oneway": "no"},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def preset_tags(name: str) -> dict[str, str]:
|
|
43
|
+
"""The tags for preset `name` (a copy, safe to modify)."""
|
|
44
|
+
try:
|
|
45
|
+
return dict(PRESETS[name])
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise InputError(
|
|
48
|
+
f"Unknown preset {name!r}. Choose one of: {', '.join(PRESETS)}",
|
|
49
|
+
guide="presets",
|
|
50
|
+
) from None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import geopandas as gpd
|
|
2
|
+
from pyproj import CRS
|
|
3
|
+
|
|
4
|
+
from .errors import InputError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_analysis_crs(
|
|
8
|
+
bbox: gpd.GeoDataFrame,
|
|
9
|
+
) -> CRS:
|
|
10
|
+
"""Determine the CRS to use for spatial analysis."""
|
|
11
|
+
|
|
12
|
+
if bbox.crs is None:
|
|
13
|
+
raise InputError(
|
|
14
|
+
"Bounding box must have a CRS assigned."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
bbox_crs = CRS.from_user_input(bbox.crs)
|
|
18
|
+
|
|
19
|
+
if bbox_crs.is_projected and _is_local(bbox_crs):
|
|
20
|
+
return bbox_crs
|
|
21
|
+
|
|
22
|
+
bbox_wgs84 = bbox.to_crs("EPSG:4326")
|
|
23
|
+
|
|
24
|
+
centroid = bbox_wgs84.geometry.union_all().centroid
|
|
25
|
+
|
|
26
|
+
longitude = centroid.x
|
|
27
|
+
latitude = centroid.y
|
|
28
|
+
|
|
29
|
+
zone = int((longitude + 180) // 6) + 1
|
|
30
|
+
|
|
31
|
+
epsg = (
|
|
32
|
+
32600 + zone
|
|
33
|
+
if latitude >= 0
|
|
34
|
+
else 32700 + zone
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return CRS.from_epsg(epsg)
|
|
38
|
+
|
|
39
|
+
def _is_local(crs: CRS) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
True for a projected CRS made for one region (a UTM zone, a national
|
|
42
|
+
grid), where a metre on the map is a metre on the ground. Not for
|
|
43
|
+
world-wide projections such as Web Mercator (EPSG:3857, the default
|
|
44
|
+
of many web maps), which stretches distances by 1/cos(latitude):
|
|
45
|
+
lengths and the snap distance would be wrong by 80% in Scotland.
|
|
46
|
+
Nor for one measured in feet: everything here is in metres.
|
|
47
|
+
"""
|
|
48
|
+
if any(axis.unit_name != "metre" for axis in crs.axis_info):
|
|
49
|
+
return False # feet (US State Plane): lengths and the snap distance are metres
|
|
50
|
+
area = crs.area_of_use
|
|
51
|
+
if area is None:
|
|
52
|
+
return True # a custom CRS: trust it
|
|
53
|
+
return (area.east - area.west) < 30
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def convert_to_wgs84_and_add_xy(
|
|
57
|
+
gdf: gpd.GeoDataFrame,
|
|
58
|
+
) -> gpd.GeoDataFrame:
|
|
59
|
+
"""
|
|
60
|
+
Convert a GeoDataFrame to WGS84 (EPSG:4326) and add
|
|
61
|
+
x/y coordinate columns.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
gdf: GeoDataFrame to convert.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
GeoDataFrame in EPSG:4326 with x and y columns.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# OSM uses WGS84 coordinates.
|
|
71
|
+
if gdf.crs is None:
|
|
72
|
+
gdf = gdf.set_crs("EPSG:4326")
|
|
73
|
+
|
|
74
|
+
elif gdf.crs.to_string() != "EPSG:4326":
|
|
75
|
+
gdf = gdf.to_crs("EPSG:4326")
|
|
76
|
+
|
|
77
|
+
# Add longitude (x) and latitude (y) columns.
|
|
78
|
+
gdf["x"] = gdf.geometry.x.round(7)
|
|
79
|
+
gdf["y"] = gdf.geometry.y.round(7)
|
|
80
|
+
|
|
81
|
+
return gdf
|
networkforge/tags.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Which OpenStreetMap tags NetworkForge understands.
|
|
3
|
+
|
|
4
|
+
Existing OSM ways, nodes and relations are written to .osm / .osm.pbf
|
|
5
|
+
exactly as OpenStreetMap has them (every tag), so routers such as
|
|
6
|
+
Valhalla see the real data. The lists here decide:
|
|
7
|
+
|
|
8
|
+
- which attributes of a *custom* feature are written as tags (anything
|
|
9
|
+
else is not an OSM tag NetworkForge knows, and is left out);
|
|
10
|
+
- which tags become columns of the edge / node tables (and so of the
|
|
11
|
+
GeoPackage). OSMnx discards every tag not in its `useful_tags_way` /
|
|
12
|
+
`useful_tags_node` settings; modes.keep_mode_tags() applies these
|
|
13
|
+
lists (plus every access tag the mode rules use) before any load;
|
|
14
|
+
- which nodes and relations of a local extract are worth reading.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Core way tags, written first on export.
|
|
18
|
+
BASE_WAY_TAGS = (
|
|
19
|
+
"highway", "name", "ref", "lanes", "maxspeed", "oneway", "access",
|
|
20
|
+
"service", "bridge", "tunnel", "layer", "junction", "surface", "width",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Further way tags routers use: per-mode one-way and access, vehicle
|
|
24
|
+
# restrictions, cycle infrastructure, and surface / difficulty.
|
|
25
|
+
ROUTING_WAY_TAGS = (
|
|
26
|
+
# One-way exceptions (contraflow cycling, bus lanes)
|
|
27
|
+
"oneway:bicycle", "oneway:bus", "oneway:psv",
|
|
28
|
+
# Access for other vehicle types
|
|
29
|
+
"vehicle", "hgv", "goods", "bus", "psv", "motorcycle", "horse", "wheelchair",
|
|
30
|
+
"taxi", "hov", "moped", "emergency", "motorroad",
|
|
31
|
+
# Access that depends on the time of day, e.g. "no @ (Mo-Fr 07:00-09:00)"
|
|
32
|
+
"access:conditional", "motor_vehicle:conditional", "vehicle:conditional",
|
|
33
|
+
"hgv:conditional", "bicycle:conditional", "foot:conditional",
|
|
34
|
+
# Vehicle size / weight limits
|
|
35
|
+
"maxweight", "maxheight", "maxwidth", "maxlength", "maxaxleload", "maxaxles", "hazmat",
|
|
36
|
+
# Direction- and vehicle-specific speeds and lanes
|
|
37
|
+
"maxspeed:forward", "maxspeed:backward", "maxspeed:hgv",
|
|
38
|
+
"lanes:forward", "lanes:backward",
|
|
39
|
+
"turn:lanes", "turn:lanes:forward", "turn:lanes:backward",
|
|
40
|
+
# Names and signs used in turn-by-turn directions
|
|
41
|
+
"alt_name", "official_name", "int_ref", "destination", "destination:ref", "junction:ref",
|
|
42
|
+
# Walking, and ramps for wheelchairs, prams and bikes (on steps)
|
|
43
|
+
"sidewalk", "footway", "crossing",
|
|
44
|
+
"ramp", "ramp:wheelchair", "ramp:stroller", "ramp:bicycle",
|
|
45
|
+
# Cycle infrastructure
|
|
46
|
+
"cycleway", "cycleway:left", "cycleway:right", "cycleway:both",
|
|
47
|
+
"segregated", "bicycle_road", "cyclestreet", "shoulder",
|
|
48
|
+
# Surface and difficulty
|
|
49
|
+
"smoothness", "tracktype", "sac_scale", "incline", "lit", "ford", "toll",
|
|
50
|
+
# Ferries and shuttle trains (route=ferry without a highway tag)
|
|
51
|
+
"route", "duration",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Node tags routers use: barriers (bollards, gates), signals,
|
|
55
|
+
# crossings, level crossings, access on the barrier itself (lorries and
|
|
56
|
+
# buses too), tolls, height / width / weight limits and entrances.
|
|
57
|
+
NODE_TAGS = (
|
|
58
|
+
"highway", "barrier", "crossing", "railway", "junction", "ref",
|
|
59
|
+
"access", "vehicle", "motor_vehicle", "motorcar", "bicycle", "foot",
|
|
60
|
+
"bus", "psv", "hgv", "wheelchair",
|
|
61
|
+
"toll", "maxheight", "maxwidth", "maxweight", "entrance",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# A node of a local extract is read (with all its tags) if it has one
|
|
65
|
+
# of these keys: NODE_TAGS plus further tags routers act on. Untagged
|
|
66
|
+
# nodes, and nodes that are only e.g. a shop or a tree, carry nothing a
|
|
67
|
+
# router uses.
|
|
68
|
+
ROUTING_NODE_KEYS = (
|
|
69
|
+
*NODE_TAGS,
|
|
70
|
+
"bus", "psv", "hgv", "taxi", "motorcycle", "moped", "emergency", "horse", "wheelchair",
|
|
71
|
+
"bollard", "entrance", "toll", "traffic_signals", "traffic_signals:direction",
|
|
72
|
+
"direction", "maxheight", "maxwidth", "maxweight", "name", "exit_to", "level",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Relations kept with the network: turn restrictions and lane
|
|
76
|
+
# connectivity (by `type`), and the routes routers read names, refs and
|
|
77
|
+
# cycle / walking networks from (type=route, by `route`).
|
|
78
|
+
RELATION_TYPES = ("restriction", "connectivity")
|
|
79
|
+
ROUTE_RELATIONS = ("road", "bicycle", "mtb", "foot", "hiking")
|
|
80
|
+
|
|
81
|
+
# Ways without a `highway` tag that routers travel on: ferries and car
|
|
82
|
+
# shuttle trains (`route=`). Existing ones are not part of NetworkForge's
|
|
83
|
+
# own network (edge table, GeoPackage), but are written to the OSM file
|
|
84
|
+
# as they are, so a router can use them; new ones drawn in the custom
|
|
85
|
+
# layer join the network at their ends. Valhalla routes on nothing else
|
|
86
|
+
# without a highway tag (not piers, platforms or squares mapped as areas).
|
|
87
|
+
FERRY_ROUTES = ("ferry", "shuttle_train")
|
|
88
|
+
|
|
89
|
+
# The column marking custom features, and the tag it's exported as.
|
|
90
|
+
CUSTOM_COLUMN = "custom"
|
|
91
|
+
CUSTOM_TAG = "nf:custom"
|
|
92
|
+
|
|
93
|
+
# A custom feature with an OSM way id in one of these attributes changes
|
|
94
|
+
# that existing way instead of adding a line (see edits.py). QuickOSM
|
|
95
|
+
# layers have `osm_id`; NetworkForge's GeoPackage has `osmid`.
|
|
96
|
+
EDIT_ID_COLUMNS = ("osm_id", "osmid")
|
|
97
|
+
EDIT_ID_COLUMN = "osm_id"
|
|
98
|
+
|
|
99
|
+
# On a feature with an OSM way id: `remove=yes` takes the stretch of the
|
|
100
|
+
# way it lies along out of the network altogether.
|
|
101
|
+
REMOVE_COLUMN = "remove"
|
|
102
|
+
# On a feature with an OSM way id: `remove_tags=maxspeed;access` deletes
|
|
103
|
+
# those tags from the stretch (an empty attribute only means "no change").
|
|
104
|
+
REMOVE_TAGS_COLUMN = "remove_tags"
|
|
105
|
+
REMOVED_ATTR = "networkforge_removed_edges"
|
|
106
|
+
|
|
107
|
+
# Marks edges (and, as a tag, ways) whose tags an edit changed, and
|
|
108
|
+
# which edit; the tag changes travel in edges.attrs[EDITS_ATTR].
|
|
109
|
+
MODIFIED_COLUMN = "modified"
|
|
110
|
+
MODIFIED_TAG = "nf:modified"
|
|
111
|
+
EDIT_COLUMN = "nf_edit"
|
|
112
|
+
EDITS_ATTR = "networkforge_edits"
|
|
113
|
+
|
|
114
|
+
# Node tags set (or, as None, deleted) by points in the custom layer:
|
|
115
|
+
# {node id: {key: value or None}}, carried to export like the edits.
|
|
116
|
+
NODE_CHANGES_ATTR = "networkforge_node_changes"
|
|
117
|
+
|
|
118
|
+
# Turn restrictions resolved on the built network (turns.Turn), carried
|
|
119
|
+
# to export like the edits.
|
|
120
|
+
TURNS_ATTR = "networkforge_turns"
|
|
121
|
+
|
|
122
|
+
# Numbers the custom lines, so the pieces a line is cut into at
|
|
123
|
+
# junctions can be written as one way again.
|
|
124
|
+
PART_COLUMN = "nf_part"
|
|
125
|
+
|
|
126
|
+
# Columns export.analysis_edges() adds to the GeoPackage edges for QGIS
|
|
127
|
+
# network analysis.
|
|
128
|
+
GPKG_ANALYSIS_COLUMNS = (
|
|
129
|
+
"car", "bike", "walk", "speed_kph", "length_m",
|
|
130
|
+
"car_minutes", "bike_minutes", "walk_minutes",
|
|
131
|
+
"car_direction", "bike_direction",
|
|
132
|
+
)
|