aadtkit 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.
aadtkit/cli.py ADDED
@@ -0,0 +1,307 @@
1
+ """Command-line interface: ``aadtkit <command> ...``.
2
+
3
+ Commands read the hourly CSV layout described in :mod:`aadtkit.io` and write
4
+ CSV to standard output or to ``-o FILE``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ from typing import Dict, List, Mapping, Optional, Sequence
12
+
13
+ from . import __version__
14
+ from .aadt import AADTResult, aadt_aashto, aadt_hourly_weighted, aadt_simple, daily_totals
15
+ from .classification import CLASS_DESCRIPTIONS, class_shares
16
+ from .errors import AADTKitError
17
+ from .expand import short_count_aadt
18
+ from .factors import cluster_stations, factors_from_result, group_factors
19
+ from .io import (
20
+ by_direction,
21
+ by_station,
22
+ class_totals,
23
+ duplicate_keys,
24
+ read_factors_csv,
25
+ read_hourly_csv,
26
+ write_factors_csv,
27
+ write_rows_csv,
28
+ )
29
+ from .peak import d_factor, nth_highest_hour
30
+ from .quality import check_hourly
31
+
32
+ __all__ = ["main"]
33
+
34
+
35
+ def _compute(series, method: str, year: Optional[int], allow_missing: bool) -> AADTResult:
36
+ if method == "hourly":
37
+ return aadt_hourly_weighted(series, year=year, allow_missing_cells=allow_missing)
38
+ daily = daily_totals(series)
39
+ if year is not None:
40
+ daily = {d: v for d, v in daily.items() if d.year == year}
41
+ if method == "aashto":
42
+ return aadt_aashto(daily, year=year, allow_missing_cells=allow_missing)
43
+ return aadt_simple(daily)
44
+
45
+
46
+ def _emit(
47
+ args: argparse.Namespace, rows: Sequence[Mapping[str, object]], fields: Sequence[str]
48
+ ) -> None:
49
+ if getattr(args, "output", None):
50
+ write_rows_csv(args.output, rows, fields)
51
+ else:
52
+ write_rows_csv(sys.stdout, rows, fields)
53
+
54
+
55
+ def _read(args: argparse.Namespace):
56
+ return read_hourly_csv(args.file, hour_convention="ending" if args.hour_ending else "beginning")
57
+
58
+
59
+ def _cmd_aadt(args: argparse.Namespace) -> int:
60
+ rows = []
61
+ for st, series in by_station(_read(args)).items():
62
+ res = _compute(series, args.method, args.year, args.allow_missing)
63
+ rows.append(
64
+ {
65
+ "station": st,
66
+ "method": res.method,
67
+ "year": res.year if res.year is not None else "",
68
+ "aadt": f"{res.aadt:.1f}",
69
+ "complete_days": len(daily_totals(series)),
70
+ "missing_cells": len(res.missing_cells),
71
+ }
72
+ )
73
+ _emit(args, rows, ["station", "method", "year", "aadt", "complete_days", "missing_cells"])
74
+ return 0
75
+
76
+
77
+ def _cmd_factors(args: argparse.Namespace) -> int:
78
+ sets = []
79
+ for st, series in by_station(_read(args)).items():
80
+ res = _compute(series, args.method, args.year, args.allow_missing)
81
+ sets.append(factors_from_result(res, name=st))
82
+ if args.group:
83
+ sets.append(group_factors(sets, name=args.group))
84
+ if args.output:
85
+ write_factors_csv(args.output, sets)
86
+ else:
87
+ write_factors_csv(sys.stdout, sets)
88
+ return 0
89
+
90
+
91
+ def _cmd_cluster(args: argparse.Namespace) -> int:
92
+ sets = list(read_factors_csv(args.factors).values())
93
+ res = cluster_stations(sets, args.k, features=args.features, seed=args.seed)
94
+ rows = [{"station": s, "group": g} for s, g in res.labels.items()]
95
+ _emit(args, rows, ["station", "group"])
96
+ return 0
97
+
98
+
99
+ def _cmd_expand(args: argparse.Namespace) -> int:
100
+ table = read_factors_csv(args.factors)
101
+ if args.group:
102
+ if args.group not in table:
103
+ raise AADTKitError(f"factor set {args.group!r} not in {sorted(table)}")
104
+ fs = table[args.group]
105
+ elif len(table) == 1:
106
+ fs = next(iter(table.values()))
107
+ else:
108
+ raise AADTKitError(f"factor file has several sets {sorted(table)}; pass --group")
109
+ rows = []
110
+ for st, series in by_station(_read(args)).items():
111
+ est = short_count_aadt(
112
+ series, fs, axle_factor=args.axle_factor, growth=args.growth, basis=args.basis
113
+ )
114
+ rows.append(
115
+ {
116
+ "station": st,
117
+ "periods": len(est.periods),
118
+ "raw_adt": f"{est.raw_adt:.1f}",
119
+ "aadt": f"{est.aadt:.1f}",
120
+ "factor_set": fs.name,
121
+ }
122
+ )
123
+ _emit(args, rows, ["station", "periods", "raw_adt", "aadt", "factor_set"])
124
+ return 0
125
+
126
+
127
+ def _cmd_peak(args: argparse.Namespace) -> int:
128
+ recs = _read(args)
129
+ rows = []
130
+ for st, series in by_station(recs).items():
131
+ aadt = args.aadt
132
+ if aadt is None:
133
+ aadt = _compute(series, "hourly", args.year, True).aadt
134
+ hour, vol = nth_highest_hour(series, args.nth)
135
+ row: Dict[str, object] = {
136
+ "station": st,
137
+ "aadt": f"{aadt:.1f}",
138
+ "nth": args.nth,
139
+ "hour": hour.strftime("%Y-%m-%d %H:%M"),
140
+ "volume": f"{vol:g}",
141
+ "k_factor": f"{vol / aadt:.4f}",
142
+ "d_factor": "",
143
+ "peak_direction": "",
144
+ }
145
+ dirs = {k: v for k, v in by_direction(recs, st).items() if k}
146
+ if len(dirs) >= 2:
147
+ d = d_factor(dirs, args.nth)
148
+ row["d_factor"] = f"{d.d:.4f}"
149
+ row["peak_direction"] = d.peak_direction
150
+ rows.append(row)
151
+ _emit(
152
+ args,
153
+ rows,
154
+ ["station", "aadt", "nth", "hour", "volume", "k_factor", "d_factor", "peak_direction"],
155
+ )
156
+ return 0
157
+
158
+
159
+ def _cmd_qc(args: argparse.Namespace) -> int:
160
+ recs = _read(args)
161
+ rows: List[Dict[str, object]] = []
162
+ for st, hour, direction, vc in duplicate_keys(recs):
163
+ rows.append(
164
+ {
165
+ "station": st,
166
+ "kind": "duplicate",
167
+ "start": hour.strftime("%Y-%m-%d %H:%M"),
168
+ "end": hour.strftime("%Y-%m-%d %H:%M"),
169
+ "hours": 1,
170
+ "detail": f"direction={direction!r} class={vc}",
171
+ }
172
+ )
173
+ for st, series in by_station(recs).items():
174
+ rep = check_hourly(
175
+ series,
176
+ zero_run_hours=args.zero_run,
177
+ constant_run_hours=args.constant_run,
178
+ max_hourly=args.max_hourly,
179
+ outlier_z=args.outlier_z,
180
+ )
181
+ for i in rep.issues:
182
+ rows.append(
183
+ {
184
+ "station": st,
185
+ "kind": i.kind,
186
+ "start": i.start.strftime("%Y-%m-%d %H:%M"),
187
+ "end": i.end.strftime("%Y-%m-%d %H:%M"),
188
+ "hours": i.hours,
189
+ "detail": i.detail,
190
+ }
191
+ )
192
+ print(
193
+ f"{st}: {rep.n_hours}/{rep.n_expected} hours ({rep.completeness:.1%}), "
194
+ f"{len(rep.issues)} issue(s)",
195
+ file=sys.stderr,
196
+ )
197
+ _emit(args, rows, ["station", "kind", "start", "end", "hours", "detail"])
198
+ return 1 if rows and args.strict else 0
199
+
200
+
201
+ def _cmd_classes(args: argparse.Namespace) -> int:
202
+ recs = _read(args)
203
+ rows = []
204
+ stations = sorted({r.station for r in recs}) if args.station is None else [args.station]
205
+ for st in stations:
206
+ totals = class_totals(recs, st)
207
+ if not totals:
208
+ continue
209
+ shares = class_shares(totals)
210
+ for c in range(1, 14):
211
+ rows.append(
212
+ {
213
+ "station": st,
214
+ "class": c,
215
+ "description": CLASS_DESCRIPTIONS[c],
216
+ "volume": f"{totals.get(c, 0.0):g}",
217
+ "share": f"{shares[c]:.4f}",
218
+ }
219
+ )
220
+ if not rows:
221
+ raise AADTKitError("no records with a vehicle_class column value")
222
+ _emit(args, rows, ["station", "class", "description", "volume", "share"])
223
+ return 0
224
+
225
+
226
+ def build_parser() -> argparse.ArgumentParser:
227
+ p = argparse.ArgumentParser(
228
+ prog="aadtkit",
229
+ description="Traffic count processing and AADT estimation (FHWA Traffic Monitoring Guide).",
230
+ )
231
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
232
+ sub = p.add_subparsers(dest="command", required=True)
233
+
234
+ def common(sp: argparse.ArgumentParser, output: bool = True) -> None:
235
+ sp.add_argument("file", help="hourly count CSV")
236
+ sp.add_argument(
237
+ "--hour-ending", action="store_true", help="timestamps/hours mark the end of the hour"
238
+ )
239
+ if output:
240
+ sp.add_argument("-o", "--output", help="output CSV (default: stdout)")
241
+
242
+ def method(sp: argparse.ArgumentParser) -> None:
243
+ sp.add_argument("--method", choices=["hourly", "aashto", "simple"], default="hourly")
244
+ sp.add_argument("--year", type=int)
245
+ sp.add_argument(
246
+ "--allow-missing", action="store_true", help="skip empty month/day-of-week cells"
247
+ )
248
+
249
+ sp = sub.add_parser("aadt", help="AADT of continuous count stations")
250
+ common(sp)
251
+ method(sp)
252
+ sp.set_defaults(func=_cmd_aadt)
253
+
254
+ sp = sub.add_parser("factors", help="monthly and day-of-week factors per station")
255
+ common(sp)
256
+ method(sp)
257
+ sp.add_argument("--group", help="also write the average of all stations under this name")
258
+ sp.set_defaults(func=_cmd_factors)
259
+
260
+ sp = sub.add_parser("cluster", help="k-means factor groups from a factor CSV")
261
+ sp.add_argument("factors", help="factor CSV (from 'aadtkit factors')")
262
+ sp.add_argument("-k", type=int, required=True, help="number of groups")
263
+ sp.add_argument("--features", choices=["monthly", "dow", "both"], default="monthly")
264
+ sp.add_argument("--seed", type=int, default=0)
265
+ sp.add_argument("-o", "--output")
266
+ sp.set_defaults(func=_cmd_cluster)
267
+
268
+ sp = sub.add_parser("expand", help="expand short counts to AADT")
269
+ common(sp)
270
+ sp.add_argument("--factors", required=True, help="factor CSV")
271
+ sp.add_argument("--group", help="factor set name inside the factor CSV")
272
+ sp.add_argument("--axle-factor", type=float, default=1.0)
273
+ sp.add_argument("--growth", type=float, default=1.0)
274
+ sp.add_argument("--basis", choices=["calendar", "blocks"], default="calendar")
275
+ sp.set_defaults(func=_cmd_expand)
276
+
277
+ sp = sub.add_parser("peak", help="K-factor (n-th highest hour) and D-factor")
278
+ common(sp)
279
+ sp.add_argument("--aadt", type=float, help="AADT (default: computed, hourly method)")
280
+ sp.add_argument("--nth", type=int, default=30)
281
+ sp.add_argument("--year", type=int)
282
+ sp.set_defaults(func=_cmd_peak)
283
+
284
+ sp = sub.add_parser("qc", help="data-quality checks")
285
+ common(sp)
286
+ sp.add_argument("--zero-run", type=int, default=6)
287
+ sp.add_argument("--constant-run", type=int, default=4)
288
+ sp.add_argument("--max-hourly", type=float)
289
+ sp.add_argument("--outlier-z", type=float, default=5.0)
290
+ sp.add_argument("--strict", action="store_true", help="exit 1 when issues are found")
291
+ sp.set_defaults(func=_cmd_qc)
292
+
293
+ sp = sub.add_parser("classes", help="FHWA 13-class vehicle shares")
294
+ common(sp)
295
+ sp.add_argument("--station")
296
+ sp.set_defaults(func=_cmd_classes)
297
+ return p
298
+
299
+
300
+ def main(argv: Optional[Sequence[str]] = None) -> int:
301
+ """Entry point of the ``aadtkit`` command."""
302
+ args = build_parser().parse_args(argv)
303
+ try:
304
+ return int(args.func(args))
305
+ except (AADTKitError, ValueError, OSError) as exc:
306
+ print(f"aadtkit: error: {exc}", file=sys.stderr)
307
+ return 2
aadtkit/errors.py ADDED
@@ -0,0 +1,17 @@
1
+ """Exceptions raised by aadtkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["AADTKitError", "CSVFormatError", "InsufficientDataError"]
6
+
7
+
8
+ class AADTKitError(Exception):
9
+ """Base class for aadtkit errors."""
10
+
11
+
12
+ class InsufficientDataError(AADTKitError, ValueError):
13
+ """The data do not meet the minimum requirements of the requested method."""
14
+
15
+
16
+ class CSVFormatError(AADTKitError, ValueError):
17
+ """A CSV file is missing required columns or holds unparsable values."""
aadtkit/expand.py ADDED
@@ -0,0 +1,148 @@
1
+ """Expansion of short-duration counts to AADT.
2
+
3
+ A short count (typically 24-48 h, or 7 days with automatic counters; TMG
4
+ 2022, Sections 3.2.1 and 3.4.2) is converted to AADT with multiplicative
5
+ adjustment factors::
6
+
7
+ AADT_est = VOL_24 * M * D * A * G
8
+
9
+ where ``VOL_24`` is a 24-hour volume, ``M`` the monthly (seasonal) factor,
10
+ ``D`` the day-of-week factor (or a single month x day-of-week factor ``M*D``),
11
+ ``A`` the axle-correction factor (only for axle-sensor counts) and ``G`` a
12
+ growth factor when the count year differs from the estimate year. The 2001 TMG
13
+ (Section 3) lists the adjustments as axle correction, day-of-week and month;
14
+ TMG 2022 Section 3.2.8 applies them multiplicatively, seasonality first, e.g.
15
+ ``518 x 0.95 x 1.24 = 610``. With several 24-hour periods the factored daily
16
+ values are averaged. (The single-line equation above is the conventional
17
+ textbook notation; FHWA prints it as images, so it is reproduced here from the
18
+ described procedure rather than quoted.)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime as dt
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, List, Mapping, Tuple
26
+
27
+ from .aadt import daily_totals
28
+ from .errors import InsufficientDataError
29
+ from .factors import FactorSet
30
+
31
+ __all__ = [
32
+ "ShortCountEstimate",
33
+ "expand_volume",
34
+ "growth_factor",
35
+ "short_count_aadt",
36
+ "split_24h_blocks",
37
+ ]
38
+
39
+
40
+ def growth_factor(annual_rate: float, years: float) -> float:
41
+ """Compound growth factor ``(1 + r) ** years`` (``years`` may be negative)."""
42
+ if annual_rate <= -1:
43
+ raise ValueError("annual_rate must be > -1")
44
+ return float((1.0 + annual_rate) ** years)
45
+
46
+
47
+ def expand_volume(
48
+ volume: float,
49
+ day: dt.date,
50
+ factors: FactorSet,
51
+ *,
52
+ axle_factor: float = 1.0,
53
+ growth: float = 1.0,
54
+ use_month_dow: bool = True,
55
+ ) -> float:
56
+ """Expand one 24-hour volume counted on ``day`` to an AADT estimate."""
57
+ return volume * factors.factor(day, use_month_dow=use_month_dow) * axle_factor * growth
58
+
59
+
60
+ @dataclass
61
+ class ShortCountEstimate:
62
+ """AADT estimated from a short count.
63
+
64
+ Attributes:
65
+ aadt: mean of the factored 24-hour estimates.
66
+ raw_adt: mean unfactored 24-hour volume (average daily traffic of the count).
67
+ periods: ``(period start, raw 24-h volume, combined factor, estimate)``.
68
+ axle_factor, growth: factors applied to every period.
69
+ """
70
+
71
+ aadt: float
72
+ raw_adt: float
73
+ periods: List[Tuple[dt.datetime, float, float, float]] = field(default_factory=list)
74
+ axle_factor: float = 1.0
75
+ growth: float = 1.0
76
+
77
+
78
+ def split_24h_blocks(hourly: Mapping[dt.datetime, float]) -> List[List[dt.datetime]]:
79
+ """Split hour stamps into non-overlapping runs of 24 consecutive hours.
80
+
81
+ Gaps break runs; leftover hours at the end of a run (< 24) are discarded.
82
+ """
83
+ hours = sorted({ts.replace(minute=0, second=0, microsecond=0) for ts in hourly})
84
+ blocks: List[List[dt.datetime]] = []
85
+ run: List[dt.datetime] = []
86
+ for h in hours:
87
+ if run and h - run[-1] != dt.timedelta(hours=1):
88
+ run = []
89
+ run.append(h)
90
+ if len(run) == 24:
91
+ blocks.append(run)
92
+ run = []
93
+ return blocks
94
+
95
+
96
+ def short_count_aadt(
97
+ hourly: Mapping[dt.datetime, float],
98
+ factors: FactorSet,
99
+ *,
100
+ axle_factor: float = 1.0,
101
+ growth: float = 1.0,
102
+ basis: str = "calendar",
103
+ use_month_dow: bool = True,
104
+ ) -> ShortCountEstimate:
105
+ """Estimate AADT from an hourly short count.
106
+
107
+ Args:
108
+ hourly: ``{hour-start datetime: volume}`` of the short count.
109
+ factors: station or group factors, e.g. from :func:`~aadtkit.group_factors`.
110
+ axle_factor: axle-correction factor for axle-sensor counts (1 otherwise).
111
+ growth: growth factor to the estimate year.
112
+ basis: ``"calendar"`` uses complete calendar days (midnight to
113
+ midnight); ``"blocks"`` uses consecutive 24-hour blocks from the
114
+ first counted hour, so a 48-h count starting at noon yields two
115
+ periods; each hour is factored with the factor of its own calendar
116
+ day.
117
+ use_month_dow: prefer month x day-of-week factors when present.
118
+ """
119
+ hourly_n: Dict[dt.datetime, float] = {}
120
+ for ts, v in hourly.items():
121
+ h = ts.replace(minute=0, second=0, microsecond=0)
122
+ hourly_n[h] = hourly_n.get(h, 0.0) + float(v)
123
+ periods: List[Tuple[dt.datetime, float, float, float]] = []
124
+ if basis == "calendar":
125
+ for day, vol in daily_totals(hourly_n).items():
126
+ f = factors.factor(day, use_month_dow=use_month_dow)
127
+ start = dt.datetime.combine(day, dt.time())
128
+ periods.append((start, vol, f, vol * f * axle_factor * growth))
129
+ elif basis == "blocks":
130
+ for block in split_24h_blocks(hourly_n):
131
+ raw = sum(hourly_n[h] for h in block)
132
+ est = sum(
133
+ hourly_n[h] * factors.factor(h.date(), use_month_dow=use_month_dow) for h in block
134
+ )
135
+ f = est / raw if raw else factors.factor(block[0].date(), use_month_dow=use_month_dow)
136
+ periods.append((block[0], raw, f, est * axle_factor * growth))
137
+ else:
138
+ raise ValueError("basis must be 'calendar' or 'blocks'")
139
+ if not periods:
140
+ raise InsufficientDataError("short count contains no complete 24-hour period")
141
+ n = len(periods)
142
+ return ShortCountEstimate(
143
+ aadt=sum(p[3] for p in periods) / n,
144
+ raw_adt=sum(p[1] for p in periods) / n,
145
+ periods=periods,
146
+ axle_factor=axle_factor,
147
+ growth=growth,
148
+ )