Skip to content

Commit bb8a325

Browse files
authored
Merge pull request #22 from TransitApp/jsteelz/py-gtfs-loader-supporting-itineraries
Support loading/patching Transit itinerary_cells.txt format
2 parents 51be240 + a9c74a8 commit bb8a325

File tree

10 files changed

+128
-12
lines changed

10 files changed

+128
-12
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
* @npaun @JMilot1
1+
* @npaun @JMilot1 @jsteelz

gtfs_loader/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ def get_files(files):
1515
return schema.FileCollection(*(schema.GTFS_FILENAMES[f] for f in files)).values()
1616

1717

18-
def load(gtfs_dir, sorted_read=False, files=None, verbose=True):
18+
def load(gtfs_dir, sorted_read=False, files=None, verbose=True, itineraries=False):
1919
gtfs_dir = Path(gtfs_dir)
2020
gtfs = types.Entity()
2121

22-
files_to_load = get_files(files) if files else schema.GTFS_SUBSET_SCHEMA.values()
22+
files_to_load = get_files(files) if files else schema.GTFS_SUBSET_SCHEMA_ITINERARIES.values() if itineraries else schema.GTFS_SUBSET_SCHEMA.values()
2323

2424
for file_schema in files_to_load:
2525
if verbose:
@@ -163,6 +163,10 @@ def convert(config, value):
163163
if not config.required and value == '':
164164
return config.default
165165

166+
# Lists are stringified as JSON in csv.
167+
if typing.get_origin(config.type) is list:
168+
return list(json.loads(value))
169+
166170
config_type = get_inner_type(config.type)
167171
if issubclass(config_type, enum.IntEnum):
168172
return config_type(int(value))
@@ -217,7 +221,7 @@ def sorted_entities(file_schema, entities):
217221
return sorted(entities.items(), key=lambda kv: kv[0])
218222

219223

220-
def patch(gtfs, gtfs_in_dir, gtfs_out_dir, files=None, sorted_output=False, verbose=True):
224+
def patch(gtfs, gtfs_in_dir, gtfs_out_dir, files=None, sorted_output=False, verbose=True, itineraries=False):
221225
gtfs_in_dir = Path(gtfs_in_dir)
222226
gtfs_out_dir = Path(gtfs_out_dir)
223227
gtfs_out_dir.mkdir(parents=True, exist_ok=True)
@@ -229,7 +233,7 @@ def patch(gtfs, gtfs_in_dir, gtfs_out_dir, files=None, sorted_output=False, verb
229233
except shutil.SameFileError:
230234
pass # No need to copy if we're working in-place
231235

232-
files_to_patch = get_files(files) if files else schema.GTFS_SUBSET_SCHEMA.values()
236+
files_to_patch = get_files(files) if files else schema.GTFS_SUBSET_SCHEMA_ITINERARIES.values() if itineraries else schema.GTFS_SUBSET_SCHEMA.values()
233237

234238
for file_schema in files_to_patch:
235239
if verbose:

gtfs_loader/schema.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,26 @@ class StopTime(Entity):
241241
def stop(self):
242242
return self._gtfs.stops[self.stop_id]
243243

244+
class ItineraryCell(Entity):
245+
_schema = File(id='itinerary_index',
246+
name='itinerary_cells',
247+
fileType=FileType.CSV,
248+
required=True,
249+
group_id='stop_sequence')
250+
251+
stop_id: str
252+
stop_sequence: int
253+
pickup_type: PickupType = PickupType.REGULARLY_SCHEDULED
254+
drop_off_type: DropOffType = DropOffType.REGULARLY_SCHEDULED
255+
mean_duration_factor: Optional[float] = None
256+
mean_duration_offset: Optional[float] = None
257+
safe_duration_factor: Optional[float] = None
258+
safe_duration_offset: Optional[float] = None
259+
260+
@property
261+
def stop(self):
262+
return self._gtfs.stops[self.stop_id]
263+
244264

245265
class Transfer(Entity):
246266
_schema = File(id='from_trip_id',
@@ -325,16 +345,85 @@ def last_stop(self):
325345
@cached_property
326346
def route(self):
327347
return self._gtfs.routes[self.route_id]
348+
349+
class ItineraryTrip(Entity):
350+
_schema = File(id='trip_id',
351+
fileType=FileType.CSV,
352+
name='trips',
353+
required=True)
354+
355+
trip_id: str
356+
service_id: str
357+
block_id: str = ''
358+
route_id: str
359+
itinerary_index: str
360+
departure_times: List[int]
361+
arrival_times: List[int]
362+
start_pickup_drop_off_windows: List[int]
363+
end_pickup_drop_off_windows: List[int]
364+
365+
@property
366+
def first_itinerary_cell(self):
367+
return self._gtfs.itinerary_cells[self.itinerary_index][0]
368+
369+
@property
370+
def last_itinerary_cell(self):
371+
return self._gtfs.itinerary_cells[self.itinerary_index][-1]
372+
373+
@property
374+
def stop_shape(self):
375+
locations = tuple(self._gtfs.stops[st.stop_id].location for st in self._gtfs.itinerary_cells[self.itinerary_index])
376+
377+
if None in locations:
378+
return None
379+
return locations
380+
381+
@cached_property
382+
def shift_days(self):
383+
return 1 if self.departure_times[0] >= DAY_SEC else 0
384+
385+
@cached_property
386+
def first_departure(self):
387+
return self.departure_times[0] - DAY_SEC * self.shift_days
388+
389+
@cached_property
390+
def last_arrival(self):
391+
return self.arrival_times[-1] - DAY_SEC * self.shift_days
392+
393+
@cached_property
394+
def first_point(self):
395+
return self.first_stop.location
396+
397+
@cached_property
398+
def last_point(self):
399+
return self.last_stop.location
400+
401+
@cached_property
402+
def first_stop(self):
403+
return self._gtfs.stops[self.first_itinerary_cell.stop_id]
404+
405+
@cached_property
406+
def last_stop(self):
407+
return self._gtfs.stops[self.last_itinerary_cell.stop_id]
408+
409+
@cached_property
410+
def route(self):
411+
return self._gtfs.routes[self.route_id]
328412

329413

330414
GTFS_SUBSET_SCHEMA = FileCollection(Agency, BookingRule, Calendar, CalendarDate,
331415
Locations, LocationGroups, Routes, Transfer, Trip, Stop, StopTime)
332416

417+
GTFS_SUBSET_SCHEMA_ITINERARIES = FileCollection(Agency, BookingRule, Calendar, CalendarDate, ItineraryCell,
418+
ItineraryTrip, Locations, LocationGroups, Routes, Transfer, Stop)
419+
333420
GTFS_FILENAMES = {
334421
Agency._schema.name: Agency,
335422
BookingRule._schema.name: BookingRule,
336423
Calendar._schema.name: Calendar,
337424
CalendarDate._schema.name: CalendarDate,
425+
ItineraryCell._schema.name: ItineraryCell,
426+
ItineraryTrip._schema.name: ItineraryTrip,
338427
Locations._schema.name: Locations,
339428
LocationGroups._schema.name: LocationGroups,
340429
Routes._schema.name: Routes,

gtfs_loader/types.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import functools
22
import enum
3+
import json
34
from datetime import datetime
4-
from typing import Any, List
5+
from typing import Any
56

67
from .schema_classes import Schema, SchemaCollection
78

@@ -12,7 +13,6 @@ class GTFSTime(int):
1213
# forward an arbitrary number of days using this notation, but we block it
1314
# as it just creates confusion.
1415
MAX_HOUR_REPRESENTATION = 36
15-
1616
def __new__(cls, time_str):
1717
if isinstance(time_str, int):
1818
return super().__new__(cls, time_str)
@@ -43,7 +43,6 @@ def __add__(self, other):
4343
def __sub__(self, other):
4444
return GTFSTime(super().__sub__(other))
4545

46-
4746
class GTFSDate(datetime):
4847

4948
def __new__(cls, *args, **kwargs):
@@ -134,6 +133,9 @@ def clone(self, **overrides):
134133

135134
@functools.singledispatch
136135
def serialize(value: Any):
136+
if isinstance(value, list):
137+
# Remove spaces after commas for compactness
138+
return json.dumps(value,separators=(',', ':'))
137139
return str(value)
138140

139141

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from setuptools import setup, find_packages
22

33
setup(name='py-gtfs-loader',
4-
version='0.1.15',
4+
version='0.2.0',
55
description='Load GTFS',
66
url='https://github.com/TransitApp/py-gtfs-loader',
7-
author='Nicholas Paun, Jonathan Milot',
7+
author='Nicholas Paun, Jonathan Milot, Jeremy Steele',
88
license='License :: OSI Approved :: MIT License',
99
packages=find_packages(),
1010
zip_safe=False,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
itinerary_index,stop_sequence,stop_id,pickup_type,drop_off_type,mean_duration_factor,mean_duration_offset,safe_duration_factor,safe_duration_offset
2+
1,0,junction,0,0,,,,
3+
1,1,slocan-park,0,0,,,,
4+
1,2,slocan-city,0,0,,,,
5+
1,3,nelson-tc,0,0,,,,
6+
1,4,junction,0,0,,,,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
route_id,trip_id,service_id,block_id,itinerary_index,departure_times,arrival_times,start_pickup_drop_off_windows,end_pickup_drop_off_windows
2+
red,trip_1,mon-tues-wed-thurs,1,1,"[79200,79260,79320,79380,79440]","[79200,79260,79320,79380,79440]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"
3+
red,trip_2,mon-tues-wed-thurs,1,1,"[79440,79500,79560,79620,79680]","[79440,79500,79560,79620,79680]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"
4+
red,trip_3,mon-tues-wed-thurs,1,1,"[79680,79740,79800,79860,79920]","[79680,79740,79800,79860,79920]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
itinerary_index,stop_sequence,stop_id
2+
1,0,junction
3+
1,1,slocan-park
4+
1,2,slocan-city
5+
1,3,nelson-tc
6+
1,4,junction
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
route_id,trip_id,service_id,block_id,itinerary_index,departure_times,arrival_times,start_pickup_drop_off_windows,end_pickup_drop_off_windows
2+
red,trip_1,mon-tues-wed-thurs,1,1,"[79200,79260,79320,79380,79440]","[79200,79260,79320,79380,79440]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"
3+
red,trip_2,mon-tues-wed-thurs,1,1,"[79440,79500,79560,79620,79680]","[79440,79500,79560,79620,79680]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"
4+
red,trip_3,mon-tues-wed-thurs,1,1,"[79680,79740,79800,79860,79920]","[79680,79740,79800,79860,79920]","[-1,-1,-1,-1,-1]","[-1,-1,-1,-1,-1]"

tests/test_runner.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ def test_default(feed_dir):
1414

1515

1616
def do_test(feed_dir):
17+
itineraries = 'itineraries' in feed_dir.name
1718
work_dir = test_support.create_test_data(feed_dir)
1819

19-
gtfs = gtfs_loader.load(work_dir, verbose=False)
20-
gtfs_loader.patch(gtfs, work_dir, work_dir, verbose=False)
20+
gtfs = gtfs_loader.load(work_dir, verbose=False, itineraries=itineraries)
21+
gtfs_loader.patch(gtfs, work_dir, work_dir, verbose=False, itineraries=itineraries)
2122
test_support.check_expected_output(feed_dir, work_dir)

0 commit comments

Comments
 (0)