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.
- transitfreq/__init__.py +74 -0
- transitfreq/__main__.py +7 -0
- transitfreq/_version.py +1 -0
- transitfreq/analysis.py +407 -0
- transitfreq/cli.py +191 -0
- transitfreq/errors.py +7 -0
- transitfreq/feed.py +294 -0
- transitfreq/geojson.py +127 -0
- transitfreq/headway.py +152 -0
- transitfreq/los.py +140 -0
- transitfreq/pandas_ext.py +37 -0
- transitfreq/py.typed +0 -0
- transitfreq/schedule.py +164 -0
- transitfreq/timeutil.py +98 -0
- transitfreq-0.1.0.dist-info/METADATA +260 -0
- transitfreq-0.1.0.dist-info/RECORD +20 -0
- transitfreq-0.1.0.dist-info/WHEEL +5 -0
- transitfreq-0.1.0.dist-info/entry_points.txt +2 -0
- transitfreq-0.1.0.dist-info/licenses/LICENSE +21 -0
- transitfreq-0.1.0.dist-info/top_level.txt +1 -0
transitfreq/__init__.py
ADDED
|
@@ -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
|
+
]
|
transitfreq/__main__.py
ADDED
transitfreq/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
transitfreq/analysis.py
ADDED
|
@@ -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
|