Skip to content

Shapes

Shapes

Shapes(lf: Optional[LazyFrame] = None, stop_shapes: Optional[LazyFrame] = None, gdf: Optional[GeoDataFrame] = None)

Manage GTFS shapes.txt data using Polars LazyFrames.

Attributes:

Name Type Description
lf LazyFrame

One row per point (real shape point or inserted stop) per synthetic shape_id, ordered by shape_pt_sequence, with cumulative shape_dist_traveled/shape_total_distance.

stop_shapes LazyFrame

The subset of lf rows that are stops (i.e. stop_id is not null) -- what Feed.build_lf joins against. After assign_direction_ids runs (during Feed construction), this also carries route_id, a resolved direction_id (0/1, constant across every row of a given shape_id), and a boolean direction_conflict flag marking rows where the geometry actually disagreed with that shape's reported direction -- see that method.

gdf GeoDataFrame

One row per shape_id, with a LINESTRING (or POINT, if degenerate) geometry built from lf's points.

Wraps already-loaded shape frames, or leaves them empty for load to fill in.

Parameters:

Name Type Description Default
lf Optional[LazyFrame]

Optional pre-loaded shape-points LazyFrame.

None
stop_shapes Optional[LazyFrame]

Optional pre-computed stop-subset LazyFrame (see the class docstring).

None
gdf Optional[GeoDataFrame]

Optional pre-built per-shape_id GeoDataFrame.

None
Source code in pyGTFSHandler/models/shapes.py
def __init__(
    self,
    lf: Optional[pl.LazyFrame] = None,
    stop_shapes: Optional[pl.LazyFrame] = None,
    gdf: Optional[gpd.GeoDataFrame] = None,
) -> None:
    """Wraps already-loaded shape frames, or leaves them empty for `load` to fill in.

    Args:
        lf: Optional pre-loaded shape-points LazyFrame.
        stop_shapes: Optional pre-computed stop-subset LazyFrame (see
            the class docstring).
        gdf: Optional pre-built per-`shape_id` GeoDataFrame.
    """
    self.lf = lf
    self.stop_shapes = stop_shapes
    self.gdf = gdf

load

load(path: Union[str, Path, List[Union[str, Path]], None], trip_shape_ids_lf: LazyFrame, stops_lf: LazyFrame, check_files: bool = False, min_file_id: int = 0, collision_registry=None, aoi: Optional[Union[GeoDataFrame, GeoSeries]] = None) -> None

Builds self.lf/self.stop_shapes/self.gdf.

Parameters:

Name Type Description Default
path Union[str, Path, List[Union[str, Path]], None]

GTFS directory/directories to look for shapes.txt in. None (or no shapes.txt found) means every group falls back to the straight-line-between-stops behavior.

required
trip_shape_ids_lf LazyFrame

Output of StopTimes.generate_shape_ids, additionally carrying a real_shape_id column (see Feed.load_shapes) mapping each synthetic shape_id to the real shape_id its trips reference, if any.

required
stops_lf LazyFrame

The feed's stops LazyFrame (stop_id, stop_lat, stop_lon).

required
aoi Optional[Union[GeoDataFrame, GeoSeries]]

Optional Area of Interest (GeoDataFrame/GeoSeries), same object callers already pass into Feed.load for stop filtering. When given, raw shapes.txt polylines are pre-filtered by aoi's bounding box (see _read_shapes_file) before any of the rest of this pipeline runs -- large national/regional feeds (e.g. a whole-country feed used for one city's study) commonly carry shapes that never come anywhere near the study area at all, and there's no reason to spend memory/time reading, joining, or interpolating their points. Whole shapes are kept or dropped based on whether any of their points fall in the bbox -- never cropped to individual in-bbox points -- so a shape's shape_dist_traveled stays a continuous, correct cumulative distance over its whole real length even where it dips outside the AOI and back in. None (default) skips this entirely, loading every shape unfiltered exactly as before.

None
Source code in pyGTFSHandler/models/shapes.py
def load(
    self,
    path: Union[str, Path, List[Union[str, Path]], None],
    trip_shape_ids_lf: pl.LazyFrame,
    stops_lf: pl.LazyFrame,
    check_files: bool = False,
    min_file_id: int = 0,
    collision_registry=None,
    aoi: Optional[Union[gpd.GeoDataFrame, "gpd.GeoSeries"]] = None,
) -> None:
    """Builds `self.lf`/`self.stop_shapes`/`self.gdf`.

    Args:
        path: GTFS directory/directories to look for `shapes.txt` in.
            `None` (or no `shapes.txt` found) means every group falls
            back to the straight-line-between-stops behavior.
        trip_shape_ids_lf: Output of `StopTimes.generate_shape_ids`,
            additionally carrying a `real_shape_id` column (see
            `Feed.load_shapes`) mapping each synthetic `shape_id` to the
            real `shape_id` its trips reference, if any.
        stops_lf: The feed's stops LazyFrame (`stop_id`, `stop_lat`, `stop_lon`).
        aoi: Optional Area of Interest (GeoDataFrame/GeoSeries), same
            object callers already pass into `Feed.load` for stop
            filtering. When given, raw `shapes.txt` polylines are
            pre-filtered by `aoi`'s bounding box (see `_read_shapes_file`)
            before any of the rest of this pipeline runs -- large
            national/regional feeds (e.g. a whole-country feed used for
            one city's study) commonly carry shapes that never come
            anywhere near the study area at all, and there's no reason
            to spend memory/time reading, joining, or interpolating
            their points. Whole shapes are kept or dropped based on
            whether *any* of their points fall in the bbox -- never
            cropped to individual in-bbox points -- so a shape's
            `shape_dist_traveled` stays a continuous, correct cumulative
            distance over its whole real length even where it dips
            outside the AOI and back in. `None` (default) skips this
            entirely, loading every shape unfiltered exactly as before.
    """
    raw_shapes_lf = self._read_shapes_file(
        path, check_files=check_files, min_file_id=min_file_id, collision_registry=collision_registry, aoi=aoi
    )

    self.lf = self._generate_shapes_file(stops_lf, trip_shape_ids_lf, raw_shapes_lf)
    self.lf = self.lf.collect().lazy()
    self.stop_shapes = self.lf.filter(pl.col("stop_id").is_not_null())
    self.stop_shapes = self._generate_shape_direction_column(self.stop_shapes)
    self.stop_shapes = self.stop_shapes.collect().lazy()
    self.gdf = self._get_shapes_gdf(self.lf)
    # `has_real_geometry`: whether this shape_id's points came from a
    # real shapes.txt polyline (possibly with stops inserted), vs a pure
    # straight-line-between-stops fallback (see `_generate_shapes_file`'s
    # docstring/`_real_geometry_shape_ids`). Callers that only want to
    # display genuine route geometry -- and, say, fall back to showing
    # just the stops rather than a straight line that never actually ran
    # -- can filter on this without re-deriving it.
    if self.gdf is not None and not self.gdf.empty:
        self.gdf["has_real_geometry"] = self.gdf["shape_id"].isin(self._real_geometry_shape_ids)

assign_direction_ids

assign_direction_ids(trip_shape_ids_lf: LazyFrame, trips_lf: LazyFrame, stops_lf: Optional[LazyFrame] = None, method: str = 'both') -> pl.LazyFrame

Integrates a globally-reconciled direction_id into self.stop_shapes.

See the module docstring's "Direction assignment pipeline" section for how this fits together with _assign_direction_ids_for_route and the module-level bearing helpers. In short: adds a route_id, a resolved direction_id (0/1), and a boolean direction_conflict flag directly onto self.stop_shapes, on top of the raw shape_direction/shape_direction_backwards bearings already there.

Bearings are gathered and split per (route_id, parent_station), not per raw stop_id (falling back to stop_id itself when stops_lf isn't given or a stop has no parent_station): a station's separate platform stop_ids (e.g. paired platforms on either side of a road) are where GTFS actually records distinct rows, but splitting each platform's handful of shapes independently starves the widest-gap computation of the corroborating shapes its sibling platform sees, right where it matters most -- multi-route interchanges. Pooling by station gives every shape passing through it, on any of its platforms, a say in that station's split.

Per-station local clustering (_widest_gap_split) only ever produces labels that are meaningful at that one station; a route whose shapes branch (e.g. one trip pattern continuing straight, another turning off) can trivially get inconsistent 0/1 numbering between stations if each is labelled independently. _assign_direction_ids_for_route reconciles that per route_id -- see its docstring for the full algorithm.

A shape_id shared by more than one route_id (uncommon, but possible) gets one row per (shape_id, stop_id, route_id) here, since direction is inherently a per-route concept -- so self.stop_shapes may end up with more rows than before for such shapes.

Parameters:

Name Type Description Default
trip_shape_ids_lf LazyFrame

Output of StopTimes.generate_shape_ids, mapping each synthetic shape_id to its member trip_ids.

required
trips_lf LazyFrame

The feed's trips LazyFrame, at least trip_id and route_id columns.

required
stops_lf Optional[LazyFrame]

The feed's stops LazyFrame (stop_id, parent_station), used to pool bearings by station rather than by raw stop_id. When omitted, every stop is treated as its own station.

None
method str

"both", "forward", or "backward" -- passed through to _reconcile_fwd_bwd for every bearing pair; see its docstring for what each does.

'both'

Returns:

Type Description
LazyFrame

self.stop_shapes with route_id, direction_id, and

LazyFrame

direction_conflict columns added -- also stored back onto

LazyFrame

self.stop_shapes as a side effect.

Source code in pyGTFSHandler/models/shapes.py
def assign_direction_ids(
    self, trip_shape_ids_lf: pl.LazyFrame, trips_lf: pl.LazyFrame, stops_lf: Optional[pl.LazyFrame] = None,
    method: str = "both",
) -> pl.LazyFrame:
    """Integrates a globally-reconciled `direction_id` into `self.stop_shapes`.

    See the module docstring's "Direction assignment pipeline" section
    for how this fits together with `_assign_direction_ids_for_route`
    and the module-level bearing helpers. In short: adds a `route_id`,
    a resolved `direction_id` (0/1), and a boolean `direction_conflict`
    flag directly onto `self.stop_shapes`, on top of the raw
    `shape_direction`/`shape_direction_backwards` bearings already
    there.

    Bearings are gathered and split per `(route_id, parent_station)`,
    not per raw `stop_id` (falling back to `stop_id` itself when
    `stops_lf` isn't given or a stop has no `parent_station`): a
    station's separate platform `stop_id`s (e.g. paired platforms on
    either side of a road) are where GTFS actually records distinct
    rows, but splitting each platform's handful of shapes independently
    starves the widest-gap computation of the corroborating shapes its
    sibling platform sees, right where it matters most -- multi-route
    interchanges. Pooling by station gives every shape passing through
    it, on any of its platforms, a say in that station's split.

    Per-station local clustering (`_widest_gap_split`) only ever
    produces labels that are meaningful *at that one station*; a route
    whose shapes branch (e.g. one trip pattern continuing straight,
    another turning off) can trivially get inconsistent 0/1 numbering
    between stations if each is labelled independently.
    `_assign_direction_ids_for_route` reconciles that per `route_id` --
    see its docstring for the full algorithm.

    A `shape_id` shared by more than one `route_id` (uncommon, but
    possible) gets one row per `(shape_id, stop_id, route_id)` here,
    since direction is inherently a per-route concept -- so
    `self.stop_shapes` may end up with more rows than before for such
    shapes.

    Args:
        trip_shape_ids_lf: Output of `StopTimes.generate_shape_ids`,
            mapping each synthetic `shape_id` to its member `trip_ids`.
        trips_lf: The feed's trips LazyFrame, at least `trip_id` and
            `route_id` columns.
        stops_lf: The feed's stops LazyFrame (`stop_id`,
            `parent_station`), used to pool bearings by station rather
            than by raw `stop_id`. When omitted, every stop is treated
            as its own station.
        method: `"both"`, `"forward"`, or `"backward"` -- passed
            through to `_reconcile_fwd_bwd` for every bearing pair;
            see its docstring for what each does.

    Returns:
        `self.stop_shapes` with `route_id`, `direction_id`, and
        `direction_conflict` columns added -- also stored back onto
        `self.stop_shapes` as a side effect.
    """
    shape_route_map = (
        trip_shape_ids_lf.select(["shape_id", "trip_ids"])
        .explode("trip_ids")
        .rename({"trip_ids": "trip_id"})
        .join(trips_lf.select(["trip_id", "route_id"]), on="trip_id", how="left")
        .select(["shape_id", "route_id"])
        .unique()
        .drop_nulls()
        .collect()
    )

    stop_shapes_df = self.stop_shapes.collect()

    base = stop_shapes_df.select(
        ["shape_id", "stop_id", "stop_sequence", "shape_direction", "shape_direction_backwards"]
    )

    if stops_lf is not None and stop_shapes_df.height > 0:
        parent_by_stop = stops_lf.select(["stop_id", "parent_station"]).collect()
        base = base.join(parent_by_stop, on="stop_id", how="left").with_columns(
            pl.col("parent_station").fill_null(pl.col("stop_id")).alias("station")
        ).drop("parent_station")
    else:
        base = base.with_columns(pl.col("stop_id").alias("station"))

    if stop_shapes_df.height == 0 or shape_route_map.height == 0:
        self.stop_shapes = stop_shapes_df.with_columns(
            pl.lit(None, dtype=pl.Utf8).alias("route_id"),
            pl.lit(None, dtype=pl.Int32).alias("direction_id"),
            pl.lit(False, dtype=pl.Boolean).alias("direction_conflict"),
        ).lazy()
        return self.stop_shapes

    base = base.join(shape_route_map, on="shape_id", how="inner")
    if base.height == 0:
        self.stop_shapes = stop_shapes_df.with_columns(
            pl.lit(None, dtype=pl.Utf8).alias("route_id"),
            pl.lit(None, dtype=pl.Int32).alias("direction_id"),
            pl.lit(False, dtype=pl.Boolean).alias("direction_conflict"),
        ).lazy()
        return self.stop_shapes

    result_frames = [
        self._assign_direction_ids_for_route(route_df, method=method)
        for _, route_df in base.group_by("route_id", maintain_order=True)
    ]
    directions = pl.concat(result_frames, how="vertical_relaxed")
    self._warn_about_direction_conflict(directions)

    # Left join: shapes that matched no route (shouldn't normally
    # happen, but e.g. a trip with no route_id) keep their original
    # single row, with nulls/False for the new columns.
    self.stop_shapes = (
        stop_shapes_df.join(
            directions.select(
                ["shape_id", "stop_id", "stop_sequence", "route_id", "direction_id", "direction_conflict"]
            ),
            on=["shape_id", "stop_id", "stop_sequence"],
            how="left",
        )
        .with_columns(pl.col("direction_conflict").fill_null(False))
        .lazy()
    )
    return self.stop_shapes

See also Direction & headway methodology for the bearing-based direction assignment implemented here.