r5py 0.1.1.dev2__py3-none-any.whl → 1.0.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.

Potentially problematic release.


This version of r5py might be problematic. Click here for more details.

Files changed (38) hide show
  1. r5py/__init__.py +8 -1
  2. r5py/__main__.py +1 -14
  3. r5py/r5/__init__.py +20 -2
  4. r5py/r5/{base_travel_time_matrix_computer.py → base_travel_time_matrix.py} +28 -8
  5. r5py/r5/{detailed_itineraries_computer.py → detailed_itineraries.py} +82 -20
  6. r5py/r5/direct_leg.py +1 -3
  7. r5py/r5/isochrones.py +351 -0
  8. r5py/r5/regional_task.py +12 -9
  9. r5py/r5/street_layer.py +8 -3
  10. r5py/r5/street_segment.py +41 -0
  11. r5py/r5/transfer_leg.py +2 -6
  12. r5py/r5/transit_layer.py +6 -0
  13. r5py/r5/transit_leg.py +1 -5
  14. r5py/r5/transport_mode.py +5 -3
  15. r5py/r5/transport_network.py +60 -138
  16. r5py/r5/travel_time_matrix.py +209 -0
  17. r5py/r5/trip.py +13 -8
  18. r5py/r5/trip_leg.py +76 -15
  19. r5py/r5/trip_planner.py +109 -54
  20. r5py/util/__init__.py +8 -0
  21. r5py/util/classpath.py +9 -5
  22. r5py/util/config.py +32 -7
  23. r5py/util/environment.py +34 -0
  24. r5py/util/file_digest.py +42 -0
  25. r5py/util/good_enough_equidistant_crs.py +8 -4
  26. r5py/util/memory_footprint.py +3 -5
  27. r5py/util/sample_data_set.py +17 -6
  28. r5py/util/spatially_clustered_geodataframe.py +78 -0
  29. r5py/util/validating_requests_session.py +2 -2
  30. r5py/util/working_copy.py +44 -0
  31. {r5py-0.1.1.dev2.dist-info → r5py-1.0.0.dist-info}/METADATA +34 -33
  32. r5py-1.0.0.dist-info/RECORD +47 -0
  33. {r5py-0.1.1.dev2.dist-info → r5py-1.0.0.dist-info}/WHEEL +1 -1
  34. r5py/r5/travel_time_matrix_computer.py +0 -134
  35. r5py/sampledata/_keep/__init__.py +0 -3
  36. r5py-0.1.1.dev2.dist-info/RECORD +0 -42
  37. {r5py-0.1.1.dev2.dist-info → r5py-1.0.0.dist-info}/LICENSE +0 -0
  38. {r5py-0.1.1.dev2.dist-info → r5py-1.0.0.dist-info}/top_level.txt +0 -0
r5py/r5/isochrones.py ADDED
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env python3
2
+
3
+
4
+ """Compute polygons of equal travel time from a destination."""
5
+
6
+
7
+ import datetime
8
+ import warnings
9
+
10
+ import geohexgrid
11
+ import geopandas
12
+ import pandas
13
+ import pyproj
14
+ import shapely
15
+ import simplification.cutil
16
+
17
+ from .base_travel_time_matrix import BaseTravelTimeMatrix
18
+ from .transport_mode import TransportMode
19
+ from .travel_time_matrix import TravelTimeMatrix
20
+ from ..util import GoodEnoughEquidistantCrs, SpatiallyClusteredGeoDataFrame
21
+
22
+
23
+ __all__ = ["Isochrones"]
24
+
25
+
26
+ EMPTY_POINT = shapely.Point()
27
+ R5_CRS = "EPSG:4326"
28
+
29
+ CONCAVE_HULL_BUFFER_SIZE = 20.0 # metres
30
+ CONCAVE_HULL_RATIO = 0.3
31
+
32
+ VERY_SMALL_BUFFER_SIZE = 0.001 # turn points into polygons
33
+
34
+
35
+ class Isochrones(BaseTravelTimeMatrix):
36
+ """Compute polygons of equal travel time from a destination."""
37
+
38
+ _r5py_attributes = BaseTravelTimeMatrix._r5py_attributes + [
39
+ "_isochrones",
40
+ "isochrones",
41
+ "point_grid_resolution",
42
+ "point_grid_sample_ratio",
43
+ ]
44
+
45
+ def __init__(
46
+ self,
47
+ transport_network,
48
+ origins,
49
+ isochrones=pandas.timedelta_range(
50
+ start=datetime.timedelta(minutes=0),
51
+ end=datetime.timedelta(hours=1),
52
+ freq=datetime.timedelta(minutes=15),
53
+ ),
54
+ point_grid_resolution=100,
55
+ point_grid_sample_ratio=1.0,
56
+ **kwargs,
57
+ ):
58
+ """
59
+ Compute polygons of equal travel time from one or more destinations.
60
+
61
+ ``r5py.Isochrones`` are child classes of ``geopandas.GeoDataFrame`` and
62
+ support all of their methods and properties, see
63
+ https://geopandas.org/en/stable/docs.html
64
+
65
+ Arguments
66
+ ---------
67
+ transport_network : r5py.TransportNetwork | tuple(str, list(str), dict)
68
+ The transport network to route on. This can either be a readily
69
+ initialised r5py.TransportNetwork or a tuple of the parameters
70
+ passed to ``TransportNetwork.__init__()``: the path to an OpenStreetMap
71
+ extract in PBF format, a list of zero of more paths to GTFS transport
72
+ schedule files, and a dict with ``build_config`` options.
73
+ origins : geopandas.GeoDataFrame | shapely.Point
74
+ Place(s) to find a route _from_
75
+ Must be/have a point geometry. If multiple origin points are passed,
76
+ isochrones will be computed as minimum travel time from any of them.
77
+ isochrones : pandas.TimedeltaIndex | collections.abc.Iterable[int]
78
+ For which interval to compute isochrone polygons. An iterable of
79
+ integers is interpreted as minutes.
80
+ point_grid_resolution : int
81
+ Distance in meters between points in the regular grid of points laid over the
82
+ transport network’s extent that is used to compute isochrones.
83
+ Increase this value for performance, decrease it for precision.
84
+ point_grid_sample_ratio : float
85
+ Share of points of the point grid that are used in computation,
86
+ ranging from 0.01 to 1.0.
87
+ Increase this value for performance, decrease it for precision.
88
+ **kwargs : mixed
89
+ Any arguments than can be passed to r5py.RegionalTask:
90
+ ``departure``, ``departure_time_window``, ``percentiles``, ``transport_modes``,
91
+ ``access_modes``, ``egress_modes``, ``max_time``, ``max_time_walking``,
92
+ ``max_time_cycling``, ``max_time_driving``, ``speed_cycling``, ``speed_walking``,
93
+ ``max_public_transport_rides``, ``max_bicycle_traffic_stress``
94
+ Note that not all arguments might make sense in this context, and the
95
+ underlying R5 engine might ignore some of them.
96
+ If percentiles are specified, the lowest one will be used for
97
+ isochrone computation.
98
+ """
99
+ geopandas.GeoDataFrame.__init__(self)
100
+ BaseTravelTimeMatrix.__init__(
101
+ self,
102
+ transport_network,
103
+ **kwargs,
104
+ )
105
+
106
+ self.EQUIDISTANT_CRS = GoodEnoughEquidistantCrs(self.transport_network.extent)
107
+
108
+ if isinstance(origins, shapely.Geometry):
109
+ origins = geopandas.GeoDataFrame(
110
+ {
111
+ "id": [
112
+ "origin",
113
+ ],
114
+ "geometry": [
115
+ origins,
116
+ ],
117
+ },
118
+ crs=R5_CRS,
119
+ )
120
+ self.origins = origins
121
+ self.isochrones = isochrones
122
+
123
+ self.point_grid_resolution = point_grid_resolution
124
+ self.point_grid_sample_ratio = max(0.01, min(1.0, point_grid_sample_ratio))
125
+
126
+ travel_times = TravelTimeMatrix(
127
+ transport_network,
128
+ origins=self.origins,
129
+ destinations=self.destinations,
130
+ max_time=self.isochrones.max(),
131
+ **kwargs,
132
+ )
133
+
134
+ data = self._compute_isochrones_from_travel_times(travel_times)
135
+
136
+ with warnings.catch_warnings():
137
+ warnings.filterwarnings(
138
+ "ignore",
139
+ message=(
140
+ "You are adding a column named 'geometry' to a GeoDataFrame "
141
+ "constructed without an active geometry column"
142
+ ),
143
+ category=FutureWarning,
144
+ )
145
+ for column in data.columns:
146
+ self[column] = data[column]
147
+ self.set_geometry("geometry")
148
+
149
+ del self.transport_network
150
+
151
+ def _compute_isochrones_from_travel_times(self, travel_times):
152
+ travel_times = travel_times.dropna().groupby("to_id").min().reset_index()
153
+
154
+ if self.request.percentiles == [50]:
155
+ travel_time_column = "travel_time"
156
+ else:
157
+ travel_time_column = f"travel_time_p{self.request.percentiles[0]:d}"
158
+
159
+ isochrones = {
160
+ "travel_time": [],
161
+ "geometry": [],
162
+ }
163
+
164
+ for isochrone in self.isochrones:
165
+ reached_nodes = (
166
+ self.destinations.set_index("id")
167
+ .join(
168
+ travel_times[
169
+ travel_times[travel_time_column]
170
+ <= (isochrone.total_seconds() / 60)
171
+ ].set_index("to_id"),
172
+ how="inner",
173
+ )
174
+ .reset_index()
175
+ )
176
+
177
+ # isochrone polygons might be disjoint (e.g., around metro stops)
178
+ if not reached_nodes.empty:
179
+ reached_nodes = SpatiallyClusteredGeoDataFrame(
180
+ reached_nodes, eps=(2.0 * self.point_grid_resolution)
181
+ ).to_crs(self.EQUIDISTANT_CRS)
182
+ isochrone_polygons = pandas.concat(
183
+ [
184
+ (
185
+ reached_nodes[reached_nodes["cluster"] != -1]
186
+ .dissolve(by="cluster")
187
+ .concave_hull(ratio=CONCAVE_HULL_RATIO)
188
+ .buffer(VERY_SMALL_BUFFER_SIZE)
189
+ ),
190
+ (
191
+ reached_nodes[reached_nodes["cluster"] == -1].buffer(
192
+ VERY_SMALL_BUFFER_SIZE
193
+ )
194
+ ),
195
+ ]
196
+ ).union_all()
197
+
198
+ isochrones["travel_time"].append(isochrone)
199
+ isochrones["geometry"].append(isochrone_polygons)
200
+
201
+ isochrones = geopandas.GeoDataFrame(
202
+ isochrones, geometry="geometry", crs=self.EQUIDISTANT_CRS
203
+ )
204
+
205
+ # clip smaller isochrones by larger isochrones
206
+ # (concave_hull’s ratio parameter depends on input shapes and does not
207
+ # produce the same results, e.g., around bridges or at the coast line)
208
+ for row in range(len(isochrones) - 2, 0, -1):
209
+ isochrones.loc[row, "geometry"] = shapely.intersection(
210
+ isochrones.loc[row, "geometry"], isochrones.loc[row + 1, "geometry"]
211
+ )
212
+
213
+ isochrones["geometry"] = (
214
+ isochrones["geometry"]
215
+ .buffer(CONCAVE_HULL_BUFFER_SIZE)
216
+ .boundary.apply(
217
+ lambda geometry: (
218
+ geometry
219
+ if isinstance(geometry, shapely.MultiLineString)
220
+ else shapely.MultiLineString([geometry])
221
+ )
222
+ )
223
+ .apply(
224
+ lambda multilinestring: (
225
+ shapely.MultiLineString(
226
+ [
227
+ simplification.cutil.simplify_coords_vwp(
228
+ linestring.coords,
229
+ self.point_grid_resolution * 5.0,
230
+ )
231
+ for linestring in multilinestring.geoms
232
+ ]
233
+ )
234
+ )
235
+ )
236
+ .to_crs(R5_CRS)
237
+ )
238
+
239
+ return isochrones
240
+
241
+ @property
242
+ def destinations(self):
243
+ """A regular grid of points covering the range of the chosen transport mode."""
244
+ try:
245
+ return self._destinations
246
+ except AttributeError:
247
+ destinations = self._regular_point_grid
248
+ destinations["geometry"] = self.transport_network.snap_to_network(
249
+ destinations["geometry"]
250
+ )
251
+ destinations = destinations[destinations["geometry"] != EMPTY_POINT]
252
+ destinations["geometry"] = destinations["geometry"].normalize()
253
+ destinations = destinations.drop_duplicates()
254
+
255
+ # with snapping, sometimes we end up with clumps of points
256
+ # below, we try to form clusters, from all clusters we retain
257
+ # one geometry, only
258
+ destinations = SpatiallyClusteredGeoDataFrame(
259
+ destinations, eps=(0.5 * self.point_grid_resolution)
260
+ )
261
+ destinations = pandas.concat(
262
+ [
263
+ (
264
+ destinations[destinations["cluster"] != -1]
265
+ .groupby("cluster")
266
+ .first()
267
+ .set_crs(R5_CRS)
268
+ ),
269
+ destinations[destinations["cluster"] == -1],
270
+ ]
271
+ )[["id", "geometry"]].copy()
272
+
273
+ if self.point_grid_sample_ratio < 1.0:
274
+ destinations = destinations.sample(frac=self.point_grid_sample_ratio)
275
+
276
+ self._destinations = destinations
277
+
278
+ return destinations
279
+
280
+ @destinations.setter
281
+ def destinations(self, destinations):
282
+ # https://bugs.python.org/issue14965
283
+ super(self.__class__, self.__class__).destinations.__set__(self, destinations)
284
+
285
+ @property
286
+ def isochrones(self):
287
+ """
288
+ Compute isochrones for these travel times.
289
+
290
+ pandas.TimedeltaIndex | collections.abc.Iterable[int]
291
+ An iterable of integers is interpreted as minutes.
292
+ """
293
+ try:
294
+ return self._isochrones
295
+ except AttributeError:
296
+ raise
297
+
298
+ @isochrones.setter
299
+ def isochrones(self, isochrones):
300
+ if not isinstance(isochrones, pandas.TimedeltaIndex):
301
+ isochrones = pandas.to_timedelta(isochrones, unit="minutes")
302
+ try:
303
+ # do not compute for 0 travel time
304
+ isochrones = isochrones.drop(datetime.timedelta(0))
305
+ except KeyError:
306
+ pass
307
+ self._isochrones = isochrones
308
+
309
+ @property
310
+ def _regular_point_grid(self):
311
+ extent = shapely.ops.transform(
312
+ pyproj.Transformer.from_crs(
313
+ R5_CRS,
314
+ self.EQUIDISTANT_CRS,
315
+ always_xy=True,
316
+ ).transform,
317
+ self.transport_network.extent,
318
+ )
319
+
320
+ grid = geohexgrid.make_grid_from_bounds(
321
+ *extent.bounds,
322
+ self.point_grid_resolution,
323
+ crs=self.EQUIDISTANT_CRS,
324
+ )
325
+ grid["geometry"] = grid["geometry"].centroid
326
+ grid["id"] = grid.index
327
+ grid = grid[["id", "geometry"]].to_crs(R5_CRS)
328
+
329
+ # for walking and cycling, we can clip the extent to an area reachable
330
+ # by the (well-defined) travel speeds:
331
+ if set(self.request.transport_modes) <= set(
332
+ (TransportMode.WALK, TransportMode.BICYCLE)
333
+ ):
334
+ if TransportMode.WALK in self.request.transport_modes:
335
+ speed = self.request.speed_walking
336
+ if TransportMode.BICYCLE in self.request.transport_modes:
337
+ speed = self.request.speed_cycling
338
+
339
+ speed = speed * (1000.0 / 3600.0) * 1.1 # km/h -> m/s, plus a bit of buffer
340
+
341
+ grid = grid.clip(
342
+ (
343
+ pandas.concat([self.origins] * 2) # workaround until
344
+ # https://github.com/pyproj4/pyproj/issues/1309 is fixed
345
+ .to_crs(self.EQUIDISTANT_CRS)
346
+ .buffer(speed * max(self.isochrones).total_seconds())
347
+ .to_crs(R5_CRS)
348
+ )
349
+ )
350
+
351
+ return grid.copy()
r5py/r5/regional_task.py CHANGED
@@ -53,11 +53,11 @@ class RegionalTask:
53
53
  A RegionalTask wraps a `com.conveyal.r5.analyst.cluster.RegionalTask`,
54
54
  which is used to specify the details of a requested computation.
55
55
  RegionalTasks underlie virtually all major computations carried out,
56
- such as, e.g., `TravelTimeMatrixComputer` or `AccessibilityEstimator`.
56
+ such as, e.g., `TravelTimeMatrix` or `AccessibilityEstimator`.
57
57
 
58
58
  In **r5py**, there is usually no need to explicitely create a
59
59
  `RegionalTask`. Rather, the constructors to the computation classes
60
- (`TravelTimeMatrixComputer`, `AccessibilityEstimator`, ...) accept the
60
+ (`TravelTimeMatrix`, `AccessibilityEstimator`, ...) accept the
61
61
  arguments, and pass them through to an internally handled
62
62
  `RegionalTask`.
63
63
 
@@ -200,12 +200,16 @@ class RegionalTask:
200
200
  # The value is a static property of com.conveyal.r5.analyst.cluster.PathResult;
201
201
  # static properites of Java classes can be modified in a singleton kind of way
202
202
  try:
203
- com.conveyal.r5.analyst.cluster.PathResult.maxDestinations = max(
204
- com.conveyal.r5.analyst.cluster.PathResult.maxDestinations,
205
- len(self.destinations) + 1,
206
- )
203
+ num_destinations = len(self.destinations)
207
204
  except AttributeError:
208
- pass
205
+ num_destinations = 0
206
+ if (
207
+ num_destinations
208
+ > com.conveyal.r5.analyst.cluster.PathResult.MAX_PATH_DESTINATIONS
209
+ ):
210
+ com.conveyal.r5.analyst.cluster.PathResult.MAX_PATH_DESTINATIONS = (
211
+ num_destinations + 1
212
+ )
209
213
 
210
214
  @property
211
215
  def departure(self):
@@ -246,8 +250,7 @@ class RegionalTask:
246
250
 
247
251
  @property
248
252
  def departure_time_window(self):
249
- """Find public transport connections leaving within
250
- ``departure_time_window`` after ``departure`` (datetime.timedelta).
253
+ """Find public transport connections leaving within ``departure_time_window`` after ``departure`` (datetime.timedelta).
251
254
 
252
255
  **Note:** The value of ``departure_time_window`` should be set with some
253
256
  caution. Specifically, setting values near or below the typical headways
r5py/r5/street_layer.py CHANGED
@@ -22,6 +22,9 @@ __all__ = ["StreetLayer"]
22
22
  start_jvm()
23
23
 
24
24
 
25
+ EMPTY_POINT = shapely.Point()
26
+
27
+
25
28
  class StreetLayer:
26
29
  """Wrap a com.conveyal.r5.streets.StreetLayer."""
27
30
 
@@ -40,6 +43,7 @@ class StreetLayer:
40
43
 
41
44
  @functools.cached_property
42
45
  def extent(self):
46
+ """The geographic area covered, as a `shapely.box`."""
43
47
  envelope = self._street_layer.envelope
44
48
  return shapely.box(
45
49
  envelope.getMinX(),
@@ -71,13 +75,14 @@ class StreetLayer:
71
75
  Closest location on the street network or `POINT EMPTY` if no
72
76
  such location could be found within `radius`
73
77
  """
74
- if split := self._street_layer.findSplit(point.y, point.x, radius, street_mode):
78
+ try:
79
+ split = self._street_layer.findSplit(point.y, point.x, radius, street_mode)
75
80
  return shapely.Point(
76
81
  split.fixedLon / com.conveyal.r5.streets.VertexStore.FIXED_FACTOR,
77
82
  split.fixedLat / com.conveyal.r5.streets.VertexStore.FIXED_FACTOR,
78
83
  )
79
- else:
80
- return shapely.Point()
84
+ except (AttributeError, TypeError):
85
+ return EMPTY_POINT
81
86
 
82
87
 
83
88
  @jpype._jcustomizer.JConversion(
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """A less complex representation of com.conveyal.r5.api.util.StreetSegment."""
4
+
5
+
6
+ import datetime
7
+
8
+ import shapely
9
+
10
+
11
+ __all__ = ["StreetSegment"]
12
+
13
+
14
+ class StreetSegment:
15
+ """A less complex representation of com.conveyal.r5.api.util.StreetSegment."""
16
+
17
+ distance = 0
18
+ duration = datetime.timedelta()
19
+ geometry = shapely.LineString()
20
+
21
+ def __init__(self, street_path):
22
+ """
23
+ Initialise a less complex representation of com.conveyal.r5.api.util.StreetSegment.
24
+
25
+ Arguments
26
+ ---------
27
+ street_path : com.conveyal.r5.profile.StreetPath
28
+ StreetPath, obtained, e.g., from StreetRouter state
29
+ """
30
+ self.distance = street_path.getDistance()
31
+ self.duration = street_path.getDuration()
32
+ self.geometry = shapely.line_merge(
33
+ shapely.MultiLineString(
34
+ [
35
+ shapely.from_wkt(
36
+ str(street_path.getEdge(edge).getGeometry().toText())
37
+ )
38
+ for edge in street_path.getEdges()
39
+ ]
40
+ )
41
+ )
r5py/r5/transfer_leg.py CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
 
4
- """Represent one leg of a trip, specifically transfers between public transport
5
- vehicles."""
4
+ """Represent one leg of a trip, specifically transfers between public transport vehicles."""
6
5
 
7
6
 
8
7
  from .direct_leg import DirectLeg
@@ -12,7 +11,4 @@ __all__ = ["TransferLeg"]
12
11
 
13
12
 
14
13
  class TransferLeg(DirectLeg):
15
- """
16
- Represent one leg of a trip, specifically transfers between public transport
17
- vehicles.
18
- """
14
+ """Represent one leg of a trip, specifically transfers between public transport vehicles."""
r5py/r5/transit_layer.py CHANGED
@@ -90,12 +90,18 @@ class TransitLayer:
90
90
 
91
91
  @functools.cached_property
92
92
  def routes(self):
93
+ """Return a list of GTFS routes."""
93
94
  return list(self._transit_layer.routes)
94
95
 
95
96
  @functools.cached_property
96
97
  def trip_patterns(self):
98
+ """Return a list of GTFS trip patterns."""
97
99
  return list(self._transit_layer.tripPatterns)
98
100
 
101
+ def get_stop_id_from_index(self, stop_index):
102
+ """Get the GTFS stop id for the `stop_index`-th stop of this transit layer."""
103
+ return self._transit_layer.stopIdForIndex[stop_index]
104
+
99
105
 
100
106
  @jpype._jcustomizer.JConversion(
101
107
  "com.conveyal.r5.transit.TransitLayer", exact=TransitLayer
r5py/r5/transit_leg.py CHANGED
@@ -11,8 +11,4 @@ __all__ = ["TransitLeg"]
11
11
 
12
12
 
13
13
  class TransitLeg(TripLeg):
14
- """
15
- Represent one leg of a public transport trip.
16
- """
17
-
18
- pass
14
+ """Represent one leg of a public transport trip."""
r5py/r5/transport_mode.py CHANGED
@@ -68,6 +68,7 @@ class TransportMode(enum.Enum):
68
68
  return None
69
69
 
70
70
  def __add__(self, other):
71
+ """Combine two transport modes."""
71
72
  if isinstance(other, self.__class__):
72
73
  return [self, other]
73
74
  elif isinstance(other, list):
@@ -78,6 +79,7 @@ class TransportMode(enum.Enum):
78
79
  )
79
80
 
80
81
  def __radd__(self, other):
82
+ """Combine two transport modes."""
81
83
  if other == 0: # first iteration of sum()
82
84
  return self
83
85
  elif isinstance(other, list):
@@ -87,17 +89,17 @@ class TransportMode(enum.Enum):
87
89
 
88
90
  @property
89
91
  def is_leg_mode(self):
90
- """Can this TransportMode function as a LegMode?"""
92
+ """Can this TransportMode function as a LegMode?."""
91
93
  return self.name in LEG_MODES
92
94
 
93
95
  @property
94
96
  def is_street_mode(self):
95
- """Can this TransportMode function as a StreetMode?"""
97
+ """Can this TransportMode function as a StreetMode?."""
96
98
  return self.name in STREET_MODES
97
99
 
98
100
  @property
99
101
  def is_transit_mode(self):
100
- """Can this TransportMode function as a TransitMode?"""
102
+ """Can this TransportMode function as a TransitMode?."""
101
103
  return self.name in TRANSIT_MODES
102
104
 
103
105
  AIR = "AIR"