r5py 0.1.1.dev0__py3-none-any.whl → 0.1.2__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.

r5py/__init__.py CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  """Python wrapper for the R5 routing analysis engine."""
4
4
 
5
- __version__ = "0.1.1.dev0"
5
+ __version__ = "0.1.2"
6
+
6
7
 
7
8
  from .r5 import (
8
9
  DetailedItinerariesComputer,
r5py/__main__.py CHANGED
@@ -1,16 +1,3 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
-
4
- """A Python wrapper for the R5 routing analysis engine."""
5
-
6
-
7
- from .util import Config
8
-
9
-
10
- def main():
11
- config = Config()
12
- arguments = config.get_arguments() # noqa F401
13
-
14
-
15
- if __name__ == "__main__":
16
- main()
3
+ """Python wrapper for the R5 routing analysis engine."""
r5py/r5/__init__.py CHANGED
@@ -2,23 +2,37 @@
2
2
 
3
3
  """R5 classes."""
4
4
 
5
+ from .access_leg import AccessLeg
5
6
  from .breakdown_stat import BreakdownStat
6
7
  from .detailed_itineraries_computer import DetailedItinerariesComputer
8
+ from .direct_leg import DirectLeg
9
+ from .egress_leg import EgressLeg
7
10
  from .regional_task import RegionalTask
8
11
  from .scenario import Scenario
9
12
  from .street_layer import StreetLayer
13
+ from .transfer_leg import TransferLeg
14
+ from .transit_leg import TransitLeg
10
15
  from .transport_mode import TransportMode
11
16
  from .transport_network import TransportNetwork
12
17
  from .travel_time_matrix_computer import TravelTimeMatrixComputer
18
+ from .trip import Trip
19
+ from .trip_planner import TripPlanner
13
20
 
14
21
  __all__ = [
22
+ "AccessLeg",
15
23
  "BreakdownStat",
16
24
  "DetailedItinerariesComputer",
25
+ "DirectLeg",
26
+ "EgressLeg",
17
27
  "RegionalTask",
18
28
  "Scenario",
19
29
  "SpeedConfig",
20
30
  "StreetLayer",
31
+ "TransferLeg",
32
+ "TransitLeg",
21
33
  "TransportMode",
22
34
  "TransportNetwork",
23
35
  "TravelTimeMatrixComputer",
36
+ "Trip",
37
+ "TripPlanner",
24
38
  ]
@@ -91,6 +91,7 @@ class BaseTravelTimeMatrixComputer:
91
91
 
92
92
  @property
93
93
  def destinations(self):
94
+ """The destinations of this travel time matrix (`geopandas.GeoDataFrame`)."""
94
95
  return self._destinations
95
96
 
96
97
  @destinations.setter
@@ -117,13 +118,10 @@ class BaseTravelTimeMatrixComputer:
117
118
  pandas.DataFrame
118
119
  Data frame in which all MAX_INT32 have been replaced by `numpy.nan`.
119
120
  """
120
- return data_set.applymap(lambda x: numpy.nan if x == MAX_INT32 else x)
121
+ return data_set.map(lambda x: numpy.nan if x == MAX_INT32 else x)
121
122
 
122
123
  def _prepare_origins_destinations(self):
123
- """
124
- Make sure we received enough information to route from origins to
125
- destinations.
126
- """
124
+ """Make sure we received enough information to route from origins to destinations."""
127
125
  try:
128
126
  self.origins
129
127
  except AttributeError as exception:
@@ -165,6 +163,7 @@ class BaseTravelTimeMatrixComputer:
165
163
 
166
164
  @property
167
165
  def origins(self):
166
+ """The origins of this travel time matrix (`geopandas.GeoDataFrame`)."""
168
167
  return self._origins
169
168
 
170
169
  @origins.setter
@@ -124,7 +124,6 @@ class DetailedItinerariesComputer(BaseTravelTimeMatrixComputer):
124
124
  route number or name), `geometry` (`shapely.LineString`)
125
125
  TODO: Add description of output data frame columns and format
126
126
  """
127
-
128
127
  self._prepare_origins_destinations()
129
128
 
130
129
  # warn if public transport routes are requested, but R5 has been
@@ -150,11 +149,12 @@ class DetailedItinerariesComputer(BaseTravelTimeMatrixComputer):
150
149
  verbose=(10 * self.verbose), # joblib has a funny verbosity scale
151
150
  n_jobs=self.NUM_THREADS,
152
151
  ) as parallel:
152
+ matrices = parallel(
153
+ joblib.delayed(self._travel_details_per_od_pair)(from_id, to_id)
154
+ for _, (from_id, to_id) in self.od_pairs.iterrows()
155
+ )
153
156
  od_matrix = pandas.concat(
154
- parallel(
155
- joblib.delayed(self._travel_details_per_od_pair)(from_id, to_id)
156
- for _, (from_id, to_id) in self.od_pairs.iterrows()
157
- ),
157
+ [matrix.astype(matrices[0].dtypes) for matrix in matrices],
158
158
  ignore_index=True,
159
159
  )
160
160
 
@@ -162,11 +162,7 @@ class DetailedItinerariesComputer(BaseTravelTimeMatrixComputer):
162
162
  return od_matrix
163
163
 
164
164
  def _prepare_origins_destinations(self):
165
- """
166
- Make sure we received enough information to route from origins to
167
- destinations.
168
- """
169
-
165
+ """Make sure we received enough information to route from origins to destinations."""
170
166
  super()._prepare_origins_destinations()
171
167
 
172
168
  if self.all_to_all:
r5py/r5/direct_leg.py CHANGED
@@ -15,9 +15,7 @@ __all__ = ["DirectLeg"]
15
15
 
16
16
 
17
17
  class DirectLeg(TripLeg):
18
- """
19
- Represent one leg of a public transport trip.
20
- """
18
+ """Represent one leg of a public transport trip."""
21
19
 
22
20
  def __init__(self, transport_mode, street_segment):
23
21
  """
r5py/r5/regional_task.py CHANGED
@@ -57,8 +57,9 @@ class RegionalTask:
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
61
- the arguments, and pass them through to an internally handled `RegionalTask`.
60
+ (`TravelTimeMatrixComputer`, `AccessibilityEstimator`, ...) accept the
61
+ arguments, and pass them through to an internally handled
62
+ `RegionalTask`.
62
63
 
63
64
  Arguments
64
65
  ---------
@@ -67,31 +68,30 @@ class RegionalTask:
67
68
  origin : shapely.geometry.Point
68
69
  Point to route from
69
70
  destinations : geopandas.GeoDataFrame
70
- Points to route to, has to have at least an ``id`` column
71
- and a geometry
71
+ Points to route to, has to have at least an ``id`` column and a
72
+ geometry
72
73
  departure : datetime.datetime
73
74
  Find public transport connections leaving every minute within
74
- ``departure_time_window`` after ``departure``.
75
- Default: current date and time
75
+ ``departure_time_window`` after ``departure``. Default: current date
76
+ and time
76
77
  departure_time_window : datetime.timedelta
77
- (see ``departure``)
78
- Default: 10 minutes
78
+ (see ``departure``) Default: 10 minutes
79
79
  percentiles : list[int]
80
80
  Return the travel time for these percentiles of all computed trips,
81
- by travel time. By default, return the median travel time.
82
- Default: [50]
81
+ by travel time. By default, return the median travel time. Default:
82
+ [50]
83
83
  transport_modes : list[r5py.TransportMode] or list[str]
84
- The mode of transport to use for routing. Can be a r5py mode enumerable, or a string representation (e.g. "TRANSIT")
85
- Default: [r5py.TransportMode.TRANSIT] (all public transport)
84
+ The mode of transport to use for routing. Can be a r5py mode
85
+ enumerable, or a string representation (e.g. "TRANSIT") Default:
86
+ [r5py.TransportMode.TRANSIT] (all public transport)
86
87
  access_modes : list[r5py.TransportMode] or list[str]
87
- Mode of transport to public transport stops. Can be a r5py mode object, or a string representation (e.g. "WALK")
88
- Default: [r5py.TransportMode.WALK]
88
+ Mode of transport to public transport stops. Can be a r5py mode
89
+ object, or a string representation (e.g. "WALK") Default:
90
+ [r5py.TransportMode.WALK]
89
91
  egress_modes : list[r5py.TransportMode]
90
- Mode of transport from public transport stops.
91
- Default: access_modes
92
+ Mode of transport from public transport stops. Default: access_modes
92
93
  max_time : datetime.timedelta
93
- Maximum trip duration.
94
- Default: 2 hours
94
+ Maximum trip duration. Default: 2 hours
95
95
  max_time_walking : datetime.timedelta
96
96
  Maximum time spent walking, potentially including access and egress
97
97
  Default: max_time
@@ -99,24 +99,19 @@ class RegionalTask:
99
99
  Maximum time spent cycling, potentially including access and egress
100
100
  Default: max_time
101
101
  max_time_driving : datetime.timedelta
102
- Maximum time spent driving
103
- Default: max_time
102
+ Maximum time spent driving Default: max_time
104
103
  speed_walking : float
105
- Mean walking speed for routing, km/h.
106
- Default: 3.6 km/h
104
+ Mean walking speed for routing, km/h. Default: 3.6 km/h
107
105
  speed_cycling : float
108
- Mean cycling speed for routing, km/h.
109
- Default: 12.0 km/h
106
+ Mean cycling speed for routing, km/h. Default: 12.0 km/h
110
107
  max_public_transport_rides : int
111
- Use at most ``max_public_transport_rides`` consecutive public transport
112
- connections. Default: 8
108
+ Use at most ``max_public_transport_rides`` consecutive public
109
+ transport connections. Default: 8
113
110
  max_bicycle_traffic_stress : int
114
- Maximum stress level for cyclist routing, ranges from 1-4
115
- see https://docs.conveyal.com/learn-more/traffic-stress
116
- Default: 3
111
+ Maximum stress level for cyclist routing, ranges from 1-4 see
112
+ https://docs.conveyal.com/learn-more/traffic-stress Default: 3
117
113
  breakdown : bool
118
- Compute a more detailed breakdown of the routes.
119
- Default: False
114
+ Compute a more detailed breakdown of the routes. Default: False
120
115
  """
121
116
  self._regional_task = com.conveyal.r5.analyst.cluster.RegionalTask()
122
117
  self.scenario = Scenario()
@@ -205,12 +200,16 @@ class RegionalTask:
205
200
  # The value is a static property of com.conveyal.r5.analyst.cluster.PathResult;
206
201
  # static properites of Java classes can be modified in a singleton kind of way
207
202
  try:
208
- com.conveyal.r5.analyst.cluster.PathResult.maxDestinations = max(
209
- com.conveyal.r5.analyst.cluster.PathResult.maxDestinations,
210
- len(self.destinations) + 1,
211
- )
203
+ num_destinations = len(self.destinations)
212
204
  except AttributeError:
213
- 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
+ )
214
213
 
215
214
  @property
216
215
  def departure(self):
@@ -251,14 +250,21 @@ class RegionalTask:
251
250
 
252
251
  @property
253
252
  def departure_time_window(self):
254
- """Find public transport connections leaving within ``departure_time_window`` after ``departure`` (datetime.timedelta)."""
253
+ """Find public transport connections leaving within ``departure_time_window`` after ``departure`` (datetime.timedelta).
254
+
255
+ **Note:** The value of ``departure_time_window`` should be set with some
256
+ caution. Specifically, setting values near or below the typical headways
257
+ in the studied transit network may lead to routing problems. See `this
258
+ GitHub discussion <https://github.com/r5py/r5py/issues/292>`_ for
259
+ details.
260
+ """
255
261
  return self._departure_time_window
256
262
 
257
263
  @departure_time_window.setter
258
264
  def departure_time_window(self, departure_time_window: datetime.timedelta):
259
265
  if departure_time_window.total_seconds() < 300:
260
266
  warnings.warn(
261
- "The provided departure time window is below 5 minutes. This may cause adverse effects with headway-based GTFS datasets.",
267
+ "The provided departure time window is below 5 minutes. This may cause adverse effects with routing.",
262
268
  RuntimeWarning,
263
269
  )
264
270
  self._departure_time_window = departure_time_window
r5py/r5/street_layer.py CHANGED
@@ -40,6 +40,7 @@ class StreetLayer:
40
40
 
41
41
  @functools.cached_property
42
42
  def extent(self):
43
+ """The geographic area covered, as a `shapely.box`."""
43
44
  envelope = self._street_layer.envelope
44
45
  return shapely.box(
45
46
  envelope.getMinX(),
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,10 +90,12 @@ 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
 
99
101
 
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"
@@ -43,12 +43,14 @@ class TransportNetwork:
43
43
 
44
44
  Arguments
45
45
  ---------
46
- osm_pbf : str
46
+ osm_pbf : str | pathlib.Path
47
47
  file path of an OpenStreetMap extract in PBF format
48
- gtfs : list[str]
49
- paths to public transport schedule information in GTFS format
48
+ gtfs : str | pathlib.Path | list[str] | list[pathlib.Path]
49
+ path(s) to public transport schedule information in GTFS format
50
50
  """
51
51
  osm_pbf = self._working_copy(pathlib.Path(osm_pbf)).absolute()
52
+ if isinstance(gtfs, (str, pathlib.Path)):
53
+ gtfs = [gtfs]
52
54
  gtfs = [str(self._working_copy(path).absolute()) for path in gtfs]
53
55
 
54
56
  transport_network = com.conveyal.r5.transit.TransportNetwork()
@@ -87,15 +89,17 @@ class TransportNetwork:
87
89
  self._transport_network = transport_network
88
90
 
89
91
  def __del__(self):
90
- """
91
- Delete all temporary files upon destruction.
92
- """
92
+ """Delete all temporary files upon destruction."""
93
93
  MAX_TRIES = 10
94
94
 
95
95
  # first, close the open osm_file,
96
96
  # delete Java objects, and
97
97
  # trigger Java garbage collection
98
- self.osm_file.close()
98
+ try:
99
+ self.osm_file.close()
100
+ except jpype.JVMNotRunning:
101
+ # JVM was stopped already, file should be closed
102
+ pass
99
103
  try:
100
104
  del self.street_layer
101
105
  except AttributeError: # might not have been accessed a single time
@@ -104,13 +108,23 @@ class TransportNetwork:
104
108
  del self.transit_layer
105
109
  except AttributeError:
106
110
  pass
107
- del self._transport_network
111
+ try:
112
+ del self._transport_network
113
+ except AttributeError:
114
+ pass
108
115
 
109
- time.sleep(0.5)
110
- jpype.java.lang.System.gc()
116
+ time.sleep(1.0)
117
+ try:
118
+ jpype.java.lang.System.gc()
119
+ except jpype.JVMNotRunning:
120
+ pass
111
121
 
112
122
  # then, try to delete all files in cache directory
113
- temporary_files = [child for child in self._cache_directory.iterdir()]
123
+ try:
124
+ temporary_files = [child for child in self._cache_directory.iterdir()]
125
+ except FileNotFoundError: # deleted in the meantime/race condition
126
+ temporary_files = []
127
+
114
128
  for _ in range(MAX_TRIES):
115
129
  for temporary_file in temporary_files:
116
130
  try:
@@ -200,6 +214,7 @@ class TransportNetwork:
200
214
 
201
215
  @property
202
216
  def extent(self):
217
+ """The geographic area covered, as a `shapely.box`."""
203
218
  # TODO: figure out how to get the extent of the GTFS schedule,
204
219
  # then find the smaller extent of the two (or the larger one?)
205
220
  return self.street_layer.extent
@@ -4,7 +4,6 @@
4
4
 
5
5
  import copy
6
6
 
7
- import joblib
8
7
  import pandas
9
8
 
10
9
  from .base_travel_time_matrix_computer import BaseTravelTimeMatrixComputer
@@ -40,20 +39,10 @@ class TravelTimeMatrixComputer(BaseTravelTimeMatrixComputer):
40
39
  self._prepare_origins_destinations()
41
40
  self.request.destinations = self.destinations
42
41
 
43
- # loop over all origins, modify the request, and compute the times
44
- # to all destinations.
45
- with joblib.Parallel(
46
- prefer="threads",
47
- verbose=(10 * self.verbose), # joblib has a funny verbosity scale
48
- n_jobs=self.NUM_THREADS,
49
- ) as parallel:
50
- od_matrix = pandas.concat(
51
- parallel(
52
- joblib.delayed(self._travel_times_per_origin)(from_id)
53
- for from_id in self.origins.id
54
- ),
55
- ignore_index=True,
56
- )
42
+ od_matrix = pandas.concat(
43
+ [self._travel_times_per_origin(from_id) for from_id in self.origins.id],
44
+ ignore_index=True,
45
+ )
57
46
 
58
47
  try:
59
48
  od_matrix = od_matrix.to_crs(self._origins_crs)
r5py/r5/trip.py CHANGED
@@ -15,9 +15,7 @@ __all__ = ["Trip"]
15
15
 
16
16
 
17
17
  class Trip:
18
- """
19
- Represent one trip, consisting of one or more `TripLeg`s.
20
- """
18
+ """Represent one trip, consisting of one or more `r5py.r5.TripLeg`."""
21
19
 
22
20
  COLUMNS = [
23
21
  "segment",
@@ -25,7 +23,7 @@ class Trip:
25
23
 
26
24
  def __init__(self, legs=[]):
27
25
  """
28
- Represent one trip, consisting of one of more `TripLeg`s.
26
+ Represent one trip, consisting of one of more `r5py.r5.TripLeg`.
29
27
 
30
28
  Arguments
31
29
  =========
@@ -34,7 +32,13 @@ class Trip:
34
32
  """
35
33
  self.legs = legs
36
34
 
35
+ def __eq__(self, other):
36
+ """Check whether `self` and `other` are equal."""
37
+ if isinstance(other, self.__class__):
38
+ return self.legs == other.legs
39
+
37
40
  def __repr__(self):
41
+ """Return a string representation of `self`."""
38
42
  legs = ", ".join([str(leg) for leg in self.legs])
39
43
  return (
40
44
  f"<{self.__class__.__name__}: "
r5py/r5/trip_leg.py CHANGED
@@ -4,6 +4,11 @@
4
4
  """Represent one leg of a trip."""
5
5
 
6
6
 
7
+ import datetime
8
+ import numpy
9
+ import shapely
10
+
11
+
7
12
  __all__ = ["TripLeg"]
8
13
 
9
14
 
@@ -28,12 +33,12 @@ class TripLeg:
28
33
  def __init__(
29
34
  self,
30
35
  transport_mode=None,
31
- departure_time=None,
36
+ departure_time=numpy.datetime64("NaT"),
32
37
  distance=None,
33
- travel_time=None,
34
- wait_time=None,
38
+ travel_time=datetime.timedelta(seconds=0),
39
+ wait_time=datetime.timedelta(seconds=0),
35
40
  route=None,
36
- geometry=None,
41
+ geometry=shapely.LineString(),
37
42
  ):
38
43
  """
39
44
  Represent one leg of a trip.
@@ -66,6 +71,7 @@ class TripLeg:
66
71
  self.geometry = geometry
67
72
 
68
73
  def __add__(self, other):
74
+ """Trip-chain `other` to `self`."""
69
75
  from .trip import Trip
70
76
 
71
77
  if isinstance(other, self.__class__):
@@ -80,6 +86,7 @@ class TripLeg:
80
86
  )
81
87
 
82
88
  def __radd__(self, other):
89
+ """Trip-chain `self` to `other`."""
83
90
  from .trip import Trip
84
91
 
85
92
  if other == 0: # first iteration of sum()
@@ -90,31 +97,43 @@ class TripLeg:
90
97
  else:
91
98
  return self.__add__(other)
92
99
 
100
+ def __eq__(self, other):
101
+ """Check if `other` is an equal `TripLeg`."""
102
+ if isinstance(other, self.__class__):
103
+ return False not in [
104
+ self._are_columns_equal(other, column) for column in self.COLUMNS
105
+ ]
106
+
93
107
  def __gt__(self, other):
108
+ """Check if `other` has a longer travel time."""
94
109
  if isinstance(other, TripLeg):
95
110
  return (self.travel_time + self.wait_time) > (
96
111
  other.travel_time + other.wait_time
97
112
  )
98
113
 
99
114
  def __ge__(self, other):
115
+ """Check if `other` has a longer or equal travel time."""
100
116
  if isinstance(other, TripLeg):
101
117
  return (self.travel_time + self.wait_time) >= (
102
118
  other.travel_time + other.wait_time
103
119
  )
104
120
 
105
121
  def __lt__(self, other):
122
+ """Check if `other` has a shorter travel time."""
106
123
  if isinstance(other, TripLeg):
107
124
  return (self.travel_time + self.wait_time) < (
108
125
  other.travel_time + other.wait_time
109
126
  )
110
127
 
111
128
  def __le__(self, other):
129
+ """Check if `other` has a shorter or equal travel time."""
112
130
  if isinstance(other, TripLeg):
113
131
  return (self.travel_time + self.wait_time) <= (
114
132
  other.travel_time + other.wait_time
115
133
  )
116
134
 
117
135
  def __repr__(self):
136
+ """Return a string representation."""
118
137
  try:
119
138
  first_point = self.geometry.coords[0]
120
139
  last_point = self.geometry.coords[-1]
@@ -127,10 +146,30 @@ class TripLeg:
127
146
  f"{first_point} -> {last_point}"
128
147
  ">"
129
148
  )
130
- except AttributeError:
149
+ except (AttributeError, IndexError):
131
150
  _repr = f"<{self.__class__.__name__}>"
132
151
  return _repr
133
152
 
153
+ def _are_columns_equal(self, other, column):
154
+ """
155
+ Check if a column equals the same column of a different `Trip`.
156
+
157
+ Compare if attribute `column` of self equals attribute `column` of
158
+ other. Also True if both values are None or NaN or NaT.
159
+ """
160
+ self_column = getattr(self, column)
161
+ other_column = getattr(other, column)
162
+
163
+ return (
164
+ self_column == other_column
165
+ or (self_column is None and other_column is None)
166
+ or (self_column == numpy.nan and other_column == numpy.nan)
167
+ or (
168
+ self_column == numpy.datetime64("NaT")
169
+ and other_column == numpy.datetime64("NaT")
170
+ )
171
+ )
172
+
134
173
  def as_table_row(self):
135
174
  """
136
175
  Return a table row (list) of this trip leg’s details.