├── requirements.txt ├── setup.cfg ├── .gitignore ├── src └── gtfs_proto │ ├── packers │ ├── __init__.py │ ├── shapes.py │ ├── packers.py │ ├── transfers.py │ ├── calendar.py │ ├── base.py │ ├── stops.py │ ├── trips.py │ └── routes.py │ ├── __init__.py │ ├── __main__.py │ ├── pack.py │ ├── base.py │ ├── util.py │ ├── dmerge.py │ ├── info.py │ ├── delta.py │ ├── wrapper.py │ └── gtfs_pb2.py ├── LICENSE ├── pyproject.toml ├── trim_eesti.py ├── README.md └── protobuf └── gtfs.proto /requirements.txt: -------------------------------------------------------------------------------- 1 | zstandard 2 | protobuf 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | 4 | [mypy] 5 | ignore_missing_imports = true 6 | check_untyped_defs = true 7 | 8 | [mypy-google.*] 9 | ignore_missing_imports = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | __pycache__/ 3 | .venv/ 4 | dist/ 5 | src/gtfs_proto.egg-info/ 6 | *.gtp* 7 | *.zip 8 | *.swp 9 | *.ids 10 | stops.txt 11 | stop_times.txt 12 | routes.txt 13 | trips.txt 14 | agency.txt 15 | calendar.txt 16 | calendar_dates.txt 17 | frequencies.txt 18 | transfers.txt 19 | networks.txt 20 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker 2 | from .packers import AgencyPacker, NetworksPacker, AreasPacker 3 | from .calendar import CalendarPacker 4 | from .shapes import ShapesPacker 5 | from .stops import StopsPacker 6 | from .routes import RoutesPacker 7 | from .trips import TripsPacker 8 | from .transfers import TransfersPacker 9 | 10 | 11 | __all__ = [ 12 | 'BasePacker', 'AgencyPacker', 'NetworksPacker', 'AreasPacker', 13 | 'CalendarPacker', 'ShapesPacker', 'StopsPacker', 'RoutesPacker', 14 | 'TripsPacker', 'TransfersPacker', 15 | ] 16 | -------------------------------------------------------------------------------- /src/gtfs_proto/__init__.py: -------------------------------------------------------------------------------- 1 | from .wrapper import GtfsBlocks, GtfsProto, GtfsDelta, is_gtfs_delta 2 | from .util import ( 3 | CalendarService, parse_calendar, build_calendar, int_to_date, 4 | parse_shape, build_shape, 5 | ) 6 | from .base import StringCache, FareLinks, IdReference 7 | from . import gtfs_pb2 as gtfs 8 | 9 | 10 | __all__ = ['gtfs', 'GtfsBlocks', 'GtfsProto', 'GtfsDelta', 'FareLinks', 11 | 'is_gtfs_delta', 'StringCache', 'IdReference', 12 | 'CalendarService', 'parse_shape', 'int_to_date', 13 | 'build_shape', 'parse_calendar', 'build_calendar'] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Ilya Zverev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/shapes.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker, StringCache, IdReference 2 | from zipfile import ZipFile 3 | from .. import gtfs_pb2 as gtfs 4 | from .. import build_shape 5 | 6 | 7 | class ShapesPacker(BasePacker): 8 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 9 | super().__init__(z, strings, id_store) 10 | 11 | @property 12 | def block(self): 13 | return gtfs.B_SHAPES 14 | 15 | def pack(self) -> list[gtfs.Shape]: 16 | result: list[gtfs.Shape] = [] 17 | with self.open_table('shapes') as f: 18 | for rows, shape_id, _ in self.sequence_reader( 19 | f, 'shape_id', 'shape_pt_sequence', max_overlapping=1): 20 | if len(rows) >= 2: 21 | result.append(build_shape( 22 | shape_id=shape_id, 23 | coords=[(float(row['shape_pt_lon']), float(row['shape_pt_lat'])) 24 | for row in rows], 25 | )) 26 | return result 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "gtfs-proto" 7 | version = "0.1.0" 8 | authors = [ 9 | { name="Ilya Zverev", email="ilya@zverev.info" }, 10 | ] 11 | description = "Library to package and process GTFS feeds in a protobuf format" 12 | keywords = ["gtfs", "transit", "feed", "gtp", "command line"] 13 | readme = "README.md" 14 | license = {file = "LICENSE"} 15 | requires-python = ">=3.9" 16 | dependencies = [ 17 | "zstandard", 18 | "protobuf", 19 | ] 20 | classifiers = [ 21 | "Programming Language :: Python :: 3", 22 | "Development Status :: 3 - Alpha", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: ISC License (ISCL)", 25 | "Operating System :: OS Independent", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: File Formats", 28 | "Topic :: Utilities", 29 | ] 30 | 31 | [project.urls] 32 | "Homepage" = "https://github.com/Zverik/gtfs_proto" 33 | "Bug Tracker" = "https://github.com/Zverik/gtfs_proto/issues" 34 | 35 | [project.scripts] 36 | gtfs_proto = "gtfs_proto.__main__:main" 37 | -------------------------------------------------------------------------------- /src/gtfs_proto/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .pack import pack 3 | from .info import info 4 | from .delta import delta 5 | from .dmerge import delta_merge 6 | 7 | 8 | def print_help(): 9 | print('GTFS in Protobuf toolkit') 10 | print() 11 | print('Usage: {} '.format(sys.argv[0])) 12 | print() 13 | print('Commands:') 14 | print(' pack\tPackage GTFS zip into a protobuf file') 15 | print(' info\tPrint information for a protobuf-packed GTFS') 16 | print(' delta\tGenerate a delta file for two packed GTFS feeds') 17 | print(' dmerge\nMerge two sequential delta files') 18 | print() 19 | print('Run {} --help to see a command help.'.format(sys.argv[0])) 20 | 21 | 22 | def main(): 23 | if len(sys.argv) <= 1: 24 | print_help() 25 | sys.exit(1) 26 | 27 | op = sys.argv[1].strip().lower() 28 | sys.argv.pop(1) 29 | 30 | if op == 'pack': 31 | pack() 32 | elif op == 'info': 33 | info() 34 | elif op == 'delta': 35 | delta() 36 | elif op == 'dmerge': 37 | delta_merge() 38 | else: 39 | print_help() 40 | sys.exit(1) 41 | 42 | 43 | if __name__ == '__main__': 44 | main() 45 | -------------------------------------------------------------------------------- /trim_eesti.py: -------------------------------------------------------------------------------- 1 | #!.venv/bin/python 2 | import argparse 3 | import csv 4 | from gtfs_proto import GtfsProto, gtfs 5 | 6 | 7 | if __name__ == '__main__': 8 | parser = argparse.ArgumentParser(description='Removes extra data from Estonia GTFS feed') 9 | parser.add_argument('input', type=argparse.FileType('rb')) 10 | parser.add_argument('stops', type=argparse.FileType('r')) 11 | parser.add_argument('-s', action='store_true', help='Skip shapes') 12 | parser.add_argument('output', type=argparse.FileType('wb')) 13 | options = parser.parse_args() 14 | 15 | siri_ids: dict[str, int] = {} 16 | for row in csv.reader(options.stops, delimiter=';'): 17 | if row[1] and row[1] != 'SiriID': 18 | siri_ids[row[0]] = int(row[1]) 19 | 20 | feed = GtfsProto(options.input) 21 | out = GtfsProto() 22 | out.header.version = feed.header.version 23 | out.strings = feed.strings 24 | out.calendar = feed.calendar 25 | out.trips = feed.trips 26 | 27 | if not options.s: 28 | out.shapes = feed.shapes 29 | 30 | out.stops = [gtfs.Stop( 31 | stop_id=s.stop_id, 32 | name=s.name, 33 | lat=s.lat, 34 | lon=s.lon, 35 | wheelchair=s.wheelchair, 36 | external_int_id=siri_ids.get(s.code, 0), 37 | ) for s in feed.stops] 38 | 39 | out.routes = [gtfs.Route( 40 | route_id=r.route_id, 41 | short_name=r.short_name, 42 | type=r.type, 43 | itineraries=[gtfs.RouteItinerary( 44 | itinerary_id=i.itinerary_id, 45 | headsign=i.headsign, 46 | stops=i.stops, 47 | shape_id=0 if options.s else i.shape_id, 48 | ) for i in r.itineraries], 49 | ) for r in feed.routes] 50 | 51 | out.write(options.output) 52 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/packers.py: -------------------------------------------------------------------------------- 1 | from zipfile import ZipFile 2 | from .base import BasePacker, StringCache, IdReference 3 | from .. import gtfs_pb2 as gtfs 4 | 5 | 6 | class AgencyPacker(BasePacker): 7 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 8 | super().__init__(z, strings, id_store) 9 | 10 | @property 11 | def block(self): 12 | return gtfs.B_AGENCY 13 | 14 | def pack(self) -> list[gtfs.Agency]: 15 | result: list[gtfs.Agency] = [] 16 | with self.open_table('agency') as f: 17 | for row, agency_id, _ in self.table_reader(f, 'agency_id'): 18 | agency = gtfs.Agency( 19 | agency_id=agency_id, 20 | name=row['agency_name'], 21 | url=row['agency_url'], 22 | timezone=self.strings.add(row['agency_timezone']), 23 | ) 24 | for k in ('lang', 'phone', 'fare_url', 'email'): 25 | if row.get(f'agency_{k}'): 26 | setattr(agency, k, row[f'agency_{k}'].strip()) 27 | result.append(agency) 28 | return result 29 | 30 | 31 | class NetworksPacker(BasePacker): 32 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 33 | super().__init__(z, strings, id_store) 34 | 35 | @property 36 | def block(self): 37 | return gtfs.B_NETWORKS 38 | 39 | def pack(self) -> dict[int, str]: 40 | result: dict[int, str] = {} 41 | if self.has_file('networks'): 42 | with self.open_table('networks') as f: 43 | for row, network_id, _ in self.table_reader(f, 'network_id'): 44 | result[network_id] = row['network_name'] 45 | return result 46 | 47 | 48 | class AreasPacker(BasePacker): 49 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 50 | super().__init__(z, strings, id_store) 51 | 52 | @property 53 | def block(self): 54 | return gtfs.B_AREAS 55 | 56 | def pack(self) -> dict[int, str]: 57 | result: dict[int, str] = {} 58 | if self.has_file('areas'): 59 | with self.open_table('areas') as f: 60 | for row, area_id, _ in self.table_reader(f, 'network_id'): 61 | result[area_id] = row['area_name'] 62 | return result 63 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/transfers.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker, StringCache, IdReference 2 | from typing import TextIO 3 | from zipfile import ZipFile 4 | from csv import DictReader 5 | from math import ceil 6 | from .. import gtfs_pb2 as gtfs 7 | 8 | 9 | class TransfersPacker(BasePacker): 10 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 11 | super().__init__(z, strings, id_store) 12 | 13 | @property 14 | def block(self): 15 | return gtfs.B_TRANSFERS 16 | 17 | def pack(self) -> list[gtfs.Transfer]: 18 | if self.has_file('transfers'): 19 | with self.open_table('transfers') as f: 20 | return self.prepare(f) 21 | return [] 22 | 23 | def prepare(self, fileobj: TextIO) -> list[gtfs.Transfer]: 24 | transfers: list[gtfs.Transfer] = [] 25 | id_stops = self.id_store[gtfs.B_STOPS] 26 | id_routes = self.id_store[gtfs.B_ROUTES] 27 | id_trips = self.id_store[gtfs.B_TRIPS] 28 | for row in DictReader(fileobj): 29 | t = gtfs.Transfer() 30 | 31 | # stops 32 | from_stop = row.get('from_stop_id') 33 | if from_stop: 34 | t.from_stop = id_stops.get(from_stop) 35 | to_stop = row.get('to_stop_id') 36 | if to_stop: 37 | t.to_stop = id_stops.get(to_stop) 38 | 39 | # routes 40 | from_route = row.get('from_route_id') 41 | if from_route: 42 | t.from_route = id_routes.get(from_route) 43 | to_route = row.get('to_route_id') 44 | if to_route: 45 | t.to_route = id_routes.get(to_route) 46 | 47 | # trips 48 | from_trip = row.get('from_trip_id') 49 | if from_trip: 50 | t.from_trip = id_trips.get(from_trip) 51 | to_trip = row.get('to_trip_id') 52 | if to_trip: 53 | t.to_trip = id_trips.get(to_trip) 54 | 55 | transfer_time = row.get('min_transfer_time') 56 | if transfer_time and transfer_time.strip(): 57 | t.min_transfer_time = ceil(float(transfer_time.strip()) / 5) 58 | t.type = self.parse_transfer_type(row['transfer_type']) 59 | transfers.append(t) 60 | return transfers 61 | 62 | def parse_transfer_type(self, value: str | None) -> int: 63 | if not value: 64 | return 0 65 | v = int(value.strip()) 66 | if v == 0: 67 | return gtfs.T_POSSIBLE 68 | if v == 1: 69 | return gtfs.T_DEPARTURE_WAITS 70 | if v == 2: 71 | return gtfs.T_NEEDS_TIME 72 | if v == 3: 73 | return gtfs.T_NOT_POSSIBLE 74 | if v == 4: 75 | return gtfs.T_IN_SEAT 76 | if v == 5: 77 | return gtfs.T_IN_SEAT_FORBIDDEN 78 | raise ValueError(f'Unknown transfer type: {v}') 79 | -------------------------------------------------------------------------------- /src/gtfs_proto/pack.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from zipfile import ZipFile 3 | from datetime import date 4 | from .wrapper import GtfsProto 5 | from .packers import ( 6 | BasePacker, AgencyPacker, NetworksPacker, AreasPacker, 7 | CalendarPacker, ShapesPacker, StopsPacker, 8 | RoutesPacker, TripsPacker, TransfersPacker, 9 | ) 10 | 11 | 12 | def run_block(feed: GtfsProto, packer: BasePacker): 13 | feed.blocks.add(packer.block, packer.pack()) 14 | 15 | 16 | def pack(): 17 | parser = argparse.ArgumentParser( 18 | description='Converts GTFS feed into a protobuf-compressed version') 19 | parser.add_argument('input', help='Input zipped gtfs file') 20 | parser.add_argument('-u', '--url', help='URL to the original feed') 21 | parser.add_argument('-d', '--date', help='Date for the original feed') 22 | parser.add_argument('-p', '--prev', type=argparse.FileType('rb'), 23 | help='Last build for keeping ids consistent') 24 | parser.add_argument('-o', '--output', required=True, 25 | help='Output protobuf file. Use %% for version') 26 | parser.add_argument('-r', '--raw', action='store_true', 27 | help='Do not compress data blocks') 28 | options = parser.parse_args() 29 | 30 | feed = GtfsProto() 31 | if options.prev: 32 | prev = GtfsProto(options.prev) 33 | feed.strings = prev.strings 34 | feed.id_store = prev.id_store 35 | feed.header.version = prev.header.version + 1 36 | feed.header.original_url = prev.header.original_url 37 | else: 38 | feed.header.version = 1 39 | 40 | if options.date: 41 | d = ''.join(c for c in options.date if c.isdecimal()) 42 | if len(d) == 6: 43 | d = '20' + d 44 | if len(d) != 8: 45 | raise ValueError('Expecting date in format YYYYMMDD') 46 | feed.header.date = int(d) 47 | else: 48 | feed.header.date = int(date.today().strftime('%Y%m%d')) 49 | 50 | feed.header.compressed = not options.raw 51 | if options.url: 52 | feed.header.original_url = options.url 53 | 54 | with ZipFile(options.input, 'r') as z: 55 | feed.agencies = AgencyPacker(z, feed.strings, feed.id_store).pack() 56 | feed.calendar = CalendarPacker(z, feed.strings, feed.id_store).pack() 57 | feed.shapes = ShapesPacker(z, feed.strings, feed.id_store).pack() 58 | feed.networks = NetworksPacker(z, feed.strings, feed.id_store).pack() 59 | feed.areas = AreasPacker(z, feed.strings, feed.id_store).pack() 60 | feed.stops = StopsPacker(z, feed.strings, feed.id_store, feed.fare_links).pack() 61 | r = RoutesPacker(z, feed.strings, feed.id_store, feed.fare_links) # reads itineraries 62 | feed.routes = r.pack() 63 | feed.trips = TripsPacker(z, feed.strings, feed.id_store, r.trip_itineraries).pack() 64 | feed.transfers = TransfersPacker(z, feed.strings, feed.id_store).pack() 65 | 66 | fn = options.output.replace('%', str(feed.header.version)) 67 | with open(fn, 'wb') as f: 68 | feed.write(f) 69 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/calendar.py: -------------------------------------------------------------------------------- 1 | from typing import TextIO 2 | from datetime import date 3 | from zipfile import ZipFile 4 | from collections import defaultdict 5 | from . import BasePacker 6 | from .. import StringCache, IdReference, int_to_date, gtfs, CalendarService, build_calendar 7 | 8 | 9 | class CalendarDates: 10 | def __init__(self): 11 | self.added: dict[int, list[date]] = defaultdict(list) 12 | self.removed: dict[int, list[date]] = defaultdict(list) 13 | 14 | 15 | class CalendarPacker(BasePacker): 16 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 17 | super().__init__(z, strings, id_store) 18 | 19 | @property 20 | def block(self): 21 | return gtfs.B_CALENDAR 22 | 23 | def pack(self): 24 | dates = CalendarDates() 25 | if self.has_file('calendar_dates'): 26 | with self.open_table('calendar_dates') as f: 27 | dates = self.read_calendar_dates(f) 28 | if self.has_file('calendar'): 29 | with self.open_table('calendar') as f: 30 | data = self.prepare_calendar(f, dates) 31 | else: 32 | # dates must not be empty 33 | data = self.prepare_calendar(None, dates) 34 | return data 35 | 36 | def read_calendar_dates(self, fileobj: TextIO) -> CalendarDates: 37 | dates = CalendarDates() 38 | for row, service_id, _ in self.table_reader(fileobj, 'service_id'): 39 | value = int_to_date(int(row['date'].strip())) 40 | if row['exception_type'].strip() == '1': 41 | dates.added[service_id].append(value) 42 | else: 43 | dates.removed[service_id].append(value) 44 | return dates 45 | 46 | def prepare_calendar( 47 | self, fileobj: TextIO | None, dates: CalendarDates) -> bytes: 48 | services: list[CalendarService] = [] 49 | seen_ids: set[int] = set() 50 | if fileobj: 51 | # First just read everything, to detect the base date. 52 | for row, service_id, _ in self.table_reader(fileobj, 'service_id'): 53 | s = CalendarService(service_id=service_id) 54 | if row['start_date']: 55 | s.start_date = int_to_date(int(row['start_date'])) 56 | if row['end_date']: 57 | s.end_date = int_to_date(int(row['end_date'])) 58 | weekdays: list[bool] = [] 59 | for i, k in enumerate(( 60 | 'monday', 'tuesday', 'wednesday', 'thursday', 61 | 'friday', 'saturday', 'sunday')): 62 | weekdays.append(row[k] == '1') 63 | s.weekdays = weekdays 64 | s.added_days = dates.added.get(service_id, []) 65 | s.removed_days = dates.removed.get(service_id, []) 66 | services.append(s) 67 | seen_ids.add(service_id) 68 | 69 | # Adding stubs for each service_id that's missing in the calendar. 70 | for sid, date_list in dates.added.items(): 71 | if sid not in seen_ids: 72 | services.append(CalendarService( 73 | service_id=sid, 74 | added_days=date_list, 75 | )) 76 | if not services: 77 | raise ValueError('Either calendar.txt or calendar_dates.txt must be present.') 78 | 79 | return build_calendar(services) 80 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/base.py: -------------------------------------------------------------------------------- 1 | from .. import gtfs_pb2 as gtfs 2 | from ..base import IdReference, FareLinks, StringCache 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Generator 5 | from contextlib import contextmanager 6 | from csv import DictReader 7 | from io import TextIOWrapper 8 | from typing import TextIO, Any 9 | from zipfile import ZipFile 10 | 11 | 12 | __all__ = ['BasePacker', 'StringCache', 'FareLinks', 'IdReference'] 13 | 14 | 15 | class BasePacker(ABC): 16 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference]): 17 | self.z = z 18 | self.id_store = id_store 19 | self.strings = strings 20 | 21 | @property 22 | @abstractmethod 23 | def block(self) -> int: 24 | return gtfs.B_HEADER 25 | 26 | @abstractmethod 27 | def pack(self) -> Any: 28 | return b'' 29 | 30 | def has_file(self, name_part: str) -> bool: 31 | return f'{name_part}.txt' in self.z.namelist() 32 | 33 | @contextmanager 34 | def open_table(self, name_part: str): 35 | with self.z.open(f'{name_part}.txt', 'r') as f: 36 | yield TextIOWrapper(f, encoding='utf-8-sig') 37 | 38 | @property 39 | def ids(self) -> IdReference: 40 | return self.id_store[self.block] 41 | 42 | def table_reader(self, fileobj: TextIO, id_column: str, 43 | ids_block: int | None = None 44 | ) -> Generator[tuple[dict, int, str], None, None]: 45 | """Iterates over CSV rows and returns (row, our_id, source_id).""" 46 | ids = self.id_store[ids_block or self.block] 47 | for row in DictReader(fileobj): 48 | yield ( 49 | {k: v.strip() for k, v in row.items()}, 50 | ids.add(row[id_column]), 51 | row[id_column], 52 | ) 53 | 54 | def sequence_reader(self, fileobj: TextIO, id_column: str, 55 | seq_column: str, ids_block: int | None = None, 56 | max_overlapping: int = 2, 57 | ) -> Generator[tuple[list[dict], int, str], None, None]: 58 | cur_ids: list[int] = [] 59 | cur_lists: list[list[tuple[int, str, dict]]] = [] 60 | seen_ids: set[int] = set() 61 | for row, row_id, orig_id in self.table_reader(fileobj, id_column, ids_block): 62 | # Find the row_id index. From the tail, because latest ids are appended there. 63 | idx = len(cur_ids) - 1 64 | while idx >= 0 and cur_ids[idx] != row_id: 65 | idx -= 1 66 | 67 | if idx < 0: 68 | # Not found: dump the oldest sequence and add the new one. 69 | if row_id in seen_ids: 70 | raise ValueError( 71 | f'Unsorted sequence file, {id_column} {orig_id} is in two parts') 72 | seen_ids.add(row_id) 73 | 74 | if len(cur_ids) >= max_overlapping: 75 | last_id = cur_ids.pop(0) 76 | last_rows = cur_lists.pop(0) 77 | last_rows.sort(key=lambda r: r[0]) 78 | yield [r[2] for r in last_rows], last_id, last_rows[0][1] 79 | 80 | cur_ids.append(row_id) 81 | cur_lists.append([]) 82 | idx = len(cur_ids) - 1 83 | 84 | cur_lists[idx].append((int(row[seq_column]), orig_id, row)) 85 | 86 | for i, row_id in enumerate(cur_ids): 87 | rows = cur_lists[i] 88 | rows.sort(key=lambda r: r[0]) 89 | yield [r[2] for r in rows], row_id, rows[0][1] 90 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/stops.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker, StringCache, IdReference, FareLinks 2 | from typing import TextIO 3 | from zipfile import ZipFile 4 | from .. import gtfs_pb2 as gtfs 5 | 6 | 7 | class StopsPacker(BasePacker): 8 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference], 9 | fl: FareLinks): 10 | super().__init__(z, strings, id_store) 11 | self.fl = fl 12 | 13 | @property 14 | def block(self): 15 | return gtfs.B_STOPS 16 | 17 | def pack(self): 18 | with self.open_table('stops') as f: 19 | st = self.prepare(f) 20 | if self.has_file('stop_areas'): 21 | with self.open_table('stop_areas') as f: 22 | self.read_stop_areas(f) 23 | return st 24 | 25 | def prepare(self, fileobj: TextIO) -> list[gtfs.Stop]: 26 | stops: list[gtfs.Stop] = [] 27 | last_lat: float = 0 28 | last_lon: float = 0 29 | for row, stop_id, orig_stop_id in self.table_reader(fileobj, 'stop_id'): 30 | stop = gtfs.Stop(stop_id=stop_id) 31 | if row.get('stop_code'): 32 | stop.code = row['stop_code'] 33 | if row['stop_name']: 34 | stop.name = self.strings.add(row['stop_name']) 35 | if row.get('stop_desc'): 36 | stop.desc = row['stop_desc'] 37 | if row.get('stop_lat'): 38 | # Actually we don't know what happens when it's missing. 39 | new_lat = round(float(row['stop_lat']) * 1e5) 40 | new_lon = round(float(row['stop_lon']) * 1e5) 41 | stop.lat = new_lat - last_lat 42 | stop.lon = new_lon - last_lon 43 | last_lat, last_lon = new_lat, new_lon 44 | if row.get('zone_id'): 45 | self.fl.stop_zones[stop_id] = self.id_store[gtfs.B_ZONES].add(row['zone_id']) 46 | stop.type = self.parse_location_type(row.get('location_type')) 47 | pstid = row.get('parent_station') 48 | if pstid: 49 | stop.parent_id = self.ids.add(pstid) 50 | if row.get('stop_timezone'): 51 | raise Exception(f'Time to implement time zones! {row["stop_timezone"]}') 52 | stop.wheelchair = self.parse_accessibility(row.get('wheelchair_boarding')) 53 | if row.get('platform_code'): 54 | stop.platform_code = row['platform_code'] 55 | stops.append(stop) 56 | return stops 57 | 58 | def read_stop_areas(self, fileobj: TextIO): 59 | for row, area_id, _ in self.table_reader(fileobj, 'area_id', gtfs.B_AREAS): 60 | self.fl.stop_areas[self.ids.add(row['stop_id'])] = area_id 61 | 62 | def parse_location_type(self, value: str | None) -> int: 63 | if not value: 64 | return 0 65 | v = int(value) 66 | if v == 0: 67 | return gtfs.L_STOP 68 | if v == 1: 69 | return gtfs.L_STATION 70 | if v == 2: 71 | return gtfs.L_EXIT 72 | if v == 3: 73 | return gtfs.L_NODE 74 | if v == 4: 75 | return gtfs.L_BOARDING 76 | raise ValueError(f'Unknown location type for a stop: {v}') 77 | 78 | def parse_accessibility(self, value: str | None) -> int: 79 | if not value: 80 | return 0 81 | if value == '0': 82 | return gtfs.A_UNKNOWN 83 | if value == '1': 84 | return gtfs.A_SOME 85 | if value == '2': 86 | return gtfs.A_NO 87 | raise ValueError(f'Unknown accessibility value for a stop: {value}') 88 | -------------------------------------------------------------------------------- /src/gtfs_proto/base.py: -------------------------------------------------------------------------------- 1 | from . import gtfs_pb2 as gtfs 2 | from functools import cached_property 3 | 4 | 5 | class StringCache: 6 | def __init__(self, source: list[str] | None = None): 7 | self.strings: list[str] = [''] 8 | self.index: dict[str, int] = {} 9 | if source: 10 | self.set(source) 11 | 12 | def clear(self): 13 | self.strings = [''] 14 | self.index = {} 15 | 16 | def set(self, source: list[str]): 17 | self.strings = list(source) or [''] 18 | self.index = {s: i for i, s in enumerate(self.strings) if s} 19 | 20 | def __getitem__(self, i: int) -> str: 21 | return self.strings[i] 22 | 23 | def add(self, s: str | None) -> int: 24 | if not s: 25 | return 0 26 | i = self.index.get(s) 27 | if i: 28 | return i 29 | else: 30 | self.strings.append(s) 31 | self.index[s] = len(self.strings) - 1 32 | return len(self.strings) - 1 33 | 34 | def search(self, s: str) -> int | None: 35 | """Looks for a string case-insensitive.""" 36 | i = self.index.get(s) 37 | if i: 38 | return i 39 | s = s.lower() 40 | for j, v in enumerate(self.strings): 41 | if s == v.lower(): 42 | return j 43 | return None 44 | 45 | def store(self) -> bytes: 46 | return gtfs.StringTable(strings=self.strings).SerializeToString() 47 | 48 | 49 | class IdReference: 50 | def __init__(self, source: list[str] | None = None, delta_skip: int = 0): 51 | self.ids: dict[str, int] = {s: i + delta_skip for i, s in enumerate(source or []) if s} 52 | self.last_id = 0 if not self.ids else max(self.ids.values()) 53 | self.delta_skip = delta_skip 54 | 55 | def __getitem__(self, k: str) -> int: 56 | return self.ids[k] 57 | 58 | def __len__(self) -> int: 59 | return len(self.ids) 60 | 61 | def clear(self): 62 | self.ids = {} 63 | self.last_id = 0 64 | 65 | def add(self, k: str) -> int: 66 | if k not in self.ids: 67 | self.last_id += 1 68 | self.ids[k] = self.last_id 69 | # Remove reversed cache. 70 | if 'original' in self.__dict__: 71 | delattr(self, 'original') 72 | return self.ids[k] 73 | 74 | def get(self, k: str | None, misses: bool = False) -> int | None: 75 | if not k: 76 | return None 77 | if misses: 78 | return self.ids.get(k) 79 | return self.ids[k] 80 | 81 | def to_list(self) -> list[str]: 82 | idstrings = [''] * (self.last_id + 1) 83 | for s, i in self.ids.items(): 84 | idstrings[i] = s 85 | return idstrings[self.delta_skip:] 86 | 87 | @cached_property 88 | def original(self) -> dict[int, str]: 89 | return {i: s for s, i in self.ids.items()} 90 | 91 | 92 | class FareLinks: 93 | def __init__(self): 94 | self.stop_zones: dict[int, int] = {} 95 | self.stop_areas: dict[int, int] = {} 96 | self.route_networks: dict[int, int] = {} 97 | 98 | def clear(self): 99 | self.stop_zones = {} 100 | self.stop_areas = {} 101 | self.route_networks = {} 102 | 103 | def load(self, fl: gtfs.FareLinks): 104 | self.stop_zones = {i: v for i, v in enumerate(fl.stop_zone_ids) if v} 105 | self.stop_areas = {i: v for i, v in enumerate(fl.stop_area_ids) if v} 106 | self.route_networks = {i: v for i, v in enumerate(fl.route_network_ids) if v} 107 | 108 | def load_delta(self, fl: gtfs.FareLinksDelta): 109 | self.stop_zones = {i: v for i, v in fl.stop_zone_ids.items()} 110 | self.stop_areas = {i: v for i, v in fl.stop_area_ids.items()} 111 | self.route_networks = {i: v for i, v in fl.route_network_ids.items()} 112 | 113 | def to_list(self, d: dict[int, int]) -> list[int]: 114 | if not d: 115 | return [] 116 | result: list[int] = [0] * (max(d.keys()) + 1) 117 | for k, v in d.items(): 118 | result[k] = v 119 | return result 120 | 121 | def store(self) -> bytes: 122 | fl = gtfs.FareLinks( 123 | stop_area_ids=self.to_list(self.stop_areas), 124 | stop_zone_ids=self.to_list(self.stop_zones), 125 | route_network_ids=self.to_list(self.route_networks), 126 | ) 127 | return fl.SerializeToString() 128 | 129 | def store_delta(self) -> bytes: 130 | fl = gtfs.FareLinksDelta( 131 | stop_area_ids=self.stop_areas, 132 | stop_zone_ids=self.stop_zones, 133 | route_network_ids=self.route_networks, 134 | ) 135 | return fl.SerializeToString() 136 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/trips.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker, StringCache, IdReference 2 | from typing import TextIO 3 | from zipfile import ZipFile 4 | from dataclasses import dataclass 5 | from .. import gtfs_pb2 as gtfs 6 | 7 | 8 | @dataclass 9 | class StopTime: 10 | seq_id: int 11 | arrival: int 12 | departure: int 13 | pickup: gtfs.PickupDropoff 14 | dropoff: gtfs.PickupDropoff 15 | approximate: bool 16 | 17 | 18 | class TripsPacker(BasePacker): 19 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference], 20 | trip_itineraries: dict[int, int]): 21 | super().__init__(z, strings, id_store) 22 | self.trip_itineraries = trip_itineraries # trip_id -> itinerary_id 23 | 24 | @property 25 | def block(self): 26 | return gtfs.B_TRIPS 27 | 28 | def pack(self) -> list[gtfs.Trips]: 29 | with self.open_table('trips') as f: 30 | trips = self.read_trips(f) 31 | if self.has_file('frequencies'): 32 | with self.open_table('frequencies') as f: 33 | self.from_frequencies(f, trips) 34 | with self.open_table('stop_times') as f: 35 | self.from_stop_times(f, trips) 36 | return list(trips.values()) 37 | 38 | def read_trips(self, fileobj: TextIO) -> dict[int, gtfs.Trip]: 39 | trips: dict[int, gtfs.Trip] = {} 40 | for row, trip_id, _ in self.table_reader(fileobj, 'trip_id'): 41 | # Skip trips without stops. 42 | if trip_id not in self.trip_itineraries: 43 | continue 44 | 45 | trips[trip_id] = gtfs.Trip( 46 | trip_id=trip_id, 47 | service_id=self.id_store[gtfs.B_CALENDAR].ids[row['service_id']], 48 | itinerary_id=self.trip_itineraries[trip_id], 49 | short_name=row.get('trip_short_name'), 50 | wheelchair=self.parse_accessibility(row.get('wheelchair_accessible')), 51 | bikes=self.parse_accessibility(row.get('bikes_allowed')), 52 | ) 53 | return trips 54 | 55 | def from_stop_times(self, fileobj: TextIO, trips: dict[int, gtfs.Trip]): 56 | for rows, trip_id, orig_trip_id in self.sequence_reader( 57 | fileobj, 'trip_id', 'stop_sequence', gtfs.B_TRIPS): 58 | cur_times: list[StopTime] = [] 59 | for row in rows: 60 | arrival = self.parse_time(row['arrival_time']) or 0 61 | arrival = round(arrival / 5) 62 | departure = self.parse_time(row['departure_time']) or 0 63 | if departure: 64 | departure = round(departure / 5) 65 | elif arrival: 66 | departure = arrival 67 | cur_times.append(StopTime( 68 | seq_id=int(row['stop_sequence']), 69 | arrival=arrival, 70 | departure=departure, 71 | pickup=self.parse_pickup_dropoff(row.get('continuous_pickup')), 72 | dropoff=self.parse_pickup_dropoff(row.get('continuous_drop_off')), 73 | approximate=row.get('timepoint') == '0', 74 | )) 75 | self.fill_trip(trips[trip_id], cur_times) 76 | 77 | def fill_trip(self, trip: gtfs.Trip, times: list[StopTime]): 78 | times.sort(key=lambda t: t.seq_id) 79 | if trip.arrivals or trip.departures: 80 | raise ValueError(f'Trip was already filled: {self.ids.original[trip.trip_id]}') 81 | arrivals: list[int] = [] 82 | for i in range(len(times)): 83 | a = times[i].arrival 84 | d = times[i].departure 85 | # Departures is the main list, arrivals is the auxillary. 86 | if i == 0 or d == 0: 87 | trip.departures.append(d) 88 | else: 89 | trip.departures.append(d - times[i-1].departure) 90 | # d - a >= 0 because if d == 0, we set it to arrival time in from_stop_times(). 91 | arrivals.append(0 if not a else d - a) 92 | trip.arrivals.extend(self.cut_empty(arrivals, 0)) 93 | trip.pickup_types.extend(self.cut_empty([t.pickup for t in times], 0)) 94 | trip.dropoff_types.extend(self.cut_empty([t.dropoff for t in times], 0)) 95 | trip.approximate = any(t.approximate for t in times) 96 | 97 | def cut_empty(self, values: list, zero) -> list: 98 | i = len(values) 99 | while i > 0 and values[i - 1] == zero: 100 | i -= 1 101 | return values[:i] 102 | 103 | def from_frequencies(self, fileobj: TextIO, trips: dict[int, gtfs.Trip]): 104 | for row, trip_id, _ in self.table_reader(fileobj, 'trip_id'): 105 | trip = trips[trip_id] # assuming it's there 106 | start = self.parse_time(row['start_time']) or 0 107 | end = self.parse_time(row['end_time']) or 0 108 | trip.start_time = round(start / 60) 109 | trip.end_time = round(end / 60) 110 | trip.interval = int(row['headway_secs']) 111 | trip.approximate = row.get('exact_times') == '1' 112 | 113 | def parse_time(self, tim: str) -> int | None: 114 | tim = tim.strip() 115 | if not tim: 116 | return None 117 | if len(tim) == 7: 118 | tim = '0' + tim 119 | if len(tim) != 8: 120 | raise ValueError(f'Wrong time value: {tim}') 121 | return int(tim[:2]) * 3600 + int(tim[3:5]) * 60 + int(tim[6:]) 122 | 123 | def parse_accessibility(self, value: str | None) -> int: 124 | if not value: 125 | return 0 126 | if value == '0': 127 | return gtfs.A_UNKNOWN 128 | if value == '1': 129 | return gtfs.A_SOME 130 | if value == '2': 131 | return gtfs.A_NO 132 | raise ValueError(f'Unknown accessibility value for a trip: {value}') 133 | 134 | def parse_pickup_dropoff(self, value: str | None) -> int: 135 | if not value: 136 | return gtfs.PickupDropoff.PD_NO # 0 137 | v = int(value) 138 | if v == 0: 139 | return gtfs.PickupDropoff.PD_YES 140 | if v == 1: 141 | return gtfs.PickupDropoff.PD_NO 142 | if v == 2: 143 | return gtfs.PickupDropoff.PD_PHONE_AGENCY 144 | if v == 3: 145 | return gtfs.PickupDropoff.PD_TELL_DRIVER 146 | raise ValueError(f'Wrong continous pickup / drop_off value: {v}') 147 | -------------------------------------------------------------------------------- /src/gtfs_proto/util.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | from . import gtfs_pb2 as gtfs 3 | from .wrapper import SHAPE_SCALE 4 | 5 | 6 | class CalendarService: 7 | def __init__(self, service_id: int, 8 | start_date: date | None = None, 9 | end_date: date | None = None, 10 | weekdays: list[bool] | None = None, 11 | added_days: list[date] | None = None, 12 | removed_days: list[date] | None = None): 13 | self.service_id = service_id 14 | self.start_date = start_date 15 | self.end_date = end_date 16 | self.weekdays = weekdays or [False] * 7 17 | self.added_days = added_days or [] 18 | self.removed_days = removed_days or [] 19 | 20 | def operates(self, on: date | None = None) -> bool: 21 | if not on: 22 | on = date.today() 23 | if self.start_date and on < self.start_date: 24 | return False 25 | if self.end_date and on > self.end_date: 26 | return False 27 | if on in self.removed_days: 28 | return False 29 | if on in self.added_days: 30 | return True 31 | return self.weekdays[on.weekday()] 32 | 33 | def equals(self, other, base_date: date | None = None) -> bool: 34 | def cut(dates: list[date], base_date: date | None) -> list[date]: 35 | if not base_date: 36 | return dates 37 | return [d for d in dates if d > base_date] 38 | 39 | def cap(d: date | None, base_date: date | None) -> date | None: 40 | if not d or not base_date or d > base_date: 41 | return d 42 | return base_date 43 | 44 | if self.service_id != other.service_id: 45 | return False 46 | if cap(self.start_date, base_date) != cap(other.start_date, base_date): 47 | return False 48 | if cap(self.end_date, base_date) != cap(other.end_date, base_date): 49 | return False 50 | if self.weekdays != other.weekdays: 51 | return False 52 | if cut(other.added_days, base_date) != cut(self.added_days, base_date): 53 | return False 54 | if cut(other.removed_days, base_date) != cut(self.removed_days, base_date): 55 | return False 56 | return True 57 | 58 | def __eq__(self, other) -> bool: 59 | return self.equals(other) 60 | 61 | 62 | def int_to_date(d: int) -> date: 63 | return date(d // 10000, (d % 10000) // 100, d % 100) 64 | 65 | 66 | def parse_calendar(c: gtfs.Calendar) -> list[CalendarService]: 67 | def add_base_date(d: list[int], base_date: date) -> list[date]: 68 | result: list[date] = [] 69 | for i, dint in enumerate(d): 70 | if i == 0: 71 | result.append(base_date + timedelta(days=dint)) 72 | else: 73 | result.append(result[-1] + timedelta(days=dint)) 74 | return result 75 | 76 | base_date = int_to_date(c.base_date) 77 | dates = {i: add_base_date(d.dates, base_date) for i, d in enumerate(c.dates)} 78 | result = [] 79 | for s in c.services: 80 | result.append(CalendarService( 81 | service_id=s.service_id, 82 | start_date=None if not s.start_date else base_date + timedelta(days=s.start_date), 83 | end_date=None if not s.end_date else base_date + timedelta(days=s.end_date), 84 | weekdays=[s.weekdays & (1 << i) > 0 for i in range(7)], 85 | added_days=dates[s.added_days], 86 | removed_days=dates[s.removed_days], 87 | )) 88 | return result 89 | 90 | 91 | def build_calendar(services: list[CalendarService], 92 | base_date: date | None = None) -> gtfs.Calendar: 93 | def to_int(d: date | None) -> int: 94 | return 0 if not d else int(d.strftime('%Y%m%d')) 95 | 96 | def pack_dates(dates: list[date], base_date: date) -> list[int]: 97 | result: list[int] = [] 98 | prev = base_date 99 | for d in sorted(dates): 100 | if d > base_date: 101 | result.append((d - prev).days) 102 | prev = d 103 | return result 104 | 105 | def find_or_add(dates: list[int], data: list[list[int]]) -> int: 106 | """Modifies the data!""" 107 | for i, stored in enumerate(data): 108 | if len(stored) == len(dates): 109 | found = True 110 | for j, v in enumerate(dates): 111 | if v != stored[j]: 112 | found = False 113 | break 114 | if found: 115 | return i 116 | data.append(dates) 117 | return len(data) - 1 118 | 119 | if not base_date: 120 | base_date = date.today() - timedelta(days=2) 121 | 122 | dates: list[list[int]] = [[]] 123 | c = gtfs.Calendar(base_date=to_int(base_date)) 124 | for s in services: 125 | if not s.end_date: 126 | end_date = base_date 127 | elif s.end_date < base_date: 128 | # 1 day effectively makes it end yesterday 129 | end_date = base_date + timedelta(days=1) 130 | else: 131 | end_date = s.end_date 132 | c.services.append(gtfs.CalendarService( 133 | service_id=s.service_id, 134 | start_date=(0 if not s.start_date or s.start_date < base_date 135 | else (s.start_date - base_date).days), 136 | end_date=(end_date - base_date).days, 137 | weekdays=sum(1 << i for i in range(7) if s.weekdays[i]), 138 | added_days=find_or_add(pack_dates(s.added_days, base_date), dates), 139 | removed_days=find_or_add(pack_dates(s.removed_days, base_date), dates), 140 | )) 141 | for d in dates: 142 | c.dates.append(gtfs.CalendarDates(dates=d)) 143 | return c 144 | 145 | 146 | def parse_shape(shape: gtfs.Shape) -> list[tuple[float, float]]: 147 | last_coord = (0, 0) 148 | coords: list[tuple[float, float]] = [] 149 | for i in range(len(shape.longitudes)): 150 | c = (shape.longitudes[i] + last_coord[0], shape.latitudes[i] + last_coord[1]) 151 | coords.append((c[0] / SHAPE_SCALE, c[1] / SHAPE_SCALE)) 152 | last_coord = c 153 | return coords 154 | 155 | 156 | def build_shape(shape_id: int, coords: list[tuple[float, float]]) -> gtfs.Shape: 157 | if len(coords) < 2: 158 | raise Exception(f'Got {len(coords)} coords for shape {shape_id}') 159 | shape = gtfs.Shape(shape_id=shape_id) 160 | last_coord = (0, 0) 161 | for c in coords: 162 | new_coord = (round(c[0] * SHAPE_SCALE), round(c[1] * SHAPE_SCALE)) 163 | shape.longitudes.append(new_coord[0] - last_coord[0]) 164 | shape.latitudes.append(new_coord[1] - last_coord[1]) 165 | last_coord = new_coord 166 | return shape 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GTFS to Protobuf Packaging 2 | 3 | This library / command-line tool introduces a protocol buffers-based format 4 | for packaging GTFS feeds. The reasons for this are: 5 | 6 | 1. Decrease the size of a feed to 8-10% of the original. 7 | 2. Allow for even smaller and easy to apply delta files. 8 | 9 | The recommended file extension for packaged feeds is _gtp_. 10 | 11 | ## Differences with GTFS 12 | 13 | The main thing missing from the packaged feed is fare information. 14 | This is planned to be added, refer to [this ticket](https://github.com/Zverik/gtfs-proto/issues/1) 15 | to track the implementation progress. 16 | 17 | Packaged feed does not reflect source tables one to one. This is true 18 | for most tables though. Here's what been deliberately skipped from 19 | the official format: 20 | 21 | * `stops.txt`: `tts_stop_name`, `stop_url`, `stop_timezone`, `level_id`. 22 | * `routes.txt`: `route_url`, `route_sort_order`. 23 | * `trips.txt`: `block_id`. 24 | * `stop_times.txt`: `stop_sequence`, `shape_dist_travelled`. 25 | * `shapes.txt`: `shape_pt_sequence`, `shape_dist_travelled`. 26 | 27 | Feed files ignored are all the `fare*.txt`, `timeframes.txt`, `pathways.txt`, 28 | `levels.txt`, `translations.txt`, `feed_info.txt`, and `attributions.txt`. 29 | Fares are to be implemented, and for other tables it is recommended 30 | to use the original feed. 31 | 32 | ### Binary Format 33 | 34 | **Note that the format is to have significant changes, including renumbering 35 | of fields, until version 1.0 is published.** 36 | 37 | Inside the file, first thing is a little-endian two-byte size for the header 38 | block. Then the header serialized message follows. 39 | 40 | The header contains a list of sizes for each block. Blocks follow in the order 41 | listed in the `Block` enum: first identifiers, then strings, then agencies, 42 | and so on. 43 | 44 | The same enum is used for keys in the `IdReference` message, that links 45 | generated numeric ids from this packed feed with the original string ids. 46 | 47 | If the feed is compressed (marked by a flag in the header), each block is 48 | compressed using the Zstandard algorithm. It proved to be both fast and efficient, 49 | decreasing the size by 70%. 50 | 51 | ### Location Encoding 52 | 53 | Floating-point values are stored inefficiently, hence all longitude and latitudes 54 | are multiplied by 100000 (10^5) and rounded. This allows for one-meter precision, 55 | which is good enough on public transit scales. 56 | 57 | In addition, when coordinates become lists, we store only a difference with the 58 | last coordinate. This applies to both stops (relative to the previous stop) and 59 | shapes: in latter, coordinates are relative to the previous coordinate, or to 60 | the last one in the previous shape. 61 | 62 | ### Routes and Trips 63 | 64 | The largest file in every GTFS feed is `stop_times.txt`. Here, it's missing, with 65 | the data spread between routes and trips. The format also adds itineraries: 66 | 67 | * An itinerary is a series of stops for a route with the same headsign and shape. 68 | * Note that there is no specific block for itineraries, instead they are packaged 69 | inside corresponding routes. But they still have unique identifiers. 70 | * Route is the same as in GTFS. 71 | * Trips reference an itinerary for stops, and add departure and arrival times for 72 | each stop (or start and end time when those are specified with `frequencies.txt`). 73 | 74 | So to find a departure time for a given stop, you find itineraries that contain it, 75 | and from those, routes and trips. You get a departure times list from the trip, 76 | and use addition to get the actual time (since we store just differences with previous 77 | times, with 5-second granularity). 78 | 79 | ### Deltas 80 | 81 | Delta files looks the same as the original, but the header size has its last bit 82 | set (`size & 0x8000`, note the unsigned integer). After that, `GtfsDeltaHeader` 83 | follows, which also has version, date, compression fields, and a list of block sizes. 84 | 85 | How the blocks are different, is explained in the [proto file](protobuf/gtfs.proto). 86 | 87 | ## Installation and Usage 88 | 89 | Installing is simple: 90 | 91 | pip install gtfs-proto 92 | 93 | ### Packaging a feed 94 | 95 | See a list of commands the tool provides by running it without arguments: 96 | 97 | gtfs_proto 98 | gtfs_proto pack --help 99 | 100 | To package a feed, call: 101 | 102 | gtfs_proto pack gtfs.zip --output city.gtp 103 | 104 | In a header, a feed stores an URL of a source zip file, and a date on which 105 | that feed was built. You should specify those, although if the date is "today", 106 | you can skip the argument: 107 | 108 | gtfs_proto pack gtfs.zip --url https://mta.org/gtfs/gtfs.zip --date 2024-03-19 -o city.gtp 109 | 110 | When setting a pipeline to package feeds regularly, do specify the previous feed 111 | file to keep identifiers from altering, and to keep delta file sizes to a minimum: 112 | 113 | gtfs_proto pack gtfs.zip --prev city_last.gtp -o city.gtp 114 | 115 | ### Deltas 116 | 117 | Delta, a list of differences between two feeds, is made with this obvious command: 118 | 119 | gtfs_proto delta city_last.gtp city.gtp -o city_delta.gtp 120 | 121 | Currently it's to be decided whether a delta requires a different file extension. 122 | Technically the format is almost the same, using the same protocol buffers definition. 123 | 124 | If you lost an even older file and wish to keep your users updated even from very 125 | old feeds, you can merge deltas: 126 | 127 | gtfs_proto dmerge city_delta_1-2.gtp city_delta_2-3.gtp -o city_delta_1-3.gtp 128 | 129 | It is recommended to avoid merging deltas and store old feeds instead to produce 130 | delta files with the `delta` command. 131 | 132 | There is no command for applying deltas: it's on end users to read the file and 133 | apply it straight to their inner database. 134 | 135 | ### Information 136 | 137 | A packaged feed contains a header and an array of blocks, similar but not exactly mirroring 138 | the original GTFS files. You can see the list, sizes and counts by running: 139 | 140 | gtfs_proto info city.gtp 141 | 142 | Any block can be dumped into a series of one-line JSON objects by specifying 143 | the block name: 144 | 145 | gtfs_proto info city.gtp --block stops 146 | 147 | Currently the blocks are `ids`, `strings`, `agency`, `calendar`, `shapes`, 148 | `stops`, `routes`, `trips`, `transfers`, `networks`, `areas`, and `fare_links`. 149 | 150 | There are two additional "blocks" that print numbers from the header: 151 | `version` and `date`. Use these to simplify automation. For example, this is 152 | how you make a version-named copy of the lastest feed: 153 | 154 | ```sh 155 | cp city-latest.gtp city-$(gtfs_proto info city-latest.gtp -b version).gtp 156 | ``` 157 | 158 | When applicable, you can print just the line for a given identifier, 159 | both for the one from the original GTFS feed, and for a numeric generated one: 160 | 161 | gtfs_proto info city.gtp -p stops --id 45 162 | 163 | Of course you can view contents of a delta file the same way. 164 | 165 | ## Python Library 166 | 167 | Reading GTFS protobuf files is pretty straightforward: 168 | 169 | ```python 170 | from gtfs_proto import GtfsProto 171 | 172 | feed = GtfsProto(open('city.gtp', 'rb')) 173 | print(f'Feed built on {feed.header.date}') 174 | for stop in feed.stops: 175 | print(f'Stop {stop.stop_id} named "{feed.strings[stop.name]}".') 176 | ``` 177 | 178 | The `GtfsProto` (and `GtfsDelta`) object reads the file header and lazily provides 179 | all blocks as lists or dicts. To read all blocks instantly, use the `read_now=True` 180 | argument for the constructor. 181 | 182 | Parsing shapes and calendar services is not easy, so there are some service 183 | functions, namely `parse_shape` and `parse_calendar`. The latter returns a list 184 | of `CalendarService` with all the dates and day lists unpacked, and an `operates` 185 | method to determine whether the line functions on a given date. 186 | 187 | All built-in commands use this library, so refer to, for example, 188 | [delta.py](src/gtfs_proto/delta.py) for an extended usage tutorial. 189 | 190 | ## Author and License 191 | 192 | The format and the code were written by Ilya Zverev. The code is published under ISC License, 193 | the the format is CC0 or in a public domain, whatever applies in your country. 194 | -------------------------------------------------------------------------------- /src/gtfs_proto/dmerge.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from . import ( 4 | gtfs, GtfsDelta, FareLinks, StringCache, 5 | parse_calendar, build_calendar, int_to_date, 6 | ) 7 | 8 | 9 | class DeltaMerger: 10 | def __init__(self, d_strings: StringCache, new_strings: StringCache): 11 | self.strings = d_strings 12 | self.s_new = new_strings 13 | 14 | def add_string(self, sid: int) -> int: 15 | if sid: 16 | return self.strings.add(self.s_new[sid]) 17 | return 0 18 | 19 | def agencies(self, a1: list[gtfs.Agency], a2: list[gtfs.Agency]) -> list[gtfs.Agency]: 20 | ad1 = {a.agency_id: a for a in a1} 21 | for a in a2: 22 | if a == gtfs.Agency(a.agency_id): 23 | ad1[a.agency_id] = a 24 | elif a.agency_id not in ad1: 25 | # Add an agency and its strings. 26 | a.timezone = self.add_string(a.timezone) 27 | ad1[a.agency_id] = a 28 | else: 29 | # Merge two changes. 30 | first = ad1[a.agency_id] 31 | ad1[a.agency_id] = gtfs.Agency( 32 | agency_id=a.agency_id, 33 | name=a.name or first.name, 34 | url=a.url or first.url, 35 | timezone=self.add_string(a.timezone) or first.timezone, 36 | lang=a.lang or first.lang, 37 | phone=a.phone or first.phone, 38 | fare_url=a.fare_url or first.fare_url, 39 | email=a.email or first.email, 40 | ) 41 | return list(ad1.values()) 42 | 43 | def calendar(self, c1: gtfs.Calendar, c2: gtfs.Calendar) -> gtfs.Calendar: 44 | cd1 = {c.service_id: c for c in parse_calendar(c1)} 45 | for c in parse_calendar(c2): 46 | cd1[c.service_id] = c 47 | return build_calendar(list(cd1.values()), int_to_date(c2.base_date)) 48 | 49 | def shapes(self, s1: list[gtfs.Shape], s2: list[gtfs.Shape]) -> list[gtfs.Shape]: 50 | sd1 = {s.shape_id: s for s in s1} 51 | for s in s2: 52 | sd1[s.shape_id] = s 53 | return list(sd1.values()) 54 | 55 | def stops(self, s1: list[gtfs.Stop], s2: list[gtfs.Stop]) -> list[gtfs.Stop]: 56 | sd1 = {s.stop_id: s for s in s1} 57 | for s in s2: 58 | s.name = self.add_string(s.name) 59 | if s.delete or s.stop_id not in sd1: 60 | sd1[s.stop_id] = s 61 | else: 62 | first = sd1[s.stop_id] 63 | sd1[s.stop_id] = gtfs.Stop( 64 | stop_id=s.stop_id, 65 | code=first.code or s.code, 66 | name=s.name or first.name, 67 | desc=s.desc or first.desc, 68 | lat=s.lat or first.lat, 69 | lon=s.lon or first.lon, 70 | type=s.type, 71 | parent_id=s.parent_id or first.parent_id, 72 | wheelchair=s.wheelchair, 73 | platform_code=s.platform_code or first.platform_code, 74 | external_str_id=s.external_str_id or first.external_str_id, 75 | external_int_id=s.external_int_id or first.external_int_id, 76 | ) 77 | return list(sd1.values()) 78 | 79 | def routes(self, r1: list[gtfs.Route], r2: list[gtfs.Route]) -> list[gtfs.Route]: 80 | rd1 = {r.route_id: r for r in r1} 81 | for r in r2: 82 | # Update all strings. 83 | for i in range(len(r.long_name)): 84 | r.long_name[i] = self.add_string(r.long_name[i]) 85 | for it in r.itineraries: 86 | it.headsign = self.add_string(it.headsign) 87 | for i in range(len(it.stop_headsigns)): 88 | it.stop_headsigns[i] = self.add_string(it.stop_headsigns[i]) 89 | 90 | if r.delete or r.route_id not in rd1: 91 | rd1[r.route_id] = r 92 | else: 93 | first = rd1[r.route_id] 94 | rd1[r.route_id] = gtfs.Route( 95 | route_id=r.route_id, 96 | agency_id=r.agency_id or first.agency_id, 97 | short_name=r.short_name or first.short_name, 98 | long_name=r.long_name or first.long_name, 99 | desc=r.desc or first.desc, 100 | type=r.type, 101 | color=r.color, 102 | text_color=r.text_color, 103 | continuous_pickup=r.continuous_pickup, 104 | continuous_dropoff=r.continuous_dropoff, 105 | ) 106 | 107 | # Now for itineraries. 108 | it1 = {i.itinerary_id: i for i in first.itineraries} 109 | for it in r.itineraries: 110 | it1[it.itinerary_id] = it 111 | rd1[r.route_id].itineraries.extend(it1.values()) 112 | return list(rd1.values()) 113 | 114 | def trips(self, t1: list[gtfs.Trip], t2: list[gtfs.Trip]) -> list[gtfs.Trip]: 115 | td1 = {t.trip_id: t for t in t1} 116 | for t in t2: 117 | if t.trip_id not in td1: 118 | td1[t.trip_id] = t 119 | elif t == gtfs.Trip(trip_id=t.trip_id): 120 | td1[t.trip_id] = t 121 | else: 122 | first = td1[t.trip_id] 123 | new_deps = t.departures or t.arrivals 124 | td1[t.trip_id] = gtfs.Trip( 125 | trip_id=t.trip_id, 126 | service_id=t.service_id or first.service_id, 127 | itinerary_id=t.itinerary_id or first.itinerary_id, 128 | short_name=t.short_name or first.short_name, 129 | wheelchair=t.wheelchair, 130 | bikes=t.bikes, 131 | approximate=t.approximate, 132 | departures=t.departures if new_deps else first.departures, 133 | arrivals=t.arrivals if new_deps else first.arrivals, 134 | pickup_types=t.pickup_types or first.pickup_types, 135 | dropoff_types=t.dropoff_types or first.dropoff_types, 136 | start_time=t.start_time or first.start_time, 137 | end_time=t.end_time or first.end_time, 138 | interval=t.interval or first.interval, 139 | ) 140 | return list(td1.values()) 141 | 142 | def transfers(self, t1: list[gtfs.Transfer], 143 | t2: list[gtfs.Transfer]) -> list[gtfs.Transfer]: 144 | def transfer_key(t: gtfs.Transfer): 145 | return ( 146 | t.from_stop, t.to_stop, 147 | t.from_route, t.to_route, 148 | t.from_trip, t.to_trip, 149 | ) 150 | 151 | td1 = {transfer_key(t): t for t in t1} 152 | for t in t2: 153 | td1[transfer_key(t)] = t 154 | return list(td1.values()) 155 | 156 | def fare_links(self, f1: FareLinks, f2: FareLinks): 157 | f1.stop_areas.update(f2.stop_areas) 158 | f1.stop_zones.update(f2.stop_zones) 159 | f1.route_networks.update(f2.route_networks) 160 | 161 | 162 | def delta_merge(): 163 | parser = argparse.ArgumentParser( 164 | description='Merges two GTFS deltas into one') 165 | parser.add_argument( 166 | 'old', type=argparse.FileType('rb'), help='The first, older delta') 167 | parser.add_argument( 168 | 'new', type=argparse.FileType('rb'), help='The second, latest delta') 169 | parser.add_argument( 170 | '-o', '--output', type=argparse.FileType('wb'), required=True, 171 | help='Resulting merged delta file') 172 | options = parser.parse_args() 173 | 174 | delta = GtfsDelta(options.old) 175 | second = GtfsDelta(options.new) 176 | if delta.header.version < second.header.old_version: 177 | print( 178 | f'Cannot fill the gap between versions {delta.header.version} ' 179 | 'and {second.header.old_version}.', file=sys.stderr) 180 | sys.exit(1) 181 | if delta.header.old_version >= second.header.old_version: 182 | print('The new delta already covers the scope of the old one.', 183 | file=sys.stderr) 184 | sys.exit(1) 185 | 186 | delta.header.version = second.header.version 187 | delta.header.date = second.header.date 188 | delta.header.compressed = second.header.compressed 189 | 190 | for k, v in delta.id_store.items(): 191 | if k in second.id_store: 192 | # Append ids from the second delta. 193 | for i in range(v.last_id + 1, v.last_id + 1): 194 | v.ids[second.id_store[k].original[i]] = i 195 | v.last_id = 0 if not v.ids else max(v.ids.values()) 196 | 197 | dm = DeltaMerger(delta.strings, second.strings) 198 | delta.agencies = dm.agencies(delta.agencies, second.agencies) 199 | delta.calendar = dm.calendar(delta.calendar, second.calendar) 200 | delta.shapes = dm.shapes(delta.shapes, second.shapes) 201 | delta.stops = dm.stops(delta.stops, second.stops) 202 | delta.routes = dm.routes(delta.routes, second.routes) 203 | delta.trips = dm.trips(delta.trips, second.trips) 204 | delta.transfers = dm.transfers(delta.transfers, second.transfers) 205 | delta.networks.update(second.networks) 206 | delta.areas.update(second.areas) 207 | dm.fare_links(delta.fare_links, second.fare_links) 208 | delta.write(options.output) 209 | -------------------------------------------------------------------------------- /src/gtfs_proto/packers/routes.py: -------------------------------------------------------------------------------- 1 | from .base import BasePacker, StringCache, IdReference, FareLinks 2 | from typing import TextIO 3 | from zipfile import ZipFile 4 | from collections import defaultdict 5 | from dataclasses import dataclass 6 | from hashlib import md5 7 | from .. import gtfs_pb2 as gtfs 8 | 9 | 10 | @dataclass 11 | class StopData: 12 | seq_id: int 13 | stop_id: int 14 | headsign: int | None 15 | 16 | 17 | class Trip: 18 | def __init__(self, trip_id: int, row: dict[str, str], 19 | stops: list[StopData], shape_id: int | None): 20 | self.trip_id = trip_id 21 | self.headsign = row.get('trip_headsign') 22 | self.opposite = row.get('direction_id') == '1' 23 | self.stops = [s.stop_id for s in stops] 24 | self.shape_id = shape_id 25 | self.headsigns = [s.headsign for s in stops] 26 | 27 | # Generate stops key. 28 | m = md5(usedforsecurity=False) 29 | m.update(row['route_id'].encode()) 30 | for s in self.stops: 31 | m.update(s.to_bytes(4)) 32 | self.stops_key = m.hexdigest() 33 | 34 | def __hash__(self) -> int: 35 | return hash(self.stops_key) 36 | 37 | def __eq__(self, other): 38 | return self.stops_key == other.stops_key 39 | 40 | 41 | class RoutesPacker(BasePacker): 42 | def __init__(self, z: ZipFile, strings: StringCache, id_store: dict[int, IdReference], 43 | fl: FareLinks): 44 | super().__init__(z, strings, id_store) 45 | self.fl = fl 46 | # trip_id → itinerary_id 47 | self.trip_itineraries: dict[int, int] = {} 48 | 49 | @property 50 | def block(self): 51 | return gtfs.B_ROUTES 52 | 53 | def pack(self) -> list[gtfs.Route]: 54 | with self.open_table('stop_times') as f: 55 | trip_stops = self.read_trip_stops(f) 56 | with self.open_table('trips') as f: 57 | itineraries, self.trip_itineraries = self.read_itineraries(f, trip_stops) 58 | with self.open_table('routes') as f: 59 | r = self.prepare(f, itineraries) 60 | if self.has_file('route_networks'): 61 | with self.open_table('route_networks') as f: 62 | self.read_route_networks(f) 63 | return r 64 | 65 | def prepare(self, fileobj: TextIO, 66 | itineraries: dict[str, list[gtfs.RouteItinerary]]) -> list[gtfs.Route]: 67 | routes: list[gtfs.Route] = [] 68 | agency_ids = self.id_store[gtfs.B_AGENCY] 69 | network_ids = self.id_store[gtfs.B_NETWORKS] 70 | for row, route_id, orig_route_id in self.table_reader(fileobj, 'route_id'): 71 | # Skip routes for which we don't have any trips. 72 | if orig_route_id not in itineraries: 73 | continue 74 | 75 | route = gtfs.Route(route_id=route_id, itineraries=itineraries[orig_route_id]) 76 | if row.get('agency_id'): 77 | route.agency_id = agency_ids[row['agency_id']] 78 | 79 | if row.get('network_id'): 80 | self.fl.route_networks[route_id] = network_ids.add(row['network_id']) 81 | 82 | if row.get('route_short_name', ''): 83 | route.short_name = row['route_short_name'] 84 | if row.get('route_long_name', ''): 85 | route.long_name.extend(self.parse_route_long_name(row['route_long_name'])) 86 | if row.get('route_desc', ''): 87 | route.desc = row['route_desc'] 88 | route.type = self.route_type_to_enum(int(row['route_type'])) 89 | if row.get('route_color', '') and row['route_color'].upper() != 'FFFFFF': 90 | route.color = int(row['route_color'], 16) 91 | if route.color == 0: 92 | route.color = 0xFFFFFF 93 | if row.get('route_text_color', '') and row['route_text_color'] != '000000': 94 | route.text_color = int(row['route_text_color'], 16) 95 | route.continuous_pickup = self.parse_pickup_dropoff(row.get('continuous_pickup')) 96 | route.continuous_dropoff = self.parse_pickup_dropoff(row.get('continuous_drop_off')) 97 | 98 | routes.append(route) 99 | return routes 100 | 101 | def read_route_networks(self, fileobj: TextIO): 102 | for row, network_id, _ in self.table_reader(fileobj, 'network_id', gtfs.B_NETWORKS): 103 | self.fl.route_networks[self.ids.add(row['route_id'])] = network_id 104 | 105 | def read_itineraries(self, fileobj: TextIO, trip_stops: dict[int, list[StopData]] 106 | ) -> tuple[dict[str, list[gtfs.RouteItinerary]], dict[int, int]]: 107 | trips: dict[str, list[Trip]] = defaultdict(list) # route_id -> list[Trip] 108 | for row, trip_id, orig_trip_id in self.table_reader(fileobj, 'trip_id', gtfs.B_TRIPS): 109 | stops = trip_stops.get(trip_id) 110 | if not stops: 111 | continue 112 | shape_id = (None if not row.get('shape_id') 113 | else self.id_store[gtfs.B_SHAPES].ids[row['shape_id']]) 114 | trips[row['route_id']].append(Trip(trip_id, row, stops, shape_id)) 115 | 116 | # Now we have a list of itinerary-type trips which we need to deduplicate. 117 | # Note: since we don't have original ids, we need to keep them stable. 118 | # Since the only thing that matters is an order of stops, we use stops' hash as the key. 119 | 120 | result: dict[str, list[gtfs.RouteItinerary]] = defaultdict(list) 121 | trip_itineraries: dict[int, int] = {} 122 | ids = self.id_store[gtfs.B_ITINERARIES] 123 | 124 | for route_id, trip_list in trips.items(): 125 | for trip in set(trip_list): 126 | itin = gtfs.RouteItinerary( 127 | itinerary_id=ids.add(trip.stops_key), 128 | opposite_direction=trip.opposite, 129 | stops=trip.stops, 130 | shape_id=trip.shape_id, 131 | ) 132 | if trip.headsign: 133 | itin.headsign = self.strings.add(trip.headsign) 134 | 135 | result[route_id].append(itin) 136 | for t in trip_list: 137 | if t.stops_key == trip.stops_key: 138 | trip_itineraries[t.trip_id] = itin.itinerary_id 139 | 140 | return result, trip_itineraries 141 | 142 | def read_trip_stops(self, fileobj: TextIO) -> dict[int, list[StopData]]: 143 | trip_stops: dict[int, list[StopData]] = {} # trip_id -> int_stop_id, string_id 144 | for rows, trip_id, orig_trip_id in self.sequence_reader( 145 | fileobj, 'trip_id', 'stop_sequence', gtfs.B_TRIPS): 146 | trip_stops[trip_id] = [StopData( 147 | seq_id=int(row['stop_sequence']), 148 | # The stop should be already in the table. 149 | stop_id=self.id_store[gtfs.B_STOPS].ids[row['stop_id']], 150 | headsign=self.strings.add(row.get('stop_headsign')), 151 | ) for row in rows] 152 | return trip_stops 153 | 154 | def route_type_to_enum(self, t: int) -> int: 155 | if t == 0 or t // 100 == 9: 156 | return gtfs.RouteType.TRAM 157 | if t in (1, 401, 402): 158 | return gtfs.RouteType.SUBWAY 159 | if t == 2 or t // 100 == 1: 160 | return gtfs.RouteType.RAIL 161 | if t == 3 or t // 100 == 7: 162 | return gtfs.RouteType.BUS 163 | if t == 4 or t == 1200: 164 | return gtfs.RouteType.FERRY 165 | if t == 5 or t == 1302: 166 | return gtfs.RouteType.CABLE_TRAM 167 | if t == 6 or t // 100 == 13: 168 | return gtfs.RouteType.AERIAL 169 | if t == 7 or t == 1400: 170 | return gtfs.RouteType.FUNICULAR 171 | if t == 1501: 172 | return gtfs.RouteType.COMMUNAL_TAXI 173 | if t // 100 == 2: 174 | return gtfs.RouteType.COACH 175 | if t == 11 or t == 800: 176 | return gtfs.RouteType.TROLLEYBUS 177 | if t == 12 or t == 405: 178 | return gtfs.RouteType.MONORAIL 179 | if t in (400, 403, 403): 180 | return gtfs.RouteType.URBAN_RAIL 181 | if t == 1000: 182 | return gtfs.RouteType.WATER 183 | if t == 1100: 184 | return gtfs.RouteType.AIR 185 | if t // 100 == 15: 186 | return gtfs.RouteType.TAXI 187 | if t // 100 == 17: 188 | return gtfs.RouteType.MISC 189 | raise ValueError(f'Wrong route type {t}') 190 | 191 | def parse_route_long_name(self, name: str) -> list[int]: 192 | if not name: 193 | return [] 194 | name = name.replace('—', '-').replace('–', '-') 195 | parts = [s.strip() for s in name.split(' - ') if s.strip()] 196 | idx = [self.strings.search(p) for p in parts] 197 | if not any(idx) or len(parts) == 1: 198 | return [self.strings.add(name)] 199 | for i in range(len(idx)): 200 | if not idx[i]: 201 | idx[i] = self.strings.add(parts[i]) 202 | # TODO: Check when parts are capitalized 203 | return idx # type: ignore 204 | 205 | def parse_pickup_dropoff(self, value: str | None) -> int: 206 | if not value: 207 | return 0 208 | v = int(value) 209 | if v == 0: 210 | return gtfs.PickupDropoff.PD_YES 211 | if v == 1: 212 | return gtfs.PickupDropoff.PD_NO 213 | if v == 2: 214 | return gtfs.PickupDropoff.PD_PHONE_AGENCY 215 | if v == 3: 216 | return gtfs.PickupDropoff.PD_TELL_DRIVER 217 | raise ValueError(f'Wrong continous pickup / drop_off value: {v}') 218 | -------------------------------------------------------------------------------- /protobuf/gtfs.proto: -------------------------------------------------------------------------------- 1 | /** Copyright (c) 2023 Ilya Zverev. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | */ 22 | 23 | syntax = "proto3"; 24 | 25 | package gtfs; 26 | 27 | /* The file starts with a 2-byte header length. */ 28 | 29 | /* This is the file header with block sizes. 30 | Fields can come in any order, but in the file, blocks are 31 | serialized exactly in the order listed here. 32 | See also the "Block" message below for the order. */ 33 | 34 | message GtfsHeader { 35 | // Versions are incremental. When generating a feed for the next day, 36 | // we're using the previous one for reference, and incrementing the version. 37 | uint32 version = 1; 38 | 39 | // Date when the original GTFS feed was built, in YYYYMMDD format (20240319). 40 | uint32 date = 2; 41 | 42 | // In case of errors, this would be the reference GTFS feed, zip file itself. 43 | // Please link to specific builds if possible, e.g. "20240319", not "latest". 44 | string original_url = 3; 45 | 46 | // If true, all blocks are compressed with Zstandard. 47 | // https://facebook.github.io/zstd/ 48 | // TODO: maybe leave the compression to external tools, on a file level? 49 | bool compressed = 4; 50 | 51 | // Array in order and matching the Blocks enum. 52 | repeated uint32 blocks = 5; 53 | } 54 | 55 | /* All identifiers are renumbered, starting from 1. 56 | We do not keep any of original identifiers, so to match 57 | the data with an OpenTripPlanner instance, for example, 58 | use the IdStore tables. */ 59 | 60 | message Agencies { 61 | repeated Agency agencies = 1; 62 | } 63 | 64 | message Agency { 65 | uint32 agency_id = 1; // identifiers are renumbered 66 | string name = 2; 67 | string url = 3; 68 | uint32 timezone = 4; // strings reference 69 | string lang = 5; 70 | string phone = 6; 71 | string fare_url = 7; 72 | string email = 8; 73 | } 74 | 75 | message Calendar { 76 | // A number just like in GTFS: e.g. 20240821. 77 | // All service dates use offsets in days from it. 78 | uint32 base_date = 1; 79 | 80 | // Most holidays will be the same I guess. 81 | repeated CalendarDates dates = 2; // first is empty 82 | repeated CalendarService services = 3; 83 | } 84 | 85 | message CalendarDates { 86 | // First is offset from base_date, each consequtive 87 | // is stored as an offset to previous. 88 | repeated uint32 dates = 1; 89 | } 90 | 91 | message CalendarService { 92 | uint32 service_id = 1; 93 | uint32 start_date = 2; // offset from base_date 94 | uint32 end_date = 3; // offset from base_date 95 | uint32 weekdays = 4; // binary: 1 for Monday, 2 for Tuesday, 4 for Wednesday etc. 96 | 97 | // These fields come from calendar_days.txt. 98 | uint32 added_days = 5; // references CalendarDates 99 | uint32 removed_days = 6; // references CalendarDates 100 | } 101 | 102 | message Shapes { 103 | repeated Shape shapes = 1; 104 | } 105 | 106 | message Shape { 107 | uint32 shape_id = 1; 108 | 109 | // Numbers multiplied by 100000 (1e5) and rounded. 110 | // Mind the accumulated rounding error! 111 | // Each number is the difference with the last. 112 | // First is difference with 0 or the last from the last non-empty shape. 113 | repeated sint32 longitudes = 2; 114 | repeated sint32 latitudes = 3; 115 | } 116 | 117 | message Networks { 118 | map networks = 1; 119 | } 120 | 121 | message Areas { 122 | map areas = 1; 123 | } 124 | 125 | message StringTable { 126 | // First string is always empty. 127 | repeated string strings = 1; 128 | } 129 | 130 | message Stops { 131 | repeated Stop stops = 1; 132 | } 133 | 134 | // Lat and lon are differences from the last non-empty stop position 135 | // (non-empty means, which has non-zero lon or lat). 136 | message Stop { 137 | uint32 stop_id = 1; // this one is generated for this feed 138 | string code = 2; 139 | uint32 name = 3; // strings reference 140 | string desc = 4; 141 | sint32 lat = 5; // multiplied by 100000 (1e5), diff from the last 142 | sint32 lon = 6; // same 143 | LocationType type = 7; 144 | uint32 parent_id = 8; 145 | Accessibility wheelchair = 9; 146 | string platform_code = 10; 147 | string external_str_id = 11; // external id for linking, as string 148 | uint32 external_int_id = 12; // external id for linking, as number 149 | bool delete = 13; 150 | } 151 | 152 | enum LocationType { 153 | L_STOP = 0; 154 | L_STATION = 1; 155 | L_EXIT = 2; 156 | L_NODE = 3; 157 | L_BOARDING = 4; 158 | } 159 | 160 | enum Accessibility { 161 | A_UNKNOWN = 0; 162 | A_SOME = 1; 163 | A_NO = 2; 164 | } 165 | 166 | message Routes { 167 | // Sorted in sort_order if present, and in the source order otherwise. 168 | repeated Route routes = 1; 169 | } 170 | 171 | message Route { 172 | uint32 route_id = 1; // renumbered 173 | uint32 agency_id = 2; 174 | string short_name = 3; 175 | // Long name is assumed to be in format "Stop 1 - Stop 2 - Stop 3". 176 | // Each part is a strings reference. 177 | // If names are not in this format, just putting all long names verbatim there. 178 | repeated uint32 long_name = 4; 179 | string desc = 5; 180 | RouteType type = 6; 181 | uint32 color = 7; // note: black (0x000000) and white (0xffffff) are swapped! 182 | uint32 text_color = 8; 183 | PickupDropoff continuous_pickup = 9; 184 | PickupDropoff continuous_dropoff = 10; 185 | 186 | repeated RouteItinerary itineraries = 11; // to skip route_id there. 187 | bool delete = 12; 188 | } 189 | 190 | // See also https://developers.google.com/transit/gtfs/reference/extended-route-types 191 | enum RouteType { 192 | // Renumbered to have the bus at 0 to save space. 193 | BUS = 0; // 3 and all 7xx 194 | TRAM = 1; // 0 and all 9xx 195 | SUBWAY = 2; // 1 and also 401-402 196 | RAIL = 3; // 2 and all 1xx 197 | FERRY = 4; // also 1200 198 | CABLE_TRAM = 5; // maybe 1302 but not sure 199 | AERIAL = 6; // also 13xx 200 | FUNICULAR = 7; // also 1400 201 | COMMUNAL_TAXI = 9; // 1501 202 | COACH = 10; // all 200-209 203 | TROLLEYBUS = 11; // also 800 204 | MONORAIL = 12; // also 405 205 | 206 | URBAN_RAIL = 21; // 400, 403-404 207 | WATER = 22; // 1000 208 | AIR = 23; // 1100 209 | TAXI = 24; // all 15xx except 1501 210 | MISC = 25; // 17xx 211 | } 212 | 213 | enum PickupDropoff { 214 | PD_NO = 0; 215 | PD_YES = 1; 216 | PD_PHONE_AGENCY = 2; 217 | PD_TELL_DRIVER = 3; 218 | } 219 | 220 | /* Note that RouteTrip does not exactly mirror trips.txt. 221 | It takes a list of stops from stop_times.txt, hence there can be multiple. 222 | Also it doesn't mean one trip, but multiple with the same parameters. */ 223 | message RouteItinerary { 224 | uint32 itinerary_id = 1; // guaranteed to be unique throughout the entire feed 225 | uint32 headsign = 2; // strings reference 226 | bool opposite_direction = 3; 227 | repeated uint32 stops = 4; 228 | uint32 shape_id = 5; 229 | 230 | // In case a bus changes its headsigns on a route. 231 | // Zero when the same as the last. References strings. 232 | repeated uint32 stop_headsigns = 6; 233 | } 234 | 235 | message Trips { 236 | repeated Trip trips = 1; 237 | } 238 | 239 | /* This message encapsulates both trips and stop_times. */ 240 | message Trip { 241 | uint32 trip_id = 1; // renumbered 242 | uint32 service_id = 2; 243 | uint32 itinerary_id = 3; // for properties and order of stops. 244 | string short_name = 4; 245 | Accessibility wheelchair = 5; 246 | Accessibility bikes = 6; 247 | bool approximate = 7; 248 | 249 | // Granularity is 5 seconds. 250 | // First number is (hours * 720) + (minutes * 12) + (seconds / 5), rounded. 251 | // After that, differences from previous time. 252 | // Mind the accumulated rounding error! 253 | // Zero (0) for a departure means an absent value. 254 | repeated uint32 departures = 8; 255 | // Counted from same departures (backwards), truncated to the last non-zero value. 256 | repeated uint32 arrivals = 9; 257 | 258 | // Both truncated to the last non-empty value. 259 | repeated PickupDropoff pickup_types = 10; 260 | repeated PickupDropoff dropoff_types = 11; 261 | 262 | // For frequency-based trips, field 8 is empty, and those three are present. 263 | // Granularity is 1 minute. Values are, hours * 60 + minutes. 264 | uint32 start_time = 12; 265 | uint32 end_time = 13; 266 | // Here, granularity is 1 seconds, like in GTFS. No point in saving a byte. 267 | uint32 interval = 14; 268 | } 269 | 270 | message Transfers { 271 | repeated Transfer transfers = 1; 272 | } 273 | 274 | message Transfer { 275 | uint32 from_stop = 1; 276 | uint32 to_stop = 2; 277 | uint32 from_route = 3; 278 | uint32 to_route = 4; 279 | uint32 from_trip = 5; 280 | uint32 to_trip = 6; 281 | TransferType type = 7; 282 | uint32 min_transfer_time = 8; // granularity is 5 seconds, rounded up (!) 283 | bool delete = 9; // for delta files 284 | } 285 | 286 | enum TransferType { 287 | T_POSSIBLE = 0; 288 | T_DEPARTURE_WAITS = 1; 289 | T_NEEDS_TIME = 2; 290 | T_NOT_POSSIBLE = 3; 291 | T_IN_SEAT = 4; 292 | T_IN_SEAT_FORBIDDEN = 5; 293 | } 294 | 295 | /* These lists extract area_id and zone_id from stops, 296 | and network_ids from routes, to easier decouple fares. */ 297 | 298 | message FareLinks { 299 | // Zero if the value is the same as the last one. 300 | // Trailing zeroes are cut. 301 | repeated uint32 stop_area_ids = 1; 302 | repeated uint32 stop_zone_ids = 2; 303 | repeated uint32 route_network_ids = 3; 304 | } 305 | 306 | // TODO: Fares 307 | 308 | 309 | /* IdStore is for keeping track of source identifiers and making 310 | extracts between versions consistent. */ 311 | 312 | message IdStore { 313 | repeated IdReference refs = 1; 314 | } 315 | 316 | message IdReference { 317 | Block block = 1; 318 | repeated string ids = 2; 319 | uint32 delta_skip = 3; 320 | } 321 | 322 | // These go in the file order. 323 | enum Block { 324 | B_HEADER = 0; // GtfsHeader 325 | B_IDS = 1; // IdStore → IdReference 326 | B_STRINGS = 2; // StringTable 327 | 328 | // First, dictionaries that are referenced by other tables. 329 | B_AGENCY = 3; // Agencies → Agency 330 | B_CALENDAR = 4; // Calendar 331 | B_SHAPES = 5; // Shapes → Shape 332 | 333 | // And here, the core: stops, routes, and fares. 334 | B_STOPS = 6; // Stops → Stop 335 | B_ROUTES = 7; // Routes → Route 336 | B_TRIPS = 8; // Trips → Trip 337 | B_TRANSFERS = 9; // Transfers → Transfer 338 | 339 | // Fare-related tables. 340 | B_NETWORKS = 10; // Networks 341 | B_AREAS = 11; // Areas 342 | B_FARE_LINKS = 12; // FareLinks 343 | B_FARES = 13; // TODO 344 | 345 | // Following blocks are not actually written, but needed for indexing. 346 | B_ITINERARIES = 14; 347 | B_ZONES = 15; 348 | } 349 | 350 | 351 | /* Deltas use mostly the same messages, but some are changed. 352 | The header length is added to 0x8000. */ 353 | 354 | message GtfsDeltaHeader { 355 | uint32 old_version = 1; 356 | uint32 version = 2; 357 | uint32 date = 3; 358 | bool compressed = 4; 359 | repeated uint32 blocks = 5; 360 | } 361 | 362 | // IdStore: same structure, only adding ids, using delta_skip to skip old ids. 363 | // - Note that if delta_skip is larger than the length of ids for a block, 364 | // that means that's the wrong delta file to apply. 365 | 366 | // StringTable: all used strings are added to it anew. 367 | 368 | // Agencies: zero element for same, non-zero for change or addition. 369 | 370 | // Calendar: first, base_date is applied only to new and changed records. 371 | // CalendarDates: rebuilt from scratch using old dates and new/changed. 372 | // CalendarService: zero for deleted service; new and changed specify every field. 373 | 374 | // Shapes: Shape with just an id to delete, with locations - change or add. 375 | 376 | // Stops: Stop with delete=True to delete, with a new id to add, with existing - 377 | // empty fields stay the same. Fields "wheelchair" and "type" are always set for changed. 378 | // New ids should continue the sequence. 379 | 380 | // Routes: like stops, and same for itineraries inside. 381 | 382 | // Trips: like stops: specify just an id to delete, some information when changed, 383 | // all info when adding a new one. New ids should continue the sequence. 384 | // Fields "wheelchair" and "bikes" are always set for changed. 385 | 386 | // Transfers: first six fields serve as a primary key. Uses "delete" field to delete, 387 | // otherwise add/change with full data inside. 388 | 389 | // Networks and Areas: listing only new and changed names in the maps. 390 | 391 | // FareLinks: use this new message instead, with the same block id: 392 | 393 | message FareLinksDelta { 394 | // Second value is zero when the link is deleted. 395 | map stop_area_ids = 1; 396 | map stop_zone_ids = 2; 397 | map route_network_ids = 3; 398 | } 399 | -------------------------------------------------------------------------------- /src/gtfs_proto/info.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import json 4 | from typing import Any 5 | from .wrapper import ( 6 | GtfsProto, gtfs, GtfsDelta, FareLinks, is_gtfs_delta, GtfsBlocks 7 | ) 8 | 9 | 10 | BLOCKS = { 11 | 'header': gtfs.B_HEADER, 12 | 'ids': gtfs.B_IDS, 13 | 'strings': gtfs.B_STRINGS, 14 | 15 | 'agency': gtfs.B_AGENCY, 16 | 'calendar': gtfs.B_CALENDAR, 17 | 'shapes': gtfs.B_SHAPES, 18 | 19 | 'stops': gtfs.B_STOPS, 20 | 'routes': gtfs.B_ROUTES, 21 | 'trips': gtfs.B_TRIPS, 22 | 'transfers': gtfs.B_TRANSFERS, 23 | 24 | 'networks': gtfs.B_NETWORKS, 25 | 'areas': gtfs.B_AREAS, 26 | 'fare_links': gtfs.B_FARE_LINKS, 27 | 'fares': gtfs.B_FARES, 28 | 29 | # Not actual blocks. 30 | 'version': -1, 31 | 'date': -2, 32 | } 33 | 34 | 35 | def read_count(block: gtfs.Block, data: bytes) -> dict[str, Any]: 36 | COUNT = 'count' 37 | if block == gtfs.B_AGENCY: 38 | agencies = gtfs.Agencies() 39 | agencies.ParseFromString(data) 40 | return {COUNT: len(agencies.agencies)} 41 | elif block == gtfs.B_CALENDAR: 42 | calendar = gtfs.Calendar() 43 | calendar.ParseFromString(data) 44 | return {'dates': len(calendar.dates), COUNT: len(calendar.services)} 45 | elif block == gtfs.B_SHAPES: 46 | shapes = gtfs.Shapes() 47 | shapes.ParseFromString(data) 48 | return {COUNT: len(shapes.shapes)} 49 | elif block == gtfs.B_NETWORKS: 50 | networks = gtfs.Networks() 51 | networks.ParseFromString(data) 52 | return {COUNT: len(networks.networks)} 53 | elif block == gtfs.B_AREAS: 54 | areas = gtfs.Areas() 55 | areas.ParseFromString(data) 56 | return {COUNT: len(areas.areas)} 57 | elif block == gtfs.B_STRINGS: 58 | strings = gtfs.StringTable() 59 | strings.ParseFromString(data) 60 | return {COUNT: len(strings.strings)} 61 | elif block == gtfs.B_STOPS: 62 | stops = gtfs.Stops() 63 | stops.ParseFromString(data) 64 | return {COUNT: len(stops.stops)} 65 | elif block == gtfs.B_ROUTES: 66 | routes = gtfs.Routes() 67 | routes.ParseFromString(data) 68 | return { 69 | COUNT: len(routes.routes), 70 | 'itineraries': sum(len(r.itineraries) for r in routes.routes) 71 | } 72 | elif block == gtfs.B_TRIPS: 73 | trips = gtfs.Trips() 74 | trips.ParseFromString(data) 75 | return {COUNT: len(trips.trips)} 76 | elif block == gtfs.B_TRANSFERS: 77 | tr = gtfs.Transfers() 78 | tr.ParseFromString(data) 79 | return {COUNT: len(tr.transfers)} 80 | elif block == gtfs.B_FARE_LINKS: 81 | pass # TODO 82 | # fl = gtfs.FareLinks() 83 | # fl.ParseFromString(data) 84 | # return { 85 | # 'stop_zones': len(fl.stop_zone_ids), 86 | # 'stop_areas': len(fl.stop_area_ids), 87 | # 'route_networks': len(fl.route_network_ids), 88 | # } 89 | return {} 90 | 91 | 92 | def print_skip_empty(d: dict[str, Any]): 93 | print(json.dumps( 94 | {k: v for k, v in d.items() if v is not None and v != ''}, 95 | ensure_ascii=False 96 | )) 97 | 98 | 99 | def print_header(header: gtfs.GtfsHeader): 100 | print_skip_empty({ 101 | 'version': header.version, 102 | 'date': header.date, 103 | 'original_url': header.original_url, 104 | 'compressed': header.compressed, 105 | }) 106 | 107 | 108 | def print_delta_header(header: gtfs.GtfsDeltaHeader): 109 | print_skip_empty({ 110 | 'old_version': header.old_version, 111 | 'version': header.version, 112 | 'date': header.date, 113 | 'compressed': header.compressed, 114 | }) 115 | 116 | 117 | def print_blocks(blocks: GtfsBlocks, compressed: bool): 118 | block_names = {v: s for s, v in BLOCKS.items()} 119 | for b in blocks.blocks: 120 | data = blocks.get(b) 121 | v = { 122 | 'block': block_names.get(b, str(b)), 123 | 'size': len(data), 124 | } 125 | if compressed: 126 | v['compressed'] = len(blocks.blocks[b]) 127 | v.update(read_count(b, data)) 128 | print(json.dumps(v)) 129 | 130 | 131 | def print_id(ids: gtfs.IdReference): 132 | block_names = {v: s for s, v in BLOCKS.items()} 133 | print_skip_empty({ 134 | 'block': block_names.get(ids.block, str(ids.block)), 135 | 'ids': {i: s for i, s in enumerate(ids.ids) if i}, 136 | }) 137 | 138 | 139 | def print_agency(a: gtfs.Agency, oid: str | None): 140 | print_skip_empty({ 141 | 'agency_id': a.agency_id, 142 | 'original_id': oid, 143 | 'name': a.name, 144 | 'url': a.url, 145 | 'timezone': a.timezone, 146 | 'lang': a.lang, 147 | 'phone': a.phone, 148 | 'fare_url': a.fare_url, 149 | 'email': a.email, 150 | }) 151 | 152 | 153 | def print_calendar(c: gtfs.Calendar): 154 | print(json.dumps({'base_date': c.base_date})) 155 | print(json.dumps({ 156 | 'dates': {i: list(dt.dates) for i, dt in enumerate(c.dates)}, 157 | })) 158 | for s in c.services: 159 | print_skip_empty({ 160 | 'service_id': s.service_id, 161 | 'start_date': None if not s.start_date else s.start_date, 162 | 'end_date': None if not s.end_date else s.end_date, 163 | 'weekdays': f'{s.weekdays:#b}', 164 | 'added_days': None if not s.added_days else s.added_days, 165 | 'removed_days': None if not s.removed_days else s.removed_days, 166 | }) 167 | 168 | 169 | def print_shape(s: gtfs.Shape, oid: str | None): 170 | print_skip_empty({ 171 | 'shape_id': s.shape_id, 172 | 'original_id': oid, 173 | 'longitudes': list(s.longitudes), 174 | 'latitudes': list(s.latitudes), 175 | }) 176 | 177 | 178 | def print_stop(s: gtfs.Stop, oid: str | None): 179 | LOC_TYPES = ['stop', 'station', 'exit', 'node', 'boarding'] 180 | ACC_TYPES = ['unknown', 'some', 'no'] 181 | print_skip_empty({ 182 | 'stop_id': s.stop_id, 183 | 'original_id': oid, 184 | 'code': s.code, 185 | 'name': s.name or None, 186 | 'desc': s.desc, 187 | 'lon': s.lon, 188 | 'lat': s.lat, 189 | 'type': None if not s.type else LOC_TYPES[s.type], 190 | 'parent_id': s.parent_id or None, 191 | 'wheelchair': None if not s.wheelchair else ACC_TYPES[s.wheelchair], 192 | 'platform_code': s.platform_code, 193 | 'external_str_id': s.external_str_id, 194 | 'external_int_id': s.external_int_id or None, 195 | 'delete': s.delete, 196 | }) 197 | 198 | 199 | def print_route(r: gtfs.Route, oid: str | None): 200 | def prepare_itinerary(i: gtfs.RouteItinerary) -> dict[str, Any]: 201 | return {k: v for k, v in { 202 | 'itinerary_id': i.itinerary_id, 203 | 'headsign': i.headsign or None, 204 | 'opposite_direction': i.opposite_direction or None, 205 | 'stops': list(i.stops), 206 | 'shape_id': i.shape_id or None, 207 | }.items() if v is not None} 208 | 209 | ROUTE_TYPES = { 210 | 0: 'bus', 211 | 1: 'tram', 212 | 2: 'subway', 213 | 3: 'rail', 214 | 4: 'ferry', 215 | 5: 'cable_tram', 216 | 6: 'aerial', 217 | 7: 'funicular', 218 | 9: 'communal_taxi', 219 | 10: 'coach', 220 | 11: 'trolleybus', 221 | 12: 'monorail', 222 | 21: 'urban_rail', 223 | 22: 'water', 224 | 23: 'air', 225 | 24: 'taxi', 226 | 25: 'misc', 227 | } 228 | PD_TYPES = ['no', 'yes', 'phone_agency', 'tell_driver'] 229 | print_skip_empty({ 230 | 'route_id': r.route_id, 231 | 'original_id': oid, 232 | 'agency_id': r.agency_id or None, 233 | 'short_name': r.short_name, 234 | 'long_name': list(r.long_name) or None, 235 | 'desc': r.desc, 236 | 'type': ROUTE_TYPES[r.type], 237 | 'color': None if not r.color else f'{r.color:#08x}', 238 | 'text_color': None if not r.text_color else f'{r.text_color:#08x}', 239 | 'continuous_pickup': None if not r.continuous_pickup else PD_TYPES[r.continuous_pickup], 240 | 'continuous_dropoff': None if not r.continuous_dropoff else PD_TYPES[r.continuous_dropoff], 241 | 'itineraries': [prepare_itinerary(i) for i in r.itineraries] or None, 242 | 'delete': r.delete, 243 | }) 244 | 245 | 246 | def print_trip(t: gtfs.Trip, oid: str | None): 247 | ACC_TYPES = ['unknown', 'some', 'no'] 248 | PD_TYPES = ['no', 'yes', 'phone_agency', 'tell_driver'] 249 | print_skip_empty({ 250 | 'trip_id': t.trip_id, 251 | 'original_id': oid, 252 | 'service_id': t.service_id or None, 253 | 'itinerary_id': t.itinerary_id or None, 254 | 'short_name': t.short_name, 255 | 'wheelchair': None if not t.wheelchair else ACC_TYPES[t.wheelchair], 256 | 'bikes': None if not t.bikes else ACC_TYPES[t.bikes], 257 | 'approximate': t.approximate or None, 258 | 'arrivals': list(t.arrivals) or None, 259 | 'departures': list(t.departures) or None, 260 | 'pickup_types': [PD_TYPES[p] for p in t.pickup_types] or None, 261 | 'dropoff_types': [PD_TYPES[p] for p in t.dropoff_types] or None, 262 | 'start_time': t.start_time or None, 263 | 'end_time': t.end_time or None, 264 | 'interval': t.interval or None, 265 | }) 266 | 267 | 268 | def print_transfer(t: gtfs.Transfer): 269 | TTYPES = ['possible', 'departure_waits', 'needs_time', 'not_possible', 270 | 'in_seat', 'in_seat_forbidden'] 271 | print_skip_empty({ 272 | 'from_stop': t.from_stop or None, 273 | 'to_stop': t.to_stop or None, 274 | 'from_route': t.from_route or None, 275 | 'to_route': t.to_route or None, 276 | 'from_trip': t.from_trip or None, 277 | 'to_trip': t.to_trip or None, 278 | 'type': None if not t.type else TTYPES[t.type], 279 | 'min_transfer_time': t.min_transfer_time or None, 280 | }) 281 | 282 | 283 | def print_fare_links(fl: FareLinks): 284 | print(json.dumps({'stop_area_ids': fl.stop_areas})) 285 | print(json.dumps({'stop_zone_ids': fl.stop_zones})) 286 | print(json.dumps({'route_network_ids': fl.route_networks})) 287 | 288 | 289 | def info(): 290 | parser = argparse.ArgumentParser( 291 | description='Print information and contents of a protobuf-compressed GTFS') 292 | parser.add_argument('input', type=argparse.FileType('rb'), help='Source file') 293 | parser.add_argument('-b', '--block', choices=BLOCKS.keys(), 294 | help='Block to dump, header by default') 295 | parser.add_argument('-i', '--id', help='Just one specific id') 296 | options = parser.parse_args() 297 | 298 | if is_gtfs_delta(options.input): 299 | feed: GtfsProto | GtfsDelta = GtfsDelta(options.input) 300 | else: 301 | feed = GtfsProto(options.input) 302 | 303 | if not options.block: 304 | feed.read_all() 305 | if isinstance(feed, GtfsDelta): 306 | print_delta_header(feed.header) 307 | else: 308 | print_header(feed.header) 309 | feed.store_strings() 310 | feed.store_ids() 311 | print_blocks(feed.blocks, feed.header.compressed) 312 | 313 | else: 314 | for_id = options.id 315 | try: 316 | int_id = int(options.id or 'none') 317 | except ValueError: 318 | int_id = -1 319 | 320 | block = BLOCKS[options.part] 321 | 322 | if options.block == 'version': 323 | print(feed.header.version) 324 | elif options.block == 'date': 325 | print(feed.header.date) 326 | elif block == gtfs.B_IDS: 327 | block_names = {v: s for s, v in BLOCKS.items()} 328 | for b, ids in feed.id_store.items(): 329 | print_skip_empty({ 330 | 'block': block_names.get(b, str(b)), 331 | 'ids': {i: s for s, i in ids.ids.items()}, 332 | }) 333 | elif block == gtfs.B_AGENCY: 334 | for a in feed.agencies: 335 | oid = feed.id_store[block].original.get(a.agency_id) 336 | if not for_id or a.agency_id == int_id or oid == for_id: 337 | print_agency(a, oid) 338 | elif block == gtfs.B_CALENDAR: 339 | print_calendar(feed.calendar) 340 | elif block == gtfs.B_SHAPES: 341 | for s in feed.shapes: 342 | oid = feed.id_store[block].original.get(s.shape_id) 343 | if not for_id or s.shape_id == int_id or oid == for_id: 344 | print_shape(s, oid) 345 | elif block == gtfs.B_NETWORKS: 346 | print(json.dumps(feed.networks, ensure_ascii=False)) 347 | elif block == gtfs.B_AREAS: 348 | print(json.dumps(feed.areas, ensure_ascii=False)) 349 | elif block == gtfs.B_STRINGS: 350 | print(json.dumps( 351 | {i: s for i, s in enumerate(feed.strings.strings)}, 352 | ensure_ascii=False 353 | )) 354 | elif block == gtfs.B_STOPS: 355 | for s in feed.stops: 356 | oid = feed.id_store[block].original.get(s.stop_id) 357 | if not for_id or s.stop_id == int_id or oid == for_id: 358 | print_stop(s, oid) 359 | elif block == gtfs.B_ROUTES: 360 | for r in feed.routes: 361 | oid = feed.id_store[block].original.get(r.route_id) 362 | if not for_id or r.route_id == int_id or oid == for_id: 363 | print_route(r, oid) 364 | elif block == gtfs.B_TRIPS: 365 | for t in feed.trips: 366 | oid = feed.id_store[block].original.get(t.trip_id) 367 | if not for_id or t.trip_id == int_id or oid == for_id: 368 | print_trip(t, oid) 369 | elif block == gtfs.B_TRANSFERS: 370 | for t in feed.transfers: 371 | print_transfer(t) 372 | elif block == gtfs.B_FARE_LINKS: 373 | print_fare_links(feed.fare_links) 374 | else: 375 | print( 376 | 'Sorry, printing blocks of this type is not implemented yet.', 377 | file=sys.stderr 378 | ) 379 | -------------------------------------------------------------------------------- /src/gtfs_proto/delta.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import datetime as dt 3 | from . import ( 4 | GtfsProto, gtfs, GtfsDelta, FareLinks, StringCache, 5 | parse_calendar, build_calendar, CalendarService, int_to_date, 6 | ) 7 | 8 | 9 | class DeltaMaker: 10 | def __init__(self, old_strings: StringCache, new_strings: StringCache, 11 | delta_strings: StringCache): 12 | self.s_old = old_strings 13 | self.s_new = new_strings 14 | self.strings = delta_strings 15 | 16 | def add_string(self, sid: int) -> int: 17 | if sid: 18 | return self.strings.add(self.s_new[sid]) 19 | return 0 20 | 21 | def from_old(self, old_str: int) -> int: 22 | if not old_str: 23 | return 0 24 | return self.s_new.index[self.s_old[old_str]] 25 | 26 | def if_str_changed(self, old_str: int, new_str: int) -> int | None: 27 | if old_str != new_str: 28 | return self.add_string(new_str) 29 | return None 30 | 31 | def agencies(self, a1: list[gtfs.Agency], a2: list[gtfs.Agency]) -> list[gtfs.Agency]: 32 | ad1 = {a.agency_id: a for a in a1} 33 | ad2 = {a.agency_id: a for a in a2} 34 | result: list[gtfs.Agency] = [] 35 | for k, v in ad2.items(): 36 | if k not in ad1: 37 | # new agency 38 | v.timezone = self.add_string(v.timezone) 39 | result.append(v) 40 | else: 41 | old = ad1[k] 42 | old.timezone = self.from_old(old.timezone) 43 | if v != old: 44 | result.append(gtfs.Agency( 45 | agency_id=k, 46 | name=None if old.name == v.name else v.name, 47 | url=None if old.url == v.url else v.url, 48 | timezone=self.if_str_changed(old.timezone, v.timezone), 49 | lang=None if old.lang == v.lang else v.lang, 50 | phone=None if old.phone == v.phone else v.phone, 51 | fare_url=None if old.fare_url == v.fare_url else v.fare_url, 52 | email=None if old.email == v.email else v.email, 53 | )) 54 | return result 55 | 56 | def calendar(self, c1: gtfs.Calendar, c2: gtfs.Calendar) -> gtfs.Calendar: 57 | def cut(dates: list[dt.date], base_date: dt.date) -> list[dt.date]: 58 | return [d for d in dates if d > base_date] 59 | 60 | cd1 = {c.service_id: c for c in parse_calendar(c1)} 61 | cd2 = {c.service_id: c for c in parse_calendar(c2)} 62 | result: list[CalendarService] = [] 63 | 64 | for k in cd1: 65 | if k not in cd2: 66 | result.append(CalendarService(service_id=k)) 67 | for k, v in cd2.items(): 68 | if k not in cd1: 69 | result.append(v) 70 | else: 71 | bd = dt.date.today() - dt.timedelta(days=1) 72 | if not cd1[k].equals(v, bd): 73 | result.append(v) 74 | 75 | return build_calendar(result, int_to_date(c2.base_date)) 76 | 77 | def shapes(self, s1: list[gtfs.Shape], s2: list[gtfs.Shape]) -> list[gtfs.Shape]: 78 | sd1 = {s.shape_id: s for s in s1} 79 | sd2 = {s.shape_id: s for s in s2} 80 | result: list[gtfs.Shape] = [] 81 | for k in sd1: 82 | if k not in sd2: 83 | result.append(gtfs.Shape(shape_id=k)) 84 | for k, v in sd2.items(): 85 | if k not in sd1: 86 | result.append(v) 87 | else: 88 | # compare 89 | old = sd1[k] 90 | if old.longitudes != v.longitudes or old.latitudes != v.latitudes: 91 | result.append(v) 92 | return result 93 | 94 | def stops(self, s1: list[gtfs.Stop], s2: list[gtfs.Stop]) -> list[gtfs.Stop]: 95 | sd1 = {s.stop_id: s for s in s1} 96 | sd2 = {s.stop_id: s for s in s2} 97 | result: list[gtfs.Stop] = [] 98 | for k in sd1: 99 | if k not in sd2: 100 | result.append(gtfs.Stop(stop_id=k, delete=True)) 101 | for k, v in sd2.items(): 102 | if k not in sd1: 103 | v.name = self.add_string(v.name) 104 | result.append(v) 105 | else: 106 | old = sd1[k] 107 | old.name = self.from_old(old.name) 108 | if old != v: 109 | result.append(gtfs.Stop( 110 | stop_id=k, 111 | code='' if old.code == v.code else v.code, 112 | name=self.if_str_changed(old.name, v.name), 113 | desc='' if old.desc == v.desc else v.desc, 114 | lat=0 if old.lat == v.lat and old.lon == v.lon else v.lat, 115 | lon=0 if old.lat == v.lat and old.lon == v.lon else v.lon, 116 | type=v.type, 117 | parent_id=0 if old.parent_id == v.parent_id else v.parent_id, 118 | wheelchair=v.wheelchair, 119 | platform_code=('' if old.platform_code == v.platform_code 120 | else v.platform_code), 121 | external_str_id='' if old.external_str_id == v.external_str_id 122 | else v.external_str_id, 123 | external_int_id=0 if old.external_int_id == v.external_int_id 124 | else v.external_int_id, 125 | )) 126 | return result 127 | 128 | def itineraries( 129 | self, i1: list[gtfs.RouteItinerary], i2: list[gtfs.RouteItinerary] 130 | ) -> list[gtfs.RouteItinerary]: 131 | result: list[gtfs.RouteItinerary] = [] 132 | di1 = {i.itinerary_id: i for i in i1} 133 | di2 = {i.itinerary_id: i for i in i2} 134 | for k in di1: 135 | if k not in di2: 136 | result.append(gtfs.RouteItinerary(itinerary_id=k)) 137 | for k, v in di2.items(): 138 | old = di1.get(k) 139 | if old: 140 | old.headsign = self.from_old(old.headsign) 141 | for i in range(len(old.stop_headsigns)): 142 | old.stop_headsigns[i] = self.from_old(old.stop_headsigns[i]) 143 | if not old or old != v: 144 | v.headsign = self.add_string(v.headsign) 145 | for i in range(len(v.stop_headsigns)): 146 | v.stop_headsigns[i] = self.add_string(v.stop_headsigns[i]) 147 | result.append(v) 148 | return result 149 | 150 | def routes(self, r1: list[gtfs.Route], r2: list[gtfs.Route]) -> list[gtfs.Route]: 151 | rd1 = {r.route_id: r for r in r1} 152 | rd2 = {r.route_id: r for r in r2} 153 | result: list[gtfs.Route] = [] 154 | for k in rd1: 155 | if k not in rd2: 156 | result.append(gtfs.Route(route_id=k, delete=True)) 157 | for k, v in rd2.items(): 158 | if k not in rd1: 159 | for i in range(len(v.long_name)): 160 | v.long_name[i] = self.add_string(v.long_name[i]) 161 | del v.itineraries[:] 162 | v.itineraries.extend(self.itineraries([], v.itineraries)) 163 | result.append(v) 164 | else: 165 | old = rd1[k] 166 | for i in range(len(old.long_name)): 167 | old.long_name[i] = self.from_old(old.long_name[i]) 168 | i1 = list(sorted(old.itineraries, key=lambda it: it.itinerary_id)) 169 | i2 = list(sorted(v.itineraries, key=lambda it: it.itinerary_id)) 170 | 171 | if old != v: 172 | # Check if anything besides itineraries has changed. 173 | ni1 = gtfs.Route() 174 | ni1.CopyFrom(old) 175 | del ni1.itineraries[:] 176 | ni2 = gtfs.Route() 177 | ni2.CopyFrom(v) 178 | del ni2.itineraries[:] 179 | 180 | if ni1 == ni2: 181 | if i1 == i2: 182 | # Just unsorted itineraries. 183 | continue 184 | r = gtfs.Route(route_id=k) 185 | else: 186 | r = gtfs.Route( 187 | route_id=k, 188 | agency_id=0 if old.agency_id != v.agency_id else v.agency_id, 189 | short_name='' if old.short_name != v.short_name else v.short_name, 190 | long_name=[] if old.long_name == v.long_name else [ 191 | self.add_string(s) for s in v.long_name], 192 | desc='' if old.desc != v.desc else v.desc, 193 | type=v.type, 194 | color=v.color, 195 | text_color=v.text_color, 196 | continuous_pickup=v.continuous_pickup, 197 | continuous_dropoff=v.continuous_dropoff, 198 | ) 199 | 200 | # Now update itineraries. 201 | r.itineraries.extend(self.itineraries(old.itineraries, v.itineraries)) 202 | result.append(r) 203 | 204 | return result 205 | 206 | def trips(self, t1: list[gtfs.Trip], t2: list[gtfs.Trip]) -> list[gtfs.Trip]: 207 | td1 = {t.trip_id: t for t in t1} 208 | td2 = {t.trip_id: t for t in t2} 209 | result: list[gtfs.Trip] = [] 210 | for k in td1: 211 | if k not in td2: 212 | result.append(gtfs.Trip(trip_id=k)) 213 | for k, v in td2.items(): 214 | if k not in td1: 215 | result.append(v) 216 | elif td1[k] != v: 217 | old = td1[k] 218 | arr_dep_changed = old.departures != v.departures or old.arrivals != v.arrivals 219 | result.append(gtfs.Trip( 220 | trip_id=k, 221 | service_id=0 if old.service_id == v.service_id else v.service_id, 222 | itinerary_id=0 if old.itinerary_id == v.itinerary_id else v.itinerary_id, 223 | short_name='' if old.short_name == v.short_name else v.short_name, 224 | wheelchair=v.wheelchair, 225 | bikes=v.bikes, 226 | approximate=v.approximate, 227 | departures=[] if not arr_dep_changed else v.departures, 228 | arrivals=[] if not arr_dep_changed else v.arrivals, 229 | pickup_types=[] if old.pickup_types == v.pickup_types else v.pickup_types, 230 | dropoff_types=[] if old.dropoff_types == v.dropoff_types else v.dropoff_types, 231 | start_time=0 if old.start_time == v.start_time else v.start_time, 232 | end_time=0 if old.end_time == v.end_time else v.end_time, 233 | interval=0 if old.interval == v.interval else v.interval, 234 | )) 235 | return result 236 | 237 | def transfers(self, t1: list[gtfs.Transfer], 238 | t2: list[gtfs.Transfer]) -> list[gtfs.Transfer]: 239 | def transfer_key(t: gtfs.Transfer): 240 | return ( 241 | t.from_stop, t.to_stop, 242 | t.from_route, t.to_route, 243 | t.from_trip, t.to_trip, 244 | ) 245 | 246 | result: list[gtfs.Transfer] = [] 247 | td1 = {transfer_key(t): t for t in t1} 248 | td2 = {transfer_key(t): t for t in t2} 249 | for k, v in td1.items(): 250 | if k not in td2: 251 | result.append(gtfs.Transfer( 252 | from_stop=v.from_stop, 253 | to_stop=v.to_stop, 254 | from_route=v.from_route, 255 | to_route=v.to_route, 256 | from_trip=v.from_trip, 257 | to_trip=v.to_trip, 258 | delete=True, 259 | )) 260 | for k, v in td2.items(): 261 | if k not in td1 or td1[k] != v: 262 | result.append(v) 263 | return result 264 | 265 | def delta_dict(self, d1: dict[int, str], d2: dict[int, str]) -> dict[int, str]: 266 | return {k: v for k, v in d2.items() if k not in d1 or d1[k] != v} 267 | 268 | def fare_links(self, f1: FareLinks, f2: FareLinks) -> FareLinks: 269 | def delta_dict_int(d1: dict[int, int], d2: dict[int, int]) -> dict[int, int]: 270 | ch = {k: v for k, v in d2.items() if k not in d1 or d1[k] != v} 271 | ch.update({k: 0 for k in d1 if k not in d2}) 272 | return ch 273 | 274 | fl = FareLinks() 275 | fl.stop_areas = delta_dict_int(f1.stop_areas, f2.stop_areas) 276 | fl.stop_zones = delta_dict_int(f1.stop_zones, f2.stop_zones) 277 | fl.route_networks = delta_dict_int(f1.route_networks, f2.route_networks) 278 | return fl 279 | 280 | 281 | def delta(): 282 | parser = argparse.ArgumentParser( 283 | description='Generates a delta between two protobuf-packed GTFS feeds') 284 | parser.add_argument( 285 | 'old', type=argparse.FileType('rb'), help='The first, older feed') 286 | parser.add_argument( 287 | 'new', type=argparse.FileType('rb'), help='The second, latest feed') 288 | parser.add_argument( 289 | '-o', '--output', type=argparse.FileType('wb'), required=True, 290 | help='Resulting delta file') 291 | options = parser.parse_args() 292 | 293 | feed1 = GtfsProto(options.old) 294 | feed2 = GtfsProto(options.new) 295 | 296 | delta = GtfsDelta() 297 | delta.header.old_version = feed1.header.version 298 | delta.header.version = feed2.header.version 299 | delta.header.date = feed2.header.date 300 | delta.header.compressed = feed2.header.compressed 301 | 302 | delta.id_store = feed2.id_store 303 | for k, v in feed1.id_store.items(): 304 | if k in feed2.id_store: 305 | feed2.id_store[k].delta_skip = v.last_id 306 | 307 | dm = DeltaMaker(feed1.strings, feed2.strings, delta.strings) 308 | delta.agencies = dm.agencies(feed1.agencies, feed2.agencies) 309 | delta.calendar = dm.calendar(feed1.calendar, feed2.calendar) 310 | delta.shapes = dm.shapes(feed1.shapes, feed2.shapes) 311 | delta.stops = dm.stops(feed1.stops, feed2.stops) 312 | delta.routes = dm.routes(feed1.routes, feed2.routes) 313 | delta.trips = dm.trips(feed1.trips, feed2.trips) 314 | delta.transfers = dm.transfers(feed1.transfers, feed2.transfers) 315 | delta.networks = dm.delta_dict(feed1.networks, feed2.networks) 316 | delta.areas = dm.delta_dict(feed1.areas, feed2.areas) 317 | delta.fare_links = dm.fare_links(feed1.fare_links, feed2.fare_links) 318 | delta.write(options.output) 319 | -------------------------------------------------------------------------------- /src/gtfs_proto/wrapper.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import zstandard 3 | from . import gtfs_pb2 as gtfs 4 | from .base import StringCache, FareLinks, IdReference 5 | from typing import BinaryIO 6 | from collections import Counter 7 | from collections.abc import Generator 8 | from functools import cached_property 9 | 10 | 11 | __all__ = ['gtfs', 'GtfsBlocks', 'GtfsProto', 'GtfsDelta', 12 | 'FareLinks', 'is_gtfs_delta', 'SHAPE_SCALE', 'STOP_SCALE'] 13 | SHAPE_SCALE = 100000 14 | STOP_SCALE = 100000 15 | 16 | 17 | def is_gtfs_delta(fileobj: BinaryIO) -> bool: 18 | fileobj.seek(0) 19 | header_len = struct.unpack(' 0 22 | 23 | 24 | class GtfsBlocks: 25 | def __init__(self): 26 | self.blocks: dict[gtfs.Block, bytes] = {} 27 | self.arch = zstandard.ZstdCompressor(level=10) 28 | 29 | def clear(self): 30 | self.blocks = {} 31 | 32 | def populate_header(self, header: gtfs.GtfsHeader, compressed: bool = False): 33 | del header.blocks[:] 34 | for b in gtfs.Block.values(): 35 | if 0 < b and b < gtfs.B_ITINERARIES: 36 | header.blocks.append(len(self.get(b, compressed))) 37 | 38 | @property 39 | def not_empty(self) -> bool: 40 | return any(self.blocks.values()) 41 | 42 | def __iter__(self) -> Generator[bytes, None, None]: 43 | for b in sorted(self.blocks): 44 | if self.blocks[b]: 45 | yield self.blocks[b] 46 | 47 | def __contains__(self, b: gtfs.Block) -> bool: 48 | return bool(self.blocks.get(b)) 49 | 50 | def __getitem__(self, b: gtfs.Block) -> bytes: 51 | return self.blocks[b] 52 | 53 | def decompressed(self) -> Generator[bytes, None, None]: 54 | dearch = zstandard.ZstdDecompressor() 55 | for b in sorted(self.blocks): 56 | if self.blocks[b]: 57 | yield dearch.decompress(self.blocks[b]) 58 | 59 | def add(self, block: int, data: bytes, is_compressed: bool = False): 60 | if not data: 61 | return 62 | self.blocks[block] = data if is_compressed else self.arch.compress(data) 63 | 64 | def get(self, block: int, compressed: bool = False) -> bytes: 65 | if block not in self.blocks: 66 | return b'' 67 | if compressed: 68 | return self.blocks[block] 69 | dearch = zstandard.ZstdDecompressor() 70 | return dearch.decompress(self.blocks[block]) 71 | 72 | 73 | class GtfsProto: 74 | def __init__(self, fileobj: BinaryIO | None = None, read_now: bool = False): 75 | self.header = gtfs.GtfsHeader() 76 | self.header.compressed = True 77 | self.strings = StringCache() 78 | self.id_store: dict[int, IdReference] = { 79 | b: IdReference() for b in gtfs.Block.values()} 80 | 81 | self.blocks = GtfsBlocks() 82 | # position, size 83 | self._block_pos: dict[gtfs.Block, tuple[int, int]] = {} 84 | self._fileobj = None if read_now else fileobj 85 | self._was_compressed: bool = True # overwritten on read 86 | if fileobj: 87 | self.read(fileobj, read_now) 88 | 89 | def clear(self): 90 | self.header = gtfs.GtfsHeader() 91 | self.header.compressed = True 92 | self.strings.clear() 93 | for k in self.id_store: 94 | self.id_store[k].clear() 95 | self.blocks.clear() 96 | self._block_pos = {} 97 | 98 | def _read_blocks(self, fileobj: BinaryIO, read_now: bool): 99 | arch = None if not self.header.compressed else zstandard.ZstdDecompressor() 100 | filepos = 2 + self._block_pos[gtfs.B_HEADER][1] 101 | for b, size in enumerate(self.header.blocks): 102 | if not size: 103 | continue 104 | self._block_pos[b + 1] = (filepos, size) 105 | filepos += size 106 | 107 | if b + 1 == gtfs.B_STRINGS: 108 | data = fileobj.read(size) 109 | if arch: 110 | data = arch.decompress(data) 111 | s = gtfs.StringTable() 112 | s.ParseFromString(data) 113 | self.strings = StringCache(s.strings) 114 | elif b + 1 == gtfs.B_IDS: 115 | data = fileobj.read(size) 116 | if arch: 117 | data = arch.decompress(data) 118 | store = gtfs.IdStore() 119 | store.ParseFromString(data) 120 | for idrefs in store.refs: 121 | self.id_store[idrefs.block] = IdReference(idrefs.ids, idrefs.delta_skip) 122 | elif read_now: 123 | data = fileobj.read(size) 124 | self.blocks.add(b + 1, data, self.header.compressed) 125 | else: 126 | fileobj.seek(size, 1) 127 | 128 | def read_all(self): 129 | if not self._fileobj: 130 | return 131 | for b in self._block_pos: 132 | if b not in (gtfs.B_STRINGS, gtfs.B_IDS, gtfs.B_HEADER): 133 | self._read_block(b, True) 134 | self._fileobj = None 135 | 136 | def read(self, fileobj: BinaryIO, read_now: bool = False): 137 | self.clear() 138 | header_len = struct.unpack(' 0: 140 | raise Exception('The file is delta, not a regular feed.') 141 | self.header = gtfs.GtfsHeader() 142 | self.header.ParseFromString(fileobj.read(header_len)) 143 | self._block_pos = {gtfs.B_HEADER: (2, header_len)} 144 | self._was_compressed = self.header.compressed 145 | self._read_blocks(fileobj, read_now) 146 | 147 | def _write_blocks(self, fileobj: BinaryIO): 148 | if self.header.compressed: 149 | for b in self.blocks: 150 | fileobj.write(b) 151 | else: 152 | for b in self.blocks.decompressed(): 153 | fileobj.write(b) 154 | 155 | def write(self, fileobj: BinaryIO, compress: bool | None = None): 156 | """When compress is None, using the value from the header.""" 157 | if not self.header.version: 158 | raise Exception('Please set version inside the header') 159 | 160 | self.store_ids() 161 | self.store_strings() 162 | self.store_fare_links() 163 | self.store_agencies() 164 | self.store_calendar() 165 | self.store_shapes() 166 | self.store_stops() 167 | self.store_routes() 168 | self.store_trips() 169 | self.store_transfers() 170 | self.store_networks() 171 | self.store_areas() 172 | 173 | if compress is not None: 174 | self.header.compressed = compress 175 | self.blocks.populate_header(self.header, self.header.compressed) 176 | 177 | header_data = self.header.SerializeToString() 178 | fileobj.write(struct.pack(' bytes | None: 198 | if block in self.blocks: 199 | return self.blocks.get(block, compressed) 200 | if not self._fileobj or block not in self._block_pos: 201 | return None 202 | bseek, bsize = self._block_pos[block] 203 | self._fileobj.seek(bseek) 204 | data = self._fileobj.read(bsize) 205 | self.blocks.add(block, data, self._was_compressed) 206 | if self._was_compressed and not compressed: 207 | arch = zstandard.ZstdDecompressor() 208 | data = arch.decompress(data) 209 | return data if not compressed else self.blocks.get(block, True) 210 | 211 | @cached_property 212 | def agencies(self) -> list[gtfs.Agency]: 213 | data = self._read_block(gtfs.B_AGENCY) 214 | if not data: 215 | return [] 216 | ag = gtfs.Agencies() 217 | ag.ParseFromString(data) 218 | return list(ag.agencies) 219 | 220 | def store_agencies(self): 221 | if 'agencies' in self.__dict__: 222 | ag = gtfs.Agencies(agencies=self.agencies) 223 | self.blocks.add(gtfs.B_AGENCY, ag.SerializeToString()) 224 | elif self._fileobj: 225 | self._read_block(gtfs.B_AGENCY, True) 226 | 227 | @cached_property 228 | def calendar(self) -> gtfs.Calendar: 229 | data = self._read_block(gtfs.B_CALENDAR) 230 | calendar = gtfs.Calendar() 231 | if not data: 232 | return calendar 233 | calendar.ParseFromString(data) 234 | return calendar 235 | 236 | def store_calendar(self): 237 | if 'calendar' in self.__dict__: 238 | self.blocks.add(gtfs.B_CALENDAR, self.calendar.SerializeToString()) 239 | elif self._fileobj: 240 | self._read_block(gtfs.B_CALENDAR, True) 241 | 242 | def _get_shape_last( 243 | self, shape: gtfs.Shape, 244 | prev_last: tuple[int, int] = (0, 0)) -> tuple[int, int]: 245 | lon = shape.longitudes[0] + prev_last[0] 246 | lat = shape.latitudes[0] + prev_last[1] 247 | for i in range(1, len(shape.longitudes)): 248 | lon += shape.longitudes[i] 249 | lat += shape.latitudes[i] 250 | return (lon, lat) 251 | 252 | @cached_property 253 | def shapes(self) -> list[gtfs.Shape]: 254 | data = self._read_block(gtfs.B_SHAPES) 255 | if not data: 256 | return [] 257 | shapes = gtfs.Shapes() 258 | shapes.ParseFromString(data) 259 | 260 | # Decouple shapes. 261 | result: list[gtfs.Shape] = [] 262 | prev_last: tuple[int, int] = (0, 0) 263 | for s in shapes.shapes: 264 | if s.longitudes: 265 | s.longitudes[0] += prev_last[0] 266 | s.latitudes[0] += prev_last[1] 267 | prev_last = self._get_shape_last(s) 268 | result.append(s) 269 | return result 270 | 271 | def store_shapes(self): 272 | if 'shapes' in self.__dict__: 273 | # Make the sequence. 274 | shapes: list[gtfs.Shape] = [] 275 | prev_last: tuple[int, int] = (0, 0) 276 | for s in sorted(self.shapes, key=lambda k: k.shape_id): 277 | if s.longitudes: 278 | s.longitudes[0] -= prev_last[0] 279 | s.latitudes[0] -= prev_last[1] 280 | prev_last = self._get_shape_last(s, prev_last) 281 | shapes.append(s) 282 | 283 | # Compress the data. 284 | sh = gtfs.Shapes(shapes=shapes) 285 | self.blocks.add(gtfs.B_SHAPES, sh.SerializeToString()) 286 | elif self._fileobj: 287 | # Off chance it's not read, re-read. 288 | self._read_block(gtfs.B_SHAPES, True) 289 | 290 | @cached_property 291 | def stops(self) -> list[gtfs.Stop]: 292 | data = self._read_block(gtfs.B_STOPS) 293 | if not data: 294 | return [] 295 | stops = gtfs.Stops() 296 | stops.ParseFromString(data) 297 | 298 | # Decouple stops. 299 | result: list[gtfs.Stop] = [] 300 | prev_coord: tuple[int, int] = (0, 0) 301 | for s in stops.stops: 302 | if s.lon and s.lat: 303 | s.lon += prev_coord[0] 304 | s.lat += prev_coord[1] 305 | prev_coord = (s.lon, s.lat) 306 | result.append(s) 307 | return result 308 | 309 | def store_stops(self): 310 | if 'stops' in self.__dict__: 311 | # Make the sequence. 312 | stops: list[gtfs.Stop] = [] 313 | prev_coord: tuple[int, int] = (0, 0) 314 | for s in sorted(self.stops, key=lambda k: k.stop_id): 315 | if s.lon and s.lat: 316 | pc = (s.lon, s.lat) 317 | s.lon -= prev_coord[0] 318 | s.lat -= prev_coord[1] 319 | prev_coord = pc 320 | stops.append(s) 321 | 322 | # Compress the data. 323 | st = gtfs.Stops(stops=stops) 324 | self.blocks.add(gtfs.B_STOPS, st.SerializeToString()) 325 | elif self._fileobj: 326 | self._read_block(gtfs.B_STOPS, True) 327 | 328 | @cached_property 329 | def routes(self) -> list[gtfs.Route]: 330 | data = self._read_block(gtfs.B_ROUTES) 331 | if not data: 332 | return [] 333 | routes = gtfs.Routes() 334 | routes.ParseFromString(data) 335 | return list(routes.routes) 336 | 337 | def store_routes(self): 338 | if 'routes' in self.__dict__: 339 | r = gtfs.Routes(routes=self.routes) 340 | self.blocks.add(gtfs.B_ROUTES, r.SerializeToString()) 341 | elif self._fileobj: 342 | self._read_block(gtfs.B_ROUTES, True) 343 | 344 | @cached_property 345 | def trips(self) -> list[gtfs.Trip]: 346 | data = self._read_block(gtfs.B_TRIPS) 347 | if not data: 348 | return [] 349 | trips = gtfs.Trips() 350 | trips.ParseFromString(data) 351 | return list(trips.trips) 352 | 353 | def store_trips(self): 354 | if 'trips' in self.__dict__: 355 | t = gtfs.Trips(trips=self.trips) 356 | self.blocks.add(gtfs.B_TRIPS, t.SerializeToString()) 357 | elif self._fileobj: 358 | self._read_block(gtfs.B_TRIPS, True) 359 | 360 | @cached_property 361 | def transfers(self) -> list[gtfs.Transfer]: 362 | data = self._read_block(gtfs.B_TRANSFERS) 363 | if not data: 364 | return [] 365 | tr = gtfs.Transfers() 366 | tr.ParseFromString(data) 367 | return list(tr.transfers) 368 | 369 | def store_transfers(self): 370 | if 'transfers' in self.__dict__: 371 | tr = gtfs.Transfers(transfers=self.transfers) 372 | self.blocks.add(gtfs.B_TRANSFERS, tr.SerializeToString()) 373 | elif self._fileobj: 374 | self._read_block(gtfs.B_TRANSFERS, True) 375 | 376 | @cached_property 377 | def networks(self) -> dict[int, str]: 378 | data = self._read_block(gtfs.B_NETWORKS) 379 | if not data: 380 | return {} 381 | networks = gtfs.Networks() 382 | networks.ParseFromString(data) 383 | return {k: v for k, v in networks.networks.items()} 384 | 385 | def store_networks(self): 386 | if 'networks' in self.__dict__: 387 | networks = gtfs.Networks(networks=self.networks) 388 | self.blocks.add(gtfs.B_NETWORKS, networks.SerializeToString()) 389 | elif self._fileobj: 390 | self._read_block(gtfs.B_NETWORKS) 391 | 392 | @cached_property 393 | def areas(self) -> dict[int, str]: 394 | data = self._read_block(gtfs.B_AREAS) 395 | if not data: 396 | return {} 397 | areas = gtfs.Areas() 398 | areas.ParseFromString(data) 399 | return {k: v for k, v in areas.areas.items()} 400 | 401 | def store_areas(self): 402 | if 'areas' in self.__dict__: 403 | areas = gtfs.Areas(areas=self.areas) 404 | self.blocks.add(gtfs.B_AREAS, areas.SerializeToString()) 405 | elif self._fileobj: 406 | self._read_block(gtfs.B_AREAS) 407 | 408 | @cached_property 409 | def fare_links(self) -> FareLinks: 410 | fl = FareLinks() 411 | data = self._read_block(gtfs.B_FARE_LINKS) 412 | if data: 413 | f = gtfs.FareLinks() 414 | f.ParseFromString(data) 415 | fl.load(f) 416 | return fl 417 | 418 | def store_fare_links(self): 419 | if 'fare_links' in self.__dict__: 420 | self.blocks.add(gtfs.B_FARE_LINKS, self.fare_links.store()) 421 | elif self._fileobj: 422 | self._read_block(gtfs.B_FARE_LINKS) 423 | 424 | def pack_strings(self, sort=False): 425 | """ 426 | Sorts strings by popularity and deletes unused entries to save a few bytes. 427 | Tests have shown that this reduces compressed feed size by 0.3%, while 428 | complicating string index management. Hence it's not used. 429 | 430 | Also, sorting somehow increases compressed size of blocks, while reducing 431 | the raw size. 432 | """ 433 | if len(self.strings.strings) <= 1: 434 | return 435 | 436 | # Count occurences. 437 | c: Counter[int] = Counter() 438 | for a in self.agencies: 439 | c[a.timezone] += 1 440 | for s in self.stops: 441 | c[s.name] += 1 442 | for r in self.routes: 443 | for n in r.long_name: 444 | c[n] += 1 445 | for i in r.itineraries: 446 | c[i.headsign] += 1 447 | for h in i.stop_headsigns: 448 | c[h] += 1 449 | del c[0] 450 | 451 | # Build the new strings list. 452 | if sort: 453 | repl = {v[0]: i + 1 for i, v in enumerate(c.most_common())} 454 | repl[0] = 0 455 | strings = [''] + [self.strings.strings[v] for v, _ in c.most_common()] 456 | else: 457 | repl = {} 458 | strings = [] 459 | for i, v in enumerate(self.strings.strings): 460 | if not i or i in c: 461 | repl[i] = len(strings) 462 | strings.append(v) 463 | self.strings.set(strings) 464 | 465 | # Replace all the references. 466 | for a in self.agencies: 467 | a.timezone = repl[a.timezone] 468 | for s in self.stops: 469 | s.name = repl[s.name] 470 | for r in self.routes: 471 | for ln in range(len(r.long_name)): 472 | r.long_name[ln] = repl[r.long_name[ln]] 473 | for i in r.itineraries: 474 | i.headsign = repl[i.headsign] 475 | for hn in range(len(i.stop_headsigns)): 476 | i.stop_headsigns[hn] = repl[i.stop_headsigns[hn]] 477 | 478 | 479 | class GtfsDelta(GtfsProto): 480 | def __init__(self, fileobj: BinaryIO | None = None, read_now: bool = False): 481 | self.header = gtfs.GtfsDeltaHeader() 482 | self.header.compressed = True 483 | self.strings = StringCache() 484 | self.id_store: dict[int, IdReference] = { 485 | b: IdReference() for b in gtfs.Block.values()} 486 | 487 | self.blocks = GtfsBlocks() 488 | # position, size 489 | self._block_pos: dict[gtfs.Block, tuple[int, int]] = {} 490 | self._fileobj = None if read_now else fileobj 491 | self._was_compressed: bool = True # overwritten on read 492 | if fileobj: 493 | self.read(fileobj, read_now) 494 | 495 | def read(self, fileobj: BinaryIO, read_now: bool = False): 496 | self.clear() 497 | header_len = struct.unpack(' FareLinks: 536 | fl = FareLinks() 537 | data = self._read_block(gtfs.B_FARE_LINKS) 538 | if data: 539 | f = gtfs.FareLinksDelta() 540 | f.ParseFromString(data) 541 | fl.load_delta(f) 542 | return fl 543 | 544 | def store_fare_links(self): 545 | if 'fare_links' in self.__dict__: 546 | self.blocks.add(gtfs.B_FARE_LINKS, self.fare_links.store_delta()) 547 | elif self._fileobj: 548 | self._read_block(gtfs.B_FARE_LINKS) 549 | -------------------------------------------------------------------------------- /src/gtfs_proto/gtfs_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: gtfs.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf.internal import enum_type_wrapper 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import message as _message 9 | from google.protobuf import reflection as _reflection 10 | from google.protobuf import symbol_database as _symbol_database 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | 17 | 18 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\ngtfs.proto\x12\x04gtfs\"e\n\nGtfsHeader\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61te\x18\x02 \x01(\r\x12\x14\n\x0coriginal_url\x18\x03 \x01(\t\x12\x12\n\ncompressed\x18\x04 \x01(\x08\x12\x0e\n\x06\x62locks\x18\x05 \x03(\r\"*\n\x08\x41gencies\x12\x1e\n\x08\x61gencies\x18\x01 \x03(\x0b\x32\x0c.gtfs.Agency\"\x86\x01\n\x06\x41gency\x12\x11\n\tagency_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0b\n\x03url\x18\x03 \x01(\t\x12\x10\n\x08timezone\x18\x04 \x01(\r\x12\x0c\n\x04lang\x18\x05 \x01(\t\x12\r\n\x05phone\x18\x06 \x01(\t\x12\x10\n\x08\x66\x61re_url\x18\x07 \x01(\t\x12\r\n\x05\x65mail\x18\x08 \x01(\t\"j\n\x08\x43\x61lendar\x12\x11\n\tbase_date\x18\x01 \x01(\r\x12\"\n\x05\x64\x61tes\x18\x02 \x03(\x0b\x32\x13.gtfs.CalendarDates\x12\'\n\x08services\x18\x03 \x03(\x0b\x32\x15.gtfs.CalendarService\"\x1e\n\rCalendarDates\x12\r\n\x05\x64\x61tes\x18\x01 \x03(\r\"\x87\x01\n\x0f\x43\x61lendarService\x12\x12\n\nservice_id\x18\x01 \x01(\r\x12\x12\n\nstart_date\x18\x02 \x01(\r\x12\x10\n\x08\x65nd_date\x18\x03 \x01(\r\x12\x10\n\x08weekdays\x18\x04 \x01(\r\x12\x12\n\nadded_days\x18\x05 \x01(\r\x12\x14\n\x0cremoved_days\x18\x06 \x01(\r\"%\n\x06Shapes\x12\x1b\n\x06shapes\x18\x01 \x03(\x0b\x32\x0b.gtfs.Shape\"@\n\x05Shape\x12\x10\n\x08shape_id\x18\x01 \x01(\r\x12\x12\n\nlongitudes\x18\x02 \x03(\x11\x12\x11\n\tlatitudes\x18\x03 \x03(\x11\"k\n\x08Networks\x12.\n\x08networks\x18\x01 \x03(\x0b\x32\x1c.gtfs.Networks.NetworksEntry\x1a/\n\rNetworksEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\\\n\x05\x41reas\x12%\n\x05\x61reas\x18\x01 \x03(\x0b\x32\x16.gtfs.Areas.AreasEntry\x1a,\n\nAreasEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x1e\n\x0bStringTable\x12\x0f\n\x07strings\x18\x01 \x03(\t\"\"\n\x05Stops\x12\x19\n\x05stops\x18\x01 \x03(\x0b\x32\n.gtfs.Stop\"\x92\x02\n\x04Stop\x12\x0f\n\x07stop_id\x18\x01 \x01(\r\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\r\x12\x0c\n\x04\x64\x65sc\x18\x04 \x01(\t\x12\x0b\n\x03lat\x18\x05 \x01(\x11\x12\x0b\n\x03lon\x18\x06 \x01(\x11\x12 \n\x04type\x18\x07 \x01(\x0e\x32\x12.gtfs.LocationType\x12\x11\n\tparent_id\x18\x08 \x01(\r\x12\'\n\nwheelchair\x18\t \x01(\x0e\x32\x13.gtfs.Accessibility\x12\x15\n\rplatform_code\x18\n \x01(\t\x12\x17\n\x0f\x65xternal_str_id\x18\x0b \x01(\t\x12\x17\n\x0f\x65xternal_int_id\x18\x0c \x01(\r\x12\x0e\n\x06\x64\x65lete\x18\r \x01(\x08\"%\n\x06Routes\x12\x1b\n\x06routes\x18\x01 \x03(\x0b\x32\x0b.gtfs.Route\"\xbf\x02\n\x05Route\x12\x10\n\x08route_id\x18\x01 \x01(\r\x12\x11\n\tagency_id\x18\x02 \x01(\r\x12\x12\n\nshort_name\x18\x03 \x01(\t\x12\x11\n\tlong_name\x18\x04 \x03(\r\x12\x0c\n\x04\x64\x65sc\x18\x05 \x01(\t\x12\x1d\n\x04type\x18\x06 \x01(\x0e\x32\x0f.gtfs.RouteType\x12\r\n\x05\x63olor\x18\x07 \x01(\r\x12\x12\n\ntext_color\x18\x08 \x01(\r\x12.\n\x11\x63ontinuous_pickup\x18\t \x01(\x0e\x32\x13.gtfs.PickupDropoff\x12/\n\x12\x63ontinuous_dropoff\x18\n \x01(\x0e\x32\x13.gtfs.PickupDropoff\x12)\n\x0bitineraries\x18\x0b \x03(\x0b\x32\x14.gtfs.RouteItinerary\x12\x0e\n\x06\x64\x65lete\x18\x0c \x01(\x08\"\x8d\x01\n\x0eRouteItinerary\x12\x14\n\x0citinerary_id\x18\x01 \x01(\r\x12\x10\n\x08headsign\x18\x02 \x01(\r\x12\x1a\n\x12opposite_direction\x18\x03 \x01(\x08\x12\r\n\x05stops\x18\x04 \x03(\r\x12\x10\n\x08shape_id\x18\x05 \x01(\r\x12\x16\n\x0estop_headsigns\x18\x06 \x03(\r\"\"\n\x05Trips\x12\x19\n\x05trips\x18\x01 \x03(\x0b\x32\n.gtfs.Trip\"\xec\x02\n\x04Trip\x12\x0f\n\x07trip_id\x18\x01 \x01(\r\x12\x12\n\nservice_id\x18\x02 \x01(\r\x12\x14\n\x0citinerary_id\x18\x03 \x01(\r\x12\x12\n\nshort_name\x18\x04 \x01(\t\x12\'\n\nwheelchair\x18\x05 \x01(\x0e\x32\x13.gtfs.Accessibility\x12\"\n\x05\x62ikes\x18\x06 \x01(\x0e\x32\x13.gtfs.Accessibility\x12\x13\n\x0b\x61pproximate\x18\x07 \x01(\x08\x12\x12\n\ndepartures\x18\x08 \x03(\r\x12\x10\n\x08\x61rrivals\x18\t \x03(\r\x12)\n\x0cpickup_types\x18\n \x03(\x0e\x32\x13.gtfs.PickupDropoff\x12*\n\rdropoff_types\x18\x0b \x03(\x0e\x32\x13.gtfs.PickupDropoff\x12\x12\n\nstart_time\x18\x0c \x01(\r\x12\x10\n\x08\x65nd_time\x18\r \x01(\r\x12\x10\n\x08interval\x18\x0e \x01(\r\".\n\tTransfers\x12!\n\ttransfers\x18\x01 \x03(\x0b\x32\x0e.gtfs.Transfer\"\xc5\x01\n\x08Transfer\x12\x11\n\tfrom_stop\x18\x01 \x01(\r\x12\x0f\n\x07to_stop\x18\x02 \x01(\r\x12\x12\n\nfrom_route\x18\x03 \x01(\r\x12\x10\n\x08to_route\x18\x04 \x01(\r\x12\x11\n\tfrom_trip\x18\x05 \x01(\r\x12\x0f\n\x07to_trip\x18\x06 \x01(\r\x12 \n\x04type\x18\x07 \x01(\x0e\x32\x12.gtfs.TransferType\x12\x19\n\x11min_transfer_time\x18\x08 \x01(\r\x12\x0e\n\x06\x64\x65lete\x18\t \x01(\x08\"T\n\tFareLinks\x12\x15\n\rstop_area_ids\x18\x01 \x03(\r\x12\x15\n\rstop_zone_ids\x18\x02 \x03(\r\x12\x19\n\x11route_network_ids\x18\x03 \x03(\r\"*\n\x07IdStore\x12\x1f\n\x04refs\x18\x01 \x03(\x0b\x32\x11.gtfs.IdReference\"J\n\x0bIdReference\x12\x1a\n\x05\x62lock\x18\x01 \x01(\x0e\x32\x0b.gtfs.Block\x12\x0b\n\x03ids\x18\x02 \x03(\t\x12\x12\n\ndelta_skip\x18\x03 \x01(\r\"i\n\x0fGtfsDeltaHeader\x12\x13\n\x0bold_version\x18\x01 \x01(\r\x12\x0f\n\x07version\x18\x02 \x01(\r\x12\x0c\n\x04\x64\x61te\x18\x03 \x01(\r\x12\x12\n\ncompressed\x18\x04 \x01(\x08\x12\x0e\n\x06\x62locks\x18\x05 \x03(\r\"\xf2\x02\n\x0e\x46\x61reLinksDelta\x12<\n\rstop_area_ids\x18\x01 \x03(\x0b\x32%.gtfs.FareLinksDelta.StopAreaIdsEntry\x12<\n\rstop_zone_ids\x18\x02 \x03(\x0b\x32%.gtfs.FareLinksDelta.StopZoneIdsEntry\x12\x44\n\x11route_network_ids\x18\x03 \x03(\x0b\x32).gtfs.FareLinksDelta.RouteNetworkIdsEntry\x1a\x32\n\x10StopAreaIdsEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\r:\x02\x38\x01\x1a\x32\n\x10StopZoneIdsEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\r:\x02\x38\x01\x1a\x36\n\x14RouteNetworkIdsEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\r:\x02\x38\x01*Q\n\x0cLocationType\x12\n\n\x06L_STOP\x10\x00\x12\r\n\tL_STATION\x10\x01\x12\n\n\x06L_EXIT\x10\x02\x12\n\n\x06L_NODE\x10\x03\x12\x0e\n\nL_BOARDING\x10\x04*4\n\rAccessibility\x12\r\n\tA_UNKNOWN\x10\x00\x12\n\n\x06\x41_SOME\x10\x01\x12\x08\n\x04\x41_NO\x10\x02*\xde\x01\n\tRouteType\x12\x07\n\x03\x42US\x10\x00\x12\x08\n\x04TRAM\x10\x01\x12\n\n\x06SUBWAY\x10\x02\x12\x08\n\x04RAIL\x10\x03\x12\t\n\x05\x46\x45RRY\x10\x04\x12\x0e\n\nCABLE_TRAM\x10\x05\x12\n\n\x06\x41\x45RIAL\x10\x06\x12\r\n\tFUNICULAR\x10\x07\x12\x11\n\rCOMMUNAL_TAXI\x10\t\x12\t\n\x05\x43OACH\x10\n\x12\x0e\n\nTROLLEYBUS\x10\x0b\x12\x0c\n\x08MONORAIL\x10\x0c\x12\x0e\n\nURBAN_RAIL\x10\x15\x12\t\n\x05WATER\x10\x16\x12\x07\n\x03\x41IR\x10\x17\x12\x08\n\x04TAXI\x10\x18\x12\x08\n\x04MISC\x10\x19*O\n\rPickupDropoff\x12\t\n\x05PD_NO\x10\x00\x12\n\n\x06PD_YES\x10\x01\x12\x13\n\x0fPD_PHONE_AGENCY\x10\x02\x12\x12\n\x0ePD_TELL_DRIVER\x10\x03*\x83\x01\n\x0cTransferType\x12\x0e\n\nT_POSSIBLE\x10\x00\x12\x15\n\x11T_DEPARTURE_WAITS\x10\x01\x12\x10\n\x0cT_NEEDS_TIME\x10\x02\x12\x12\n\x0eT_NOT_POSSIBLE\x10\x03\x12\r\n\tT_IN_SEAT\x10\x04\x12\x17\n\x13T_IN_SEAT_FORBIDDEN\x10\x05*\xf0\x01\n\x05\x42lock\x12\x0c\n\x08\x42_HEADER\x10\x00\x12\t\n\x05\x42_IDS\x10\x01\x12\r\n\tB_STRINGS\x10\x02\x12\x0c\n\x08\x42_AGENCY\x10\x03\x12\x0e\n\nB_CALENDAR\x10\x04\x12\x0c\n\x08\x42_SHAPES\x10\x05\x12\x0b\n\x07\x42_STOPS\x10\x06\x12\x0c\n\x08\x42_ROUTES\x10\x07\x12\x0b\n\x07\x42_TRIPS\x10\x08\x12\x0f\n\x0b\x42_TRANSFERS\x10\t\x12\x0e\n\nB_NETWORKS\x10\n\x12\x0b\n\x07\x42_AREAS\x10\x0b\x12\x10\n\x0c\x42_FARE_LINKS\x10\x0c\x12\x0b\n\x07\x42_FARES\x10\r\x12\x11\n\rB_ITINERARIES\x10\x0e\x12\x0b\n\x07\x42_ZONES\x10\x0f\x62\x06proto3') 19 | 20 | _LOCATIONTYPE = DESCRIPTOR.enum_types_by_name['LocationType'] 21 | LocationType = enum_type_wrapper.EnumTypeWrapper(_LOCATIONTYPE) 22 | _ACCESSIBILITY = DESCRIPTOR.enum_types_by_name['Accessibility'] 23 | Accessibility = enum_type_wrapper.EnumTypeWrapper(_ACCESSIBILITY) 24 | _ROUTETYPE = DESCRIPTOR.enum_types_by_name['RouteType'] 25 | RouteType = enum_type_wrapper.EnumTypeWrapper(_ROUTETYPE) 26 | _PICKUPDROPOFF = DESCRIPTOR.enum_types_by_name['PickupDropoff'] 27 | PickupDropoff = enum_type_wrapper.EnumTypeWrapper(_PICKUPDROPOFF) 28 | _TRANSFERTYPE = DESCRIPTOR.enum_types_by_name['TransferType'] 29 | TransferType = enum_type_wrapper.EnumTypeWrapper(_TRANSFERTYPE) 30 | _BLOCK = DESCRIPTOR.enum_types_by_name['Block'] 31 | Block = enum_type_wrapper.EnumTypeWrapper(_BLOCK) 32 | L_STOP = 0 33 | L_STATION = 1 34 | L_EXIT = 2 35 | L_NODE = 3 36 | L_BOARDING = 4 37 | A_UNKNOWN = 0 38 | A_SOME = 1 39 | A_NO = 2 40 | BUS = 0 41 | TRAM = 1 42 | SUBWAY = 2 43 | RAIL = 3 44 | FERRY = 4 45 | CABLE_TRAM = 5 46 | AERIAL = 6 47 | FUNICULAR = 7 48 | COMMUNAL_TAXI = 9 49 | COACH = 10 50 | TROLLEYBUS = 11 51 | MONORAIL = 12 52 | URBAN_RAIL = 21 53 | WATER = 22 54 | AIR = 23 55 | TAXI = 24 56 | MISC = 25 57 | PD_NO = 0 58 | PD_YES = 1 59 | PD_PHONE_AGENCY = 2 60 | PD_TELL_DRIVER = 3 61 | T_POSSIBLE = 0 62 | T_DEPARTURE_WAITS = 1 63 | T_NEEDS_TIME = 2 64 | T_NOT_POSSIBLE = 3 65 | T_IN_SEAT = 4 66 | T_IN_SEAT_FORBIDDEN = 5 67 | B_HEADER = 0 68 | B_IDS = 1 69 | B_STRINGS = 2 70 | B_AGENCY = 3 71 | B_CALENDAR = 4 72 | B_SHAPES = 5 73 | B_STOPS = 6 74 | B_ROUTES = 7 75 | B_TRIPS = 8 76 | B_TRANSFERS = 9 77 | B_NETWORKS = 10 78 | B_AREAS = 11 79 | B_FARE_LINKS = 12 80 | B_FARES = 13 81 | B_ITINERARIES = 14 82 | B_ZONES = 15 83 | 84 | 85 | _GTFSHEADER = DESCRIPTOR.message_types_by_name['GtfsHeader'] 86 | _AGENCIES = DESCRIPTOR.message_types_by_name['Agencies'] 87 | _AGENCY = DESCRIPTOR.message_types_by_name['Agency'] 88 | _CALENDAR = DESCRIPTOR.message_types_by_name['Calendar'] 89 | _CALENDARDATES = DESCRIPTOR.message_types_by_name['CalendarDates'] 90 | _CALENDARSERVICE = DESCRIPTOR.message_types_by_name['CalendarService'] 91 | _SHAPES = DESCRIPTOR.message_types_by_name['Shapes'] 92 | _SHAPE = DESCRIPTOR.message_types_by_name['Shape'] 93 | _NETWORKS = DESCRIPTOR.message_types_by_name['Networks'] 94 | _NETWORKS_NETWORKSENTRY = _NETWORKS.nested_types_by_name['NetworksEntry'] 95 | _AREAS = DESCRIPTOR.message_types_by_name['Areas'] 96 | _AREAS_AREASENTRY = _AREAS.nested_types_by_name['AreasEntry'] 97 | _STRINGTABLE = DESCRIPTOR.message_types_by_name['StringTable'] 98 | _STOPS = DESCRIPTOR.message_types_by_name['Stops'] 99 | _STOP = DESCRIPTOR.message_types_by_name['Stop'] 100 | _ROUTES = DESCRIPTOR.message_types_by_name['Routes'] 101 | _ROUTE = DESCRIPTOR.message_types_by_name['Route'] 102 | _ROUTEITINERARY = DESCRIPTOR.message_types_by_name['RouteItinerary'] 103 | _TRIPS = DESCRIPTOR.message_types_by_name['Trips'] 104 | _TRIP = DESCRIPTOR.message_types_by_name['Trip'] 105 | _TRANSFERS = DESCRIPTOR.message_types_by_name['Transfers'] 106 | _TRANSFER = DESCRIPTOR.message_types_by_name['Transfer'] 107 | _FARELINKS = DESCRIPTOR.message_types_by_name['FareLinks'] 108 | _IDSTORE = DESCRIPTOR.message_types_by_name['IdStore'] 109 | _IDREFERENCE = DESCRIPTOR.message_types_by_name['IdReference'] 110 | _GTFSDELTAHEADER = DESCRIPTOR.message_types_by_name['GtfsDeltaHeader'] 111 | _FARELINKSDELTA = DESCRIPTOR.message_types_by_name['FareLinksDelta'] 112 | _FARELINKSDELTA_STOPAREAIDSENTRY = _FARELINKSDELTA.nested_types_by_name['StopAreaIdsEntry'] 113 | _FARELINKSDELTA_STOPZONEIDSENTRY = _FARELINKSDELTA.nested_types_by_name['StopZoneIdsEntry'] 114 | _FARELINKSDELTA_ROUTENETWORKIDSENTRY = _FARELINKSDELTA.nested_types_by_name['RouteNetworkIdsEntry'] 115 | GtfsHeader = _reflection.GeneratedProtocolMessageType('GtfsHeader', (_message.Message,), { 116 | 'DESCRIPTOR' : _GTFSHEADER, 117 | '__module__' : 'gtfs_pb2' 118 | # @@protoc_insertion_point(class_scope:gtfs.GtfsHeader) 119 | }) 120 | _sym_db.RegisterMessage(GtfsHeader) 121 | 122 | Agencies = _reflection.GeneratedProtocolMessageType('Agencies', (_message.Message,), { 123 | 'DESCRIPTOR' : _AGENCIES, 124 | '__module__' : 'gtfs_pb2' 125 | # @@protoc_insertion_point(class_scope:gtfs.Agencies) 126 | }) 127 | _sym_db.RegisterMessage(Agencies) 128 | 129 | Agency = _reflection.GeneratedProtocolMessageType('Agency', (_message.Message,), { 130 | 'DESCRIPTOR' : _AGENCY, 131 | '__module__' : 'gtfs_pb2' 132 | # @@protoc_insertion_point(class_scope:gtfs.Agency) 133 | }) 134 | _sym_db.RegisterMessage(Agency) 135 | 136 | Calendar = _reflection.GeneratedProtocolMessageType('Calendar', (_message.Message,), { 137 | 'DESCRIPTOR' : _CALENDAR, 138 | '__module__' : 'gtfs_pb2' 139 | # @@protoc_insertion_point(class_scope:gtfs.Calendar) 140 | }) 141 | _sym_db.RegisterMessage(Calendar) 142 | 143 | CalendarDates = _reflection.GeneratedProtocolMessageType('CalendarDates', (_message.Message,), { 144 | 'DESCRIPTOR' : _CALENDARDATES, 145 | '__module__' : 'gtfs_pb2' 146 | # @@protoc_insertion_point(class_scope:gtfs.CalendarDates) 147 | }) 148 | _sym_db.RegisterMessage(CalendarDates) 149 | 150 | CalendarService = _reflection.GeneratedProtocolMessageType('CalendarService', (_message.Message,), { 151 | 'DESCRIPTOR' : _CALENDARSERVICE, 152 | '__module__' : 'gtfs_pb2' 153 | # @@protoc_insertion_point(class_scope:gtfs.CalendarService) 154 | }) 155 | _sym_db.RegisterMessage(CalendarService) 156 | 157 | Shapes = _reflection.GeneratedProtocolMessageType('Shapes', (_message.Message,), { 158 | 'DESCRIPTOR' : _SHAPES, 159 | '__module__' : 'gtfs_pb2' 160 | # @@protoc_insertion_point(class_scope:gtfs.Shapes) 161 | }) 162 | _sym_db.RegisterMessage(Shapes) 163 | 164 | Shape = _reflection.GeneratedProtocolMessageType('Shape', (_message.Message,), { 165 | 'DESCRIPTOR' : _SHAPE, 166 | '__module__' : 'gtfs_pb2' 167 | # @@protoc_insertion_point(class_scope:gtfs.Shape) 168 | }) 169 | _sym_db.RegisterMessage(Shape) 170 | 171 | Networks = _reflection.GeneratedProtocolMessageType('Networks', (_message.Message,), { 172 | 173 | 'NetworksEntry' : _reflection.GeneratedProtocolMessageType('NetworksEntry', (_message.Message,), { 174 | 'DESCRIPTOR' : _NETWORKS_NETWORKSENTRY, 175 | '__module__' : 'gtfs_pb2' 176 | # @@protoc_insertion_point(class_scope:gtfs.Networks.NetworksEntry) 177 | }) 178 | , 179 | 'DESCRIPTOR' : _NETWORKS, 180 | '__module__' : 'gtfs_pb2' 181 | # @@protoc_insertion_point(class_scope:gtfs.Networks) 182 | }) 183 | _sym_db.RegisterMessage(Networks) 184 | _sym_db.RegisterMessage(Networks.NetworksEntry) 185 | 186 | Areas = _reflection.GeneratedProtocolMessageType('Areas', (_message.Message,), { 187 | 188 | 'AreasEntry' : _reflection.GeneratedProtocolMessageType('AreasEntry', (_message.Message,), { 189 | 'DESCRIPTOR' : _AREAS_AREASENTRY, 190 | '__module__' : 'gtfs_pb2' 191 | # @@protoc_insertion_point(class_scope:gtfs.Areas.AreasEntry) 192 | }) 193 | , 194 | 'DESCRIPTOR' : _AREAS, 195 | '__module__' : 'gtfs_pb2' 196 | # @@protoc_insertion_point(class_scope:gtfs.Areas) 197 | }) 198 | _sym_db.RegisterMessage(Areas) 199 | _sym_db.RegisterMessage(Areas.AreasEntry) 200 | 201 | StringTable = _reflection.GeneratedProtocolMessageType('StringTable', (_message.Message,), { 202 | 'DESCRIPTOR' : _STRINGTABLE, 203 | '__module__' : 'gtfs_pb2' 204 | # @@protoc_insertion_point(class_scope:gtfs.StringTable) 205 | }) 206 | _sym_db.RegisterMessage(StringTable) 207 | 208 | Stops = _reflection.GeneratedProtocolMessageType('Stops', (_message.Message,), { 209 | 'DESCRIPTOR' : _STOPS, 210 | '__module__' : 'gtfs_pb2' 211 | # @@protoc_insertion_point(class_scope:gtfs.Stops) 212 | }) 213 | _sym_db.RegisterMessage(Stops) 214 | 215 | Stop = _reflection.GeneratedProtocolMessageType('Stop', (_message.Message,), { 216 | 'DESCRIPTOR' : _STOP, 217 | '__module__' : 'gtfs_pb2' 218 | # @@protoc_insertion_point(class_scope:gtfs.Stop) 219 | }) 220 | _sym_db.RegisterMessage(Stop) 221 | 222 | Routes = _reflection.GeneratedProtocolMessageType('Routes', (_message.Message,), { 223 | 'DESCRIPTOR' : _ROUTES, 224 | '__module__' : 'gtfs_pb2' 225 | # @@protoc_insertion_point(class_scope:gtfs.Routes) 226 | }) 227 | _sym_db.RegisterMessage(Routes) 228 | 229 | Route = _reflection.GeneratedProtocolMessageType('Route', (_message.Message,), { 230 | 'DESCRIPTOR' : _ROUTE, 231 | '__module__' : 'gtfs_pb2' 232 | # @@protoc_insertion_point(class_scope:gtfs.Route) 233 | }) 234 | _sym_db.RegisterMessage(Route) 235 | 236 | RouteItinerary = _reflection.GeneratedProtocolMessageType('RouteItinerary', (_message.Message,), { 237 | 'DESCRIPTOR' : _ROUTEITINERARY, 238 | '__module__' : 'gtfs_pb2' 239 | # @@protoc_insertion_point(class_scope:gtfs.RouteItinerary) 240 | }) 241 | _sym_db.RegisterMessage(RouteItinerary) 242 | 243 | Trips = _reflection.GeneratedProtocolMessageType('Trips', (_message.Message,), { 244 | 'DESCRIPTOR' : _TRIPS, 245 | '__module__' : 'gtfs_pb2' 246 | # @@protoc_insertion_point(class_scope:gtfs.Trips) 247 | }) 248 | _sym_db.RegisterMessage(Trips) 249 | 250 | Trip = _reflection.GeneratedProtocolMessageType('Trip', (_message.Message,), { 251 | 'DESCRIPTOR' : _TRIP, 252 | '__module__' : 'gtfs_pb2' 253 | # @@protoc_insertion_point(class_scope:gtfs.Trip) 254 | }) 255 | _sym_db.RegisterMessage(Trip) 256 | 257 | Transfers = _reflection.GeneratedProtocolMessageType('Transfers', (_message.Message,), { 258 | 'DESCRIPTOR' : _TRANSFERS, 259 | '__module__' : 'gtfs_pb2' 260 | # @@protoc_insertion_point(class_scope:gtfs.Transfers) 261 | }) 262 | _sym_db.RegisterMessage(Transfers) 263 | 264 | Transfer = _reflection.GeneratedProtocolMessageType('Transfer', (_message.Message,), { 265 | 'DESCRIPTOR' : _TRANSFER, 266 | '__module__' : 'gtfs_pb2' 267 | # @@protoc_insertion_point(class_scope:gtfs.Transfer) 268 | }) 269 | _sym_db.RegisterMessage(Transfer) 270 | 271 | FareLinks = _reflection.GeneratedProtocolMessageType('FareLinks', (_message.Message,), { 272 | 'DESCRIPTOR' : _FARELINKS, 273 | '__module__' : 'gtfs_pb2' 274 | # @@protoc_insertion_point(class_scope:gtfs.FareLinks) 275 | }) 276 | _sym_db.RegisterMessage(FareLinks) 277 | 278 | IdStore = _reflection.GeneratedProtocolMessageType('IdStore', (_message.Message,), { 279 | 'DESCRIPTOR' : _IDSTORE, 280 | '__module__' : 'gtfs_pb2' 281 | # @@protoc_insertion_point(class_scope:gtfs.IdStore) 282 | }) 283 | _sym_db.RegisterMessage(IdStore) 284 | 285 | IdReference = _reflection.GeneratedProtocolMessageType('IdReference', (_message.Message,), { 286 | 'DESCRIPTOR' : _IDREFERENCE, 287 | '__module__' : 'gtfs_pb2' 288 | # @@protoc_insertion_point(class_scope:gtfs.IdReference) 289 | }) 290 | _sym_db.RegisterMessage(IdReference) 291 | 292 | GtfsDeltaHeader = _reflection.GeneratedProtocolMessageType('GtfsDeltaHeader', (_message.Message,), { 293 | 'DESCRIPTOR' : _GTFSDELTAHEADER, 294 | '__module__' : 'gtfs_pb2' 295 | # @@protoc_insertion_point(class_scope:gtfs.GtfsDeltaHeader) 296 | }) 297 | _sym_db.RegisterMessage(GtfsDeltaHeader) 298 | 299 | FareLinksDelta = _reflection.GeneratedProtocolMessageType('FareLinksDelta', (_message.Message,), { 300 | 301 | 'StopAreaIdsEntry' : _reflection.GeneratedProtocolMessageType('StopAreaIdsEntry', (_message.Message,), { 302 | 'DESCRIPTOR' : _FARELINKSDELTA_STOPAREAIDSENTRY, 303 | '__module__' : 'gtfs_pb2' 304 | # @@protoc_insertion_point(class_scope:gtfs.FareLinksDelta.StopAreaIdsEntry) 305 | }) 306 | , 307 | 308 | 'StopZoneIdsEntry' : _reflection.GeneratedProtocolMessageType('StopZoneIdsEntry', (_message.Message,), { 309 | 'DESCRIPTOR' : _FARELINKSDELTA_STOPZONEIDSENTRY, 310 | '__module__' : 'gtfs_pb2' 311 | # @@protoc_insertion_point(class_scope:gtfs.FareLinksDelta.StopZoneIdsEntry) 312 | }) 313 | , 314 | 315 | 'RouteNetworkIdsEntry' : _reflection.GeneratedProtocolMessageType('RouteNetworkIdsEntry', (_message.Message,), { 316 | 'DESCRIPTOR' : _FARELINKSDELTA_ROUTENETWORKIDSENTRY, 317 | '__module__' : 'gtfs_pb2' 318 | # @@protoc_insertion_point(class_scope:gtfs.FareLinksDelta.RouteNetworkIdsEntry) 319 | }) 320 | , 321 | 'DESCRIPTOR' : _FARELINKSDELTA, 322 | '__module__' : 'gtfs_pb2' 323 | # @@protoc_insertion_point(class_scope:gtfs.FareLinksDelta) 324 | }) 325 | _sym_db.RegisterMessage(FareLinksDelta) 326 | _sym_db.RegisterMessage(FareLinksDelta.StopAreaIdsEntry) 327 | _sym_db.RegisterMessage(FareLinksDelta.StopZoneIdsEntry) 328 | _sym_db.RegisterMessage(FareLinksDelta.RouteNetworkIdsEntry) 329 | 330 | if _descriptor._USE_C_DESCRIPTORS == False: 331 | 332 | DESCRIPTOR._options = None 333 | _NETWORKS_NETWORKSENTRY._options = None 334 | _NETWORKS_NETWORKSENTRY._serialized_options = b'8\001' 335 | _AREAS_AREASENTRY._options = None 336 | _AREAS_AREASENTRY._serialized_options = b'8\001' 337 | _FARELINKSDELTA_STOPAREAIDSENTRY._options = None 338 | _FARELINKSDELTA_STOPAREAIDSENTRY._serialized_options = b'8\001' 339 | _FARELINKSDELTA_STOPZONEIDSENTRY._options = None 340 | _FARELINKSDELTA_STOPZONEIDSENTRY._serialized_options = b'8\001' 341 | _FARELINKSDELTA_ROUTENETWORKIDSENTRY._options = None 342 | _FARELINKSDELTA_ROUTENETWORKIDSENTRY._serialized_options = b'8\001' 343 | _LOCATIONTYPE._serialized_start=3077 344 | _LOCATIONTYPE._serialized_end=3158 345 | _ACCESSIBILITY._serialized_start=3160 346 | _ACCESSIBILITY._serialized_end=3212 347 | _ROUTETYPE._serialized_start=3215 348 | _ROUTETYPE._serialized_end=3437 349 | _PICKUPDROPOFF._serialized_start=3439 350 | _PICKUPDROPOFF._serialized_end=3518 351 | _TRANSFERTYPE._serialized_start=3521 352 | _TRANSFERTYPE._serialized_end=3652 353 | _BLOCK._serialized_start=3655 354 | _BLOCK._serialized_end=3895 355 | _GTFSHEADER._serialized_start=20 356 | _GTFSHEADER._serialized_end=121 357 | _AGENCIES._serialized_start=123 358 | _AGENCIES._serialized_end=165 359 | _AGENCY._serialized_start=168 360 | _AGENCY._serialized_end=302 361 | _CALENDAR._serialized_start=304 362 | _CALENDAR._serialized_end=410 363 | _CALENDARDATES._serialized_start=412 364 | _CALENDARDATES._serialized_end=442 365 | _CALENDARSERVICE._serialized_start=445 366 | _CALENDARSERVICE._serialized_end=580 367 | _SHAPES._serialized_start=582 368 | _SHAPES._serialized_end=619 369 | _SHAPE._serialized_start=621 370 | _SHAPE._serialized_end=685 371 | _NETWORKS._serialized_start=687 372 | _NETWORKS._serialized_end=794 373 | _NETWORKS_NETWORKSENTRY._serialized_start=747 374 | _NETWORKS_NETWORKSENTRY._serialized_end=794 375 | _AREAS._serialized_start=796 376 | _AREAS._serialized_end=888 377 | _AREAS_AREASENTRY._serialized_start=844 378 | _AREAS_AREASENTRY._serialized_end=888 379 | _STRINGTABLE._serialized_start=890 380 | _STRINGTABLE._serialized_end=920 381 | _STOPS._serialized_start=922 382 | _STOPS._serialized_end=956 383 | _STOP._serialized_start=959 384 | _STOP._serialized_end=1233 385 | _ROUTES._serialized_start=1235 386 | _ROUTES._serialized_end=1272 387 | _ROUTE._serialized_start=1275 388 | _ROUTE._serialized_end=1594 389 | _ROUTEITINERARY._serialized_start=1597 390 | _ROUTEITINERARY._serialized_end=1738 391 | _TRIPS._serialized_start=1740 392 | _TRIPS._serialized_end=1774 393 | _TRIP._serialized_start=1777 394 | _TRIP._serialized_end=2141 395 | _TRANSFERS._serialized_start=2143 396 | _TRANSFERS._serialized_end=2189 397 | _TRANSFER._serialized_start=2192 398 | _TRANSFER._serialized_end=2389 399 | _FARELINKS._serialized_start=2391 400 | _FARELINKS._serialized_end=2475 401 | _IDSTORE._serialized_start=2477 402 | _IDSTORE._serialized_end=2519 403 | _IDREFERENCE._serialized_start=2521 404 | _IDREFERENCE._serialized_end=2595 405 | _GTFSDELTAHEADER._serialized_start=2597 406 | _GTFSDELTAHEADER._serialized_end=2702 407 | _FARELINKSDELTA._serialized_start=2705 408 | _FARELINKSDELTA._serialized_end=3075 409 | _FARELINKSDELTA_STOPAREAIDSENTRY._serialized_start=2917 410 | _FARELINKSDELTA_STOPAREAIDSENTRY._serialized_end=2967 411 | _FARELINKSDELTA_STOPZONEIDSENTRY._serialized_start=2969 412 | _FARELINKSDELTA_STOPZONEIDSENTRY._serialized_end=3019 413 | _FARELINKSDELTA_ROUTENETWORKIDSENTRY._serialized_start=3021 414 | _FARELINKSDELTA_ROUTENETWORKIDSENTRY._serialized_end=3075 415 | # @@protoc_insertion_point(module_scope) 416 | --------------------------------------------------------------------------------