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/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
@@ -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
+ )