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/coverage.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Does a local OSM file cover the whole area? A region's extract stops at
|
|
3
|
+
its boundary: a box reaching across it builds a network with part of
|
|
4
|
+
the streets missing, and nothing else says so - the file overlaps the
|
|
5
|
+
area (so it isn't refused) and keeps the ways crossing its edge whole
|
|
6
|
+
(so none is "cut at the extract's edge").
|
|
7
|
+
|
|
8
|
+
What a missing part looks like: a stretch of the area, reaching its
|
|
9
|
+
edge, with no street at all. Real places like that exist (sea, a lake,
|
|
10
|
+
moorland), so this only warns, and counts the sea side of a coastline
|
|
11
|
+
as covered.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections import deque
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import shapely
|
|
19
|
+
from pyproj import CRS, Transformer
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# The area is split into about CELLS_ACROSS x CELLS_ACROSS cells, none
|
|
24
|
+
# smaller than MIN_CELL_M; a cell no street passes through is empty.
|
|
25
|
+
CELLS_ACROSS = 40
|
|
26
|
+
MIN_CELL_M = 25.0
|
|
27
|
+
|
|
28
|
+
# Empty cells joined side to side, reaching the area's edge, covering at
|
|
29
|
+
# least this share of it: warned about.
|
|
30
|
+
MIN_GAP_SHARE = 0.05
|
|
31
|
+
|
|
32
|
+
_WINDS = ["east", "north-east", "north", "north-west", "west", "south-west", "south",
|
|
33
|
+
"south-east"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warn_about_gaps(elements: list[dict], coastlines: list, polygon, analysis_crs: CRS,
|
|
37
|
+
name: str) -> list:
|
|
38
|
+
"""
|
|
39
|
+
Log a warning for each part of `polygon` (the area, WGS84) where the
|
|
40
|
+
file `name` has no streets: `elements` are its Overpass-JSON nodes
|
|
41
|
+
and ways, `coastlines` its natural=coastline ways as [(lon, lat), ...].
|
|
42
|
+
Returns the parts warned about (WGS84 polygons), largest first.
|
|
43
|
+
"""
|
|
44
|
+
to_crs = Transformer.from_crs("EPSG:4326", analysis_crs, always_xy=True)
|
|
45
|
+
area = shapely.ops.transform(to_crs.transform, polygon)
|
|
46
|
+
if area.area <= 0:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
cells, rows, cols = _cells(area)
|
|
50
|
+
empty = ~_crossed(cells, _ways(elements, to_crs))
|
|
51
|
+
if empty.any() and coastlines:
|
|
52
|
+
centres = shapely.centroid(cells[empty])
|
|
53
|
+
empty[np.flatnonzero(empty)[_at_sea(centres, coastlines, to_crs)]] = False
|
|
54
|
+
|
|
55
|
+
edge = shapely.intersects(cells, area.boundary)
|
|
56
|
+
in_area = shapely.area(shapely.intersection(cells, area))
|
|
57
|
+
to_wgs84 = Transformer.from_crs(analysis_crs, "EPSG:4326", always_xy=True)
|
|
58
|
+
gaps = []
|
|
59
|
+
for region in _regions(empty, rows, cols):
|
|
60
|
+
share = in_area[region].sum() / area.area
|
|
61
|
+
if share < MIN_GAP_SHARE or not edge[region].any():
|
|
62
|
+
continue
|
|
63
|
+
gap = shapely.intersection(shapely.union_all(cells[region]), area)
|
|
64
|
+
gaps.append((share, gap))
|
|
65
|
+
|
|
66
|
+
if not gaps:
|
|
67
|
+
return []
|
|
68
|
+
gaps.sort(key=lambda gap: -gap[0])
|
|
69
|
+
found = [shapely.ops.transform(to_wgs84.transform, gap) for _, gap in gaps]
|
|
70
|
+
parts = [
|
|
71
|
+
("" if len(gaps) == 1 else f"{100 * share:.0f}%, ")
|
|
72
|
+
+ f"{gap.area / 1e6:.1f} km2 to the {_direction(gap, area)}: "
|
|
73
|
+
f"lon {west:.3f}..{east:.3f}, lat {south:.3f}..{north:.3f}"
|
|
74
|
+
for (share, gap), (west, south, east, north)
|
|
75
|
+
in zip(gaps, (part.bounds for part in found), strict=True)
|
|
76
|
+
]
|
|
77
|
+
log.warning(
|
|
78
|
+
"%s has no streets in about %.0f%% of the area (%s). The file probably stops "
|
|
79
|
+
"there - a region's extract ends at its boundary - so those streets are missing "
|
|
80
|
+
"from the network. Use a file that covers the whole area (see docs/osm-data.md). "
|
|
81
|
+
"If that part really has no streets (water, open country), ignore this.",
|
|
82
|
+
name, 100 * sum(share for share, _ in gaps), "; ".join(parts),
|
|
83
|
+
extra={"gaps": [tuple(round(v, 5) for v in part.bounds) for part in found]},
|
|
84
|
+
)
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cells(area) -> tuple[np.ndarray, int, int]:
|
|
89
|
+
"""Square cells over `area`'s bounds, row by row from the south-west."""
|
|
90
|
+
west, south, east, north = area.bounds
|
|
91
|
+
size = max(np.sqrt(area.area) / CELLS_ACROSS, MIN_CELL_M)
|
|
92
|
+
cols = max(int(np.ceil((east - west) / size)), 1)
|
|
93
|
+
rows = max(int(np.ceil((north - south) / size)), 1)
|
|
94
|
+
x0, y0 = np.meshgrid(west + size * np.arange(cols), south + size * np.arange(rows))
|
|
95
|
+
cells = shapely.box(x0.ravel(), y0.ravel(), x0.ravel() + size, y0.ravel() + size)
|
|
96
|
+
return cells, rows, cols
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ways(elements: list[dict], to_crs: Transformer) -> np.ndarray:
|
|
100
|
+
"""Every way (street or ferry) in `elements` as a projected line."""
|
|
101
|
+
nodes = [e for e in elements if e["type"] == "node"]
|
|
102
|
+
if not nodes:
|
|
103
|
+
return np.array([], dtype=object)
|
|
104
|
+
x, y = to_crs.transform([e["lon"] for e in nodes], [e["lat"] for e in nodes])
|
|
105
|
+
position = {e["id"]: i for i, e in enumerate(nodes)}
|
|
106
|
+
refs = [[position[ref] for ref in e["nodes"] if ref in position]
|
|
107
|
+
for e in elements if e["type"] == "way"]
|
|
108
|
+
refs = [way for way in refs if len(way) >= 2]
|
|
109
|
+
if not refs:
|
|
110
|
+
return np.array([], dtype=object)
|
|
111
|
+
order = np.concatenate(refs)
|
|
112
|
+
way = np.repeat(np.arange(len(refs)), [len(r) for r in refs])
|
|
113
|
+
return shapely.linestrings(np.column_stack([np.asarray(x)[order], np.asarray(y)[order]]),
|
|
114
|
+
indices=way)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _crossed(cells: np.ndarray, lines: np.ndarray) -> np.ndarray:
|
|
118
|
+
"""For each cell: does any of `lines` pass through it?"""
|
|
119
|
+
crossed = np.zeros(len(cells), dtype=bool)
|
|
120
|
+
if len(lines):
|
|
121
|
+
hits = shapely.STRtree(lines).query(cells, predicate="intersects")
|
|
122
|
+
crossed[hits[0]] = True
|
|
123
|
+
return crossed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _at_sea(points: np.ndarray, coastlines: list, to_crs: Transformer) -> np.ndarray:
|
|
127
|
+
"""
|
|
128
|
+
For each point: is it on the sea side of the nearest coastline? OSM
|
|
129
|
+
draws coastlines with the land on the left and the sea on the right.
|
|
130
|
+
"""
|
|
131
|
+
starts, ends = [], []
|
|
132
|
+
for line in coastlines:
|
|
133
|
+
if len(line) < 2:
|
|
134
|
+
continue
|
|
135
|
+
x, y = to_crs.transform(*zip(*line, strict=True))
|
|
136
|
+
xy = np.column_stack([x, y])
|
|
137
|
+
starts.append(xy[:-1])
|
|
138
|
+
ends.append(xy[1:])
|
|
139
|
+
if not starts:
|
|
140
|
+
return np.zeros(len(points), dtype=bool)
|
|
141
|
+
a, b = np.concatenate(starts), np.concatenate(ends)
|
|
142
|
+
segments = shapely.linestrings(np.stack([a, b], axis=1))
|
|
143
|
+
nearest = shapely.STRtree(segments).query_nearest(points, all_matches=False)[1]
|
|
144
|
+
p = shapely.get_coordinates(points)
|
|
145
|
+
a, b = a[nearest], b[nearest]
|
|
146
|
+
right = (b[:, 0] - a[:, 0]) * (p[:, 1] - a[:, 1]) - (b[:, 1] - a[:, 1]) * (p[:, 0] - a[:, 0])
|
|
147
|
+
return right < 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _regions(empty: np.ndarray, rows: int, cols: int) -> list[np.ndarray]:
|
|
151
|
+
"""Groups of empty cells joined side to side (cell numbers)."""
|
|
152
|
+
grid = empty.reshape(rows, cols)
|
|
153
|
+
seen = np.zeros_like(grid)
|
|
154
|
+
regions = []
|
|
155
|
+
for start in zip(*np.nonzero(grid), strict=True):
|
|
156
|
+
if seen[start]:
|
|
157
|
+
continue
|
|
158
|
+
seen[start] = True
|
|
159
|
+
queue, region = deque([start]), []
|
|
160
|
+
while queue:
|
|
161
|
+
row, col = queue.popleft()
|
|
162
|
+
region.append(row * cols + col)
|
|
163
|
+
for r, c in ((row + 1, col), (row - 1, col), (row, col + 1), (row, col - 1)):
|
|
164
|
+
if 0 <= r < rows and 0 <= c < cols and grid[r, c] and not seen[r, c]:
|
|
165
|
+
seen[r, c] = True
|
|
166
|
+
queue.append((r, c))
|
|
167
|
+
regions.append(np.array(region))
|
|
168
|
+
return regions
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _direction(gap, area) -> str:
|
|
172
|
+
"""Compass direction of `gap` from the middle of `area`."""
|
|
173
|
+
dx, dy = np.subtract(gap.centroid.coords[0], area.centroid.coords[0])
|
|
174
|
+
if np.hypot(dx, dy) < 1e-9:
|
|
175
|
+
return "middle"
|
|
176
|
+
return _WINDS[int(np.round(np.degrees(np.arctan2(dy, dx)) / 45)) % 8]
|
networkforge/edits.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Changing existing streets.
|
|
3
|
+
|
|
4
|
+
A custom feature with an OSM way id (an `osm_id` or `osmid` attribute,
|
|
5
|
+
as on a QuickOSM layer or NetworkForge's own GeoPackage) is not a new
|
|
6
|
+
line: it says "this stretch of that way should have these tags". Copy
|
|
7
|
+
the street out of such a layer, change an attribute (oneway=yes,
|
|
8
|
+
maxspeed=20 mph, access=no) and add it to the custom layer.
|
|
9
|
+
|
|
10
|
+
What changes:
|
|
11
|
+
- Only tags whose value differs from the way's value in OSM. The
|
|
12
|
+
rest of the feature's attributes are ignored, so a row copied with
|
|
13
|
+
all its attributes changes nothing until one is edited.
|
|
14
|
+
- Only the stretches of the way the feature lies along, in whole
|
|
15
|
+
steps from one OSM node to the next (every junction is a node).
|
|
16
|
+
A feature covering part of a way changes that part.
|
|
17
|
+
- `oneway` follows the direction the feature is drawn, as for new
|
|
18
|
+
lines: reverse the line (or use -1) to reverse the street.
|
|
19
|
+
- Several features may cover the same stretch (rows copied twice,
|
|
20
|
+
overlapping selections) as long as they ask for the same change.
|
|
21
|
+
- A tag is set by giving it a value: close a street with access=no,
|
|
22
|
+
open a bus gate with motor_vehicle=yes. An empty attribute changes
|
|
23
|
+
nothing; to delete tags from the way, list them in `remove_tags`
|
|
24
|
+
(`remove_tags=maxspeed;access`). Deleting `oneway` makes the
|
|
25
|
+
stretch two-way (a roundabout stays one-way).
|
|
26
|
+
- `remove=yes` takes the stretch out of the network altogether (its
|
|
27
|
+
other attributes are then ignored): it is in neither the edge table
|
|
28
|
+
nor the OSM file. The way's remaining parts stay; a turn
|
|
29
|
+
restriction that needs the removed part goes with it.
|
|
30
|
+
|
|
31
|
+
apply_edits() updates the edge table (so the GeoPackage columns and
|
|
32
|
+
NetworkForge's own routing follow) and returns the tag changes; export
|
|
33
|
+
writes each edited stretch as its own way with those tags and
|
|
34
|
+
`nf:modified=yes` (the id of the way it was cut from stays with the
|
|
35
|
+
unchanged part). The "before" network is never touched.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
|
|
40
|
+
import geopandas as gpd
|
|
41
|
+
import numpy as np
|
|
42
|
+
import osmnx as ox
|
|
43
|
+
import pandas as pd
|
|
44
|
+
import shapely
|
|
45
|
+
|
|
46
|
+
from .errors import InputError, InvalidTagsError
|
|
47
|
+
from .export import reverse_copies
|
|
48
|
+
from .osm import OSMSource
|
|
49
|
+
from .tags import (
|
|
50
|
+
EDIT_COLUMN,
|
|
51
|
+
EDIT_ID_COLUMN,
|
|
52
|
+
EDIT_ID_COLUMNS,
|
|
53
|
+
MODIFIED_COLUMN,
|
|
54
|
+
REMOVE_COLUMN,
|
|
55
|
+
REMOVE_TAGS_COLUMN,
|
|
56
|
+
)
|
|
57
|
+
from .validation import (
|
|
58
|
+
KNOWN_HIGHWAYS,
|
|
59
|
+
KNOWN_TAG_KEYS,
|
|
60
|
+
_tag_value,
|
|
61
|
+
tag_value_problems,
|
|
62
|
+
unknown_values,
|
|
63
|
+
warn_about_unknown_values,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
log = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
# How OSMnx (and OSM) read oneway values.
|
|
69
|
+
ONEWAY_FORWARD = {"yes", "true", "1"}
|
|
70
|
+
ONEWAY_BACKWARD = {"-1", "reverse", "t"}
|
|
71
|
+
|
|
72
|
+
# Two features this close (m) to the same stretch are both on it.
|
|
73
|
+
ON_FEATURE = 0.01
|
|
74
|
+
|
|
75
|
+
# A feature lies along a stretch if, beside the stretch's middle, the two
|
|
76
|
+
# run within this angle (degrees) of each other: one drawn ACROSS a block
|
|
77
|
+
# passes its middle too.
|
|
78
|
+
MAX_ANGLE = 45
|
|
79
|
+
|
|
80
|
+
# The "change" a feature with remove=yes asks for.
|
|
81
|
+
REMOVAL = {REMOVE_COLUMN: "yes"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def take_edit_ids(custom_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
85
|
+
"""
|
|
86
|
+
Put the OSM way id of each feature that edits an existing way in one
|
|
87
|
+
column, EDIT_ID_COLUMN (missing for new lines), whichever of
|
|
88
|
+
EDIT_ID_COLUMNS the data uses.
|
|
89
|
+
"""
|
|
90
|
+
present = [column for column in EDIT_ID_COLUMNS if column in custom_gdf.columns]
|
|
91
|
+
if not present:
|
|
92
|
+
return custom_gdf
|
|
93
|
+
|
|
94
|
+
custom = custom_gdf.copy()
|
|
95
|
+
ids = pd.Series(np.nan, index=custom.index, dtype="float64")
|
|
96
|
+
issues = []
|
|
97
|
+
for column in present:
|
|
98
|
+
for feature, value in custom[column].items():
|
|
99
|
+
text = _tag_value(value)
|
|
100
|
+
if text is None or text == "":
|
|
101
|
+
continue
|
|
102
|
+
# QuickOSM writes "123"; a full_id-style "w123" is accepted too.
|
|
103
|
+
number = text[1:] if text[:1] in "wW" else text
|
|
104
|
+
if number.isdigit() and int(number) > 0:
|
|
105
|
+
ids.at[feature] = int(number)
|
|
106
|
+
else:
|
|
107
|
+
issues.append({"feature": feature,
|
|
108
|
+
"message": f"{column}={text!r} is not an OSM way id"})
|
|
109
|
+
if issues:
|
|
110
|
+
raise InputError(
|
|
111
|
+
"Some features have an OSM id that isn't a way id (a whole number) - "
|
|
112
|
+
+ "; ".join(f"feature {i['feature']}: {i['message']}" for i in issues) + ".",
|
|
113
|
+
guide="changing-existing-streets", issues=issues,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
custom = custom.drop(columns=present)
|
|
117
|
+
custom[EDIT_ID_COLUMN] = ids
|
|
118
|
+
return custom
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# What a `remove` attribute may say; anything else is a mistake worth reporting.
|
|
122
|
+
REMOVE_YES = {"yes", "true", "1"}
|
|
123
|
+
REMOVE_NO = {"no", "false", "0", ""}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def wants_removal(value) -> bool | None:
|
|
127
|
+
"""True / False for a `remove` attribute value; None if it isn't a yes or no."""
|
|
128
|
+
text = (_tag_value(value) or "").strip().lower()
|
|
129
|
+
return True if text in REMOVE_YES else False if text in REMOVE_NO else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def split_edits(custom_gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
|
|
133
|
+
"""(new lines, edits to existing ways) of cleaned custom data."""
|
|
134
|
+
is_edit = (custom_gdf[EDIT_ID_COLUMN].notna() if EDIT_ID_COLUMN in custom_gdf.columns
|
|
135
|
+
else pd.Series(False, index=custom_gdf.index))
|
|
136
|
+
new_lines = custom_gdf[~is_edit].drop(columns=[EDIT_ID_COLUMN], errors="ignore")
|
|
137
|
+
edits = custom_gdf[is_edit]
|
|
138
|
+
if EDIT_ID_COLUMN not in edits.columns:
|
|
139
|
+
edits = edits.assign(**{EDIT_ID_COLUMN: np.nan})
|
|
140
|
+
|
|
141
|
+
for column, is_set in ((REMOVE_COLUMN, lambda value: wants_removal(value) is not False),
|
|
142
|
+
(REMOVE_TAGS_COLUMN, lambda value: bool(tags_to_remove(value)))):
|
|
143
|
+
if column not in custom_gdf.columns:
|
|
144
|
+
continue
|
|
145
|
+
# Only an existing street can lose tags or be removed: a new line
|
|
146
|
+
# marked so has most likely lost its OSM id on the way here.
|
|
147
|
+
marked = [feature for feature, value in new_lines[column].items() if is_set(value)]
|
|
148
|
+
if marked:
|
|
149
|
+
raise InputError(
|
|
150
|
+
f"{column}= is for existing streets: feature(s) "
|
|
151
|
+
f"{', '.join(map(str, marked))} have no OSM id ({' / '.join(EDIT_ID_COLUMNS)}) "
|
|
152
|
+
"saying which street to change.",
|
|
153
|
+
guide="changing-existing-streets",
|
|
154
|
+
issues=[{"feature": feature, "message": f"{column} set, but no OSM id"}
|
|
155
|
+
for feature in marked],
|
|
156
|
+
)
|
|
157
|
+
new_lines = new_lines.drop(columns=[column])
|
|
158
|
+
return new_lines, edits
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def tags_to_remove(value) -> list[str]:
|
|
162
|
+
"""The tag keys a `remove_tags` attribute lists (separated by ; or ,)."""
|
|
163
|
+
text = _tag_value(value) or ""
|
|
164
|
+
return list(dict.fromkeys(key.strip() for key in text.replace(",", ";").split(";")
|
|
165
|
+
if key.strip()))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def edit_tags(row: pd.Series) -> dict[str, str]:
|
|
169
|
+
"""The feature's attributes that are tags NetworkForge knows, as text."""
|
|
170
|
+
tags = {}
|
|
171
|
+
for key, value in row.items():
|
|
172
|
+
text = _tag_value(value)
|
|
173
|
+
if key in KNOWN_TAG_KEYS and text is not None:
|
|
174
|
+
tags[key] = text
|
|
175
|
+
return tags
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_edit_tags(edits_gdf: gpd.GeoDataFrame, strict: bool = True) -> None:
|
|
179
|
+
"""The values edits would set must be valid, like those of new lines."""
|
|
180
|
+
issues = []
|
|
181
|
+
unknown = {}
|
|
182
|
+
for feature, row in edits_gdf.drop(columns=edits_gdf.geometry.name).iterrows():
|
|
183
|
+
tags = edit_tags(row)
|
|
184
|
+
problems, _ = tag_value_problems(tags)
|
|
185
|
+
for pair in unknown_values(tags):
|
|
186
|
+
unknown.setdefault(pair, []).append(feature)
|
|
187
|
+
if REMOVE_COLUMN in row and wants_removal(row[REMOVE_COLUMN]) is None:
|
|
188
|
+
problems.append(f"{REMOVE_COLUMN}={_tag_value(row[REMOVE_COLUMN])!r} must be yes or no")
|
|
189
|
+
if "highway" in tags and tags["highway"] not in KNOWN_HIGHWAYS:
|
|
190
|
+
problems.append(f"highway={tags['highway']!r} is not a routable highway value")
|
|
191
|
+
removing = tags_to_remove(row.get(REMOVE_TAGS_COLUMN))
|
|
192
|
+
if "highway" in removing:
|
|
193
|
+
problems.append(f"{REMOVE_TAGS_COLUMN} can't delete highway: use "
|
|
194
|
+
f"{REMOVE_COLUMN}=yes to take the street out")
|
|
195
|
+
for key in sorted(set(removing) & tags.keys()):
|
|
196
|
+
problems.append(f"{key} is both given a value ({tags[key]!r}) and listed in "
|
|
197
|
+
f"{REMOVE_TAGS_COLUMN}")
|
|
198
|
+
issues += [(feature, problem) for problem in problems]
|
|
199
|
+
|
|
200
|
+
warn_about_unknown_values(unknown)
|
|
201
|
+
if issues:
|
|
202
|
+
error = InvalidTagsError(issues)
|
|
203
|
+
if strict:
|
|
204
|
+
raise error
|
|
205
|
+
log.warning("strict=False, continuing anyway: %s", error,
|
|
206
|
+
extra={"features": [i["feature"] for i in error.issues]})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _oneway_state(value: str | None, tags: dict | None = None) -> str:
|
|
210
|
+
"""
|
|
211
|
+
'forward', 'backward' or 'both', relative to the direction of the
|
|
212
|
+
way's nodes. With the way's `tags`: a roundabout is one-way without
|
|
213
|
+
saying so.
|
|
214
|
+
"""
|
|
215
|
+
text = (value or "").lower()
|
|
216
|
+
if text in ONEWAY_FORWARD:
|
|
217
|
+
return "forward"
|
|
218
|
+
if text in ONEWAY_BACKWARD:
|
|
219
|
+
return "backward"
|
|
220
|
+
if value is None and tags and tags.get("junction") in ("roundabout", "circular"):
|
|
221
|
+
return "forward"
|
|
222
|
+
return "both"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def apply_edits(
|
|
226
|
+
edges_gdf: gpd.GeoDataFrame,
|
|
227
|
+
nodes_gdf: gpd.GeoDataFrame,
|
|
228
|
+
edits_gdf: gpd.GeoDataFrame,
|
|
229
|
+
source: OSMSource,
|
|
230
|
+
tolerance: float,
|
|
231
|
+
bidirectional: bool = False,
|
|
232
|
+
strict: bool = True,
|
|
233
|
+
) -> tuple[gpd.GeoDataFrame, dict[int, dict[str, str]], int]:
|
|
234
|
+
"""
|
|
235
|
+
Apply the edit features (see the module docstring) to a copy of the
|
|
236
|
+
OSM edge table.
|
|
237
|
+
|
|
238
|
+
Returns (edges, changes, removed). Edited edges carry
|
|
239
|
+
MODIFIED_COLUMN="yes" and EDIT_COLUMN=n; changes[n] holds the tags
|
|
240
|
+
edit n sets on its stretch of the way, as OSM tags of that way
|
|
241
|
+
(oneway relative to the way's own direction). `removed` is the
|
|
242
|
+
number of streets (GeoPackage rows) taken out.
|
|
243
|
+
|
|
244
|
+
edits_gdf: features with EDIT_ID_COLUMN, in the edges' CRS.
|
|
245
|
+
bidirectional: the network type holds every way in both directions
|
|
246
|
+
(OSMnx's walk network), so one-way changes don't alter the edges.
|
|
247
|
+
"""
|
|
248
|
+
# Each part of a multi-part feature on its own: `oneway` follows the
|
|
249
|
+
# direction each part is drawn in.
|
|
250
|
+
edits_gdf = edits_gdf.explode(index_parts=False)
|
|
251
|
+
|
|
252
|
+
edges = edges_gdf.copy()
|
|
253
|
+
edges[MODIFIED_COLUMN] = None
|
|
254
|
+
edges[EDIT_COLUMN] = np.nan
|
|
255
|
+
issues = [] # (feature, message): the feature can't be applied
|
|
256
|
+
unchanged = [] # features that differ from OSM in nothing
|
|
257
|
+
node_xy = nodes_gdf.geometry
|
|
258
|
+
edit_columns = [c for c in edits_gdf.columns if c != edits_gdf.geometry.name]
|
|
259
|
+
features = list(edits_gdf.index)
|
|
260
|
+
|
|
261
|
+
# 1. What each feature would change, and which edges it lies along.
|
|
262
|
+
wanted: dict[int, dict[str, str]] = {}
|
|
263
|
+
way_steps: dict[int, set[tuple[int, int]]] = {}
|
|
264
|
+
claims = [] # (edge label, distance of its middle from the feature, edit number)
|
|
265
|
+
for number, (feature, row) in enumerate(edits_gdf[edit_columns].iterrows()):
|
|
266
|
+
way_id = int(row[EDIT_ID_COLUMN])
|
|
267
|
+
line = edits_gdf.geometry.iloc[number] # by position: parts share their feature id
|
|
268
|
+
of_way = (edges["osmid"] == way_id).to_numpy()
|
|
269
|
+
if way_id not in source.ways or not of_way.any():
|
|
270
|
+
issues.append((feature, f"OSM way {way_id} is not in this network (outside the "
|
|
271
|
+
"area, not a street, or changed in OSM since the "
|
|
272
|
+
"layer was made)"))
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# The stretches (node to node) of the way the feature lies along.
|
|
276
|
+
middles = shapely.line_interpolate_point(
|
|
277
|
+
edges.geometry.to_numpy()[of_way], 0.5, normalized=True)
|
|
278
|
+
distances = shapely.distance(middles, line)
|
|
279
|
+
near = (distances <= tolerance) & _parallel(edges.geometry.to_numpy()[of_way], line)
|
|
280
|
+
covered = edges.index[of_way][near]
|
|
281
|
+
if len(covered) == 0:
|
|
282
|
+
issues.append((feature, f"doesn't lie along OSM way {way_id}"))
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
refs, current = source.ways[way_id]
|
|
286
|
+
# The way's own steps, node to node. (Not positions along it: a
|
|
287
|
+
# way that closes on itself has its first node twice, and the
|
|
288
|
+
# closing step was read backwards.)
|
|
289
|
+
steps = set(zip(refs, refs[1:], strict=False))
|
|
290
|
+
along_way = _drawn_along(line, edges.loc[covered], steps, node_xy)
|
|
291
|
+
|
|
292
|
+
change = {}
|
|
293
|
+
if REMOVE_COLUMN in row and wants_removal(row[REMOVE_COLUMN]):
|
|
294
|
+
change = dict(REMOVAL)
|
|
295
|
+
# A deleted tag is a change to None.
|
|
296
|
+
if not change:
|
|
297
|
+
change = {key: None for key in tags_to_remove(row.get(REMOVE_TAGS_COLUMN))
|
|
298
|
+
if key in current}
|
|
299
|
+
for key, value in ({} if REMOVE_COLUMN in change
|
|
300
|
+
else edit_tags(row.drop(EDIT_ID_COLUMN))).items():
|
|
301
|
+
if key == "oneway":
|
|
302
|
+
state = _oneway_state(value)
|
|
303
|
+
if not along_way:
|
|
304
|
+
state = {"forward": "backward", "backward": "forward"}.get(state, state)
|
|
305
|
+
if state != _oneway_state(current.get("oneway"), current):
|
|
306
|
+
change[key] = {"forward": "yes", "backward": "-1", "both": "no"}[state]
|
|
307
|
+
elif current.get(key) != value:
|
|
308
|
+
change[key] = value
|
|
309
|
+
if not change:
|
|
310
|
+
unchanged.append(feature)
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
wanted[number], way_steps[number] = change, steps
|
|
314
|
+
claims += zip(covered, distances[near], [number] * len(covered),
|
|
315
|
+
strict=True)
|
|
316
|
+
|
|
317
|
+
# 2. One feature per edge. A stretch shorter than the tolerance is also
|
|
318
|
+
# within reach of the feature on the next stretch: the feature it lies
|
|
319
|
+
# on (distance 0) wins. Two features really on the same stretch must
|
|
320
|
+
# agree on the change.
|
|
321
|
+
owner: dict = {}
|
|
322
|
+
for label, distance, number in sorted(claims, key=lambda claim: (claim[1], claim[2])):
|
|
323
|
+
if label not in owner:
|
|
324
|
+
owner[label] = (distance, number)
|
|
325
|
+
continue
|
|
326
|
+
best_distance, best = owner[label]
|
|
327
|
+
same_place = distance - best_distance <= ON_FEATURE
|
|
328
|
+
both_stand = number in wanted and best in wanted
|
|
329
|
+
if same_place and both_stand and wanted[number] != wanted[best]:
|
|
330
|
+
way_id = int(edges.at[label, "osmid"])
|
|
331
|
+
issues.append((features[number], f"overlaps feature {features[best]} on OSM "
|
|
332
|
+
f"way {way_id} with a different change"))
|
|
333
|
+
wanted.pop(number)
|
|
334
|
+
|
|
335
|
+
# 3. Apply.
|
|
336
|
+
changes: dict[int, dict[str, str]] = {}
|
|
337
|
+
drop, rebuilt, removed = [], [], 0
|
|
338
|
+
for number, change in wanted.items():
|
|
339
|
+
rows = pd.Index([label for label, (_, n) in owner.items() if n == number])
|
|
340
|
+
if rows.empty:
|
|
341
|
+
continue
|
|
342
|
+
if change == REMOVAL:
|
|
343
|
+
log.info("Feature %s removes %d edge(s) of OSM way %d", features[number],
|
|
344
|
+
len(rows), int(edges.at[rows[0], "osmid"]))
|
|
345
|
+
drop.append(rows)
|
|
346
|
+
# Counted per street, as the GeoPackage has them.
|
|
347
|
+
removed += int((~reverse_copies(edges.loc[rows])).sum())
|
|
348
|
+
continue
|
|
349
|
+
changes[number] = change
|
|
350
|
+
log.info("Feature %s changes %d edge(s) of OSM way %d: %s", features[number], len(rows),
|
|
351
|
+
int(edges.at[rows[0], "osmid"]),
|
|
352
|
+
", ".join(f"{k}={v}" if v is not None else f"{k} deleted"
|
|
353
|
+
for k, v in change.items()))
|
|
354
|
+
|
|
355
|
+
for key, value in change.items():
|
|
356
|
+
if key not in edges.columns:
|
|
357
|
+
edges[key] = None
|
|
358
|
+
if key != "oneway":
|
|
359
|
+
edges[key] = edges[key].astype(object)
|
|
360
|
+
edges.loc[rows, key] = value
|
|
361
|
+
edges.loc[rows, MODIFIED_COLUMN] = "yes"
|
|
362
|
+
edges.loc[rows, EDIT_COLUMN] = number
|
|
363
|
+
if "oneway" in change and not bidirectional:
|
|
364
|
+
drop.append(rows)
|
|
365
|
+
way_tags = source.ways[int(edges.at[rows[0], "osmid"])][1]
|
|
366
|
+
rebuilt += _in_direction(edges.loc[rows], way_steps[number],
|
|
367
|
+
_oneway_state(change["oneway"], {**way_tags, **change}))
|
|
368
|
+
if drop:
|
|
369
|
+
edges = pd.concat([edges.drop(drop[0].append(drop[1:])), *rebuilt], ignore_index=True)
|
|
370
|
+
|
|
371
|
+
issues = list(dict.fromkeys(issues)) # the same problem with two parts of one feature
|
|
372
|
+
if issues:
|
|
373
|
+
error = InputError(
|
|
374
|
+
"Some features that change an existing street can't be applied - "
|
|
375
|
+
+ "; ".join(f"feature {feature}: {message}" for feature, message in issues) + ".",
|
|
376
|
+
guide="changing-existing-streets",
|
|
377
|
+
issues=[{"feature": feature, "message": message} for feature, message in issues],
|
|
378
|
+
)
|
|
379
|
+
if strict:
|
|
380
|
+
raise error
|
|
381
|
+
log.warning("strict=False, skipping them: %s", error,
|
|
382
|
+
extra={"features": [feature for feature, _ in issues]})
|
|
383
|
+
unchanged = list(dict.fromkeys(unchanged)) # a feature's parts are listed once
|
|
384
|
+
if unchanged:
|
|
385
|
+
log.warning("%d feature(s) with an OSM id change nothing: their tags are the same "
|
|
386
|
+
"as the way's in OpenStreetMap (features %s).", len(unchanged),
|
|
387
|
+
", ".join(map(str, unchanged)), extra={"features": unchanged})
|
|
388
|
+
|
|
389
|
+
if not changes:
|
|
390
|
+
edges = edges.drop(columns=[MODIFIED_COLUMN, EDIT_COLUMN])
|
|
391
|
+
return edges, changes, removed
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _direction(lines, at) -> np.ndarray:
|
|
395
|
+
"""Unit vectors along `lines` at distances `at` along them (1 m each side)."""
|
|
396
|
+
lengths = shapely.length(lines)
|
|
397
|
+
ahead = shapely.line_interpolate_point(lines, np.minimum(at + 1, lengths))
|
|
398
|
+
behind = shapely.line_interpolate_point(lines, np.maximum(at - 1, 0))
|
|
399
|
+
vectors = shapely.get_coordinates(ahead) - shapely.get_coordinates(behind)
|
|
400
|
+
return vectors / np.maximum(np.linalg.norm(vectors, axis=1), 1e-12)[:, None]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _parallel(stretches: np.ndarray, line) -> np.ndarray:
|
|
404
|
+
"""For each stretch: does `line`, where nearest its middle, run along it (MAX_ANGLE)?"""
|
|
405
|
+
middles = shapely.line_interpolate_point(stretches, 0.5, normalized=True)
|
|
406
|
+
along = _direction(stretches, shapely.length(stretches) / 2)
|
|
407
|
+
feature = np.full(len(stretches), line, dtype=object)
|
|
408
|
+
drawn = _direction(feature, shapely.line_locate_point(feature, middles))
|
|
409
|
+
return np.abs((along * drawn).sum(axis=1)) >= np.cos(np.radians(MAX_ANGLE))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _drawn_along(line, covered: gpd.GeoDataFrame, steps: set, node_xy) -> bool:
|
|
413
|
+
"""
|
|
414
|
+
Is the feature `line` drawn in the direction of the way's nodes?
|
|
415
|
+
Each covered step of the way votes: does a point early on it come
|
|
416
|
+
before a point late on it, along the line.
|
|
417
|
+
"""
|
|
418
|
+
votes = 0
|
|
419
|
+
for u, v in {(int(u), int(v)) for u, v in zip(covered["u"], covered["v"], strict=True)}:
|
|
420
|
+
if (u, v) not in steps:
|
|
421
|
+
if (v, u) not in steps:
|
|
422
|
+
continue
|
|
423
|
+
u, v = v, u
|
|
424
|
+
step = shapely.LineString([node_xy.loc[u], node_xy.loc[v]])
|
|
425
|
+
early, late = (line.project(step.interpolate(part, normalized=True))
|
|
426
|
+
for part in (0.25, 0.75))
|
|
427
|
+
votes += (late > early) - (late < early)
|
|
428
|
+
return votes >= 0
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _in_direction(stretch: gpd.GeoDataFrame, steps: set[tuple[int, int]], state: str) -> list:
|
|
432
|
+
"""
|
|
433
|
+
The edges of some stretches of one way, rebuilt for a new one-way
|
|
434
|
+
state the way OSMnx holds them: a one-way stretch is one edge in the
|
|
435
|
+
direction of travel; a two-way stretch is two, the second marked
|
|
436
|
+
reversed. `steps`: the way's (node, next node) pairs.
|
|
437
|
+
"""
|
|
438
|
+
if "reversed" in stretch:
|
|
439
|
+
stretch = stretch[stretch["reversed"] != True] # noqa: E712 - one row per stretch
|
|
440
|
+
|
|
441
|
+
# Each row oriented along the way (from the earlier node to the later).
|
|
442
|
+
against = np.array([(int(u), int(v)) not in steps and (int(v), int(u)) in steps
|
|
443
|
+
for u, v in zip(stretch["u"], stretch["v"], strict=True)], dtype=bool)
|
|
444
|
+
forward = stretch.copy()
|
|
445
|
+
forward.loc[against, ["u", "v"]] = stretch.loc[against, ["v", "u"]].to_numpy()
|
|
446
|
+
geometries = forward.geometry.to_numpy().copy()
|
|
447
|
+
geometries[against] = shapely.reverse(geometries[against])
|
|
448
|
+
forward = forward.set_geometry(geometries, crs=stretch.crs)
|
|
449
|
+
|
|
450
|
+
backward = forward.copy()
|
|
451
|
+
backward[["u", "v"]] = forward[["v", "u"]].to_numpy()
|
|
452
|
+
backward = backward.set_geometry(shapely.reverse(geometries), crs=stretch.crs)
|
|
453
|
+
|
|
454
|
+
forward["reversed"], backward["reversed"] = False, state == "both"
|
|
455
|
+
forward["oneway"] = backward["oneway"] = state != "both"
|
|
456
|
+
return {"forward": [forward], "backward": [backward], "both": [forward, backward]}[state]
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def is_bidirectional(network_type: str) -> bool:
|
|
460
|
+
return network_type in ox.settings.bidirectional_network_types
|