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.
@@ -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