r5py 1.1.0__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.
Files changed (49) hide show
  1. r5py/__init__.py +27 -0
  2. r5py/__main__.py +3 -0
  3. r5py/r5/__init__.py +39 -0
  4. r5py/r5/access_leg.py +12 -0
  5. r5py/r5/base_travel_time_matrix.py +255 -0
  6. r5py/r5/detailed_itineraries.py +226 -0
  7. r5py/r5/direct_leg.py +38 -0
  8. r5py/r5/egress_leg.py +12 -0
  9. r5py/r5/elevation_cost_function.py +50 -0
  10. r5py/r5/elevation_model.py +89 -0
  11. r5py/r5/file_storage.py +82 -0
  12. r5py/r5/isochrones.py +345 -0
  13. r5py/r5/regional_task.py +600 -0
  14. r5py/r5/scenario.py +36 -0
  15. r5py/r5/street_layer.py +90 -0
  16. r5py/r5/street_segment.py +39 -0
  17. r5py/r5/transfer_leg.py +12 -0
  18. r5py/r5/transit_layer.py +87 -0
  19. r5py/r5/transit_leg.py +12 -0
  20. r5py/r5/transport_mode.py +148 -0
  21. r5py/r5/transport_network.py +299 -0
  22. r5py/r5/travel_time_matrix.py +186 -0
  23. r5py/r5/trip.py +97 -0
  24. r5py/r5/trip_leg.py +204 -0
  25. r5py/r5/trip_planner.py +576 -0
  26. r5py/util/__init__.py +31 -0
  27. r5py/util/camel_to_snake_case.py +25 -0
  28. r5py/util/classpath.py +95 -0
  29. r5py/util/config.py +176 -0
  30. r5py/util/contains_gtfs_data.py +46 -0
  31. r5py/util/data_validation.py +28 -0
  32. r5py/util/environment.py +32 -0
  33. r5py/util/exceptions.py +43 -0
  34. r5py/util/file_digest.py +40 -0
  35. r5py/util/good_enough_equidistant_crs.py +73 -0
  36. r5py/util/jvm.py +138 -0
  37. r5py/util/memory_footprint.py +178 -0
  38. r5py/util/parse_int_date.py +24 -0
  39. r5py/util/sample_data_set.py +76 -0
  40. r5py/util/snake_to_camel_case.py +16 -0
  41. r5py/util/spatially_clustered_geodataframe.py +66 -0
  42. r5py/util/validating_requests_session.py +58 -0
  43. r5py/util/warnings.py +7 -0
  44. r5py/util/working_copy.py +42 -0
  45. r5py-1.1.0.dist-info/METADATA +176 -0
  46. r5py-1.1.0.dist-info/RECORD +49 -0
  47. r5py-1.1.0.dist-info/WHEEL +5 -0
  48. r5py-1.1.0.dist-info/licenses/LICENSE +3 -0
  49. r5py-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ """Load a digital elevation model and apply it to an r5py.TransportNetwork."""
5
+
6
+ import rasterio
7
+
8
+ from .elevation_cost_function import ElevationCostFunction
9
+ from .file_storage import FileStorage
10
+ from ..util import WorkingCopy
11
+
12
+ import com.conveyal.analysis
13
+ import com.conveyal.r5
14
+
15
+ __all__ = ["ElevationModel"]
16
+
17
+
18
+ class ElevationModel:
19
+ """Load a digital elevation model and apply it to an r5py.TransportNetwork."""
20
+
21
+ def __init__(
22
+ self,
23
+ elevation_model,
24
+ elevation_cost_function=ElevationCostFunction.TOBLER,
25
+ ):
26
+ """
27
+ Load an elevation model.
28
+
29
+ Arguments
30
+ ---------
31
+ elevation_model : str | pathlib.Path
32
+ file path to a digital elevation model in TIF format,
33
+ single-band, the value of which is the elevation in metres
34
+ elevation_cost_function : r5py.ElevationCostFunction
35
+ which algorithm to use to compute the added effort and travel time
36
+ of slopes
37
+ """
38
+ elevation_model = WorkingCopy(elevation_model)
39
+ elevation_model = self._convert_tiff_to_format_readable_by_r5(elevation_model)
40
+
41
+ # instantiate an com.conveyal.file.FileStorage singleton
42
+ com.conveyal.analysis.components.WorkerComponents.fileStorage = FileStorage()
43
+
44
+ self._elevation_model = com.conveyal.r5.analyst.scenario.RasterCost()
45
+ self._elevation_model.dataSourceId = f"{elevation_model.with_suffix('')}"
46
+ self._elevation_model.costFunction = elevation_cost_function
47
+
48
+ def apply_to(self, transport_network):
49
+ """
50
+ Add the costs associated with elevation traversal to a transport network.
51
+
52
+ Arguments
53
+ ---------
54
+ transport_network : r5py.TransportNetwork
55
+ The transport network to which to add slope costs
56
+ """
57
+ self._elevation_model.resolve(transport_network)
58
+ self._elevation_model.apply(transport_network)
59
+
60
+ @staticmethod
61
+ def _convert_tiff_to_format_readable_by_r5(tiff):
62
+ # javax.imagio does not allow all compression/predictor
63
+ # combinations of TIFFs
64
+ # to work around it, convert the input to a format known to work.
65
+
66
+ input_tiff = tiff.with_stem(f".{tiff.stem}")
67
+ output_tiff = tiff.with_suffix(".tif")
68
+ tiff.rename(input_tiff)
69
+
70
+ with rasterio.open(input_tiff) as source:
71
+ metadata = source.profile
72
+ metadata.update(
73
+ {
74
+ "compress": "LZW",
75
+ "predictor": "2",
76
+ }
77
+ )
78
+
79
+ # rasterio warns if these are in invalid combinations,
80
+ # let it choose itself
81
+ del metadata["blockxsize"]
82
+ del metadata["blockysize"]
83
+ del metadata["tiled"]
84
+
85
+ with rasterio.open(output_tiff, "w", **metadata) as destination:
86
+ destination.write(source.read())
87
+ input_tiff.unlink()
88
+
89
+ return output_tiff
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ """A thin layer around com.conveyal.r5.file.FileStorage."""
5
+
6
+ import jpype
7
+
8
+ from ..util import start_jvm
9
+
10
+ import com.conveyal.file
11
+ import java.io.File
12
+
13
+ __all__ = ["FileStorage"]
14
+
15
+
16
+ start_jvm()
17
+
18
+
19
+ @jpype.JImplements(com.conveyal.file.FileStorage)
20
+ class FileStorage:
21
+ """A thin layer around com.conveyal.r5.file.FileStorage."""
22
+
23
+ @jpype.JOverride
24
+ def moveIntoStorage(self, *args):
25
+ """Not implemented."""
26
+ pass
27
+
28
+ @jpype.JOverride
29
+ def getFile(self, file_storage_key):
30
+ """
31
+ Return a java.io.File for the file identified by file_storage_key.
32
+
33
+ Arguments
34
+ ---------
35
+ file_storage_key : com.conveyal.r5.file.FileStorageKey
36
+ An R5 object referencing a certain file
37
+
38
+ Returns
39
+ -------
40
+ java.io.File
41
+ The file identified by file_storage_key
42
+ """
43
+ return java.io.File(file_storage_key.path)
44
+
45
+ @jpype.JOverride
46
+ def getURL(self, file_storage_key):
47
+ """
48
+ Return an URL for the file identified by file_storage_key.
49
+
50
+ Arguments
51
+ ---------
52
+ file_storage_key : com.conveyal.r5.file.FileStorageKey
53
+ An R5 object referencing a certain file
54
+
55
+ Returns
56
+ -------
57
+ str
58
+ An imaginary URL pointing to the file identified by file_storage_key
59
+ """
60
+ return f"file://{file_storage_key.path}"
61
+
62
+ @jpype.JOverride
63
+ def delete(self, *args):
64
+ """Not implemented."""
65
+ pass
66
+
67
+ @jpype.JOverride
68
+ def exists(self, file_storage_key):
69
+ """
70
+ Check whether the file identified by file_storage_key exists.
71
+
72
+ Arguments
73
+ ---------
74
+ file_storage_key : com.conveyal.r5.file.FileStorageKey
75
+ An R5 object referencing a certain file
76
+
77
+ Returns
78
+ -------
79
+ bool
80
+ Whether or not the file identified by file_storage_key exists
81
+ """
82
+ return self.getFile(file_storage_key).exists()
r5py/r5/isochrones.py ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ """Compute polygons of equal travel time from a destination."""
5
+
6
+ import datetime
7
+ import warnings
8
+
9
+ import geohexgrid
10
+ import geopandas
11
+ import pandas
12
+ import pyproj
13
+ import shapely
14
+ import simplification.cutil
15
+
16
+ from .base_travel_time_matrix import BaseTravelTimeMatrix
17
+ from .transport_mode import TransportMode
18
+ from .travel_time_matrix import TravelTimeMatrix
19
+ from ..util import GoodEnoughEquidistantCrs, SpatiallyClusteredGeoDataFrame
20
+
21
+ __all__ = ["Isochrones"]
22
+
23
+
24
+ EMPTY_POINT = shapely.Point()
25
+ R5_CRS = "EPSG:4326"
26
+
27
+ CONCAVE_HULL_BUFFER_SIZE = 20.0 # metres
28
+ CONCAVE_HULL_RATIO = 0.3
29
+
30
+ VERY_SMALL_BUFFER_SIZE = 0.001 # turn points into polygons
31
+
32
+
33
+ class Isochrones(BaseTravelTimeMatrix):
34
+ """Compute polygons of equal travel time from a destination."""
35
+
36
+ _r5py_attributes = BaseTravelTimeMatrix._r5py_attributes + [
37
+ "_isochrones",
38
+ "isochrones",
39
+ "point_grid_resolution",
40
+ "point_grid_sample_ratio",
41
+ ]
42
+
43
+ def __init__(
44
+ self,
45
+ transport_network,
46
+ origins,
47
+ isochrones=pandas.timedelta_range( # noqa: B008
48
+ start=datetime.timedelta(minutes=0), # noqa: B008
49
+ end=datetime.timedelta(hours=1), # noqa: B008
50
+ freq=datetime.timedelta(minutes=15), # noqa: B008
51
+ ),
52
+ point_grid_resolution=100,
53
+ point_grid_sample_ratio=1.0,
54
+ **kwargs,
55
+ ):
56
+ """
57
+ Compute polygons of equal travel time from one or more destinations.
58
+
59
+ ``r5py.Isochrones`` are child classes of ``geopandas.GeoDataFrame`` and
60
+ support all of their methods and properties, see
61
+ https://geopandas.org/en/stable/docs.html
62
+
63
+ Arguments
64
+ ---------
65
+ transport_network : r5py.TransportNetwork | tuple(str, list(str), dict)
66
+ The transport network to route on. This can either be a readily
67
+ initialised r5py.TransportNetwork or a tuple of the parameters
68
+ passed to ``TransportNetwork.__init__()``: the path to an OpenStreetMap
69
+ extract in PBF format, a list of zero of more paths to GTFS transport
70
+ schedule files, and a dict with ``build_config`` options.
71
+ origins : geopandas.GeoDataFrame | shapely.Point
72
+ Place(s) to find a route _from_
73
+ Must be/have a point geometry. If multiple origin points are passed,
74
+ isochrones will be computed as minimum travel time from any of them.
75
+ isochrones : pandas.TimedeltaIndex | collections.abc.Iterable[int]
76
+ For which interval to compute isochrone polygons. An iterable of
77
+ integers is interpreted as minutes.
78
+ point_grid_resolution : int
79
+ Distance in meters between points in the regular grid of points laid
80
+ over the transport network’s extent that is used to compute
81
+ isochrones. Increase this value for performance, decrease it for
82
+ precision.
83
+ point_grid_sample_ratio : float
84
+ Share of points of the point grid that are used in computation,
85
+ ranging from 0.01 to 1.0.
86
+ Increase this value for performance, decrease it for precision.
87
+ **kwargs : mixed
88
+ Any arguments than can be passed to r5py.RegionalTask:
89
+ ``departure``, ``departure_time_window``, ``percentiles``,
90
+ ``transport_modes``, ``access_modes``, ``egress_modes``,
91
+ ``max_time``, ``max_time_walking``, ``max_time_cycling``,
92
+ ``max_time_driving``, ``speed_cycling``, ``speed_walking``,
93
+ ``max_public_transport_rides``, ``max_bicycle_traffic_stress`` Note
94
+ that not all arguments might make sense in this context, and the
95
+ underlying R5 engine might ignore some of them. If percentiles are
96
+ specified, the lowest one will be used for isochrone computation.
97
+ """
98
+ super().__init__(transport_network, **kwargs)
99
+
100
+ self.EQUIDISTANT_CRS = GoodEnoughEquidistantCrs(self.transport_network.extent)
101
+
102
+ if isinstance(origins, shapely.Geometry):
103
+ origins = geopandas.GeoDataFrame(
104
+ {
105
+ "id": [
106
+ "origin",
107
+ ],
108
+ "geometry": [
109
+ origins,
110
+ ],
111
+ },
112
+ crs=R5_CRS,
113
+ )
114
+ self.origins = origins
115
+ self.isochrones = isochrones
116
+
117
+ self.point_grid_resolution = point_grid_resolution
118
+ self.point_grid_sample_ratio = max(0.01, min(1.0, point_grid_sample_ratio))
119
+
120
+ travel_times = TravelTimeMatrix(
121
+ transport_network,
122
+ origins=self.origins,
123
+ destinations=self.destinations,
124
+ max_time=self.isochrones.max(),
125
+ **kwargs,
126
+ )
127
+
128
+ data = self._compute_isochrones_from_travel_times(travel_times)
129
+
130
+ with warnings.catch_warnings():
131
+ warnings.filterwarnings(
132
+ "ignore",
133
+ message=(
134
+ "You are adding a column named 'geometry' to a GeoDataFrame "
135
+ "constructed without an active geometry column"
136
+ ),
137
+ category=FutureWarning,
138
+ )
139
+ for column in data.columns:
140
+ self[column] = data[column]
141
+ self.set_geometry("geometry")
142
+
143
+ del self.transport_network
144
+
145
+ def _compute_isochrones_from_travel_times(self, travel_times):
146
+ travel_times = travel_times.dropna().groupby("to_id").min().reset_index()
147
+
148
+ if self.request.percentiles == [50]:
149
+ travel_time_column = "travel_time"
150
+ else:
151
+ travel_time_column = f"travel_time_p{self.request.percentiles[0]:d}"
152
+
153
+ isochrones = {
154
+ "travel_time": [],
155
+ "geometry": [],
156
+ }
157
+
158
+ for isochrone in self.isochrones:
159
+ reached_nodes = (
160
+ self.destinations.set_index("id")
161
+ .join(
162
+ travel_times[
163
+ travel_times[travel_time_column]
164
+ <= (isochrone.total_seconds() / 60)
165
+ ].set_index("to_id"),
166
+ how="inner",
167
+ )
168
+ .reset_index()
169
+ )
170
+
171
+ # isochrone polygons might be disjoint (e.g., around metro stops)
172
+ if not reached_nodes.empty:
173
+ reached_nodes = SpatiallyClusteredGeoDataFrame(
174
+ reached_nodes, eps=(2.0 * self.point_grid_resolution)
175
+ ).to_crs(self.EQUIDISTANT_CRS)
176
+ isochrone_polygons = pandas.concat(
177
+ [
178
+ (
179
+ reached_nodes[reached_nodes["cluster"] != -1]
180
+ .dissolve(by="cluster")
181
+ .concave_hull(ratio=CONCAVE_HULL_RATIO)
182
+ .buffer(VERY_SMALL_BUFFER_SIZE)
183
+ ),
184
+ (
185
+ reached_nodes[reached_nodes["cluster"] == -1].buffer(
186
+ VERY_SMALL_BUFFER_SIZE
187
+ )
188
+ ),
189
+ ]
190
+ ).union_all()
191
+
192
+ isochrones["travel_time"].append(isochrone)
193
+ isochrones["geometry"].append(isochrone_polygons)
194
+
195
+ isochrones = geopandas.GeoDataFrame(
196
+ isochrones, geometry="geometry", crs=self.EQUIDISTANT_CRS
197
+ )
198
+
199
+ # clip smaller isochrones by larger isochrones
200
+ # (concave_hull’s ratio parameter depends on input shapes and does not
201
+ # produce the same results, e.g., around bridges or at the coast line)
202
+ for row in range(len(isochrones) - 2, 0, -1):
203
+ isochrones.loc[row, "geometry"] = shapely.intersection(
204
+ isochrones.loc[row, "geometry"], isochrones.loc[row + 1, "geometry"]
205
+ )
206
+
207
+ isochrones["geometry"] = (
208
+ isochrones["geometry"]
209
+ .buffer(CONCAVE_HULL_BUFFER_SIZE)
210
+ .boundary.apply(
211
+ lambda geometry: (
212
+ geometry
213
+ if isinstance(geometry, shapely.MultiLineString)
214
+ else shapely.MultiLineString([geometry])
215
+ )
216
+ )
217
+ .apply(
218
+ lambda multilinestring: (
219
+ shapely.MultiLineString(
220
+ [
221
+ simplification.cutil.simplify_coords_vwp(
222
+ linestring.coords,
223
+ self.point_grid_resolution * 5.0,
224
+ )
225
+ for linestring in multilinestring.geoms
226
+ ]
227
+ )
228
+ )
229
+ )
230
+ .to_crs(R5_CRS)
231
+ )
232
+
233
+ return isochrones
234
+
235
+ @property
236
+ def destinations(self):
237
+ """A regular grid of points covering the range of the chosen transport mode."""
238
+ try:
239
+ return self._destinations
240
+ except AttributeError:
241
+ destinations = self._regular_point_grid
242
+ destinations["geometry"] = self.transport_network.snap_to_network(
243
+ destinations["geometry"]
244
+ )
245
+ destinations = destinations[destinations["geometry"] != EMPTY_POINT]
246
+ destinations["geometry"] = destinations["geometry"].normalize()
247
+ destinations = destinations.drop_duplicates()
248
+
249
+ # with snapping, sometimes we end up with clumps of points
250
+ # below, we try to form clusters, from all clusters we retain
251
+ # one geometry, only
252
+ destinations = SpatiallyClusteredGeoDataFrame(
253
+ destinations, eps=(0.5 * self.point_grid_resolution)
254
+ )
255
+ destinations = pandas.concat(
256
+ [
257
+ (
258
+ destinations[destinations["cluster"] != -1]
259
+ .groupby("cluster")
260
+ .first()
261
+ .set_crs(R5_CRS)
262
+ ),
263
+ destinations[destinations["cluster"] == -1],
264
+ ]
265
+ )[["id", "geometry"]].copy()
266
+
267
+ if self.point_grid_sample_ratio < 1.0:
268
+ destinations = destinations.sample(frac=self.point_grid_sample_ratio)
269
+
270
+ self._destinations = destinations
271
+
272
+ return destinations
273
+
274
+ @destinations.setter
275
+ def destinations(self, destinations):
276
+ # https://bugs.python.org/issue14965
277
+ super(self.__class__, self.__class__).destinations.__set__(self, destinations)
278
+
279
+ @property
280
+ def isochrones(self):
281
+ """
282
+ Compute isochrones for these travel times.
283
+
284
+ pandas.TimedeltaIndex | collections.abc.Iterable[int]
285
+ An iterable of integers is interpreted as minutes.
286
+ """
287
+ try:
288
+ return self._isochrones
289
+ except AttributeError:
290
+ raise
291
+
292
+ @isochrones.setter
293
+ def isochrones(self, isochrones):
294
+ if not isinstance(isochrones, pandas.TimedeltaIndex):
295
+ isochrones = pandas.to_timedelta(isochrones, unit="minutes")
296
+ try:
297
+ # do not compute for 0 travel time
298
+ isochrones = isochrones.drop(datetime.timedelta(0))
299
+ except KeyError:
300
+ pass
301
+ self._isochrones = isochrones
302
+
303
+ @property
304
+ def _regular_point_grid(self):
305
+ extent = shapely.ops.transform(
306
+ pyproj.Transformer.from_crs(
307
+ R5_CRS,
308
+ self.EQUIDISTANT_CRS,
309
+ always_xy=True,
310
+ ).transform,
311
+ self.transport_network.extent,
312
+ )
313
+
314
+ grid = geohexgrid.make_grid_from_bounds(
315
+ *extent.bounds,
316
+ self.point_grid_resolution,
317
+ crs=self.EQUIDISTANT_CRS,
318
+ )
319
+ grid["geometry"] = grid["geometry"].centroid
320
+ grid["id"] = grid.index
321
+ grid = grid[["id", "geometry"]].to_crs(R5_CRS)
322
+
323
+ # for walking and cycling, we can clip the extent to an area reachable
324
+ # by the (well-defined) travel speeds:
325
+ if set(self.request.transport_modes) <= set(
326
+ (TransportMode.WALK, TransportMode.BICYCLE)
327
+ ):
328
+ if TransportMode.WALK in self.request.transport_modes:
329
+ speed = self.request.speed_walking
330
+ if TransportMode.BICYCLE in self.request.transport_modes:
331
+ speed = self.request.speed_cycling
332
+
333
+ speed = speed * (1000.0 / 3600.0) * 1.1 # km/h -> m/s, plus a bit of buffer
334
+
335
+ grid = grid.clip(
336
+ (
337
+ pandas.concat([self.origins] * 2) # workaround until
338
+ # https://github.com/pyproj4/pyproj/issues/1309 is fixed
339
+ .to_crs(self.EQUIDISTANT_CRS)
340
+ .buffer(speed * max(self.isochrones).total_seconds())
341
+ .to_crs(R5_CRS)
342
+ )
343
+ )
344
+
345
+ return grid.copy()