Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Run Tests

on:
pull_request:
branches: [main]
paths:
- "gtfstoosm/**"
- "tests/**"
- "pyproject.toml"
- "requirements*.txt"
- "noxfile.py"
- ".github/workflows/test.yml"
push:
branches: [main]
paths:
- "gtfstoosm/**"
- "tests/**"
- "pyproject.toml"
- "requirements*.txt"
- "noxfile.py"
- ".github/workflows/test.yml"

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python versions
uses: actions/setup-python@v5
with:
python-version: |
3.10
3.11
3.12
3.13
3.14

- name: Install nox
run: |
python -m pip install --upgrade pip
pip install nox

- name: Run tests with nox
run: nox
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ python -m gtfstoosm.cli --input /path/to/gtfs.zip --output output.osc

#### Route Filtering

- `--route-ref-pattern`: Regex pattern to filter routes by their `route_id`. This allows you to process only specific routes that match the pattern
- `--route-ref-pattern`: Regex pattern to filter routes by their `route_id`. This allows you to process only specific routes that match the pattern. Note that most GTFS feeds use the route reference code as the `route_id`, but some use a random integer.
- Example: `"^[0-9]+$"` - Only numeric route IDs
- Example: `"^C"` - Only routes starting with 'C'
- Example: `"^(10|20|30)$"` - Only routes 10, 20, or 30
Expand Down
4 changes: 3 additions & 1 deletion gtfstoosm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ def main(args: list[str] | None = None) -> int:
"stop_search_radius": parsed_args.stop_search_radius,
"add_route_direction": parsed_args.add_route_direction,
"route_ref_pattern": parsed_args.route_ref_pattern,
"relation_tags": parse_tag_string(parsed_args.relation_tags),
"relation_tags": parse_tag_string(parsed_args.relation_tags)
if parsed_args.relation_tags
else None,
# "route_types": parsed_args.route_types,
# "agency_id": parsed_args.agency_id,
}
Expand Down
65 changes: 22 additions & 43 deletions gtfstoosm/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import logging
import random
import time
from typing import Any

import polars as pl
import requests
Expand Down Expand Up @@ -44,18 +43,18 @@ def __init__(
"""
Initialize the OSM relation builder.
"""
self.exclude_stops = exclude_stops
self.exclude_routes = exclude_routes
self.add_missing_stops = add_missing_stops
self.route_types = route_types
self.agency_id = agency_id
self.exclude_stops: bool = exclude_stops
self.exclude_routes: bool = exclude_routes
self.add_missing_stops: bool = add_missing_stops
self.route_types: list[int] | None = route_types
self.agency_id: str | None = agency_id
self.relations: list[OSMRelation] = []
self.nodes: list[OSMNode] = []
self.new_stops: list[OSMNode] = []
self.search_radius = search_radius
self.route_direction = route_direction
self.route_ref_pattern = route_ref_pattern
self.relation_tags = relation_tags
self.search_radius: float = search_radius
self.route_direction: bool = route_direction
self.route_ref_pattern: str | None = route_ref_pattern
self.relation_tags: dict[str, str] | None = relation_tags

def __str__(self) -> str:
# Exclude None values and internal collections
Expand Down Expand Up @@ -96,15 +95,15 @@ def build_route_masters(self, gtfs_data: dict[str, pl.DataFrame]) -> None:

made_routes = {variant.tags["ref"] for variant in self.relations}
unique_routes = gtfs_data["routes"].filter(
pl.col("route_id").is_in(made_routes)
pl.col("route_id").cast(pl.Utf8).is_in(made_routes)
)

for unique_route in unique_routes.iter_rows(named=True):
route_master_tags = {
"type": "route_master",
"route_master": "bus",
"ref": unique_route["route_id"],
"name": f"Route {unique_route['route_id']} {unique_route['route_long_name']}".strip(),
"name": f"Route {unique_route['route_short_name']} {unique_route['route_long_name']}".strip(),
}
if "route_color" in unique_route:
route_master_tags["route_color"] = "#" + unique_route["route_color"]
Expand All @@ -117,7 +116,7 @@ def build_route_masters(self, gtfs_data: dict[str, pl.DataFrame]) -> None:
tags=route_master_tags,
)
for route in self.relations:
if route.tags.get("ref") == unique_route["route_id"]:
if route.tags.get("ref") == unique_route["route_short_name"]:
master.add_member(osm_type="relation", ref=route.id)
self.relations.append(master)
logger.info(
Expand Down Expand Up @@ -160,7 +159,7 @@ def _process_routes(self, gtfs_data: dict[str, pl.DataFrame]) -> None:
# Process routes using vectorized operations
route_trip_stops = (
trips.join(stop_times, on="trip_id")
.filter(pl.col("route_id").is_in(routes_to_process["route_id"]))
.filter(pl.col("route_id").is_in(routes_to_process["route_id"].to_list()))
.sort(["route_id", "trip_id", "stop_sequence"])
.group_by(["route_id", "trip_id", "shape_id"], maintain_order=True)
.agg([pl.col("stop_id").alias("stops")])
Expand All @@ -173,7 +172,7 @@ def _process_routes(self, gtfs_data: dict[str, pl.DataFrame]) -> None:

try:
route_info = routes_to_process.filter(
pl.col("route_id") == route_ref
pl.col("route_id").cast(pl.Utf8) == route_ref
).row(0)
logger.info(f"Processing route {route_ref}")
except pl.exceptions.OutOfBoundsError:
Expand Down Expand Up @@ -222,8 +221,8 @@ def _process_routes(self, gtfs_data: dict[str, pl.DataFrame]) -> None:
"ref": route_info[2],
"name": f"Route {route_info[2]} {format_name(route_info[3])} {direction}".strip(),
}
if route_info[7]:
route_tags["colour"] = "#" + route_info[7]
if route_info[7] and len(route_info[7].strip("#")) in (3, 6):
route_tags["colour"] = "#" + route_info[7].strip("#")

if self.relation_tags:
route_tags.update(self.relation_tags)
Expand Down Expand Up @@ -263,7 +262,7 @@ def _get_stop_objects(
if stops.is_empty():
return []

osm_elements = []
osm_elements: list[OSMElement] = []
# overpass_url = "https://overpass-api.de/api/interpreter"
overpass_url = "https://maps.mail.ru/osm/tools/overpass/api/interpreter"

Expand Down Expand Up @@ -630,29 +629,7 @@ def _get_osm_route_type(self, gtfs_route_type: int | str) -> str:

return route_type_map.get(route_type, "bus")

def _get_network_name(
self, route: dict[str, Any], agencies: list[dict[str, Any]]
) -> str:
"""
Get the network name for a route.

Args:
route: GTFS route dictionary
agencies: List of GTFS agency dictionaries

Returns:
Network name for OSM
"""
agency_id = route.get("agency_id")
if agency_id and agencies:
for agency in agencies:
if agency.get("agency_id") == agency_id:
return agency.get("agency_name", "")

# Default if no agency found
return ""

def is_stop_duplicate(self, new_stop):
def is_stop_duplicate(self, new_stop: OSMNode) -> bool:
"""Check if a stop is already in self.new_stops."""
for existing_stop in self.new_stops:
# Check if ID matches
Expand Down Expand Up @@ -695,10 +672,12 @@ def write_to_file(self, output_path: str) -> None:

except Exception as e:
logger.error(f"Error writing OSM file: {str(e)}")
raise OSError(f"Failed to write OSM file: {str(e)}")
raise OSError(f"Failed to write OSM file: {str(e)}") from e


def convert_gtfs_to_osm(gtfs_path: str, osm_path: str, **options: dict) -> bool:
def convert_gtfs_to_osm(
gtfs_path: str, osm_path: str, **options: dict[str, bool | int | str]
) -> bool:
"""
Convert a GTFS feed to OSM relations.

Expand Down
Loading