transitfreq 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,74 @@
1
+ """transitfreq: transit service frequency analysis from GTFS Schedule feeds.
2
+
3
+ Quick start::
4
+
5
+ import transitfreq as tf
6
+
7
+ day = tf.ServiceDay("gtfs.zip", "2024-05-06")
8
+ for r in day.route_summaries(["07:00-09:00", "09:00-15:00"]):
9
+ print(r.route_name, r.direction_id, r.windows[0].mean_headway_min, r.hours_los)
10
+ print(day.frequent_routes())
11
+ tf.write_geojson(tf.stops_geojson(day), "stops.geojson")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._version import __version__
17
+ from .analysis import FrequentCriteria, RouteSummary, ServiceDay, StopSummary, service_day
18
+ from .errors import GTFSError
19
+ from .feed import GTFSFeed, load_feed
20
+ from .geojson import routes_geojson, stops_geojson, write_geojson
21
+ from .headway import (
22
+ HeadwayStats,
23
+ Span,
24
+ headway_stats,
25
+ span_of_service,
26
+ tcqsm_hours_of_service,
27
+ trips_per_hour,
28
+ )
29
+ from .los import (
30
+ TCQSM2_FREQUENCY_LOS,
31
+ TCQSM2_HOURS_LOS,
32
+ TCQSM_FREQUENCY_LOS,
33
+ TCQSM_HOURS_LOS,
34
+ FrequencyBand,
35
+ HoursBand,
36
+ frequency_los,
37
+ hours_of_service_los,
38
+ )
39
+ from .schedule import Departure, active_service_ids, service_departures
40
+ from .timeutil import format_time, parse_time
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "Departure",
45
+ "FrequencyBand",
46
+ "FrequentCriteria",
47
+ "GTFSError",
48
+ "GTFSFeed",
49
+ "HeadwayStats",
50
+ "HoursBand",
51
+ "RouteSummary",
52
+ "ServiceDay",
53
+ "Span",
54
+ "StopSummary",
55
+ "TCQSM2_FREQUENCY_LOS",
56
+ "TCQSM2_HOURS_LOS",
57
+ "TCQSM_FREQUENCY_LOS",
58
+ "TCQSM_HOURS_LOS",
59
+ "active_service_ids",
60
+ "format_time",
61
+ "frequency_los",
62
+ "headway_stats",
63
+ "hours_of_service_los",
64
+ "load_feed",
65
+ "parse_time",
66
+ "routes_geojson",
67
+ "service_day",
68
+ "service_departures",
69
+ "span_of_service",
70
+ "stops_geojson",
71
+ "tcqsm_hours_of_service",
72
+ "trips_per_hour",
73
+ "write_geojson",
74
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m transitfreq``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,407 @@
1
+ """Service-day frequency analysis: stops, routes, the frequent network and LOS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
8
+
9
+ from .feed import GTFSFeed, PathLike
10
+ from .headway import HeadwayStats, headway_stats, span_of_service, trips_per_hour
11
+ from .los import (
12
+ TCQSM_FREQUENCY_LOS,
13
+ TCQSM_HOURS_LOS,
14
+ FrequencyBand,
15
+ HoursBand,
16
+ frequency_los,
17
+ hours_of_service_los,
18
+ )
19
+ from .schedule import Departure, active_service_ids, service_departures
20
+ from .timeutil import DateLike, Window, format_time, parse_date, parse_windows, window_label
21
+
22
+ WindowSpec = Union[str, Sequence[object]]
23
+ RouteKey = Tuple[str, Optional[int]]
24
+
25
+ DEFAULT_WINDOWS: Tuple[str, ...] = ("07:00-19:00",)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FrequentCriteria:
30
+ """Definition of "frequent service" (configurable; there is no universal standard).
31
+
32
+ The default, a headway of **15 minutes or better from 07:00 to 19:00**, is a common
33
+ planning convention for "frequent network" maps (see J. Walker, *Human Transit*,
34
+ Island Press, 2012; TCQSM 3rd ed. Exhibit 5-2 notes that 11-15 min service is "often
35
+ branded as 'frequent service'"). Agencies use other values (10 or 12 min,
36
+ 06:00-22:00, weekends ...), so every field is configurable.
37
+
38
+ Attributes
39
+ ----------
40
+ max_headway_min:
41
+ Threshold in minutes.
42
+ windows:
43
+ Time windows (``"HH:MM-HH:MM"``) that must *all* satisfy the threshold.
44
+ measure:
45
+ ``"max"`` (default; the longest wait in each window must not exceed the
46
+ threshold, see :mod:`transitfreq.headway`) or ``"mean"`` (average headway).
47
+ all_directions:
48
+ For routes, require every operated direction to qualify (default ``True``).
49
+ """
50
+
51
+ max_headway_min: float = 15.0
52
+ windows: Tuple[str, ...] = DEFAULT_WINDOWS
53
+ measure: str = "max"
54
+ all_directions: bool = True
55
+
56
+ def __post_init__(self) -> None:
57
+ if self.measure not in ("max", "mean"):
58
+ raise ValueError("measure must be 'max' or 'mean'")
59
+ if self.max_headway_min <= 0:
60
+ raise ValueError("max_headway_min must be positive")
61
+
62
+ def parsed_windows(self) -> List[Window]:
63
+ """Windows in seconds."""
64
+ return parse_windows(list(self.windows))
65
+
66
+ def is_met(self, times: Sequence[int]) -> bool:
67
+ """Whether sorted departure ``times`` meet the criteria in every window."""
68
+ for w in self.parsed_windows():
69
+ st = headway_stats(times, w)
70
+ if st.n_departures == 0:
71
+ return False
72
+ value = st.max_headway_min if self.measure == "max" else st.mean_headway_min
73
+ if value is None or value > self.max_headway_min + 1e-9:
74
+ return False
75
+ return True
76
+
77
+
78
+ def _flatten_windows(stats: Sequence[HeadwayStats], los: Dict[str, str]) -> Dict[str, object]:
79
+ out: Dict[str, object] = {}
80
+ for st in stats:
81
+ p = st.window.replace(":", "")
82
+ out[f"{p}_departures"] = st.n_departures
83
+ out[f"{p}_trips_per_hour"] = round(st.trips_per_hour, 3)
84
+ out[f"{p}_mean_headway_min"] = _r(st.mean_headway_min)
85
+ out[f"{p}_max_headway_min"] = _r(st.max_headway_min)
86
+ out[f"{p}_frequency_los"] = los.get(st.window, "")
87
+ return out
88
+
89
+
90
+ def _r(v: Optional[float]) -> Optional[float]:
91
+ return None if v is None else round(v, 2)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class StopSummary:
96
+ """Combined service at a stop (or station) across all routes."""
97
+
98
+ stop_id: str
99
+ stop_name: str
100
+ lat: Optional[float]
101
+ lon: Optional[float]
102
+ n_departures: int
103
+ route_ids: Tuple[str, ...]
104
+ first_departure: Optional[int]
105
+ last_departure: Optional[int]
106
+ span_hours: float
107
+ hours_of_service: int
108
+ peak_trips_per_hour: int
109
+ windows: Tuple[HeadwayStats, ...] = field(default=())
110
+ frequency_los: Dict[str, str] = field(default_factory=dict)
111
+ hours_los: str = ""
112
+ frequent: bool = False
113
+
114
+ def as_dict(self) -> Dict[str, object]:
115
+ """Flat dict for CSV/JSON/GeoJSON properties (times as ``HH:MM:SS``)."""
116
+ d: Dict[str, object] = {
117
+ "stop_id": self.stop_id,
118
+ "stop_name": self.stop_name,
119
+ "lat": self.lat,
120
+ "lon": self.lon,
121
+ "n_departures": self.n_departures,
122
+ "n_routes": len(self.route_ids),
123
+ "route_ids": " ".join(self.route_ids),
124
+ "first_departure": format_time(self.first_departure),
125
+ "last_departure": format_time(self.last_departure),
126
+ "span_hours": round(self.span_hours, 2),
127
+ "hours_of_service": self.hours_of_service,
128
+ "peak_trips_per_hour": self.peak_trips_per_hour,
129
+ }
130
+ d.update(_flatten_windows(self.windows, self.frequency_los))
131
+ d["hours_los"] = self.hours_los
132
+ d["frequent"] = self.frequent
133
+ return d
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class RouteSummary:
138
+ """Service of one route in one direction, measured at trip start times."""
139
+
140
+ route_id: str
141
+ route_name: str
142
+ route_type: int
143
+ direction_id: Optional[int]
144
+ n_trips: int
145
+ first_departure: Optional[int]
146
+ last_departure: Optional[int]
147
+ span_hours: float
148
+ hours_of_service: int
149
+ peak_trips_per_hour: int
150
+ windows: Tuple[HeadwayStats, ...] = field(default=())
151
+ frequency_los: Dict[str, str] = field(default_factory=dict)
152
+ hours_los: str = ""
153
+ frequent: bool = False
154
+
155
+ def as_dict(self) -> Dict[str, object]:
156
+ """Flat dict for CSV/JSON/GeoJSON properties (times as ``HH:MM:SS``)."""
157
+ d: Dict[str, object] = {
158
+ "route_id": self.route_id,
159
+ "route_name": self.route_name,
160
+ "route_type": self.route_type,
161
+ "direction_id": "" if self.direction_id is None else self.direction_id,
162
+ "n_trips": self.n_trips,
163
+ "first_departure": format_time(self.first_departure),
164
+ "last_departure": format_time(self.last_departure),
165
+ "span_hours": round(self.span_hours, 2),
166
+ "hours_of_service": self.hours_of_service,
167
+ "peak_trips_per_hour": self.peak_trips_per_hour,
168
+ }
169
+ d.update(_flatten_windows(self.windows, self.frequency_los))
170
+ d["hours_los"] = self.hours_los
171
+ d["frequent"] = self.frequent
172
+ return d
173
+
174
+
175
+ class ServiceDay:
176
+ """All scheduled service of one date in a GTFS feed, ready for frequency analysis.
177
+
178
+ Parameters
179
+ ----------
180
+ feed:
181
+ A :class:`~transitfreq.feed.GTFSFeed` or a path to a feed (zip or folder).
182
+ date:
183
+ The service date (``YYYY-MM-DD``, ``YYYYMMDD`` or :class:`datetime.date`).
184
+ include_previous_day:
185
+ Add after-midnight trips of the previous service day (see
186
+ :func:`~transitfreq.schedule.service_departures`).
187
+ frequency_table, hours_table:
188
+ LOS tables (default TCQSM 3rd ed. Exhibits 5-2 and 5-3).
189
+ criteria:
190
+ :class:`FrequentCriteria` used for the ``frequent`` flags.
191
+ """
192
+
193
+ def __init__(
194
+ self,
195
+ feed: Union[GTFSFeed, PathLike],
196
+ date: DateLike,
197
+ *,
198
+ include_previous_day: bool = False,
199
+ frequency_table: Sequence[FrequencyBand] = TCQSM_FREQUENCY_LOS,
200
+ hours_table: Sequence[HoursBand] = TCQSM_HOURS_LOS,
201
+ criteria: Optional[FrequentCriteria] = None,
202
+ ) -> None:
203
+ self.feed = feed if isinstance(feed, GTFSFeed) else GTFSFeed(feed)
204
+ self.date = parse_date(date)
205
+ self.service_ids: Set[str] = active_service_ids(self.feed, self.date)
206
+ self.departures: List[Departure] = service_departures(
207
+ self.feed, self.date, include_previous_day=include_previous_day
208
+ )
209
+ self.frequency_table = frequency_table
210
+ self.hours_table = hours_table
211
+ self.criteria = criteria or FrequentCriteria()
212
+
213
+ # ------------------------------------------------------------------ grouping
214
+ def _station_of(self, stop_id: str) -> str:
215
+ stop = self.feed.stops.get(stop_id)
216
+ return stop.parent_station if stop and stop.parent_station else stop_id
217
+
218
+ def stop_departure_times(
219
+ self, *, by_station: bool = False, route_ids: Optional[Iterable[str]] = None
220
+ ) -> Dict[str, List[int]]:
221
+ """Sorted boarding times per stop (or parent station), all routes combined."""
222
+ keep = set(route_ids) if route_ids is not None else None
223
+ out: Dict[str, List[int]] = defaultdict(list)
224
+ for d in self.departures:
225
+ if not d.boarding or (keep is not None and d.route_id not in keep):
226
+ continue
227
+ key = self._station_of(d.stop_id) if by_station else d.stop_id
228
+ out[key].append(d.time)
229
+ return {k: sorted(v) for k, v in out.items()}
230
+
231
+ def route_start_times(self) -> Dict[RouteKey, List[int]]:
232
+ """Sorted trip start times per ``(route_id, direction_id)``."""
233
+ out: Dict[RouteKey, List[int]] = defaultdict(list)
234
+ for d in self.departures:
235
+ if d.first_stop:
236
+ out[(d.route_id, d.direction_id)].append(d.time)
237
+ return {k: sorted(v) for k, v in out.items()}
238
+
239
+ def trips_per_hour(self, by: str = "route") -> Dict[object, List[int]]:
240
+ """Departures per clock hour.
241
+
242
+ ``by`` is ``"route"`` (trip starts per route), ``"route_direction"`` (keys are
243
+ ``(route_id, direction_id)``), ``"stop"`` (boardings per stop, all routes) or
244
+ ``"stop_route"`` (keys are ``(stop_id, route_id)``).
245
+ """
246
+ groups: Dict[object, List[int]] = defaultdict(list)
247
+ for d in self.departures:
248
+ if by == "route":
249
+ if d.first_stop:
250
+ groups[d.route_id].append(d.time)
251
+ elif by == "route_direction":
252
+ if d.first_stop:
253
+ groups[(d.route_id, d.direction_id)].append(d.time)
254
+ elif by == "stop":
255
+ if d.boarding:
256
+ groups[d.stop_id].append(d.time)
257
+ elif by == "stop_route":
258
+ if d.boarding:
259
+ groups[(d.stop_id, d.route_id)].append(d.time)
260
+ else:
261
+ raise ValueError("by must be 'route', 'route_direction', 'stop' or 'stop_route'")
262
+ hours = max([24] + [max(v) // 3600 + 1 for v in groups.values() if v])
263
+ return {k: trips_per_hour(v, hours=hours) for k, v in groups.items()}
264
+
265
+ # ------------------------------------------------------------------ summaries
266
+ def _windows(self, windows: Optional[Sequence[WindowSpec]]) -> List[Window]:
267
+ return parse_windows(list(windows) if windows else list(DEFAULT_WINDOWS))
268
+
269
+ def _measure(
270
+ self, times: List[int], windows: List[Window]
271
+ ) -> Tuple[Tuple[HeadwayStats, ...], Dict[str, str], int]:
272
+ stats = tuple(headway_stats(times, w) for w in windows)
273
+ los = {s.window: frequency_los(s.mean_headway_min, self.frequency_table) for s in stats}
274
+ peak = max(trips_per_hour(times)) if times else 0
275
+ return stats, los, peak
276
+
277
+ def stop_summaries(
278
+ self,
279
+ windows: Optional[Sequence[WindowSpec]] = None,
280
+ *,
281
+ by_station: bool = False,
282
+ route_ids: Optional[Iterable[str]] = None,
283
+ ) -> List[StopSummary]:
284
+ """Combined headways across routes at each served stop (or parent station).
285
+
286
+ Only boarding events count (not the last stop of a trip, not ``pickup_type=1``).
287
+ """
288
+ wins = self._windows(windows)
289
+ stops = self.feed.stops
290
+ rmap: Dict[str, Set[str]] = defaultdict(set)
291
+ keep = set(route_ids) if route_ids is not None else None
292
+ for d in self.departures:
293
+ if d.boarding and (keep is None or d.route_id in keep):
294
+ rmap[self._station_of(d.stop_id) if by_station else d.stop_id].add(d.route_id)
295
+ out: List[StopSummary] = []
296
+ for sid, times in sorted(
297
+ self.stop_departure_times(by_station=by_station, route_ids=route_ids).items()
298
+ ):
299
+ stats, los, peak = self._measure(times, wins)
300
+ sp = span_of_service(times)
301
+ stop = stops.get(sid)
302
+ out.append(
303
+ StopSummary(
304
+ stop_id=sid,
305
+ stop_name=stop.name if stop else "",
306
+ lat=stop.lat if stop else None,
307
+ lon=stop.lon if stop else None,
308
+ n_departures=len(times),
309
+ route_ids=tuple(sorted(rmap[sid])),
310
+ first_departure=sp.first,
311
+ last_departure=sp.last,
312
+ span_hours=sp.span_hours,
313
+ hours_of_service=sp.hours_of_service,
314
+ peak_trips_per_hour=peak,
315
+ windows=stats,
316
+ frequency_los=los,
317
+ hours_los=hours_of_service_los(sp.hours_of_service, self.hours_table),
318
+ frequent=self.criteria.is_met(times),
319
+ )
320
+ )
321
+ return out
322
+
323
+ def route_summaries(self, windows: Optional[Sequence[WindowSpec]] = None) -> List[RouteSummary]:
324
+ """Service summary per route and direction, measured at trip start times.
325
+
326
+ Trip start times (first stop of each trip) are used as the reference, which is
327
+ exact for simple routes; for routes with short-turn or branch patterns all starts
328
+ are pooled per direction.
329
+ """
330
+ wins = self._windows(windows)
331
+ routes = self.feed.routes
332
+ out: List[RouteSummary] = []
333
+ items = sorted(
334
+ self.route_start_times().items(),
335
+ key=lambda kv: (kv[0][0], -1 if kv[0][1] is None else kv[0][1]),
336
+ )
337
+ for (rid, did), times in items:
338
+ stats, los, peak = self._measure(times, wins)
339
+ sp = span_of_service(times)
340
+ route = routes.get(rid)
341
+ out.append(
342
+ RouteSummary(
343
+ route_id=rid,
344
+ route_name=route.name if route else rid,
345
+ route_type=route.route_type if route else -1,
346
+ direction_id=did,
347
+ n_trips=len(times),
348
+ first_departure=sp.first,
349
+ last_departure=sp.last,
350
+ span_hours=sp.span_hours,
351
+ hours_of_service=sp.hours_of_service,
352
+ peak_trips_per_hour=peak,
353
+ windows=stats,
354
+ frequency_los=los,
355
+ hours_los=hours_of_service_los(sp.hours_of_service, self.hours_table),
356
+ frequent=self.criteria.is_met(times),
357
+ )
358
+ )
359
+ return out
360
+
361
+ # ------------------------------------------------------------------ frequent network
362
+ def frequent_routes(self, criteria: Optional[FrequentCriteria] = None) -> List[str]:
363
+ """Route ids that meet ``criteria`` (default: the day's :class:`FrequentCriteria`)."""
364
+ crit = criteria or self.criteria
365
+ per_route: Dict[str, List[bool]] = defaultdict(list)
366
+ for (rid, _), times in self.route_start_times().items():
367
+ per_route[rid].append(crit.is_met(times))
368
+ return sorted(
369
+ rid for rid, flags in per_route.items() if (all if crit.all_directions else any)(flags)
370
+ )
371
+
372
+ def frequent_stops(
373
+ self, criteria: Optional[FrequentCriteria] = None, *, by_station: bool = False
374
+ ) -> List[str]:
375
+ """Stops whose combined service (all routes) meets ``criteria``."""
376
+ crit = criteria or self.criteria
377
+ return sorted(
378
+ sid
379
+ for sid, times in self.stop_departure_times(by_station=by_station).items()
380
+ if crit.is_met(times)
381
+ )
382
+
383
+ def summary(self, windows: Optional[Sequence[WindowSpec]] = None) -> Dict[str, object]:
384
+ """System-level overview of the day."""
385
+ starts = [d.time for d in self.departures if d.first_stop]
386
+ sp = span_of_service(starts)
387
+ routes = self.route_summaries(windows)
388
+ return {
389
+ "date": self.date.isoformat(),
390
+ "service_ids": sorted(self.service_ids),
391
+ "n_trips": len(starts),
392
+ "n_routes": len({r.route_id for r in routes}),
393
+ "n_stops_served": len(self.stop_departure_times()),
394
+ "first_departure": format_time(sp.first),
395
+ "last_departure": format_time(sp.last),
396
+ "frequent_routes": self.frequent_routes(),
397
+ "criteria": {
398
+ "max_headway_min": self.criteria.max_headway_min,
399
+ "windows": [window_label(w) for w in self.criteria.parsed_windows()],
400
+ "measure": self.criteria.measure,
401
+ },
402
+ }
403
+
404
+
405
+ def service_day(feed: Union[GTFSFeed, PathLike], date: DateLike, **kwargs: object) -> ServiceDay:
406
+ """Shortcut for :class:`ServiceDay` ``(feed, date, **kwargs)``."""
407
+ return ServiceDay(feed, date, **kwargs) # type: ignore[arg-type]
transitfreq/cli.py ADDED
@@ -0,0 +1,191 @@
1
+ """Command line interface: ``transitfreq <command> FEED --date YYYY-MM-DD``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from typing import Dict, List, Optional, Sequence, TextIO
10
+
11
+ from ._version import __version__
12
+ from .analysis import FrequentCriteria, ServiceDay
13
+ from .errors import GTFSError
14
+ from .geojson import routes_geojson, stops_geojson, write_geojson
15
+ from .schedule import active_service_ids
16
+ from .timeutil import format_time
17
+
18
+
19
+ def _write_rows(rows: List[Dict[str, object]], fmt: str, out: TextIO) -> None:
20
+ if fmt == "json":
21
+ json.dump(rows, out, ensure_ascii=False, indent=1)
22
+ out.write("\n")
23
+ return
24
+ if not rows:
25
+ return
26
+ fields: List[str] = []
27
+ for r in rows:
28
+ for k in r:
29
+ if k not in fields:
30
+ fields.append(k)
31
+ if fmt == "csv":
32
+ w = csv.DictWriter(out, fieldnames=fields, lineterminator="\n")
33
+ w.writeheader()
34
+ w.writerows(rows)
35
+ return
36
+ # plain table
37
+ cells = [[("" if r.get(f) is None else str(r.get(f, ""))) for f in fields] for r in rows]
38
+ widths = [max(len(f), *(len(c[i]) for c in cells)) for i, f in enumerate(fields)]
39
+ out.write(" ".join(f.ljust(widths[i]) for i, f in enumerate(fields)).rstrip() + "\n")
40
+ for c in cells:
41
+ out.write(" ".join(v.ljust(widths[i]) for i, v in enumerate(c)).rstrip() + "\n")
42
+
43
+
44
+ def _emit(rows: List[Dict[str, object]], args: argparse.Namespace) -> None:
45
+ if args.output:
46
+ with open(args.output, "w", encoding="utf-8", newline="") as fh:
47
+ _write_rows(rows, args.format if args.format != "table" else "csv", fh)
48
+ else:
49
+ _write_rows(rows, args.format, sys.stdout)
50
+
51
+
52
+ def _criteria(args: argparse.Namespace) -> FrequentCriteria:
53
+ return FrequentCriteria(
54
+ max_headway_min=args.threshold,
55
+ windows=tuple(args.frequent_window or args.window or ("07:00-19:00",)),
56
+ measure=args.measure,
57
+ all_directions=not args.any_direction,
58
+ )
59
+
60
+
61
+ def _day(args: argparse.Namespace) -> ServiceDay:
62
+ return ServiceDay(
63
+ args.feed,
64
+ args.date,
65
+ include_previous_day=args.include_previous_day,
66
+ criteria=_criteria(args),
67
+ )
68
+
69
+
70
+ def build_parser() -> argparse.ArgumentParser:
71
+ """Argument parser (exposed for documentation and tests)."""
72
+ p = argparse.ArgumentParser(
73
+ prog="transitfreq",
74
+ description="Transit service frequency analysis from a GTFS Schedule feed.",
75
+ )
76
+ p.add_argument("--version", action="version", version=f"transitfreq {__version__}")
77
+ sub = p.add_subparsers(dest="command", required=True)
78
+
79
+ common = argparse.ArgumentParser(add_help=False)
80
+ common.add_argument("feed", help="GTFS feed (.zip or folder)")
81
+ common.add_argument("--date", required=True, help="service date, YYYY-MM-DD or YYYYMMDD")
82
+ common.add_argument(
83
+ "--window",
84
+ action="append",
85
+ metavar="HH:MM-HH:MM",
86
+ help="analysis window (repeatable; default 07:00-19:00)",
87
+ )
88
+ common.add_argument(
89
+ "--include-previous-day",
90
+ action="store_true",
91
+ help="add after-midnight trips of the previous service day",
92
+ )
93
+ common.add_argument(
94
+ "--threshold", type=float, default=15.0, help="frequent headway in minutes (15)"
95
+ )
96
+ common.add_argument(
97
+ "--frequent-window",
98
+ action="append",
99
+ metavar="HH:MM-HH:MM",
100
+ help="windows for the frequent test (default: --window, else 07:00-19:00)",
101
+ )
102
+ common.add_argument("--measure", choices=("max", "mean"), default="max")
103
+ common.add_argument(
104
+ "--any-direction", action="store_true", help="a route is frequent if any direction is"
105
+ )
106
+ common.add_argument("--format", choices=("table", "csv", "json"), default="table")
107
+ common.add_argument("-o", "--output", help="write to this file instead of stdout")
108
+
109
+ sub.add_parser("summary", parents=[common], help="system overview of the day")
110
+ sub.add_parser("services", parents=[common], help="service_ids active on the date")
111
+ sub.add_parser("routes", parents=[common], help="per route-direction summary")
112
+ sp = sub.add_parser("stops", parents=[common], help="combined headways per stop")
113
+ sp.add_argument("--by-station", action="store_true", help="group platforms by parent")
114
+ sub.add_parser("frequent", parents=[common], help="frequent routes and stops")
115
+ tp = sub.add_parser("tph", parents=[common], help="trips per hour table")
116
+ tp.add_argument(
117
+ "--by", choices=("route", "route_direction", "stop", "stop_route"), default="route"
118
+ )
119
+ gp = sub.add_parser("geojson", parents=[common], help="export stops/routes GeoJSON")
120
+ gp.add_argument("--stops", help="output path for stops GeoJSON")
121
+ gp.add_argument("--routes", help="output path for routes GeoJSON")
122
+ gp.add_argument("--by-station", action="store_true")
123
+ return p
124
+
125
+
126
+ def run(args: argparse.Namespace) -> int:
127
+ """Execute a parsed command; returns the exit code."""
128
+ cmd = args.command
129
+ if cmd == "services":
130
+ services: List[Dict[str, object]] = [
131
+ {"service_id": s} for s in active_service_ids_for(args)
132
+ ]
133
+ _emit(services, args)
134
+ return 0
135
+ day = _day(args)
136
+ if cmd == "summary":
137
+ info = day.summary(args.window)
138
+ if args.format == "json" or args.output:
139
+ _emit([info], args)
140
+ else:
141
+ for k, v in info.items():
142
+ print(f"{k}: {v}")
143
+ elif cmd == "routes":
144
+ _emit([r.as_dict() for r in day.route_summaries(args.window)], args)
145
+ elif cmd == "stops":
146
+ _emit(
147
+ [s.as_dict() for s in day.stop_summaries(args.window, by_station=args.by_station)],
148
+ args,
149
+ )
150
+ elif cmd == "frequent":
151
+ rows: List[Dict[str, object]] = []
152
+ rows.extend({"kind": "route", "id": r} for r in day.frequent_routes())
153
+ rows.extend({"kind": "stop", "id": s} for s in day.frequent_stops())
154
+ _emit(rows, args)
155
+ elif cmd == "tph":
156
+ rows = []
157
+ for key, counts in sorted(day.trips_per_hour(args.by).items(), key=lambda kv: str(kv[0])):
158
+ label = key if isinstance(key, str) else "|".join(str(k) for k in key) # type: ignore[attr-defined]
159
+ row: Dict[str, object] = {"key": label}
160
+ for h, c in enumerate(counts):
161
+ row[format_time(h * 3600)[:5]] = c
162
+ rows.append(row)
163
+ _emit(rows, args)
164
+ elif cmd == "geojson":
165
+ if not args.stops and not args.routes:
166
+ print("geojson: give --stops and/or --routes output paths", file=sys.stderr)
167
+ return 2
168
+ if args.stops:
169
+ write_geojson(stops_geojson(day, args.window, by_station=args.by_station), args.stops)
170
+ if args.routes:
171
+ write_geojson(routes_geojson(day, args.window), args.routes)
172
+ return 0
173
+
174
+
175
+ def active_service_ids_for(args: argparse.Namespace) -> List[str]:
176
+ """Active service ids for the parsed ``feed``/``date`` arguments."""
177
+ from .feed import GTFSFeed
178
+
179
+ with GTFSFeed(args.feed) as feed:
180
+ return sorted(active_service_ids(feed, args.date))
181
+
182
+
183
+ def main(argv: Optional[Sequence[str]] = None) -> int:
184
+ """Entry point of the ``transitfreq`` console script."""
185
+ parser = build_parser()
186
+ args = parser.parse_args(argv)
187
+ try:
188
+ return run(args)
189
+ except (GTFSError, ValueError, OSError) as exc:
190
+ print(f"transitfreq: error: {exc}", file=sys.stderr)
191
+ return 1
transitfreq/errors.py ADDED
@@ -0,0 +1,7 @@
1
+ """Exceptions raised by transitfreq."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class GTFSError(ValueError):
7
+ """A GTFS feed is missing a required file/column or contains an invalid value."""