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/__init__.py +134 -0
- aadtkit/__main__.py +7 -0
- aadtkit/_version.py +1 -0
- aadtkit/aadt.py +282 -0
- aadtkit/classification.py +105 -0
- aadtkit/cli.py +307 -0
- aadtkit/errors.py +17 -0
- aadtkit/expand.py +148 -0
- aadtkit/factors.py +372 -0
- aadtkit/io.py +312 -0
- aadtkit/peak.py +154 -0
- aadtkit/py.typed +0 -0
- aadtkit/quality.py +231 -0
- aadtkit-0.1.0.dist-info/METADATA +225 -0
- aadtkit-0.1.0.dist-info/RECORD +19 -0
- aadtkit-0.1.0.dist-info/WHEEL +5 -0
- aadtkit-0.1.0.dist-info/entry_points.txt +2 -0
- aadtkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- aadtkit-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|