docs: def compute_trip_stats(
      feed: "Feed",
      route_ids: Optional[List[str]] = None,
      *,
      compute_dist_from_shapes: bool = False,
  ) -> DataFrame:
      """
      Return a DataFrame with the following columns:
  
      - ``'trip_id'``
      - ``'route_id'``
      - ``'route_short_name'``
      - ``'route_type'``
      - ``'direction_id'``: NaN if missing from feed
      - ``'shape_id'``: NaN if missing from feed
      - ``'num_stops'``: number of stops on trip
      - ``'start_time'``: first departure time of the trip
      - ``'end_time'``: last departure time of the trip
      - ``'start_stop_id'``: stop ID of the first stop of the trip
      - ``'end_stop_id'``: stop ID of the last stop of the trip
      - ``'is_loop'``: 1 if the start and end stop are less than 400m apart and
        0 otherwise
      - ``'distance'``: distance of the trip in ``feed.dist_units``;
        contains all ``np.nan`` entries if ``feed.shapes is None``
      - ``'duration'``: duration of the trip in hours
      - ``'speed'``: distance/duration
  
      If ``feed.stop_times`` has a ``shape_dist_traveled`` column with at
      least one non-NaN value and ``compute_dist_from_shapes == False``,
      then use that column to compute the distance column.
      Else if ``feed.shapes is not None``, then compute the distance
      column using the shapes and Shapely.
      Otherwise, set the distances to NaN.
  
      If route IDs are given, then restrict to trips on those routes.
  
      Notes
      -----
      - Assume the following feed attributes are not ``None``:
  
          * ``feed.trips``
          * ``feed.routes``
          * ``feed.stop_times``
          * ``feed.shapes`` (optionally)
          * Those used in :func:`.stops.build_geometry_by_stop`
  
      - Calculating trip distances with ``compute_dist_from_shapes=True``
        seems pretty accurate.  For example, calculating trip distances on
        `this Portland feed
        <https://transitfeeds.com/p/trimet/43/1400947517>`_
        using ``compute_dist_from_shapes=False`` and
        ``compute_dist_from_shapes=True``,
        yields a difference of at most 0.83km from the original values.
  
      """
      f = feed.trips.copy()
  
      # Restrict to given route IDs
      if route_ids is not None:
          f = f[f["route_id"].isin(route_ids)].copy()
  
      # Merge with stop times and extra trip info.
      # Convert departure times to seconds past midnight to
      # compute trip durations later.
      if "direction_id" not in f.columns:
          f["direction_id"] = np.nan
      if "shape_id" not in f.columns:
          f["shape_id"] = np.nan
  
      f = (
          f[["route_id", "trip_id", "direction_id", "shape_id"]]
          .merge(feed.routes[["route_id", "route_short_name", "route_type"]])
          .merge(feed.stop_times)
          .sort_values(["trip_id", "stop_sequence"])
          .assign(
              departure_time=lambda x: x["departure_time"].map(
                  hp.timestr_to_seconds
              )
          )
      )
  
      # Compute all trips stats except distance,
      # which is possibly more involved
      geometry_by_stop = feed.build_geometry_by_stop(use_utm=True)
      g = f.groupby("trip_id")
  
      def my_agg(group):
          d = OrderedDict()
          d["route_id"] = group["route_id"].iat[0]
          d["route_short_name"] = group["route_short_name"].iat[0]
          d["route_type"] = group["route_type"].iat[0]
          d["direction_id"] = group["direction_id"].iat[0]
          d["shape_id"] = group["shape_id"].iat[0]
          d["num_stops"] = group.shape[0]
          d["start_time"] = group["departure_time"].iat[0]
          d["end_time"] = group["departure_time"].iat[-1]
          d["start_stop_id"] = group["stop_id"].iat[0]
          d["end_stop_id"] = group["stop_id"].iat[-1]
          dist = geometry_by_stop[d["start_stop_id"]].distance(
              geometry_by_stop[d["end_stop_id"]]
          )
          d["is_loop"] = int(dist < 400)
          d["duration"] = (d["end_time"] - d["start_time"]) / 3600
          return pd.Series(d)
  
      # Apply my_agg, but don't reset index yet.
      # Need trip ID as index to line up the results of the
      # forthcoming distance calculation
      h = g.apply(my_agg)
  
      # Compute distance
      if (
          hp.is_not_null(f, "shape_dist_traveled")
          and not compute_dist_from_shapes
      ):
          # Compute distances using shape_dist_traveled column
          h["distance"] = g.apply(
              lambda group: group["shape_dist_traveled"].max()
          )
      elif feed.shapes is not None:
          # Compute distances using the shapes and Shapely
          geometry_by_shape = feed.build_geometry_by_shape(use_utm=True)
          geometry_by_stop = feed.build_geometry_by_stop(use_utm=True)
          m_to_dist = hp.get_convert_dist("m", feed.dist_units)
  
          def compute_dist(group):
              """
              Return the distance traveled along the trip between the
              first and last stops.
              If that distance is negative or if the trip's linestring
              intersects itfeed, then return the length of the trip's
              linestring instead.
              """
              shape = group["shape_id"].iat[0]
              try:
                  # Get the linestring for this trip
                  linestring = geometry_by_shape[shape]
              except KeyError:
                  # Shape ID is NaN or doesn't exist in shapes.
                  # No can do.
                  return np.nan
  
              # If the linestring intersects itfeed, then that can cause
              # errors in the computation below, so just
              # return the length of the linestring as a good approximation
              D = linestring.length
              if not linestring.is_simple:
                  return D
  
              # Otherwise, return the difference of the distances along
              # the linestring of the first and last stop
              start_stop = group["stop_id"].iat[0]
              end_stop = group["stop_id"].iat[-1]
              try:
                  start_point = geometry_by_stop[start_stop]
                  end_point = geometry_by_stop[end_stop]
              except KeyError:
                  # One of the two stop IDs is NaN, so just
                  # return the length of the linestring
                  return D
              d1 = linestring.project(start_point)
              d2 = linestring.project(end_point)
              d = d2 - d1
              if 0 < d < D + 100:
                  return d
              else:
                  # Something is probably wrong, so just
                  # return the length of the linestring
                  return D
  
          h["distance"] = g.apply(compute_dist)
          # Convert from meters
          h["distance"] = h["distance"].map(m_to_dist)
      else:
          h["distance"] = np.nan
  
      # Reset index and compute final stats
      h = h.reset_index()
      h["speed"] = h["distance"] / h["duration"]
      h[["start_time", "end_time"]] = h[["start_time", "end_time"]].applymap(
          lambda x: hp.timestr_to_seconds(x, inverse=True)
      )
  
      return h.sort_values(["route_id", "direction_id", "start_time"])