gtfs-parser 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ from .gtfs import GTFS
2
+ from . import aggregate, parse
@@ -0,0 +1,128 @@
1
+ import json
2
+ import os
3
+ import zipfile
4
+ import tempfile
5
+ import argparse
6
+ import shutil
7
+
8
+ from .gtfs import GTFS
9
+ from .parse import read_routes, read_stops
10
+ from .aggregate import Aggregator
11
+
12
+
13
+ def load_args():
14
+ parser = argparse.ArgumentParser()
15
+ parser.add_argument("mode")
16
+ parser.add_argument("src")
17
+ parser.add_argument("dst")
18
+ parser.add_argument("--parse_ignoreshapes", action="store_true")
19
+ parser.add_argument("--parse_ignorenoroute", action="store_true")
20
+ parser.add_argument("--aggregate_yyyymmdd")
21
+ parser.add_argument("--aggregate_nounifystops", action="store_true")
22
+ parser.add_argument("--aggregate_delimiter")
23
+ parser.add_argument("--aggregate_begintime")
24
+ parser.add_argument("--aggregate_endtime")
25
+ args = parser.parse_args()
26
+ return args
27
+
28
+
29
+ def validate_args(args):
30
+ if args.aggregate_yyyymmdd:
31
+ if len(args.aggregate_yyyymmdd) != 8:
32
+ raise RuntimeError(
33
+ f"yyyymmdd must be 8 characters string, for example 20210401, \
34
+ your is {args.aggregate_yyyymmdd} ({len(args.aggregate_yyyymmdd)} characters)"
35
+ )
36
+
37
+ if args.aggregate_begintime:
38
+ if len(args.aggregate_begintime) != 6:
39
+ raise RuntimeError(
40
+ f'begintime must be "hhmmss", your is {args.aggregate_begintime}'
41
+ )
42
+ if not args.aggregate_endtime:
43
+ raise RuntimeError("endtime is not set.")
44
+
45
+ if args.aggregate_endtime:
46
+ if len(args.aggregate_endtime) != 6:
47
+ raise RuntimeError(
48
+ f'endtime must be "hhmmss", your is {args.aggregate_endtime}'
49
+ )
50
+ if not args.aggregate_begintime:
51
+ raise RuntimeError("begintime is not set.")
52
+
53
+
54
+ def main():
55
+ args = load_args()
56
+ validate_args(args)
57
+
58
+ if args.src.endswith(".zip"): # TODO: wiser checking
59
+ print("extracting zipfile...")
60
+ temp_dir = os.path.join(tempfile.gettempdir(), "gtfs_parser")
61
+ if os.path.exists(temp_dir):
62
+ shutil.rmtree(temp_dir)
63
+ os.mkdir(temp_dir)
64
+ with zipfile.ZipFile(args.zip) as z:
65
+ z.extractall(temp_dir)
66
+ output_dir = temp_dir
67
+ else:
68
+ output_dir = args.src
69
+
70
+ gtfs = GTFS(output_dir)
71
+ print("GTFS loaded.")
72
+
73
+ os.makedirs(args.dst, exist_ok=True)
74
+
75
+ if args.mode == "aggregate":
76
+ aggregator = Aggregator(
77
+ gtfs,
78
+ no_unify_stops=args.aggregate_nounifystops,
79
+ delimiter=args.aggregate_delimiter,
80
+ yyyymmdd=args.aggregate_yyyymmdd,
81
+ begin_time=args.aggregate_begintime,
82
+ end_time=args.aggregate_endtime,
83
+ )
84
+ aggregated_routes_geojson = {
85
+ "type": "FeatureCollection",
86
+ "features": aggregator.read_route_frequency(),
87
+ }
88
+ aggregated_stops_geojson = {
89
+ "type": "FeatureCollection",
90
+ "features": aggregator.read_interpolated_stops(),
91
+ }
92
+
93
+ with open(
94
+ os.path.join(args.dst, "aggregated_routes.geojson"),
95
+ mode="w",
96
+ encoding="utf-8",
97
+ ) as f:
98
+ json.dump(aggregated_routes_geojson, f, ensure_ascii=False)
99
+ with open(
100
+ os.path.join(args.dst, "aggregated_stops.geojson"),
101
+ mode="w",
102
+ encoding="utf-8",
103
+ ) as f:
104
+ json.dump(aggregated_stops_geojson, f, ensure_ascii=False)
105
+ elif args.mode == "parse":
106
+ routes_geojson = {
107
+ "type": "FeatureCollection",
108
+ "features": read_routes(gtfs, ignore_shapes=args.parse_ignoreshapes),
109
+ }
110
+ stops_geojson = {
111
+ "type": "FeatureCollection",
112
+ "features": read_stops(gtfs, ignore_no_route=args.parse_ignorenoroute),
113
+ }
114
+
115
+ with open(
116
+ os.path.join(args.dst, "routes.geojson"), mode="w", encoding="utf-8"
117
+ ) as f:
118
+ json.dump(routes_geojson, f, ensure_ascii=False)
119
+ with open(
120
+ os.path.join(args.dst, "stops.geojson"), mode="w", encoding="utf-8"
121
+ ) as f:
122
+ json.dump(stops_geojson, f, ensure_ascii=False)
123
+ else:
124
+ raise RuntimeError("mode must be 'parse' or 'aggregate")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,452 @@
1
+ from functools import lru_cache
2
+ import datetime
3
+
4
+ import pandas as pd
5
+
6
+
7
+ def latlon_to_str(latlon):
8
+ return "".join(list(map(lambda coord: str(round(coord, 4)), latlon)))
9
+
10
+
11
+ class Aggregator:
12
+ def __init__(
13
+ self,
14
+ gtfs: dict,
15
+ no_unify_stops=False,
16
+ delimiter="",
17
+ max_distance_degree=0.01,
18
+ yyyymmdd="",
19
+ begin_time="",
20
+ end_time="",
21
+ ):
22
+ self.gtfs = gtfs
23
+ self.similar_stops_df = None
24
+
25
+ self.__aggregate_similar_stops(
26
+ delimiter,
27
+ max_distance_degree,
28
+ no_unify_stops,
29
+ yyyymmdd=yyyymmdd,
30
+ begin_time=begin_time,
31
+ end_time=end_time,
32
+ )
33
+
34
+ def __aggregate_similar_stops(
35
+ self,
36
+ delimiter: str,
37
+ max_distance_degree: float,
38
+ no_unify_stops: bool,
39
+ yyyymmdd="",
40
+ begin_time="",
41
+ end_time="",
42
+ ):
43
+ """
44
+ this method occurs side-effect to modify self.gtfs and self.similar_stops_df
45
+ """
46
+ # filter stop_times by whether serviced or not
47
+ if yyyymmdd:
48
+ trips_filtered_by_day = self.__get_trips_on_a_date(yyyymmdd)
49
+ self.gtfs["stop_times"] = pd.merge(
50
+ self.gtfs["stop_times"],
51
+ trips_filtered_by_day,
52
+ on="trip_id",
53
+ how="left",
54
+ )
55
+ self.gtfs["stop_times"] = self.gtfs["stop_times"][
56
+ self.gtfs["stop_times"]["service_flag"] == 1
57
+ ]
58
+
59
+ # time filter
60
+ if begin_time and end_time:
61
+ # departure_time is nullable and expressed in "hh:mm:ss" or "h:mm:ss" format.
62
+ # Hour can be mor than 24.
63
+ # Therefore, drop null records and convert times to integers.
64
+ int_dep_times = (
65
+ self.gtfs["stop_times"].departure_time.str.replace(":", "").astype(int)
66
+ )
67
+ self.gtfs["stop_times"] = self.gtfs["stop_times"][
68
+ self.gtfs["stop_times"].departure_time != ""
69
+ ][(int_dep_times >= int(begin_time)) & (int_dep_times < int(end_time))]
70
+
71
+ if no_unify_stops:
72
+ # no unifying stops
73
+ self.gtfs["stops"]["similar_stop_id"] = self.gtfs["stops"]["stop_id"]
74
+ self.gtfs["stops"]["similar_stop_name"] = self.gtfs["stops"]["stop_name"]
75
+ self.gtfs["stops"]["similar_stops_centroid"] = self.gtfs["stops"][
76
+ ["stop_lon", "stop_lat"]
77
+ ].values.tolist()
78
+ self.gtfs["stops"]["position_count"] = 1
79
+ self.similar_stops_df = self.gtfs["stops"][
80
+ [
81
+ "similar_stop_id",
82
+ "similar_stop_name",
83
+ "similar_stops_centroid",
84
+ "position_count",
85
+ ]
86
+ ].copy()
87
+ else:
88
+ parent_ids = self.gtfs["stops"]["parent_station"].unique()
89
+ self.gtfs["stops"]["is_parent"] = self.gtfs["stops"]["stop_id"].map(
90
+ lambda stop_id: 1 if stop_id in parent_ids else 0
91
+ )
92
+
93
+ self.gtfs["stops"][
94
+ ["similar_stop_id", "similar_stop_name", "similar_stops_centroid"]
95
+ ] = (
96
+ self.gtfs["stops"]["stop_id"]
97
+ .map(
98
+ lambda stop_id: self.__get_similar_stop_tuple(
99
+ stop_id, delimiter, max_distance_degree
100
+ )
101
+ )
102
+ .apply(pd.Series)
103
+ )
104
+ self.gtfs["stops"]["position_id"] = self.gtfs["stops"][
105
+ "similar_stops_centroid"
106
+ ].map(latlon_to_str)
107
+ self.gtfs["stops"]["unique_id"] = (
108
+ self.gtfs["stops"]["similar_stop_id"]
109
+ + self.gtfs["stops"]["position_id"]
110
+ )
111
+
112
+ # sometimes stop_name accidently becomes pd.Series instead of str.
113
+ self.gtfs["stops"]["similar_stop_name"] = self.gtfs["stops"][
114
+ "similar_stop_name"
115
+ ].map(lambda val: val if type(val) == str else val.stop_name)
116
+
117
+ position_count = (
118
+ self.gtfs["stop_times"]
119
+ .merge(self.gtfs["stops"], on="stop_id", how="left")
120
+ .groupby("position_id")
121
+ .size()
122
+ .to_frame()
123
+ .reset_index()
124
+ )
125
+ position_count.columns = ["position_id", "position_count"]
126
+
127
+ self.similar_stops_df = pd.merge(
128
+ self.gtfs["stops"].drop_duplicates(subset="position_id")[
129
+ [
130
+ "position_id",
131
+ "similar_stop_id",
132
+ "similar_stop_name",
133
+ "similar_stops_centroid",
134
+ ]
135
+ ],
136
+ position_count,
137
+ on="position_id",
138
+ how="left",
139
+ )
140
+
141
+ @lru_cache(maxsize=None)
142
+ def __get_similar_stop_tuple(
143
+ self, stop_id: str, delimiter="", max_distance_degree=0.01
144
+ ):
145
+ """
146
+ With one stop_id, group stops by parent, stop_id, or stop_name and each distance.
147
+ - parent: if stop has parent_station, the 'centroid' is parent_station lat-lon
148
+ - stop_id: by delimiter seperate stop_id into prefix and suffix, and group stops having same stop_id-prefix
149
+ - name and distance: group stops by stop_name, excluding stops are far than max_distance_degree
150
+
151
+ Args:
152
+ stop_id (str): target stop_id
153
+ max_distance_degree (float, optional): distance limit on grouping, Defaults to 0.01.
154
+ Returns:
155
+ str, str, [float, float]: similar_stop_id, similar_stop_name, similar_stops_centroid
156
+ """
157
+ stops_df = self.gtfs["stops"].sort_values("stop_id")
158
+ stop = stops_df[stops_df["stop_id"] == stop_id].iloc[0]
159
+
160
+ if stop["is_parent"] == 1:
161
+ return (
162
+ stop["stop_id"],
163
+ stop["stop_name"],
164
+ [stop["stop_lon"], stop["stop_lat"]],
165
+ )
166
+
167
+ if str(stop["parent_station"]) != "nan":
168
+ similar_stop_id = stop["parent_station"]
169
+ similar_stop = stops_df[stops_df["stop_id"] == similar_stop_id]
170
+ similar_stop_name = similar_stop[["stop_name"]].iloc[0]
171
+ similar_stop_centroid = (
172
+ similar_stop[["stop_lon", "stop_lat"]].iloc[0].values.tolist()
173
+ )
174
+ return similar_stop_id, similar_stop_name, similar_stop_centroid
175
+
176
+ if delimiter:
177
+ stops_df_id_delimited = self.__get_stops_id_delimited(delimiter)
178
+ stop_id_prefix = stop_id.rsplit(delimiter, 1)[0]
179
+ if stop_id_prefix != stop_id:
180
+ similar_stop_id = stop_id_prefix
181
+ seperated_only_stops = stops_df_id_delimited[
182
+ stops_df_id_delimited["delimited"]
183
+ ]
184
+ similar_stops = seperated_only_stops[
185
+ seperated_only_stops["stop_id_prefix"] == stop_id_prefix
186
+ ][
187
+ [
188
+ "stop_name",
189
+ "similar_stops_centroid_lon",
190
+ "similar_stops_centroid_lat",
191
+ ]
192
+ ]
193
+ similar_stop_name = similar_stops[["stop_name"]].iloc[0]
194
+ similar_stop_centroid = similar_stops[
195
+ ["similar_stops_centroid_lon", "similar_stops_centroid_lat"]
196
+ ].values.tolist()[0]
197
+ return similar_stop_id, similar_stop_name, similar_stop_centroid
198
+ else:
199
+ # when cannot seperate stop_id, grouping by name and distance
200
+ stops_df = stops_df_id_delimited[~stops_df_id_delimited["delimited"]]
201
+
202
+ # grouping by name and distance
203
+ similar_stops = stops_df[stops_df["stop_name"] == stop["stop_name"]][
204
+ ["stop_id", "stop_name", "stop_lon", "stop_lat"]
205
+ ]
206
+ similar_stops = similar_stops.query(
207
+ f'(stop_lon - {stop["stop_lon"]}) ** 2 + (stop_lat - {stop["stop_lat"]}) ** 2 < {max_distance_degree ** 2}'
208
+ )
209
+ similar_stop_centroid = (
210
+ similar_stops[["stop_lon", "stop_lat"]].mean().values.tolist()
211
+ )
212
+ similar_stop_id = similar_stops["stop_id"].iloc[0]
213
+ similar_stop_name = stop["stop_name"]
214
+ return similar_stop_id, similar_stop_name, similar_stop_centroid
215
+
216
+ @lru_cache(maxsize=None)
217
+ def __get_stops_id_delimited(self, delimiter: str):
218
+ stops_df = self.gtfs.get("stops")[
219
+ ["stop_id", "stop_name", "stop_lon", "stop_lat", "parent_station"]
220
+ ].copy()
221
+ stops_df["stop_id_prefix"] = stops_df["stop_id"].map(
222
+ lambda stop_id: stop_id.rsplit(delimiter, 1)[0]
223
+ )
224
+ stops_df["delimited"] = stops_df["stop_id"] != stops_df["stop_id_prefix"]
225
+ grouped_by_prefix = (
226
+ stops_df[["stop_id_prefix", "stop_lon", "stop_lat"]]
227
+ .groupby("stop_id_prefix")
228
+ .mean()
229
+ .reset_index()
230
+ )
231
+ grouped_by_prefix.columns = [
232
+ "stop_id_prefix",
233
+ "similar_stops_centroid_lon",
234
+ "similar_stops_centroid_lat",
235
+ ]
236
+ stops_df_with_centroid = pd.merge(
237
+ stops_df, grouped_by_prefix, on="stop_id_prefix", how="left"
238
+ )
239
+ return stops_df_with_centroid
240
+
241
+ def read_interpolated_stops(self):
242
+ """
243
+ Read stops "interpolated" by parent station or stop_id or stop_name and distance.
244
+ There are many similar stops that are near to each, has same name, or has same prefix in stop_id.
245
+ In traffic analyzing, it is good for that similar stops to be grouped as same stop.
246
+ This method group them by some elements, parent, id, name and distance.
247
+
248
+ Args:
249
+ delimiter (str, optional): stop_id delimiter, sample_A, sample_B, then delimiter is '_'. Defaults to ''.
250
+ max_distance_degree (float, optional): distance limit in grouping by stop_name. Defaults to 0.01.
251
+
252
+ Returns:
253
+ [type]: [description]
254
+ """
255
+
256
+ stop_dicts = self.similar_stops_df[
257
+ [
258
+ "similar_stop_id",
259
+ "similar_stop_name",
260
+ "similar_stops_centroid",
261
+ "position_count",
262
+ ]
263
+ ].to_dict(orient="records")
264
+ return [
265
+ {
266
+ "type": "Feature",
267
+ "geometry": {
268
+ "type": "Point",
269
+ "coordinates": stop["similar_stops_centroid"],
270
+ },
271
+ "properties": {
272
+ "similar_stop_name": stop["similar_stop_name"],
273
+ "similar_stop_id": stop["similar_stop_id"],
274
+ "count": stop["position_count"],
275
+ },
276
+ }
277
+ for stop in stop_dicts
278
+ ]
279
+
280
+ def read_route_frequency(self):
281
+ """
282
+ By grouped stops, aggregate route frequency.
283
+ Filtering trips by a date, you can aggregate frequency only route serviced on the date.
284
+
285
+ Args:
286
+ yyyymmdd (str, optional): date, like 20210401. Defaults to ''.
287
+ begin_time (str, optional): 'hhmmss' <= departure time, like 030000. Defaults to ''.
288
+ end_time (str, optional): 'hhmmss' > departure time, like 280000. Defaults to ''.
289
+
290
+ Returns:
291
+ [type]: [description]
292
+ """
293
+ stop_times_df = (
294
+ self.gtfs.get("stop_times")[
295
+ ["stop_id", "trip_id", "stop_sequence", "departure_time"]
296
+ ]
297
+ .sort_values(["trip_id", "stop_sequence"])
298
+ .copy()
299
+ )
300
+
301
+ # join agency info)
302
+ stop_times_df = pd.merge(
303
+ stop_times_df,
304
+ self.gtfs["trips"][["trip_id", "route_id"]],
305
+ on="trip_id",
306
+ how="left",
307
+ )
308
+ stop_times_df = pd.merge(
309
+ stop_times_df,
310
+ self.gtfs["routes"][["route_id", "agency_id"]],
311
+ on="route_id",
312
+ how="left",
313
+ )
314
+ stop_times_df = pd.merge(
315
+ stop_times_df,
316
+ self.gtfs["agency"][["agency_id", "agency_name"]],
317
+ on="agency_id",
318
+ how="left",
319
+ )
320
+
321
+ # get prev and next stops_id, stop_name, trip_id
322
+ stop_times_df = pd.merge(
323
+ stop_times_df,
324
+ self.gtfs["stops"][
325
+ [
326
+ "stop_id",
327
+ "similar_stop_id",
328
+ "similar_stop_name",
329
+ "similar_stops_centroid",
330
+ ]
331
+ ],
332
+ on="stop_id",
333
+ how="left",
334
+ )
335
+ stop_times_df["prev_stop_id"] = stop_times_df["similar_stop_id"]
336
+ stop_times_df["prev_trip_id"] = stop_times_df["trip_id"]
337
+ stop_times_df["prev_stop_name"] = stop_times_df["similar_stop_name"]
338
+ stop_times_df["prev_similar_stops_centroid"] = stop_times_df[
339
+ "similar_stops_centroid"
340
+ ]
341
+ stop_times_df["next_stop_id"] = stop_times_df["similar_stop_id"].shift(-1)
342
+ stop_times_df["next_trip_id"] = stop_times_df["trip_id"].shift(-1)
343
+ stop_times_df["next_stop_name"] = stop_times_df["similar_stop_name"].shift(-1)
344
+ stop_times_df["next_similar_stops_centroid"] = stop_times_df[
345
+ "similar_stops_centroid"
346
+ ].shift(-1)
347
+
348
+ # drop last stops (-> stops has no next stop)
349
+ stop_times_df = stop_times_df.drop(
350
+ index=stop_times_df.query("prev_trip_id != next_trip_id").index
351
+ )
352
+
353
+ # define path_id by prev-stops-centroid and next-stops-centroid
354
+ stop_times_df["path_id"] = (
355
+ stop_times_df["prev_stop_id"]
356
+ + stop_times_df["next_stop_id"]
357
+ + stop_times_df["prev_similar_stops_centroid"].map(latlon_to_str)
358
+ + stop_times_df["next_similar_stops_centroid"].map(latlon_to_str)
359
+ )
360
+
361
+ # aggregate path-frequency
362
+ path_frequency = (
363
+ stop_times_df[["similar_stop_id", "path_id"]]
364
+ .groupby("path_id")
365
+ .count()
366
+ .reset_index()
367
+ )
368
+ path_frequency.columns = ["path_id", "path_count"]
369
+ path_data = pd.merge(
370
+ path_frequency,
371
+ stop_times_df.drop_duplicates(subset="path_id"),
372
+ on="path_id",
373
+ )
374
+ path_data_dict = path_data.to_dict(orient="records")
375
+
376
+ return [
377
+ {
378
+ "type": "Feature",
379
+ "geometry": {
380
+ "type": "LineString",
381
+ "coordinates": (
382
+ path["prev_similar_stops_centroid"],
383
+ path["next_similar_stops_centroid"],
384
+ ),
385
+ },
386
+ "properties": {
387
+ "frequency": path["path_count"],
388
+ "prev_stop_id": path["prev_stop_id"],
389
+ "prev_stop_name": path["prev_stop_name"],
390
+ "next_stop_id": path["next_stop_id"],
391
+ "next_stop_name": path["next_stop_name"],
392
+ "agency_id": path["agency_id"],
393
+ "agency_name": path["agency_name"],
394
+ },
395
+ }
396
+ for path in path_data_dict
397
+ ]
398
+
399
+ def __get_trips_on_a_date(self, yyyymmdd: str):
400
+ """
401
+ get trips are on service on a date.
402
+
403
+ Args:
404
+ yyyymmdd (str): [description]
405
+
406
+ Returns:
407
+ [type]: [description]
408
+ """
409
+ # sunday, monday, tuesday...
410
+ day_of_week = (
411
+ datetime.date(int(yyyymmdd[0:4]), int(yyyymmdd[4:6]), int(yyyymmdd[6:8]))
412
+ .strftime("%A")
413
+ .lower()
414
+ )
415
+
416
+ # filter services by day
417
+ calendar_df = self.gtfs["calendar"].copy()
418
+ calendar_df = calendar_df.astype({"start_date": int, "end_date": int})
419
+ calendar_df = calendar_df[calendar_df[day_of_week] == "1"]
420
+ calendar_df = calendar_df.query(
421
+ f"start_date <= {int(yyyymmdd)} and {int(yyyymmdd)} <= end_date",
422
+ engine="python",
423
+ )
424
+
425
+ services_on_a_day = calendar_df[["service_id"]]
426
+
427
+ calendar_dates_df = self.gtfs.get("calendar_dates")
428
+ if calendar_dates_df is not None:
429
+ filtered = calendar_dates_df[calendar_dates_df["date"] == yyyymmdd][
430
+ ["service_id", "exception_type"]
431
+ ]
432
+ to_be_removed_services = filtered[filtered["exception_type"] == "2"]
433
+ to_be_appended_services = filtered[filtered["exception_type"] == "1"][
434
+ ["service_id"]
435
+ ]
436
+
437
+ services_on_a_day = pd.merge(
438
+ services_on_a_day, to_be_removed_services, on="service_id", how="left"
439
+ )
440
+ services_on_a_day = services_on_a_day[
441
+ services_on_a_day["exception_type"] != "2"
442
+ ]
443
+ services_on_a_day = pd.concat([services_on_a_day, to_be_appended_services])
444
+
445
+ services_on_a_day["service_flag"] = 1
446
+
447
+ # filter trips
448
+ trips_df = self.gtfs["trips"].copy()
449
+ trip_service = pd.merge(trips_df, services_on_a_day, on="service_id")
450
+ trip_service = trip_service[trip_service["service_flag"] == 1]
451
+
452
+ return trip_service[["trip_id", "service_flag"]]
gtfs_parser/gtfs.py ADDED
@@ -0,0 +1,32 @@
1
+ import glob
2
+ import os
3
+
4
+ import pandas as pd
5
+
6
+
7
+ def GTFS(gtfs_dir: list) -> dict:
8
+ tables = {}
9
+ table_files = glob.glob(os.path.join(gtfs_dir, "*.txt"))
10
+ for table_file in table_files:
11
+ datatype = os.path.basename(table_file).split(".")[0]
12
+ with open(table_file, encoding="utf-8_sig") as f:
13
+ df = pd.read_csv(f, dtype=str)
14
+ if len(df) == 0:
15
+ print(f"{datatype}.txt is empty, skipping...")
16
+ continue
17
+ tables[datatype] = df
18
+
19
+ # cast some numeric columns from str to numeric
20
+ tables["stops"] = tables["stops"].astype({"stop_lon": float, "stop_lat": float})
21
+ tables["stop_times"] = tables["stop_times"].astype({"stop_sequence": int})
22
+ if tables.get("shapes") is not None:
23
+ tables["shapes"] = tables["shapes"].astype(
24
+ {"shape_pt_lon": float, "shape_pt_lat": float, "shape_pt_sequence": int}
25
+ )
26
+
27
+ # parent_station is optional column on GTFS but use in this module
28
+ # when parent_station is not in stops, fill by 'nan' (not NaN)
29
+ if "parent_station" not in tables.get("stops").columns:
30
+ tables["stops"]["parent_station"] = "nan"
31
+
32
+ return tables
gtfs_parser/parse.py ADDED
@@ -0,0 +1,178 @@
1
+ import pandas as pd
2
+
3
+ def read_stops(gtfs:dict, ignore_no_route=False) -> list:
4
+ """
5
+ read stops by stops table
6
+
7
+ Args:
8
+ ignore_no_route (bool, optional): stops unconnected to routes are skipped. Defaults to False.
9
+
10
+ Returns:
11
+ list: [description]
12
+ """
13
+
14
+ # get unique list of route_id related to each stop
15
+ stop_times_trip_df = pd.merge(
16
+ gtfs["stop_times"],
17
+ gtfs["trips"],
18
+ on="trip_id",
19
+ )
20
+ route_ids_on_stops = stop_times_trip_df.groupby("stop_id")["route_id"].unique()
21
+ route_ids_on_stops.apply(lambda x: x.sort())
22
+
23
+ # parse stops to GeoJSON-Features
24
+ features = []
25
+ for stop in gtfs["stops"][
26
+ ["stop_id", "stop_lat", "stop_lon", "stop_name"]
27
+ ].itertuples():
28
+ # get all route_id related to the stop
29
+ route_ids = []
30
+ if stop.stop_id in route_ids_on_stops:
31
+ route_ids = route_ids_on_stops.at[stop.stop_id].tolist()
32
+
33
+ if len(route_ids) == 0 and ignore_no_route:
34
+ # skip to output the stop
35
+ continue
36
+
37
+ features.append(
38
+ {
39
+ "type": "Feature",
40
+ "geometry": {
41
+ "type": "Point",
42
+ "coordinates": (stop.stop_lon, stop.stop_lat),
43
+ },
44
+ "properties": {
45
+ "stop_id": stop.stop_id,
46
+ "stop_name": stop.stop_name,
47
+ "route_ids": route_ids,
48
+ },
49
+ }
50
+ )
51
+ return features
52
+
53
+
54
+ def read_routes(gtfs:dict, ignore_shapes=False) -> list:
55
+ """
56
+ read routes by shapes or stop_times
57
+ First, this method try to load shapes and parse it into routes,
58
+ but shapes is optional table in GTFS. Then is shapes does not exist or no_shapes is True,
59
+ this parse routes by stop_time, stops, trips, and routes.
60
+
61
+ Args:
62
+ no_shapes (bool, optional): ignore shapes table. Defaults to False.
63
+
64
+ Returns:
65
+ [list]: list of GeoJSON-Feature-dict
66
+ """
67
+ features = []
68
+
69
+ if gtfs.get("shapes") is None or ignore_shapes:
70
+ # trip-route-merge:A
71
+ trips_routes = pd.merge(
72
+ gtfs["trips"][["trip_id", "route_id"]],
73
+ gtfs["routes"][
74
+ ["route_id", "route_long_name", "route_short_name"]
75
+ ],
76
+ on="route_id",
77
+ )
78
+
79
+ # stop_times-stops-merge:B
80
+ stop_times_stop = pd.merge(
81
+ gtfs["stop_times"][["stop_id", "trip_id", "stop_sequence"]],
82
+ gtfs.get("stops")[["stop_id", "stop_lon", "stop_lat"]],
83
+ on="stop_id",
84
+ )
85
+
86
+ # A-B-merge
87
+ merged = pd.merge(stop_times_stop, trips_routes, on="trip_id")
88
+ merged["route_concat_name"] = merged["route_long_name"].fillna("") + merged[
89
+ "route_short_name"
90
+ ].fillna("")
91
+
92
+ # parse routes
93
+ for route_id in merged["route_id"].unique():
94
+ route = merged[merged["route_id"] == route_id]
95
+ trip_id = route["trip_id"].unique()[0]
96
+ route = route[route["trip_id"] == trip_id].sort_values("stop_sequence")
97
+ features.append(
98
+ {
99
+ "type": "Feature",
100
+ "geometry": {
101
+ "type": "LineString",
102
+ "coordinates": route[
103
+ ["stop_lon", "stop_lat"]
104
+ ].values.tolist(),
105
+ },
106
+ "properties": {
107
+ "route_id": str(route_id),
108
+ "route_name": route.route_concat_name.values.tolist()[0],
109
+ },
110
+ }
111
+ )
112
+ else:
113
+ # get_shapeids_on route
114
+ trips_with_shape_df = gtfs["trips"][["route_id", "shape_id"]].dropna(
115
+ subset=["shape_id"]
116
+ )
117
+ shape_ids_on_routes = trips_with_shape_df.groupby("route_id")["shape_id"].unique()
118
+ shape_ids_on_routes.apply(lambda x: x.sort())
119
+
120
+ #get shape coordinate
121
+ shapes_df = gtfs["shapes"].copy()
122
+ shapes_df.sort_values("shape_pt_sequence")
123
+ shapes_df["pt"] = shapes_df[["shape_pt_lon", "shape_pt_lat"]].values.tolist()
124
+ shape_coords = shapes_df.groupby("shape_id")["pt"].apply(tuple)
125
+
126
+ # list-up already loaded shape_ids
127
+ loaded_shape_ids = set()
128
+ for route in gtfs.get("routes").itertuples():
129
+ if shape_ids_on_routes.get(route.route_id) is None:
130
+ continue
131
+
132
+ # get coords by route_id
133
+ coordinates = []
134
+ for shape_id in shape_ids_on_routes[route.route_id]:
135
+ coordinates.append(shape_coords.at[shape_id])
136
+ loaded_shape_ids.add(shape_id) # update loaded shape_ids
137
+
138
+ #get_route_name_from_tupple
139
+ if not pd.isna(route.route_short_name):
140
+ route_name = route.route_short_name
141
+ elif not pd.isna(route.route_long_name):
142
+ route_name = route.route_long_name
143
+ else:
144
+ ValueError(f'{route} have neither "route_long_name" or "route_short_time".')
145
+
146
+ features.append(
147
+ {
148
+ "type": "Feature",
149
+ "geometry": {
150
+ "type": "MultiLineString",
151
+ "coordinates": coordinates,
152
+ },
153
+ "properties": {
154
+ "route_id": str(route.route_id),
155
+ "route_name": route_name,
156
+ },
157
+ }
158
+ )
159
+
160
+ # load shapes unloaded yet
161
+ for shape_id in list(
162
+ filter(lambda id: id not in loaded_shape_ids, shape_coords.index)
163
+ ):
164
+ features.append(
165
+ {
166
+ "type": "Feature",
167
+ "geometry": {
168
+ "type": "MultiLineString",
169
+ "coordinates": [shape_coords.at[shape_id]],
170
+ },
171
+ "properties": {
172
+ "route_id": None,
173
+ "route_name": str(shape_id),
174
+ },
175
+ }
176
+ )
177
+
178
+ return features
@@ -0,0 +1,7 @@
1
+ Copyright 2020 MIERUNE Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.1
2
+ Name: gtfs-parser
3
+ Version: 0.1.0
4
+ Summary: parse and aggregate GTFS
5
+ Home-page: https://github.com/MIERUNE
6
+ License: MIT
7
+ Author: MIERUNE Inc.
8
+ Requires-Python: >=3.7.1
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Dist: pandas (>=1.3.3)
16
+ Project-URL: Repository, https://github.com/MIERUNE/{project-name}
17
+ Description-Content-Type: text/markdown
18
+
19
+ # gtfs_parser
20
+
21
+ ## LICENSE
22
+
23
+ MIT License
24
+
25
+ ## Installation
26
+
27
+ TODO
28
+
29
+ ## API
30
+
31
+ TODO
32
+
33
+ ## CLI
34
+
35
+ ```
36
+ usage: __main__.py [-h] [--parse_ignoreshapes] [--parse_ignorenoroute]
37
+ [--aggregate_yyyymmdd AGGREGATE_YYYYMMDD]
38
+ [--aggregate_nounifystops]
39
+ [--aggregate_delimiter AGGREGATE_DELIMITER]
40
+ [--aggregate_begintime AGGREGATE_BEGINTIME]
41
+ [--aggregate_endtime AGGREGATE_ENDTIME]
42
+ mode src dst
43
+
44
+ positional arguments:
45
+ mode
46
+ src
47
+ dst
48
+
49
+ optional arguments:
50
+ -h, --help show this help message and exit
51
+ --parse_ignoreshapes
52
+ --parse_ignorenoroute
53
+ --aggregate_yyyymmdd AGGREGATE_YYYYMMDD
54
+ --aggregate_nounifystops
55
+ --aggregate_delimiter AGGREGATE_DELIMITER
56
+ --aggregate_begintime AGGREGATE_BEGINTIME
57
+ --aggregate_endtime AGGREGATE_ENDTIME
58
+ ```
59
+
60
+ ### Example
61
+
62
+ ```sh
63
+ python -m gtfs_parser parse gtfs.zip output
64
+ python -m gtfs_parser parse gtfs_dir output --parse_ignoreshapes
65
+ python -m gtfs_parser aggregate gtfs.zip output
66
+ python -m gtfs_parser aggregate gtfs_dir output --aggregate_nounifystops
67
+ ```
@@ -0,0 +1,10 @@
1
+ gtfs_parser/__init__.py,sha256=AnaeEmVmXQi-AK-U3ZlPedWe-FKi3WXQ-Mj_nWtQLm8,54
2
+ gtfs_parser/__main__.py,sha256=zfE3xyT62Clu1u13q5ESktatcLhaeFOP7md2dVrmjAY,4198
3
+ gtfs_parser/aggregate.py,sha256=WoXZCPYNso0Y1D2xUHcwfYtmrVdWnY_MmetHWM3vSJA,17228
4
+ gtfs_parser/gtfs.py,sha256=rPPEzOPVP3h0wbRzgJutZ5uaX3gDcMlI9Ug7zI9mBqc,1178
5
+ gtfs_parser/parse.py,sha256=9jb32aeu7rMVfl-65KL5cGzAg26ye_CAoKyaEpzk_D4,6135
6
+ gtfs_parser-0.1.0.dist-info/LICENSE,sha256=zLQWXGgmFXub4HDAPU-w8vvRUFSX7tBJszPoYnvN7e0,1051
7
+ gtfs_parser-0.1.0.dist-info/METADATA,sha256=ZFwFJzxzTN9gljr881-5SLqnQOpLWuYXZi7LIu4Link,1732
8
+ gtfs_parser-0.1.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
9
+ gtfs_parser-0.1.0.dist-info/entry_points.txt,sha256=UaB49aNvntx4NNQTZ-3G25l2vvghejTcsmhPBXQHxWE,57
10
+ gtfs_parser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.6.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gtfs-parser=gtfs_parser.__main__:main
3
+