Skip to content

Utils

Helper modules used throughout the package.

Geometry (geo_polars)

haversine_distance_m

haversine_distance_m(lat1: str | Expr, lon1: str | Expr, lat2: str | Expr, lon2: str | Expr) -> pl.Expr

Builds a polars expression computing great-circle distance in meters.

Parameters:

Name Type Description Default
lat1 str | Expr

Column name or expression for the first point's latitude (degrees).

required
lon1 str | Expr

Column name or expression for the first point's longitude (degrees).

required
lat2 str | Expr

Column name or expression for the second point's latitude (degrees).

required
lon2 str | Expr

Column name or expression for the second point's longitude (degrees).

required

Returns:

Type Description
Expr

pl.Expr: Distance in meters between the two points, evaluated

Expr

row-wise via the standard haversine formula.

Source code in pyGTFSHandler/utils/geo_polars.py
def haversine_distance_m(
    lat1: str | pl.Expr,
    lon1: str | pl.Expr,
    lat2: str | pl.Expr,
    lon2: str | pl.Expr,
) -> pl.Expr:
    """Builds a polars expression computing great-circle distance in meters.

    Args:
        lat1: Column name or expression for the first point's latitude (degrees).
        lon1: Column name or expression for the first point's longitude (degrees).
        lat2: Column name or expression for the second point's latitude (degrees).
        lon2: Column name or expression for the second point's longitude (degrees).

    Returns:
        pl.Expr: Distance in meters between the two points, evaluated
        row-wise via the standard haversine formula.
    """
    def _col(x: str | pl.Expr) -> pl.Expr:
        return pl.col(x) if isinstance(x, str) else x

    lat1, lon1, lat2, lon2 = _col(lat1), _col(lon1), _col(lat2), _col(lon2)

    lat1_rad = lat1.radians()
    lat2_rad = lat2.radians()
    dlat = (lat2 - lat1).radians()
    dlon = (lon2 - lon1).radians()

    a = (dlat / 2).sin() ** 2 + lat1_rad.cos() * lat2_rad.cos() * (dlon / 2).sin() ** 2
    return 2 * EARTH_RADIUS_M * a.sqrt().arcsin()

degrees_per_meter

degrees_per_meter(latitude_deg: float) -> tuple[float, float]

Returns (degrees-latitude-per-meter, degrees-longitude-per-meter) at a given latitude, for sizing a lon/lat grid cell to a real-world distance.

Used to bucket points into grid cells approximately cell_size_m wide, so nearby-candidate-pair search only needs to compare each point against the ~9 cells around it instead of an all-pairs cross join. It is intentionally an approximation (a flat local reference frame around the given latitude) -- fine for the modest cell sizes clustering uses (tens to low hundreds of meters); actual distances between candidate pairs are still verified with the exact haversine_distance_m above.

Parameters:

Name Type Description Default
latitude_deg float

Latitude (degrees) to approximate the local scale at.

required

Returns:

Type Description
float

tuple[float, float]: (degrees latitude per meter, degrees longitude

float

per meter).

Source code in pyGTFSHandler/utils/geo_polars.py
def degrees_per_meter(latitude_deg: float) -> tuple[float, float]:
    """Returns (degrees-latitude-per-meter, degrees-longitude-per-meter) at a
    given latitude, for sizing a lon/lat grid cell to a real-world distance.

    Used to bucket points into grid cells approximately `cell_size_m` wide,
    so nearby-candidate-pair search only needs to compare each point against
    the ~9 cells around it instead of an all-pairs cross join. It is
    intentionally an approximation (a flat local reference frame around the
    given latitude) -- fine for the modest cell sizes clustering uses (tens
    to low hundreds of meters); actual distances between candidate pairs are
    still verified with the exact `haversine_distance_m` above.

    Args:
        latitude_deg: Latitude (degrees) to approximate the local scale at.

    Returns:
        tuple[float, float]: (degrees latitude per meter, degrees longitude
        per meter).
    """
    meters_per_degree_lat = 111320.0
    meters_per_degree_lon = max(111320.0 * math.cos(math.radians(latitude_deg)), 1e-6)
    return 1.0 / meters_per_degree_lat, 1.0 / meters_per_degree_lon

mean_angle

mean_angle(column: str, over: Optional[List[str]] = None) -> pl.Expr

Compute the mean angle in degrees of a column of angles.

Source code in pyGTFSHandler/utils/geo_polars.py
def mean_angle(column: str, over: Optional[List[str]] = None) -> pl.Expr:
    """Compute the mean angle in degrees of a column of angles."""
    if over is None:
        mean_cos = pl.col(column).radians().cos().mean()
        mean_sin = pl.col(column).radians().sin().mean()
    else:
        mean_cos = pl.col(column).radians().cos().mean().over(over)
        mean_sin = pl.col(column).radians().sin().mean().over(over)

    return pl.arctan2(mean_sin, mean_cos).degrees().mod(360)

max_separation_angle

max_separation_angle(df: DataFrame, column: str) -> pl.Series

Compute the maximum separation angle in a list column of angles (degrees). Returns a Series of maximum separation angles.

Source code in pyGTFSHandler/utils/geo_polars.py
def max_separation_angle(df: pl.DataFrame, column: str) -> pl.Series:
    """
    Compute the maximum separation angle in a list column of angles (degrees).
    Returns a Series of maximum separation angles.
    """
    df = (
        df.with_columns(pl.col(column).list.concat(pl.col(column) + 180).alias(column))
          .with_columns([(pl.col(column) % 360).list.min().alias(f"{column}_min_angle")])
          .with_columns((pl.col(column) % 360 - pl.col(f"{column}_min_angle")).alias(f"{column}_normalized"))
          .with_columns([(pl.col(f"{column}_normalized").list.sort().list.concat(pl.lit([360]))).alias(f"{column}_angle_sorted")])
          .with_columns([pl.col(f"{column}_angle_sorted").list.diff(null_behavior="drop").alias(f"{column}_arc_angle")])
          .with_columns([(pl.col(f"{column}_arc_angle").list.max() / 2 +
                          pl.col(f"{column}_angle_sorted").list.get(pl.col(f"{column}_arc_angle").list.arg_max()) +
                          pl.col(f"{column}_min_angle")).alias(f"{column}_max_separation_angle")])
    )
    return df[f"{column}_max_separation_angle"]

connected_components_from_edges

connected_components_from_edges(n_nodes: int, edge_index_pairs: list[tuple[int, int]]) -> list[int]

Labels each of n_nodes node indices [0, n_nodes) with its connected component id, given an undirected edge list.

This is the one step that isn't a polars primitive -- graph connectivity -- so it's delegated to a single vectorized scipy.sparse.csgraph call over the whole edge list at once (not per-pair, not per-cluster), rather than pulling in a full clustering library (e.g. scikit-learn) for what is fundamentally just "which points are transitively within the distance threshold of one another."

Parameters:

Name Type Description Default
n_nodes int

Total number of nodes (points) being clustered.

required
edge_index_pairs list[tuple[int, int]]

List of (i, j) node-index pairs known to be within the clustering distance of one another.

required

Returns:

Type Description
list[int]

list[int]: Component label for each node index 0..n_nodes-1.

Source code in pyGTFSHandler/utils/geo_polars.py
def connected_components_from_edges(n_nodes: int, edge_index_pairs: list[tuple[int, int]]) -> list[int]:
    """Labels each of `n_nodes` node indices [0, n_nodes) with its connected
    component id, given an undirected edge list.

    This is the one step that isn't a polars primitive -- graph connectivity
    -- so it's delegated to a single vectorized `scipy.sparse.csgraph` call
    over the whole edge list at once (not per-pair, not per-cluster), rather
    than pulling in a full clustering library (e.g. scikit-learn) for what is
    fundamentally just "which points are transitively within the distance
    threshold of one another."

    Args:
        n_nodes: Total number of nodes (points) being clustered.
        edge_index_pairs: List of `(i, j)` node-index pairs known to be
            within the clustering distance of one another.

    Returns:
        list[int]: Component label for each node index `0..n_nodes-1`.
    """
    from scipy.sparse import coo_matrix
    from scipy.sparse.csgraph import connected_components

    if n_nodes == 0:
        return []

    if not edge_index_pairs:
        return list(range(n_nodes))

    rows = [pair[0] for pair in edge_index_pairs]
    cols = [pair[1] for pair in edge_index_pairs]
    data = [1] * len(rows)
    adjacency = coo_matrix((data, (rows, cols)), shape=(n_nodes, n_nodes))
    _, labels = connected_components(csgraph=adjacency, directed=False)
    return labels.tolist()

grid_cell_columns

grid_cell_columns(lat_col: str, lon_col: str, cell_size_m: float, reference_latitude_deg: float) -> list[pl.Expr]

Returns [cell_lat, cell_lon] integer-bucket expressions for lat_col/lon_col.

Parameters:

Name Type Description Default
lat_col str

Name of the latitude column.

required
lon_col str

Name of the longitude column.

required
cell_size_m float

Desired grid cell size, in meters.

required
reference_latitude_deg float

Latitude used to approximate the local meters-per-degree scale (see degrees_per_meter); typically the mean latitude of the points being bucketed.

required

Returns:

Type Description
list[Expr]

list[pl.Expr]: Two expressions aliased "cell_lat"/"cell_lon".

Source code in pyGTFSHandler/utils/geo_polars.py
def grid_cell_columns(
    lat_col: str,
    lon_col: str,
    cell_size_m: float,
    reference_latitude_deg: float,
) -> list[pl.Expr]:
    """Returns `[cell_lat, cell_lon]` integer-bucket expressions for `lat_col`/`lon_col`.

    Args:
        lat_col: Name of the latitude column.
        lon_col: Name of the longitude column.
        cell_size_m: Desired grid cell size, in meters.
        reference_latitude_deg: Latitude used to approximate the local
            meters-per-degree scale (see `degrees_per_meter`); typically the
            mean latitude of the points being bucketed.

    Returns:
        list[pl.Expr]: Two expressions aliased `"cell_lat"`/`"cell_lon"`.
    """
    deg_per_m_lat, deg_per_m_lon = degrees_per_meter(reference_latitude_deg)
    cell_size_lat_deg = max(cell_size_m * deg_per_m_lat, 1e-9)
    cell_size_lon_deg = max(cell_size_m * deg_per_m_lon, 1e-9)
    return [
        (pl.col(lat_col) / cell_size_lat_deg).floor().cast(pl.Int64).alias("cell_lat"),
        (pl.col(lon_col) / cell_size_lon_deg).floor().cast(pl.Int64).alias("cell_lon"),
    ]

filter_by_id_column

filter_by_id_column(lf: Optional[LazyFrame], column: str, ids: list | None = []) -> Optional[pl.LazyFrame]

Semi-joins lf down to rows whose column value is in ids.

IDs are suffixed with "_<original_value>_file_<n>" only when more than one GTFS directory was loaded together (see io.read_csv_list), to keep ids that collide across the loaded feeds distinct; a single-feed load keeps plain ids. To support both a plain id and an already-suffixed one (e.g. re-using an id obtained from a previous multi-feed query's output), this matches on the id with any trailing _file_<n> suffix stripped from both sides.

Parameters:

Name Type Description Default
lf Optional[LazyFrame]

LazyFrame to filter, or None (returned unchanged).

required
column str

Name of the id column to filter on.

required
ids list | None

List of ids to keep (plain or already _file_<n>-suffixed). Empty/None means "no filter" (returns lf unchanged).

[]

Returns:

Type Description
Optional[LazyFrame]

Optional[pl.LazyFrame]: The filtered LazyFrame, or None if lf is None.

Source code in pyGTFSHandler/utils/geo_polars.py
def filter_by_id_column(lf: Optional[pl.LazyFrame], column: str, ids: list | None = []) -> Optional[pl.LazyFrame]:
    """Semi-joins `lf` down to rows whose `column` value is in `ids`.

    IDs are suffixed with `"_<original_value>_file_<n>"` only when more than
    one GTFS directory was loaded together (see `io.read_csv_list`), to keep
    ids that collide across the loaded feeds distinct; a single-feed load
    keeps plain ids. To support both a plain id and an already-suffixed one
    (e.g. re-using an id obtained from a previous multi-feed query's
    output), this matches on the id with any trailing `_file_<n>` suffix
    stripped from both sides.

    Args:
        lf: LazyFrame to filter, or None (returned unchanged).
        column: Name of the id column to filter on.
        ids: List of ids to keep (plain or already `_file_<n>`-suffixed).
            Empty/None means "no filter" (returns `lf` unchanged).

    Returns:
        Optional[pl.LazyFrame]: The filtered LazyFrame, or None if `lf` is None.
    """
    if ids is None:
        ids = []

    if lf is None:
        return None

    if len(ids) > 0:
        unsuffixed_ids = [
            re.sub(r"_file_\d+$", "", str(i)) if i is not None else i for i in ids
        ]
        ids_df = pl.LazyFrame({"__unsuffixed_id": unsuffixed_ids}).unique()
        lf = lf.with_columns(
            pl.col(column).str.replace(r"_file_\d+$", "").alias("__unsuffixed_id")
        ).join(ids_df, on="__unsuffixed_id", how="semi").drop("__unsuffixed_id")

    return lf

Time (time_parsing)

time_to_seconds

time_to_seconds(t: Union[datetime, time]) -> int

Convert datetime/time object to seconds since midnight.

Source code in pyGTFSHandler/utils/time_parsing.py
def time_to_seconds(t: Union[datetime, time]) -> int:
    """Convert datetime/time object to seconds since midnight."""
    if isinstance(t, datetime):
        t = t.time()
    return t.hour * 3600 + t.minute * 60 + t.second

normalize_time_expr

normalize_time_expr(col: str) -> pl.Expr

Normalizes a GTFS-style time column into a clean "HH:MM:SS" string.

Parameters:

Name Type Description Default
col str

Name of the column to normalize (e.g. "arrival_time", "start_time").

required

Returns:

Type Description
Expr

pl.Expr: null for empty/null input, "None" (the literal string,

Expr

used as a sentinel by callers to distinguish "malformed" from "was

Expr

genuinely blank") for anything that doesn't match H(:MM(:SS)),

Expr

otherwise the zero-padded "HH:MM:SS" form. Hours are not clamped

Expr

to 24, so GTFS's >=24:00:00 overnight convention round-trips

Expr

through this function unchanged.

Source code in pyGTFSHandler/utils/time_parsing.py
def normalize_time_expr(col: str) -> pl.Expr:
    """Normalizes a GTFS-style time column into a clean `"HH:MM:SS"` string.

    Args:
        col: Name of the column to normalize (e.g. `"arrival_time"`,
            `"start_time"`).

    Returns:
        pl.Expr: `null` for empty/null input, `"None"` (the literal string,
        used as a sentinel by callers to distinguish "malformed" from "was
        genuinely blank") for anything that doesn't match `H(:MM(:SS))`,
        otherwise the zero-padded `"HH:MM:SS"` form. Hours are not clamped
        to 24, so GTFS's `>=24:00:00` overnight convention round-trips
        through this function unchanged.
    """
    # Cast to Utf8 and remove unwanted characters
    cleaned = pl.col(col).cast(pl.Utf8).str.replace_all(r'[^0-9:]', '')

    # Split into exactly 3 parts → struct
    parts_struct = cleaned.str.split_exact(":", 3)

    # Fields safely
    h = parts_struct.struct.field("field_0").fill_null("0").cast(pl.Utf8).str.zfill(2)
    m = parts_struct.struct.field("field_1").fill_null("0").cast(pl.Utf8).str.zfill(2)
    s = parts_struct.struct.field("field_2").fill_null("0").cast(pl.Utf8).str.zfill(2)

    # Valid pattern: 1-2 digits, optional :1-2 digits, optional :1-2 digits
    is_valid = cleaned.str.contains(r'^\d{1,2}(:\d{1,2}){0,2}$')

    # Handle empty strings or nulls
    is_empty_or_null = pl.col(col).is_null() | (pl.col(col) == "")

    # Combine parts safely
    result = pl.when(is_empty_or_null).then(pl.lit(None)) \
               .when(is_valid).then(h + ":" + m + ":" + s) \
               .otherwise(pl.lit("None"))

    return result

time_displacement

time_displacement(gtfs_lf, secs_disp)
Source code in pyGTFSHandler/utils/time_parsing.py
def time_displacement(gtfs_lf,secs_disp):
    gtfs_lf = gtfs_lf.with_columns(
        (pl.col("shape_time_traveled") + secs_disp).alias("target_time")
    )
    gtfs_lf = gtfs_lf.with_columns(
        pl.when(
            pl.col("target_time") < 0
        ).then(
            pl.lit(0)
        ).otherwise(
            pl.col("target_time")
        ).alias("target_time"),
    )
    gtfs_lf = gtfs_lf.with_columns(
        pl.when(
            pl.col("target_time") > pl.col("shape_total_travel_time")
        ).then(
            pl.col("shape_total_travel_time")
        ).otherwise(
            pl.col("target_time")
        ).alias("target_time"),
    )
    # `target_time` is `shape_time_traveled + secs_disp`, a per-trip
    # monotonic shift of the column we sort by, so it is already sorted
    # within each `trip_id` group -- `check_sortedness` can't verify this
    # itself once a `by` group is involved (hence disabled here) but the
    # invariant holds regardless.
    gtfs_lf = gtfs_lf.sort(["trip_id", "shape_time_traveled"])
    gtfs_lf = gtfs_lf.join_asof(
        gtfs_lf.select([
            "trip_id",
            pl.col("shape_time_traveled").alias("t_lb"),
            pl.col("shape_dist_traveled").alias("d_lb"),
        ]),
        left_on="target_time",
        right_on="t_lb",
        by="trip_id",
        strategy="backward",
        check_sortedness=False,
    ).sort(["trip_id", "shape_time_traveled"])

    gtfs_lf = gtfs_lf.join_asof(
        gtfs_lf.select([
            "trip_id",
            pl.col("shape_time_traveled").alias("t_ub"),
            pl.col("shape_dist_traveled").alias("d_ub"),
        ]),
        left_on="target_time",
        right_on="t_ub",
        by="trip_id",
        strategy="forward",
        check_sortedness=False,
    )

    gtfs_lf = gtfs_lf.with_columns(
        pl.when(pl.col("d_lb").is_null())
            .then(
                pl.lit(None)
            ).otherwise(
                pl.col("t_lb")
            ).alias("t_lb"),
        pl.when(pl.col("t_lb").is_null())
            .then(
                pl.lit(None)
            ).otherwise(
                pl.col("d_lb")
            ).alias("d_lb"),
        pl.when(pl.col("d_ub").is_null())
            .then(
                pl.lit(None)
            ).otherwise(
                pl.col("t_ub")
            ).alias("t_ub"),
        pl.when(pl.col("t_ub").is_null())
            .then(
                pl.lit(None)
            ).otherwise(
                pl.col("d_ub")
            ).alias("d_ub"),
    )

    gtfs_lf = gtfs_lf.with_columns(
        pl.when(
            pl.col("target_time") == 0
        ).then(
            pl.col("shape_time_traveled")
        ).otherwise(
            pl.col("t_ub")
        ).alias("t_ub"),
        pl.when(
            pl.col("target_time") == pl.col("shape_total_travel_time")
        ).then(
            pl.col("shape_time_traveled")
        ).otherwise(
            pl.col("t_lb")
        ).alias("t_lb"),
        pl.when(
            pl.col("target_time") == 0
        ).then(
            pl.col("shape_dist_traveled")
        ).otherwise(
            pl.col("d_ub")
        ).alias("d_ub"),
        pl.when(
            pl.col("target_time") == pl.col("shape_total_travel_time")
        ).then(
            pl.col("shape_dist_traveled")
        ).otherwise(
            pl.col("d_lb")
        ).alias("d_lb"),
    )

    # **Bug fixed 2026-08-12** (found chasing a real downstream symptom:
    # `analysis/stops.py`'s `get_speed_at_stops` returning `null` speed for
    # ~30-90% of stops on real-world GTFS data, e.g. every MBTA Green Line
    # branch at Boylston station). This used to unconditionally backfill any
    # null `t_lb`/`t_ub`/`d_lb`/`d_ub` (from a join_asof side that found no
    # match) with the row's OWN `shape_time_traveled`/`shape_dist_traveled`
    # *before* computing time_weight/distance_weight below -- which created
    # two distinct failure modes once every input was guaranteed non-null:
    #   1. Whenever `target_time` landed exactly on another stop's own
    #      `shape_time_traveled` (common with real, minute-rounded transit
    #      schedules), the backward and forward join_asof both find that
    #      *same* exact point, so t_lb == t_ub. The old formula's
    #      `(d_ub - d_lb) / (t_ub - t_lb)` then divided by zero, producing
    #      NaN -> silently null distance_weight, even though the answer
    #      (d_lb, no interpolation needed) was sitting right there.
    #   2. Whenever only ONE side had no real join_asof match (target_time
    #      beyond the trip's first/last known point), backfilling the
    #      missing side with the row's own point could put `t_ub < t_lb`
    #      (inverted), silently extrapolating backwards through zero/negative
    #      denominators instead of clamping to the one side that IS known.
    # The old null-checks a few lines below (`pl.when(t_lb.is_null())...`)
    # were meant to catch exactly these cases and return a clean null
    # instead -- but they were dead code, since by the time they ran,
    # nothing was null anymore (this same unconditional backfill had already
    # replaced every null). Handling all three cases explicitly, on the raw
    # (still-possibly-null) t_lb/t_ub, fixes both failure modes and makes
    # the null-check intent actually reachable.
    gtfs_lf = gtfs_lf.with_columns(
        pl.when(pl.col("t_lb").is_null() & pl.col("t_ub").is_null())
        .then(pl.lit(None))
        .when(pl.col("t_lb").is_null())
        .then(pl.col("d_ub"))
        .when(pl.col("t_ub").is_null())
        .then(pl.col("d_lb"))
        .when(pl.col("t_lb") == pl.col("t_ub"))
        .then(pl.col("d_lb"))
        .otherwise(
            pl.col("d_lb")
            + (
                (pl.col("d_ub") - pl.col("d_lb"))
                / (pl.col("t_ub") - pl.col("t_lb"))
            )
            * (pl.col("target_time") - pl.col("t_lb"))
        )
        .alias("_interpolated_dist")
    )

    gtfs_lf = gtfs_lf.with_columns(
        # Calculate "time" -- always known (target_time is derived from this
        # row's own shape_time_traveled +/- secs_disp), so this never needed
        # the null-gating the old code (unreachably) attempted.
        (pl.col("target_time") - pl.col("shape_time_traveled")).abs().alias("time_weight"),

        # Calculate "distance" -- null only when *neither* side of the
        # window found a match at all (should not happen in practice, since
        # a trip's own row is always a candidate match for itself; handled
        # defensively regardless).
        (pl.col("_interpolated_dist") - pl.col("shape_dist_traveled")).abs().alias("distance_weight"),
    ).drop("_interpolated_dist")#.drop(["d_lb","d_ub","t_lb","t_ub", "target_time"])

    gtfs_lf = gtfs_lf.with_columns(
        pl.when(
            pl.col("time_weight").is_infinite() | pl.col("time_weight").is_nan()
        ).then(
            pl.lit(None)
        ).otherwise(
            pl.col("time_weight")
        ).alias("time_weight"),

        pl.when(
            pl.col("distance_weight").is_infinite() | pl.col("distance_weight").is_nan()
        ).then(
            pl.lit(None)
        ).otherwise(
            pl.col("distance_weight")
        ).alias("distance_weight"),
    )

    gtfs_lf = gtfs_lf.with_columns(
        pl.when(
            pl.col("time_weight").is_null()
        ).then(
            pl.lit(None)
        ).otherwise(
            pl.col("distance_weight")
        ).alias("distance_weight"),
        pl.when(
            pl.col("distance_weight").is_null()
        ).then(
            pl.lit(None)
        ).otherwise(
            pl.col("time_weight")
        ).alias("time_weight")
    )

    return gtfs_lf

Dates (date_parsing)

datetime_to_days_since_epoch

datetime_to_days_since_epoch(dt: Union[datetime, date]) -> int

Convert datetime/date to number of days since 1970-01-01.

Source code in pyGTFSHandler/utils/date_parsing.py
def datetime_to_days_since_epoch(dt: Union[datetime, date]) -> int:
    """Convert datetime/date to number of days since 1970-01-01."""
    if isinstance(dt, datetime):
        dt = dt.date()
    return (dt - EPOCH).days

get_holidays

get_holidays(year: int, country_code: str, subdivision_code: Optional[str] = None) -> pl.DataFrame

Fetch public holidays for a country and optional subdivision.

Source code in pyGTFSHandler/utils/date_parsing.py
def get_holidays(year: int, country_code: str, subdivision_code: Optional[str] = None) -> pl.DataFrame:
    """Fetch public holidays for a country and optional subdivision."""
    url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country_code}"

    empty_df = pl.DataFrame(schema={
        "date": pl.Int32,
        "localName": pl.Utf8,
        "name": pl.Utf8,
        "countryCode": pl.Utf8,
        "fixed": pl.Boolean,
        "global": pl.Boolean,
        "counties": pl.List(pl.Utf8),
        "launchYear": pl.Int32,
        "types": pl.List(pl.Utf8),
    })

    try:
        resp = requests.get(url, timeout=10)
        if resp.status_code != 200:
            warnings.warn(f"Holiday API request failed: {resp.status_code}")
            return empty_df
        holidays = resp.json()
        if not holidays:
            return empty_df
    except Exception as e:
        warnings.warn(f"Holiday API request error: {e}")
        return empty_df

    if subdivision_code:
        holidays = [h for h in holidays if not h.get("counties") or subdivision_code in h.get("counties", [])]

    df = pl.DataFrame(holidays)
    if "date" in df.columns:
        df = df.with_columns(
            pl.col("date")
            .str.strptime(pl.Date, "%Y-%m-%d", strict=False)
            .dt.epoch(time_unit="d")
        )
    return df

GTFS checking & normalization (gtfs_checker)

parse_date

parse_date(d)

Parse a date from various formats and return an int in YYYYMMDD format.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def parse_date(d):
    """
    Parse a date from various formats and return an int in YYYYMMDD format.
    """
    if isinstance(d, int):
        d = str(d)

    date_formats = [
        "%Y%m%d", "%Y-%m-%d", "%Y%m%d", "%Y%m-%d",
        "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d", "%m/%d/%Y",
        "%m-%d-%Y", "%d.%m.%Y",
    ]

    for fmt in date_formats:
        try:
            parsed_date = datetime.strptime(d, fmt)
            return str(parsed_date.strftime("%Y%m%d"))
        except ValueError:
            continue

    raise ValueError(f"Could not parse date: {d}")

parse_time

parse_time(t: str) -> str

Parse a time string and return HH:MM:SS. Rules: - With colons (:): parse flexibly (e.g., "7:1" -> "07:01:00"). - Without colons: only 4 or 6 digits are allowed: 4 digits (HHMM) -> HH:MM:00 6 digits (HHMMSS) -> HH:MM:SS Supports hours up to 47.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def parse_time(t: str) -> str:
    """
    Parse a time string and return HH:MM:SS.
    Rules:
      - With colons (:): parse flexibly (e.g., "7:1" -> "07:01:00").
      - Without colons: only 4 or 6 digits are allowed:
            4 digits (HHMM) -> HH:MM:00
            6 digits (HHMMSS) -> HH:MM:SS
      Supports hours up to 47.
    """
    if isinstance(t, int):
        t = str(t)
    t = t.strip().lower().replace(',', '.')

    # Handle AM/PM (unchanged)
    ampm_match = re.match(r'(\d{1,2}):?(\d{1,2})?:?(\d{1,2})?\s*(am|pm)', t)
    if ampm_match:
        h, m, s, meridiem = ampm_match.groups()
        h, m, s = int(h), int(m or 0), int(s or 0)
        if meridiem == 'pm' and h < 12:
            h += 12
        if meridiem == 'am' and h == 12:
            h = 0
        if h > 47:
            raise ValueError(f"Hour value over 47: {h}")
        return f"{h:02}:{m:02}:{s:02}"

    # Case 1: String has colons → flexible parsing
    if ':' in t:
        parts = t.split(':')
        parts = [p.zfill(2) if p else '00' for p in parts]
        while len(parts) < 3:
            parts.append('00')

        h, m, s = map(int, parts[:3])
        if m > 59 or s > 59:
            raise ValueError(f"Invalid time value: {t}")
        if h > 47:
            raise ValueError(f"Invalid hour value: {t} is over 47 hours")

        return f"{h:02}:{m:02}:{s:02}"

    # Case 2: No colons → must be 4 or 6 digits
    digits = ''.join(c for c in t if c.isdigit())
    if not digits:
        raise ValueError(f"Could not parse time: {t}")

    if len(digits) == 4:  # HHMM
        h, m, s = int(digits[:2]), int(digits[2:4]), 0
    elif len(digits) == 6:  # HHMMSS
        h, m, s = int(digits[:2]), int(digits[2:4]), int(digits[4:6])
    else:
        raise ValueError(f"Invalid time format (must be 4 or 6 digits): {t}")

    if m > 59 or s > 59:
        raise ValueError(f"Invalid time value: {t}")
    if h > 47:
        raise ValueError(f"Invalid hour value: {t} is over 47 hours")

    return f"{h:02}:{m:02}:{s:02}"

get_df_schema_dict

get_df_schema_dict(path: str) -> Tuple[Dict[str, Any], List[str]]

Returns the expected Polars dtype schema and mandatory columns for a GTFS file.

Used throughout models/*.py to pass schema_overrides to utils.io.read_csv_list/read_csv_lazy and to know which columns check_files must validate as present/non-null.

Parameters:

Name Type Description Default
path str

A GTFS filename or path (only the basename, e.g. "stops.txt", is inspected -- any directory portion or extension besides .txt is normalized away).

required

Returns:

Type Description
Dict[str, Any]

Tuple[Dict[str, Any], List[str]]: (schema_dict, mandatory_cols)

List[str]

where schema_dict maps column name to Python/Polars type and

Tuple[Dict[str, Any], List[str]]

mandatory_cols lists the GTFS-required columns for that file.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def get_df_schema_dict(path: str) -> Tuple[Dict[str, Any], List[str]]:
    """Returns the expected Polars dtype schema and mandatory columns for a GTFS file.

    Used throughout `models/*.py` to pass `schema_overrides` to
    `utils.io.read_csv_list`/`read_csv_lazy` and to know which columns
    `check_files` must validate as present/non-null.

    Args:
        path: A GTFS filename or path (only the basename, e.g.
            `"stops.txt"`, is inspected -- any directory portion or
            extension besides `.txt` is normalized away).

    Returns:
        Tuple[Dict[str, Any], List[str]]: `(schema_dict, mandatory_cols)`
        where `schema_dict` maps column name to Python/Polars type and
        `mandatory_cols` lists the GTFS-required columns for that file.
    """
    path = os.path.splitext(path)[0]
    path += ".txt"
    if "stops.txt" in str(path):
        schema_dict = {"stop_id": str, "stop_name": str, "stop_lat": float, "stop_lon": float}
        mandatory_cols = ["stop_id", "stop_lat", "stop_lon"]
    elif "trips.txt" in str(path):
        schema_dict = {"route_id": str, "service_id": str, "trip_id": str, "direction_id": int}
        mandatory_cols = ["route_id", "service_id", "trip_id"]
    elif "stop_times.txt" in str(path):
        schema_dict = {
            "trip_id": str,
            "arrival_time": "time|None",
            "departure_time": "time|None",
            "stop_id": str,
            "stop_sequence": int
        }
        mandatory_cols = ["trip_id", "arrival_time", "departure_time", "stop_id"]
    elif "routes.txt" in str(path):
        schema_dict = {"route_id": str, "agency_id": str, "route_short_name": str,
                       "route_long_name": str, "route_type": "route_type"}
        mandatory_cols = ["route_id"]
    elif "calendar.txt" in str(path):
        schema_dict = {
            "service_id": str, "monday": "int|bool", "tuesday": "int|bool", "wednesday": "int|bool",
            "thursday": "int|bool", "friday": "int|bool", "saturday": "int|bool", "sunday": "int|bool",
            "start_date": "date", "end_date": "date"
        }
        mandatory_cols = ["service_id", "monday", "tuesday","wednesday","thursday","friday","saturday","sunday","start_date","end_date"]
    elif "calendar_dates.txt" in str(path):
        schema_dict = {"service_id": str, "date": "date", "exception_type": "exception_type"}
        mandatory_cols = ["service_id","date","exception_type"]
    elif "frequencies.txt" in str(path):
        schema_dict = {
            "trip_id": str,
            "start_time": "time",
            "end_time": "time",
            "headway_secs": "seconds",
        }
        mandatory_cols = ["trip_id","start_time","end_time","headway_secs"]
    elif "shapes.txt" in str(path):
        schema_dict = {
            "shape_id": str,
            "shape_pt_sequence": int,
            "shape_pt_lat": float,
            "shape_pt_lon": float,
            "shape_dist_traveled": float,
        }
        mandatory_cols = ["shape_id","shape_pt_lat","shape_pt_lon"]
    elif "agency.txt" in str(path):
        schema_dict = {
            "agency_id": str,
        }
        mandatory_cols = ["agency_id"]
    else:
        raise Exception(f"File {path} not implemented.")
    return schema_dict, mandatory_cols

normalize_string

normalize_string(s: str, *, strict: bool = True) -> str

Normalize a string:

  • Converts accented characters to ASCII.
  • Strips leading/trailing whitespace in all cases.

strict=True: - Lowercase - Replace spaces (and multiple consecutive spaces) with a single underscore - Keep only a-z, 0-9, and underscores - Discard all other symbols

strict=False: - Preserve case and spaces - Keep URL-safe characters: a-zA-Z0-9-_.~:/?#[]@!$&'()*+,;= - Collapse multiple spaces into a single space

Parameters:

Name Type Description Default
s str

Input string

required
strict bool

Whether to apply strict rules

True

Returns:

Name Type Description
str str

Normalized string

Source code in pyGTFSHandler/utils/gtfs_checker.py
def normalize_string(s: str, *, strict: bool = True) -> str:
    """
    Normalize a string:

    - Converts accented characters to ASCII.
    - Strips leading/trailing whitespace in all cases.

    strict=True:
        - Lowercase
        - Replace spaces (and multiple consecutive spaces) with a single underscore
        - Keep only a-z, 0-9, and underscores
        - Discard all other symbols

    strict=False:
        - Preserve case and spaces
        - Keep URL-safe characters: a-zA-Z0-9-_.~:/?#[]@!$&'()*+,;=
        - Collapse multiple spaces into a single space

    Parameters:
        s (str): Input string
        strict (bool): Whether to apply strict rules

    Returns:
        str: Normalized string
    """
    if not isinstance(s, str):
        raise TypeError("Input must be a string.")

    # Normalize accented characters
    s = unicodedata.normalize("NFKD", s)
    s = s.encode("ascii", "ignore").decode("ascii")
    s = s.strip()  # Strip whitespace in both cases

    if strict:
        s = s.lower()
        # Replace one or more spaces with a single underscore
        s = re.sub(r"\s+", "_", s)
        # Keep only a-z, 0-9, and underscores
        s = re.sub(r"[^a-z0-9_]", "", s)
    else:
        # Preserve case and spaces, keep URL-safe characters
        s = re.sub(r"[^a-zA-Z0-9\-_.~:/?#\[\]@!$&'()*+,;=\s]", "", s)
        # Collapse multiple spaces into a single space
        s = re.sub(r"\s{2,}", " ", s)

    # Final strip to remove any leading/trailing underscores or spaces
    return s.strip()

normalize_df

normalize_df(lf: LazyFrame | DataFrame) -> pl.LazyFrame | pl.DataFrame

Normalize both column names and string values in a Polars LazyFrame or DataFrame.

Column names: - Lowercased, stripped of spaces and special characters.

String columns (pl.Utf8): - Strip leading/trailing whitespace. - Remove diacritical marks (accents) from characters. - Optionally preserve uppercase (used here to keep valid URL casing). - Retain all valid URL characters. - Remove invalid characters.

Parameters:

Name Type Description Default
lf LazyFrame | DataFrame

Input LazyFrame or DataFrame.

required

Returns:

Type Description
LazyFrame | DataFrame

pl.LazyFrame | pl.DataFrame: The normalized LazyFrame or DataFrame.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def normalize_df(lf: pl.LazyFrame | pl.DataFrame) -> pl.LazyFrame | pl.DataFrame:
    """
    Normalize both column names and string values in a Polars LazyFrame or DataFrame.

    Column names:
    - Lowercased, stripped of spaces and special characters.

    String columns (pl.Utf8):
    - Strip leading/trailing whitespace.
    - Remove diacritical marks (accents) from characters.
    - Optionally preserve uppercase (used here to keep valid URL casing).
    - Retain all valid URL characters.
    - Remove invalid characters.

    Parameters:
        lf (pl.LazyFrame | pl.DataFrame): Input LazyFrame or DataFrame.

    Returns:
        pl.LazyFrame | pl.DataFrame: The normalized LazyFrame or DataFrame.
    """
    # Get the schema to avoid triggering expensive computation in lazy mode
    schema = lf.collect_schema()
    column_names = schema.names()

    # Normalize all column names
    normalized_column_names = [normalize_string(col) for col in column_names]
    rename_map = dict(zip(column_names, normalized_column_names))
    lf = lf.rename(rename_map)

    # Normalize string column values (for columns with Utf8 type)
    for old_name, new_name in zip(column_names, normalized_column_names):
        dtype = schema.get(old_name)
        if dtype == pl.Utf8:
            expr = (
                pl.col(new_name)
                .str.strip_chars()  # Trim leading/trailing whitespace
                # .str.to_lowercase()  # Uncomment to force lowercase values
                # Remove diacritics from characters (accented letters)
                .str.replace_all(r"[áàãâäåāÁÀÃÂÄÅĀ]", "a")
                .str.replace_all(r"[éèêëēėęÉÈÊËĒĖĘ]", "e")
                .str.replace_all(r"[íìîïīįıÍÌÎÏĪĮ]", "i")
                .str.replace_all(r"[óòõôöøōÓÒÕÔÖØŌ]", "o")
                .str.replace_all(r"[úùûüūÚÙÛÜŪ]", "u")
                .str.replace_all(r"[çćčÇĆČ]", "c")
                .str.replace_all(r"[ñńÑŃ]", "n")
                .str.replace_all(r"[ß]", "ss")
                .str.replace_all(r"[ÿŸ]", "y")
                .str.replace_all(r"[žźżŽŹŻ]", "z")
                .str.replace_all(
                    r"\s+", "_"
                )  # Replace internal spaces with underscores
                # Keep only valid URL characters
                .str.replace_all(r"[^a-zA-Z0-9\-_.~:/?#\[\]@!$&'()*+,;=]", "")
            )
            lf = lf.with_columns(expr.alias(new_name))

    return lf

normalize_route_type

normalize_route_type(route_type: Union[int, str]) -> Optional[int]

Normalizes a route type (int code or name string) to its integer GTFS code.

Accepts either the standard/extended numeric route_type code (returned as-is) or a human-friendly name (e.g. "bus", "subway"), which is mapped to the corresponding standard code. Used by Feed and Routes.load to normalize the caller-supplied route_types filter before comparing it against routes.txt.

Parameters:

Name Type Description Default
route_type Union[int, str]

An int GTFS route type code, or a str that is either an integer-like code or a recognized route type name.

required

Returns:

Type Description
Optional[int]

int | None: The normalized integer route type code, or None if

Optional[int]

route_type could not be parsed as an int and did not match a

Optional[int]

known name.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def normalize_route_type(route_type: Union[int, str]) -> Optional[int]:
    """Normalizes a route type (int code or name string) to its integer GTFS code.

    Accepts either the standard/extended numeric `route_type` code
    (returned as-is) or a human-friendly name (e.g. `"bus"`, `"subway"`),
    which is mapped to the corresponding standard code. Used by `Feed`
    and `Routes.load` to normalize the caller-supplied `route_types`
    filter before comparing it against `routes.txt`.

    Args:
        route_type: An `int` GTFS route type code, or a `str` that is
            either an integer-like code or a recognized route type name.

    Returns:
        int | None: The normalized integer route type code, or `None` if
        `route_type` could not be parsed as an int and did not match a
        known name.
    """
    if isinstance(route_type,int):
        return route_type 

    try:
        return int(route_type)
    except:
        None 

    if isinstance(route_type, str):
        if route_type == "tram":
            route_type = 0

        elif (route_type == "subway") or (route_type == "metro") or (route_type == "underground"):
            route_type = 1

        elif route_type == "rail":
            route_type = 2

        elif route_type == "bus":
            route_type = 3

        elif route_type == "ferry":
            route_type = 4

        elif (
            (route_type == "cable car")
            or (route_type == "cable_car")
            or (route_type == "cable-car")
            or (route_type == "cablecar")
        ):
            route_type = 5

        elif route_type == "gondola":
            route_type = 6

        elif route_type == "funicular":
            route_type = 7
        else:
            raise Exception(
                f"Got route_type {route_type} but accepted values are tram, subway, rail, bus, ferry, cable car, gondola and funicular"
            )

    else:
        raise Exception(
            f"Route type {route_type} with dtype {type(route_type)} not implemented"
        )

    return route_type

extended_to_standard_route_type

extended_to_standard_route_type(route_type: int) -> int | None

Convert any GTFS route_type or Extended GTFS (Transmodel) route_type into a standard GTFS route_type (0–7).

Returns:

Type Description
int | None

int in {0..7} or None if not mappable (either because the code is

int | None

not a recognized GTFS/extended route_type at all, or because it's a

int | None

recognized extended category -- e.g. Air/Taxi/Misc -- with no

int | None

closer standard-GTFS analog; use is_recognized_extended_route_type

int | None

to tell these two cases apart).

Source code in pyGTFSHandler/utils/gtfs_checker.py
def extended_to_standard_route_type(route_type: int) -> int | None:
    """
    Convert any GTFS route_type or Extended GTFS (Transmodel) route_type
    into a standard GTFS route_type (0–7).

    Returns:
        int in {0..7} or None if not mappable (either because the code is
        not a recognized GTFS/extended route_type at all, or because it's a
        recognized extended category -- e.g. Air/Taxi/Misc -- with no
        closer standard-GTFS analog; use `is_recognized_extended_route_type`
        to tell these two cases apart).
    """

    # --- If already a standard GTFS type, return unchanged ---
    if route_type in {-1, 0, 1, 2, 3, 4, 5, 6, 7}:
        return route_type

    # --- Extra custom values you asked to convert ---
    if route_type == 8:    # Shared taxi / "sherut" (informal code used by
        return 3           # Israel's MOT national GTFS feed; not part of the
                            # base 0-7 or standard 3-digit extended taxonomy,
                            # but a real, documented-in-practice mode with no
                            # closer GTFS analog -> fall back to Bus.
    if route_type == 11:   # Trolleybus
        return 3           # Bus
    if route_type == 12:   # Monorail
        return 2           # Rail

    # --- Rail services (100–117) ---
    if 100 <= route_type <= 117:
        return 2

    # --- Coach (200–209) → Bus ---
    if 200 <= route_type <= 209:
        return 3

    # --- Urban Rail, Metro, Underground, Monorail ---
    urban_rail_map = {
        400: 2,  # urban rail → rail
        401: 1,  # metro
        402: 1,  # underground/subway
        403: 2,  # urban rail
        404: 2,  # all urban rail
        405: 2,  # monorail → rail (per your instruction)
    }
    if route_type in urban_rail_map:
        return urban_rail_map[route_type]

    # --- Bus (700–716) & Trolleybus (800) ---
    if 700 <= route_type <= 716:
        return 3
    if route_type == 800:
        return 3  # electric bus

    # --- Tram / Light rail (900–906) ---
    if 900 <= route_type <= 906:
        return 0

    # --- Water / Ferry ---
    if route_type in {1000, 1200}:
        return 4

    # --- Air (no GTFS mapping) ---
    if route_type == 1100:
        return None

    # --- Aerial lift services (1300–1307) ---
    lift_map = {
        1300: 6,  # aerial lift, gondola-like
        1301: 6,
        1302: 5,  # cable car
        1303: 5,  # elevator → closest = cable car
        1304: 6,  # chair lift
        1305: 6,  # drag lift
        1306: 6,
        1307: 6,
    }
    if route_type in lift_map:
        return lift_map[route_type]

    # --- Funicular ---
    if route_type == 1400:
        return 7

    # --- Taxi (1500–1507) or Misc (1700, 1702): no GTFS mapping ---
    if 1500 <= route_type <= 1507 or route_type in {1700, 1702}:
        return None

    # --- Any other code within a documented extended-route-type category
    # (e.g. an Air/Water/Aerial-Lift/... sub-code not individually
    # hardcoded above, such as `1102`) falls back to that category's
    # general mapping rather than being treated as unrecognized. ---
    for low, high, base in _EXTENDED_ROUTE_TYPE_RANGES:
        if low <= route_type <= high:
            return base

    # --- Unknown ---
    return None

route_type_to_str

route_type_to_str(route_type: int) -> str

Converts a standard GTFS route_type integer code to its lowercase name.

Inverse of the numeric side of normalize_route_type.

Parameters:

Name Type Description Default
route_type int

A standard GTFS route type code (int), e.g. 3.

required

Returns:

Name Type Description
str str

The lowercase route type name, e.g. "bus".

Raises:

Type Description
Exception

If route_type is not an int.

Source code in pyGTFSHandler/utils/gtfs_checker.py
def route_type_to_str(route_type: int) -> str:
    """Converts a standard GTFS `route_type` integer code to its lowercase name.

    Inverse of the numeric side of `normalize_route_type`.

    Args:
        route_type: A standard GTFS route type code (`int`), e.g. `3`.

    Returns:
        str: The lowercase route type name, e.g. `"bus"`.

    Raises:
        Exception: If `route_type` is not an `int`.
    """
    if not isinstance(route_type, int):
        raise Exception(f"route_type must be an int, got {type(route_type)}")

    mapping = {
        -1: "None",
        0: "tram",
        1: "subway",
        2: "rail",
        3: "bus",
        4: "ferry",
        5: "cable car",
        6: "gondola",
        7: "funicular",
    }

    if route_type not in mapping:
        raise Exception(f"route_type {route_type} is not in range 0–7")

    return mapping[route_type]

Geocoding

get_city_geometry

get_city_geometry(city_name: str, user_agent: str = 'pyGTFSHandlerClient') -> gpd.GeoDataFrame

Retrieve a place's boundary geometry from OpenStreetMap/Nominatim.

Uses geopy's Nominatim geocoder with GeoJSON polygon output, so it needs no extra dependency beyond the geocoding optional extra (pip install pyGTFSHandler[geocoding]) already used by the other functions in this module.

Source code in pyGTFSHandler/utils/geocoding.py
def get_city_geometry(city_name: str, user_agent: str = "pyGTFSHandlerClient") -> gpd.GeoDataFrame:
    """Retrieve a place's boundary geometry from OpenStreetMap/Nominatim.

    Uses `geopy`'s Nominatim geocoder with GeoJSON polygon output, so it
    needs no extra dependency beyond the `geocoding` optional extra
    (`pip install pyGTFSHandler[geocoding]`) already used by the other
    functions in this module.
    """
    from geopy.geocoders import Nominatim

    geolocator = Nominatim(user_agent=user_agent, timeout=10)
    location = geolocator.geocode(city_name, geometry="geojson")
    if location is None or "geojson" not in location.raw:
        raise ValueError(f"Could not find boundary geometry for {city_name!r}.")

    geometry = shapely_shape(location.raw["geojson"])
    gdf = gpd.GeoDataFrame({"name": [location.address]}, geometry=[geometry], crs="EPSG:4326")
    return gdf

get_geographic_suggestions_from_string

get_geographic_suggestions_from_string(query: str, user_agent: str = 'MobilityDatabaseClient', max_results: int = 25) -> Dict[str, List[str]]

Suggest country codes, subdivisions, and municipalities from a query string.

Source code in pyGTFSHandler/utils/geocoding.py
def get_geographic_suggestions_from_string(
    query: str,
    user_agent: str = "MobilityDatabaseClient",
    max_results: int = 25
) -> Dict[str, List[str]]:
    """Suggest country codes, subdivisions, and municipalities from a query string."""
    from geopy.geocoders import Nominatim
    from geopy.exc import GeocoderTimedOut, GeocoderServiceError

    geolocator = Nominatim(user_agent=user_agent, timeout=10)
    suggested_country_codes = set()
    suggested_subdivision_names = set()
    suggested_municipalities = set()

    try:
        locations = geolocator.geocode(
            query, addressdetails=True, language='en', exactly_one=False, limit=max_results
        )
        if locations:
            for loc in locations:
                address = loc.raw.get('address', {})
                if country_code := address.get('country_code'):
                    suggested_country_codes.add(country_code.upper())
                for key in ['state', 'province', 'region', 'county']:
                    if value := address.get(key):
                        suggested_subdivision_names.add(value)
                for key in ['city', 'town', 'village', 'county']:
                    if value := address.get(key):
                        suggested_municipalities.add(value)
    except (GeocoderTimedOut, GeocoderServiceError) as e:
        logger.warning(f"Geocoding failed: {e}")
    except Exception as e:
        logger.error(f"Unexpected geocoding error: {e}")

    return {
        'country_codes': sorted(suggested_country_codes),
        'subdivision_names': sorted(suggested_subdivision_names),
        'municipalities': sorted(suggested_municipalities)
    }

get_geographic_suggestions_from_aoi

get_geographic_suggestions_from_aoi(aoi: Union[Polygon, MultiPolygon, GeoDataFrame, GeoSeries], num_points: int = 1, user_agent: str = 'MobilityDatabaseClient') -> Dict[str, List[str]]

Reverse-geocode AOI geometry to suggest country, subdivision, and municipality.

Source code in pyGTFSHandler/utils/geocoding.py
def get_geographic_suggestions_from_aoi(
    aoi: Union[Polygon, MultiPolygon, gpd.GeoDataFrame, gpd.GeoSeries],
    num_points: int = 1,
    user_agent: str = "MobilityDatabaseClient"
) -> Dict[str, List[str]]:
    """Reverse-geocode AOI geometry to suggest country, subdivision, and municipality."""
    import random
    from geopy.geocoders import Nominatim
    from geopy.exc import GeocoderTimedOut, GeocoderServiceError

    if isinstance(aoi, (gpd.GeoDataFrame, gpd.GeoSeries)):
        if aoi.empty:
            raise ValueError("GeoDataFrame/GeoSeries is empty.")
        target_geometry = aoi.to_crs(4326).unary_union
    elif isinstance(aoi, (Polygon, MultiPolygon)):
        target_geometry = aoi
    else:
        raise TypeError("AOI must be Polygon, MultiPolygon, GeoDataFrame, or GeoSeries.")

    if target_geometry.is_empty:
        raise ValueError("AOI geometry is empty.")

    geolocator = Nominatim(user_agent=user_agent, timeout=10)
    suggested_country_codes = set()
    suggested_subdivision_names = set()
    suggested_municipalities = set()

    points_to_geocode: List[Point] = []
    min_lon, min_lat, max_lon, max_lat = target_geometry.bounds

    if num_points <= 0:
        num_points = 1
    if num_points == 1:
        points_to_geocode.append(target_geometry.representative_point())
    else:
        for _ in range(num_points):
            points_to_geocode.append(Point(random.uniform(min_lon, max_lon), random.uniform(min_lat, max_lat)))

    for i, point in enumerate(points_to_geocode):
        lat, lon = point.y, point.x
        logger.debug(f"Reverse geocoding point {i+1}/{len(points_to_geocode)}: ({lat}, {lon})")
        try:
            location = geolocator.reverse((lat, lon), language='en')
            if location and location.raw:
                address = location.raw.get('address', {})
                if cc := address.get('country_code'):
                    suggested_country_codes.add(cc.upper())
                if subdivision := address.get('state') or address.get('province') or address.get('region') or address.get('county'):
                    suggested_subdivision_names.add(subdivision)
                if municipality := address.get('city') or address.get('town') or address.get('village'):
                    suggested_municipalities.add(municipality)
        except (GeocoderTimedOut, GeocoderServiceError) as e:
            logger.warning(f"Geocoding failed for point ({lat}, {lon}): {e}")
        except Exception as e:
            logger.error(f"Unexpected error for point ({lat}, {lon}): {e}")

    return {
        'country_codes': sorted(list(suggested_country_codes)),
        'subdivision_names': sorted(list(suggested_subdivision_names)),
        'municipalities': sorted(list(suggested_municipalities))
    }

get_country_region

get_country_region(lat: float, lon: float) -> (str, Optional[str])

Return ISO country code and subdivision code for a lat/lon location.

Source code in pyGTFSHandler/utils/geocoding.py
def get_country_region(lat: float, lon: float) -> (str, Optional[str]):
    """Return ISO country code and subdivision code for a lat/lon location."""
    url = "https://nominatim.openstreetmap.org/reverse"
    headers = {"User-Agent": "pyGTFSHandler/0.1.0"}
    params = {"lat": lat, "lon": lon, "format": "json", "zoom": 10, "addressdetails": 1}

    resp = requests.get(url, headers=headers, params=params)
    resp.raise_for_status()
    data = resp.json().get("address", {})

    country_code = data.get("country_code", "").upper()
    region_name = data.get("state") or data.get("region")
    subdivision_code = None

    if country_code and region_name:
        try:
            subdivisions = list(pycountry.subdivisions.get(country_code=country_code))
            for subdiv in subdivisions:
                if subdiv.name.lower() == region_name.lower():
                    subdivision_code = subdiv.code
                    break
            if not subdivision_code:
                close_matches = get_close_matches(region_name, [s.name for s in subdivisions], n=3, cutoff=0.6)
                for match in close_matches:
                    for s in subdivisions:
                        if s.name == match:
                            subdivision_code = s.code
                            warnings.warn(f"Fuzzy match used for region '{region_name}' -> '{match}'")
                            break
                    if subdivision_code:
                        break
        except LookupError:
            subdivision_code = None

    return country_code, subdivision_code

Hashing / caching

hash_path

hash_path(path: str, chunk_size: int = 8192) -> str

Compute MD5 hash of a file, folder, or zip archive.

Source code in pyGTFSHandler/utils/hashing.py
def hash_path(path: str, chunk_size: int = 8192) -> str:
    """Compute MD5 hash of a file, folder, or zip archive."""
    if os.path.isdir(path):
        return hash_folder(path, chunk_size)
    if zipfile.is_zipfile(path):
        return hash_zip(path, chunk_size)
    if os.path.isfile(path):
        return hash_file(path, chunk_size)
    raise ValueError(f"{path} is not a file, folder, or zip archive")

compare_paths

compare_paths(path1: str, path2: str) -> bool

Compare any two paths by content hash.

Source code in pyGTFSHandler/utils/hashing.py
def compare_paths(path1: str, path2: str) -> bool:
    """Compare any two paths by content hash."""
    return hash_path(path1) == hash_path(path2)

Colors

route_id_to_color

route_id_to_color(route_id: str) -> str

Hashes route_id to a deterministic, varied hex color (no leading #), by picking a hue around the color wheel and a fixed saturation/lightness so results stay readable as marker/badge fills.

Source code in pyGTFSHandler/utils/colors.py
def route_id_to_color(route_id: str) -> str:
    """Hashes `route_id` to a deterministic, varied hex color (no leading
    `#`), by picking a hue around the color wheel and a fixed
    saturation/lightness so results stay readable as marker/badge fills."""
    digest = hashlib.md5(str(route_id).encode("utf-8")).hexdigest()
    hue = (int(digest[:8], 16) % 360) / 360.0
    r, g, b = colorsys.hls_to_rgb(hue, _LIGHTNESS, _SATURATION)
    return f"{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"

contrasting_text_color

contrasting_text_color(hex_color: str) -> str

Returns "000000" or "ffffff", whichever contrasts better against hex_color as a background, using WCAG-ish relative luminance.

Source code in pyGTFSHandler/utils/colors.py
def contrasting_text_color(hex_color: str) -> str:
    """Returns `"000000"` or `"ffffff"`, whichever contrasts better against
    `hex_color` as a background, using WCAG-ish relative luminance."""
    return "000000" if relative_luminance(hex_color) > 150 else "ffffff"