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/osm.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Getting the existing OSM network: from the Overpass API (small areas)
|
|
3
|
+
or from a local OSM file such as a Geofabrik .osm.pbf extract (any size).
|
|
4
|
+
|
|
5
|
+
Both return the same thing: nodes and edges GeoDataFrames in the
|
|
6
|
+
analysis CRS, built as ox.graph_from_bbox builds them with
|
|
7
|
+
retain_all=True (500 m buffer, cropped to the box, every piece of
|
|
8
|
+
network kept), so a build from a local extract matches a build from
|
|
9
|
+
Overpass for the same area and data.
|
|
10
|
+
|
|
11
|
+
Both also keep the OSM data itself (OSMSource: every way with its
|
|
12
|
+
nodes and all its tags, tagged nodes, turn restrictions and route
|
|
13
|
+
relations), attached to the edges as `edges.attrs[SOURCE_ATTR]`. The
|
|
14
|
+
edge table only holds the tags OSMnx is told to keep; export writes the
|
|
15
|
+
existing network from the source, so routers get OSM as it is.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import geopandas as gpd
|
|
26
|
+
import networkx as nx
|
|
27
|
+
import osmium
|
|
28
|
+
import osmnx as ox
|
|
29
|
+
import requests
|
|
30
|
+
import shapely
|
|
31
|
+
from osmnx import _overpass
|
|
32
|
+
from osmnx._errors import InsufficientResponseError, ResponseStatusCodeError
|
|
33
|
+
from osmnx.graph import _create_graph
|
|
34
|
+
from pyproj import CRS
|
|
35
|
+
from shapely.geometry import box
|
|
36
|
+
|
|
37
|
+
from .coverage import warn_about_gaps
|
|
38
|
+
from .errors import InputError, OSMDownloadError
|
|
39
|
+
from .modes import _passes_osmnx_filter, is_ferry, keep_mode_tags
|
|
40
|
+
from .paths import readable
|
|
41
|
+
from .tags import FERRY_ROUTES, RELATION_TYPES, ROUTE_RELATIONS, ROUTING_NODE_KEYS
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Where the OSMSource travels: edges.attrs[SOURCE_ATTR].
|
|
46
|
+
SOURCE_ATTR = "networkforge_osm"
|
|
47
|
+
|
|
48
|
+
# OSMnx's built-in network filters, applied in the Overpass query.
|
|
49
|
+
# "all" (the default) keeps every mode's ways plus their access tags,
|
|
50
|
+
# so one build can be routed for any mode later (see modes.py).
|
|
51
|
+
# Narrower types drop ways at download, and custom lines can then
|
|
52
|
+
# only be joined to what's left.
|
|
53
|
+
NETWORK_TYPES = ("all", "all_public", "bike", "drive", "drive_service", "walk")
|
|
54
|
+
|
|
55
|
+
# ox.graph_from_bbox downloads this far around the box, then crops.
|
|
56
|
+
BUFFER_M = 500
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def configure_osmnx_cache() -> None:
|
|
60
|
+
"""
|
|
61
|
+
Configure the OSMnx cache directory from the
|
|
62
|
+
NETWORKFORGE_OSMNX_CACHE environment variable.
|
|
63
|
+
"""
|
|
64
|
+
cache_folder = os.getenv(
|
|
65
|
+
"NETWORKFORGE_OSMNX_CACHE",
|
|
66
|
+
os.path.expanduser("~/.cache/networkforge/osmnx"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
os.makedirs(cache_folder, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
ox.settings.cache_folder = cache_folder
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_network_type(network_type: str) -> None:
|
|
75
|
+
if network_type not in NETWORK_TYPES:
|
|
76
|
+
raise InputError(
|
|
77
|
+
f"Unknown network_type {network_type!r}. "
|
|
78
|
+
f"Choose one of: {', '.join(NETWORK_TYPES)}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, eq=False)
|
|
83
|
+
class OSMSource:
|
|
84
|
+
"""
|
|
85
|
+
The area's OpenStreetMap data as OSM has it. Elements use the
|
|
86
|
+
Overpass JSON shapes OSMnx reads.
|
|
87
|
+
|
|
88
|
+
ways: way id -> (node ids in order, all tags)
|
|
89
|
+
node_tags: node id -> all tags, for nodes a router acts on
|
|
90
|
+
(barriers, signals, crossings ... see ROUTING_NODE_KEYS)
|
|
91
|
+
relations: (id, tags, [(member type "n"/"w"/"r", ref, role), ...])
|
|
92
|
+
for turn restrictions and routes that use a kept way
|
|
93
|
+
ferries: way id -> (node ids, all tags) of ferry routes inside the
|
|
94
|
+
area (see FERRY_ROUTES): written to OSM files as they are
|
|
95
|
+
ferry_nodes: node id -> (lon, lat) of the ferries' nodes
|
|
96
|
+
ferry_terminals: the ferries' end nodes inside the area (not the
|
|
97
|
+
ends of a piece cropped at its edge)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
ways: dict[int, tuple[list[int], dict[str, str]]] = field(default_factory=dict)
|
|
101
|
+
node_tags: dict[int, dict[str, str]] = field(default_factory=dict)
|
|
102
|
+
relations: list[tuple[int, dict[str, str], list[tuple[str, int, str]]]] = field(
|
|
103
|
+
default_factory=list)
|
|
104
|
+
ferries: dict[int, tuple[list[int], dict[str, str]]] = field(default_factory=dict)
|
|
105
|
+
ferry_nodes: dict[int, tuple[float, float]] = field(default_factory=dict)
|
|
106
|
+
ferry_terminals: set[int] = field(default_factory=set)
|
|
107
|
+
|
|
108
|
+
# pandas deep-copies .attrs on most operations; the source is
|
|
109
|
+
# read-only, so share it rather than copy a whole city each time.
|
|
110
|
+
def __deepcopy__(self, memo):
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __copy__(self):
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _keeps_relation(tags: dict[str, str]) -> bool:
|
|
118
|
+
kind = tags.get("type", "")
|
|
119
|
+
if kind in RELATION_TYPES or kind.startswith("restriction:"):
|
|
120
|
+
return True
|
|
121
|
+
return kind == "route" and tags.get("route") in ROUTE_RELATIONS
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _source_from_elements(elements: list[dict], polygon) -> OSMSource:
|
|
125
|
+
"""`polygon`: the area itself (not buffered); ferries are cropped to it."""
|
|
126
|
+
ways, node_tags, relations = {}, {}, []
|
|
127
|
+
ferries, ferry_nodes, ferry_terminals, locations = {}, {}, set(), {}
|
|
128
|
+
for element in elements:
|
|
129
|
+
tags = element.get("tags") or {}
|
|
130
|
+
if element["type"] == "way" and not is_ferry(tags):
|
|
131
|
+
ways[element["id"]] = (list(element["nodes"]), dict(tags))
|
|
132
|
+
elif element["type"] == "node":
|
|
133
|
+
locations[element["id"]] = (element["lon"], element["lat"])
|
|
134
|
+
if any(key in tags for key in ROUTING_NODE_KEYS):
|
|
135
|
+
node_tags[element["id"]] = dict(tags)
|
|
136
|
+
|
|
137
|
+
extra_ids = iter(range(-(10**15), 0)) # for further pieces of a cropped ferry
|
|
138
|
+
for element in elements:
|
|
139
|
+
if element["type"] != "way" or not is_ferry(element.get("tags") or {}):
|
|
140
|
+
continue
|
|
141
|
+
inside = [(ref, locations[ref]) if ref in locations and shapely.contains_xy(
|
|
142
|
+
polygon, *locations[ref]) else (ref, None) for ref in element["nodes"]]
|
|
143
|
+
for number, run in enumerate(_known_runs(inside)):
|
|
144
|
+
way_id = element["id"] if number == 0 else next(extra_ids)
|
|
145
|
+
ferries[way_id] = ([ref for ref, _ in run], dict(element["tags"]))
|
|
146
|
+
ferry_nodes.update(run)
|
|
147
|
+
ferry_terminals |= {ref for ref, where in (inside[0], inside[-1]) if where}
|
|
148
|
+
for element in elements:
|
|
149
|
+
if element["type"] != "relation" or not _keeps_relation(element.get("tags") or {}):
|
|
150
|
+
continue
|
|
151
|
+
members = [(m["type"][0], m["ref"], m.get("role", "")) for m in element["members"]]
|
|
152
|
+
if any(kind == "w" and ref in ways for kind, ref, _ in members):
|
|
153
|
+
relations.append((element["id"], dict(element["tags"]), members))
|
|
154
|
+
return OSMSource(ways, node_tags, relations, ferries, ferry_nodes, ferry_terminals)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _network_from_elements(
|
|
158
|
+
elements: list[dict],
|
|
159
|
+
polygon,
|
|
160
|
+
polygon_buffered,
|
|
161
|
+
network_type: str,
|
|
162
|
+
analysis_crs: CRS,
|
|
163
|
+
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
164
|
+
"""
|
|
165
|
+
Nodes and edges from OSM elements covering `polygon_buffered`: the
|
|
166
|
+
same steps as ox.graph_from_polygon (what graph_from_bbox uses).
|
|
167
|
+
"""
|
|
168
|
+
# The street network: every way but the ferries, and those ways' nodes.
|
|
169
|
+
streets = [e for e in elements
|
|
170
|
+
if e["type"] == "way" and not is_ferry(e.get("tags") or {})]
|
|
171
|
+
on_streets = {ref for way in streets for ref in way["nodes"]}
|
|
172
|
+
graph_buffered = _create_graph(
|
|
173
|
+
[{"elements": streets + [e for e in elements
|
|
174
|
+
if e["type"] == "node" and e["id"] in on_streets]}],
|
|
175
|
+
bidirectional=network_type in ox.settings.bidirectional_network_types,
|
|
176
|
+
)
|
|
177
|
+
# Every piece of network in the area is kept (OSMnx's retain_all), not
|
|
178
|
+
# only the largest: other islands, streets that join the rest outside
|
|
179
|
+
# the area, isolated paths. They are part of OpenStreetMap, routers
|
|
180
|
+
# use them, and Valhalla sets speeds by how dense the streets around
|
|
181
|
+
# an edge are - leaving pieces out changed travel times on the rest.
|
|
182
|
+
graph_buffered = ox.truncate.truncate_graph_polygon(graph_buffered, polygon_buffered)
|
|
183
|
+
graph = ox.truncate.truncate_graph_polygon(graph_buffered, polygon)
|
|
184
|
+
street_counts = ox.stats.count_streets_per_node(graph_buffered, nodes=graph.nodes)
|
|
185
|
+
nx.set_node_attributes(graph, values=street_counts, name="street_count")
|
|
186
|
+
|
|
187
|
+
nodes, edges = ox.graph_to_gdfs(graph)
|
|
188
|
+
nodes, edges = nodes.to_crs(analysis_crs), edges.to_crs(analysis_crs).reset_index()
|
|
189
|
+
edges.attrs[SOURCE_ATTR] = _source_from_elements(elements, polygon)
|
|
190
|
+
return nodes, edges
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _buffered(polygon, bbox: gpd.GeoDataFrame):
|
|
194
|
+
"""`polygon` (WGS84) grown by BUFFER_M, as ox.graph_from_polygon does."""
|
|
195
|
+
utm = bbox.estimate_utm_crs()
|
|
196
|
+
buffered = gpd.GeoSeries([polygon], crs="EPSG:4326").to_crs(utm).buffer(BUFFER_M)
|
|
197
|
+
return buffered.to_crs("EPSG:4326").iloc[0]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_osm_data_from_bbox(
|
|
201
|
+
bbox: gpd.GeoDataFrame,
|
|
202
|
+
analysis_crs: CRS,
|
|
203
|
+
network_type: str = "all",
|
|
204
|
+
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
205
|
+
"""Download an OSM network within a bounding box from the Overpass API."""
|
|
206
|
+
|
|
207
|
+
_check_network_type(network_type)
|
|
208
|
+
|
|
209
|
+
# Keep motor_vehicle / foot / bicycle etc., which OSMnx drops by
|
|
210
|
+
# default, so access restrictions survive into the edge table.
|
|
211
|
+
keep_mode_tags()
|
|
212
|
+
|
|
213
|
+
# Otherwise OSMnx caches responses in ./cache wherever it's run from.
|
|
214
|
+
configure_osmnx_cache()
|
|
215
|
+
|
|
216
|
+
polygon = box(*bbox.to_crs("EPSG:4326").total_bounds)
|
|
217
|
+
polygon_buffered = _buffered(polygon, bbox)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
elements = _download_with_retries(polygon_buffered, network_type)
|
|
221
|
+
return _network_from_elements(
|
|
222
|
+
elements, polygon, polygon_buffered, network_type, analysis_crs)
|
|
223
|
+
except (InsufficientResponseError, ValueError) as exc:
|
|
224
|
+
raise OSMDownloadError(
|
|
225
|
+
f"OpenStreetMap has no {network_type!r} ways in this bounding box "
|
|
226
|
+
f"({exc}). Check the box covers an area with streets."
|
|
227
|
+
) from exc
|
|
228
|
+
except (requests.RequestException, ResponseStatusCodeError) as exc:
|
|
229
|
+
raise OSMDownloadError(
|
|
230
|
+
f"Couldn't download OpenStreetMap data from the Overpass API ({exc}). "
|
|
231
|
+
"Check your internet connection, or try again later - Overpass "
|
|
232
|
+
"rate-limits heavy use."
|
|
233
|
+
) from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Seconds to wait before each new attempt when Overpass can't be reached
|
|
237
|
+
# (it refuses connections for a while when busy).
|
|
238
|
+
DOWNLOAD_RETRY_WAITS = (10, 30)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _download_with_retries(polygon, network_type: str) -> list[dict]:
|
|
242
|
+
"""_download_elements, tried again after a pause if Overpass can't be reached."""
|
|
243
|
+
for wait in (*DOWNLOAD_RETRY_WAITS, None):
|
|
244
|
+
try:
|
|
245
|
+
return _download_elements(polygon, network_type)
|
|
246
|
+
except requests.ConnectionError as exc:
|
|
247
|
+
if wait is None:
|
|
248
|
+
raise
|
|
249
|
+
log.warning("Couldn't reach the Overpass API (%s); trying again in %d s.",
|
|
250
|
+
type(exc).__name__, wait)
|
|
251
|
+
time.sleep(wait)
|
|
252
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _download_elements(polygon, network_type: str) -> list[dict]:
|
|
256
|
+
"""
|
|
257
|
+
Every way OSMnx's filter for `network_type` matches inside `polygon`,
|
|
258
|
+
with its nodes (OSMnx's own query), plus the turn restrictions and
|
|
259
|
+
routes those ways belong to, and the ferry routes in the area with
|
|
260
|
+
their nodes (a second query). Overpass JSON elements.
|
|
261
|
+
"""
|
|
262
|
+
elements = [
|
|
263
|
+
element
|
|
264
|
+
for response in _overpass._download_overpass_network(polygon, network_type, None)
|
|
265
|
+
for element in response["elements"]
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
settings = _overpass._make_overpass_settings()
|
|
269
|
+
way_filter = _overpass._get_network_filter(network_type)
|
|
270
|
+
kinds = "|".join(RELATION_TYPES)
|
|
271
|
+
routes = "|".join(ROUTE_RELATIONS)
|
|
272
|
+
ferries = "|".join(FERRY_ROUTES)
|
|
273
|
+
for coords in _overpass._make_overpass_polygon_coord_strs(polygon):
|
|
274
|
+
query = (
|
|
275
|
+
f"{settings};way{way_filter}(poly:{coords!r})->.w;"
|
|
276
|
+
f'way["route"~"^({ferries})$"]["highway"!~"."](poly:{coords!r})->.f;'
|
|
277
|
+
f'(rel(bw.w)["type"~"^({kinds})(:|$)"];'
|
|
278
|
+
f'rel(bw.w)["type"="route"]["route"~"^({routes})$"];'
|
|
279
|
+
f".f;node(w.f););out;"
|
|
280
|
+
)
|
|
281
|
+
elements += _overpass._overpass_request(OrderedDict(data=query))["elements"]
|
|
282
|
+
|
|
283
|
+
# Large areas are downloaded in pieces that overlap.
|
|
284
|
+
return list({(e["type"], e["id"]): e for e in elements}.values())
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def get_osm_data_from_file(
|
|
288
|
+
osm_file: str | Path,
|
|
289
|
+
bbox: gpd.GeoDataFrame,
|
|
290
|
+
analysis_crs: CRS,
|
|
291
|
+
network_type: str = "all",
|
|
292
|
+
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
293
|
+
"""
|
|
294
|
+
Read the OSM network within a bounding box from a local OSM file
|
|
295
|
+
(.osm.pbf, .osm, .osm.bz2 ... anything osmium reads), e.g. a
|
|
296
|
+
Geofabrik extract. No size limit and no network access.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
_check_network_type(network_type)
|
|
300
|
+
|
|
301
|
+
path = Path(osm_file)
|
|
302
|
+
if not path.is_file():
|
|
303
|
+
raise InputError(f"OSM file not found: {path}", guide="osm-data.md#getting-an-extract")
|
|
304
|
+
|
|
305
|
+
keep_mode_tags()
|
|
306
|
+
|
|
307
|
+
polygon = bbox.to_crs("EPSG:4326").union_all()
|
|
308
|
+
polygon_buffered = _buffered(polygon, bbox)
|
|
309
|
+
|
|
310
|
+
_check_file_covers(path, polygon)
|
|
311
|
+
|
|
312
|
+
coastlines = []
|
|
313
|
+
elements = _read_elements(path, polygon_buffered.bounds, network_type, coastlines)
|
|
314
|
+
if not any(element["type"] == "way" and not is_ferry(element["tags"])
|
|
315
|
+
for element in elements):
|
|
316
|
+
raise InputError(
|
|
317
|
+
f"{path.name} has no {network_type!r} ways inside the bounding box. "
|
|
318
|
+
"Check the file covers the area.",
|
|
319
|
+
guide="osm-data.md#getting-an-extract",
|
|
320
|
+
)
|
|
321
|
+
warn_about_gaps(elements, coastlines, polygon, analysis_crs, path.name)
|
|
322
|
+
|
|
323
|
+
return _network_from_elements(elements, polygon, polygon_buffered, network_type, analysis_crs)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _check_file_covers(path: Path, polygon) -> None:
|
|
327
|
+
"""Refuse a file whose header says it doesn't reach the area."""
|
|
328
|
+
try:
|
|
329
|
+
with (readable(path) as name,
|
|
330
|
+
osmium.io.Reader(name, osmium.osm.osm_entity_bits.NOTHING) as reader):
|
|
331
|
+
box = reader.header().box()
|
|
332
|
+
except RuntimeError as exc:
|
|
333
|
+
raise InputError(f"Can't read {path.name} as an OSM file ({exc}).") from exc
|
|
334
|
+
|
|
335
|
+
if not box.valid(): # many files don't record their extent
|
|
336
|
+
return
|
|
337
|
+
west, south = box.bottom_left.lon, box.bottom_left.lat
|
|
338
|
+
east, north = box.top_right.lon, box.top_right.lat
|
|
339
|
+
min_lon, min_lat, max_lon, max_lat = polygon.bounds
|
|
340
|
+
if max_lon < west or min_lon > east or max_lat < south or min_lat > north:
|
|
341
|
+
raise InputError(
|
|
342
|
+
f"{path.name} covers lon {west:.3f}..{east:.3f}, lat {south:.3f}..{north:.3f}, "
|
|
343
|
+
"which doesn't overlap the bounding box.",
|
|
344
|
+
guide="osm-data.md#getting-an-extract",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _read_elements(path: Path, bounds, network_type: str,
|
|
349
|
+
coastlines: list | None = None) -> list[dict]:
|
|
350
|
+
"""
|
|
351
|
+
The ways in `path` within `bounds` (filtered like the Overpass query
|
|
352
|
+
for network_type) with their nodes, and the relations kept with them
|
|
353
|
+
(see OSMSource), as Overpass JSON elements. `coastlines`, if given,
|
|
354
|
+
gets the [(lon, lat), ...] of every natural=coastline way reaching
|
|
355
|
+
`bounds` (for coverage.warn_about_gaps: which side is sea).
|
|
356
|
+
"""
|
|
357
|
+
west, south, east, north = bounds
|
|
358
|
+
|
|
359
|
+
def inside(lon: float, lat: float) -> bool:
|
|
360
|
+
return west <= lon <= east and south <= lat <= north
|
|
361
|
+
|
|
362
|
+
tagged_nodes: dict[int, dict[str, str]] = {}
|
|
363
|
+
locations: dict[int, tuple[float, float]] = {}
|
|
364
|
+
ways, relations = [], []
|
|
365
|
+
cut_ways = 0
|
|
366
|
+
extra_way_ids = iter(range(-1, -(10**15), -1)) # ids for pieces of cut ways
|
|
367
|
+
|
|
368
|
+
# Only objects with a highway, node-routing or relation-type tag reach
|
|
369
|
+
# Python; the location cache still sees every node, so ways get coordinates.
|
|
370
|
+
try:
|
|
371
|
+
with readable(path) as name:
|
|
372
|
+
processor = (
|
|
373
|
+
osmium.FileProcessor(name)
|
|
374
|
+
.with_locations()
|
|
375
|
+
.with_filter(osmium.filter.KeyFilter(
|
|
376
|
+
"highway", "type", "route", "natural", *ROUTING_NODE_KEYS))
|
|
377
|
+
)
|
|
378
|
+
for obj in processor:
|
|
379
|
+
if obj.is_node():
|
|
380
|
+
if inside(obj.location.lon, obj.location.lat):
|
|
381
|
+
tags = {tag.k: tag.v for tag in obj.tags}
|
|
382
|
+
if any(key in tags for key in ROUTING_NODE_KEYS):
|
|
383
|
+
tagged_nodes[obj.id] = tags
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
tags = {tag.k: tag.v for tag in obj.tags}
|
|
387
|
+
if obj.is_relation():
|
|
388
|
+
if _keeps_relation(tags):
|
|
389
|
+
members = [{"type": {"n": "node", "w": "way", "r": "relation"}[m.type],
|
|
390
|
+
"ref": m.ref, "role": m.role} for m in obj.members]
|
|
391
|
+
relations.append({"type": "relation", "id": obj.id, "tags": tags,
|
|
392
|
+
"members": members})
|
|
393
|
+
continue
|
|
394
|
+
if not obj.is_way():
|
|
395
|
+
continue
|
|
396
|
+
if tags.get("natural") == "coastline" and "highway" not in tags:
|
|
397
|
+
if coastlines is not None:
|
|
398
|
+
line = [(n.location.lon, n.location.lat) for n in obj.nodes
|
|
399
|
+
if n.location.valid()]
|
|
400
|
+
if any(inside(*loc) for loc in line):
|
|
401
|
+
coastlines.append(line)
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if not is_ferry(tags) and (
|
|
405
|
+
"highway" not in tags or not _passes_osmnx_filter(tags, network_type)
|
|
406
|
+
):
|
|
407
|
+
continue
|
|
408
|
+
nodes = [(n.ref, (n.location.lon, n.location.lat)) if n.location.valid()
|
|
409
|
+
else (n.ref, None) for n in obj.nodes]
|
|
410
|
+
if not any(loc and inside(*loc) for _, loc in nodes):
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# An extract can cut a way at its edge, leaving refs to nodes it
|
|
414
|
+
# doesn't contain. Keep each unbroken run of known nodes.
|
|
415
|
+
runs = _known_runs(nodes)
|
|
416
|
+
if len(runs) != 1 or len(runs[0]) != len(nodes):
|
|
417
|
+
cut_ways += 1
|
|
418
|
+
for number, run in enumerate(runs):
|
|
419
|
+
way_id = obj.id if number == 0 else next(extra_way_ids)
|
|
420
|
+
ways.append({"type": "way", "id": way_id, "tags": tags,
|
|
421
|
+
"nodes": [ref for ref, _ in run]})
|
|
422
|
+
locations.update(run)
|
|
423
|
+
except RuntimeError as exc:
|
|
424
|
+
raise InputError(f"Can't read {path.name} as an OSM file ({exc}).") from exc
|
|
425
|
+
|
|
426
|
+
if cut_ways:
|
|
427
|
+
log.warning("%d way(s) in %s reference nodes the file doesn't contain (cut at "
|
|
428
|
+
"the extract's edge); kept the parts it does contain. Use an "
|
|
429
|
+
"extract that fully covers the area to avoid gaps.", cut_ways, path.name)
|
|
430
|
+
|
|
431
|
+
nodes = [
|
|
432
|
+
{"type": "node", "id": ref, "lon": lon, "lat": lat, "tags": tagged_nodes.get(ref, {})}
|
|
433
|
+
for ref, (lon, lat) in sorted(locations.items())
|
|
434
|
+
]
|
|
435
|
+
return [*nodes, *sorted(ways, key=lambda way: way["id"]), *relations]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _known_runs(nodes: list[tuple[int, tuple[float, float] | None]]) -> list[list]:
|
|
439
|
+
"""Split (ref, location-or-None) into runs of 2+ consecutive known nodes."""
|
|
440
|
+
runs, current = [], []
|
|
441
|
+
for ref, location in nodes:
|
|
442
|
+
if location is None:
|
|
443
|
+
if len(current) >= 2:
|
|
444
|
+
runs.append(current)
|
|
445
|
+
current = []
|
|
446
|
+
else:
|
|
447
|
+
current.append((ref, location))
|
|
448
|
+
if len(current) >= 2:
|
|
449
|
+
runs.append(current)
|
|
450
|
+
return runs
|
networkforge/paths.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File names osmium can open on Windows.
|
|
3
|
+
|
|
4
|
+
osmium (the library that reads and writes .osm.pbf) opens files through
|
|
5
|
+
an old Windows interface that doesn't take Unicode names: a file in
|
|
6
|
+
"C:\\Users\\Zoë\\Documents" is "not found". Python itself has no such
|
|
7
|
+
trouble, so the two helpers here let Python do the part with the
|
|
8
|
+
awkward letters:
|
|
9
|
+
|
|
10
|
+
- the folder: Python makes it the working folder, and osmium is given
|
|
11
|
+
the bare file name;
|
|
12
|
+
- the file name, if that has such letters too: osmium reads a second,
|
|
13
|
+
plainly named entry for the same file (a hard link, no copy), or
|
|
14
|
+
writes to a plainly named file that is renamed when it is finished.
|
|
15
|
+
|
|
16
|
+
Everywhere else, and for plain ASCII paths on Windows, the path is
|
|
17
|
+
passed on untouched.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .errors import InputError
|
|
27
|
+
|
|
28
|
+
# Longest first: "x.osm.pbf" ends in ".pbf" too.
|
|
29
|
+
OSM_ENDINGS = (".osm.pbf", ".osm.bz2", ".osm.gz", ".pbf", ".osm")
|
|
30
|
+
|
|
31
|
+
# Whether osmium needs this help (tests switch it on to try it anywhere).
|
|
32
|
+
_NARROW_NAMES_ONLY = os.name == "nt"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _needs_help(path: Path) -> bool:
|
|
36
|
+
return _NARROW_NAMES_ONLY and not str(path).isascii() and path.parent.is_dir()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plain_name(name: str) -> str:
|
|
40
|
+
"""A name of plain letters that osmium reads as the same format as `name`."""
|
|
41
|
+
ending = next((e for e in OSM_ENDINGS if name.lower().endswith(e)), "")
|
|
42
|
+
return f"networkforge-{uuid.uuid4().hex}{ending}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def _working_in(folder: Path) -> Iterator[None]:
|
|
47
|
+
before = os.getcwd()
|
|
48
|
+
os.chdir(folder)
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
os.chdir(before)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _remove(name: str) -> None:
|
|
56
|
+
try:
|
|
57
|
+
os.unlink(name)
|
|
58
|
+
except OSError: # never there, or Windows still has it open
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def readable(path: str | Path) -> Iterator[str]:
|
|
64
|
+
"""
|
|
65
|
+
Give the name to hand to osmium to read `path`. Keep reading inside
|
|
66
|
+
the `with` block: the name is only good there.
|
|
67
|
+
"""
|
|
68
|
+
path = Path(path)
|
|
69
|
+
if not _needs_help(path):
|
|
70
|
+
yield str(path)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
path = path.absolute()
|
|
74
|
+
with _working_in(path.parent):
|
|
75
|
+
if path.name.isascii():
|
|
76
|
+
yield path.name
|
|
77
|
+
return
|
|
78
|
+
link = _plain_name(path.name)
|
|
79
|
+
try:
|
|
80
|
+
os.link(path.name, link)
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise InputError(
|
|
83
|
+
f"Can't open {path.name}: on Windows, give the file a name without "
|
|
84
|
+
"accented or other special letters and try again."
|
|
85
|
+
) from exc
|
|
86
|
+
try:
|
|
87
|
+
yield link
|
|
88
|
+
finally:
|
|
89
|
+
_remove(link)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def writable(path: str | Path) -> Iterator[str]:
|
|
94
|
+
"""
|
|
95
|
+
Give the name to hand to osmium to write `path`. The file is in
|
|
96
|
+
place under its real name when the `with` block ends without error.
|
|
97
|
+
"""
|
|
98
|
+
path = Path(path)
|
|
99
|
+
if not _needs_help(path):
|
|
100
|
+
yield str(path)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
path = path.absolute()
|
|
104
|
+
with _working_in(path.parent):
|
|
105
|
+
if path.name.isascii():
|
|
106
|
+
yield path.name
|
|
107
|
+
return
|
|
108
|
+
unfinished = _plain_name(path.name)
|
|
109
|
+
try:
|
|
110
|
+
yield unfinished
|
|
111
|
+
os.replace(unfinished, path.name)
|
|
112
|
+
finally:
|
|
113
|
+
_remove(unfinished)
|