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 ADDED
@@ -0,0 +1,134 @@
1
+ """aadtkit: traffic count processing and AADT estimation.
2
+
3
+ Implements the continuous-count and short-count procedures of the FHWA
4
+ *Traffic Monitoring Guide* (TMG 2022, with the AASHTO method as given in
5
+ TMG 2013): AADT from continuous count stations, monthly and day-of-week
6
+ expansion factors, factor groups, short-count expansion with axle-correction
7
+ and growth factors, K-, D- and peak-hour factors, FHWA 13-class vehicle
8
+ shares, data-quality checks and CSV I/O for hourly counts.
9
+ """
10
+
11
+ from ._version import __version__
12
+ from .aadt import (
13
+ DOW_NAMES,
14
+ AADTResult,
15
+ aadt_aashto,
16
+ aadt_hourly_weighted,
17
+ aadt_simple,
18
+ daily_totals,
19
+ dow_occurrences,
20
+ madt,
21
+ )
22
+ from .classification import (
23
+ CLASS_DESCRIPTIONS,
24
+ DEFAULT_GROUPS,
25
+ FHWAClass,
26
+ class_shares,
27
+ group_shares,
28
+ truck_share,
29
+ )
30
+ from .errors import AADTKitError, CSVFormatError, InsufficientDataError
31
+ from .expand import (
32
+ ShortCountEstimate,
33
+ expand_volume,
34
+ growth_factor,
35
+ short_count_aadt,
36
+ split_24h_blocks,
37
+ )
38
+ from .factors import (
39
+ DEFAULT_AXLES_PER_CLASS,
40
+ ClusterResult,
41
+ FactorGroup,
42
+ FactorSet,
43
+ FactorStats,
44
+ axle_correction_factor,
45
+ axle_correction_from_classes,
46
+ cluster_stations,
47
+ factors_from_result,
48
+ group_factors,
49
+ t_critical_95,
50
+ )
51
+ from .io import (
52
+ HourlyRecord,
53
+ by_direction,
54
+ by_station,
55
+ class_totals,
56
+ duplicate_keys,
57
+ read_factors_csv,
58
+ read_hourly_csv,
59
+ to_series,
60
+ write_factors_csv,
61
+ write_hourly_csv,
62
+ write_rows_csv,
63
+ )
64
+ from .peak import (
65
+ DFactor,
66
+ PeakHour,
67
+ d_factor,
68
+ design_hour_volume,
69
+ highest_hours,
70
+ k_factor,
71
+ nth_highest_hour,
72
+ peak_hour_factor,
73
+ )
74
+ from .quality import QualityIssue, QualityReport, check_hourly, drop_flagged
75
+
76
+ __all__ = [
77
+ "AADTKitError",
78
+ "AADTResult",
79
+ "CLASS_DESCRIPTIONS",
80
+ "CSVFormatError",
81
+ "ClusterResult",
82
+ "DEFAULT_AXLES_PER_CLASS",
83
+ "DEFAULT_GROUPS",
84
+ "DFactor",
85
+ "DOW_NAMES",
86
+ "FHWAClass",
87
+ "FactorGroup",
88
+ "FactorSet",
89
+ "FactorStats",
90
+ "HourlyRecord",
91
+ "InsufficientDataError",
92
+ "PeakHour",
93
+ "QualityIssue",
94
+ "QualityReport",
95
+ "ShortCountEstimate",
96
+ "__version__",
97
+ "aadt_aashto",
98
+ "aadt_hourly_weighted",
99
+ "aadt_simple",
100
+ "axle_correction_factor",
101
+ "axle_correction_from_classes",
102
+ "by_direction",
103
+ "by_station",
104
+ "check_hourly",
105
+ "class_shares",
106
+ "class_totals",
107
+ "cluster_stations",
108
+ "d_factor",
109
+ "daily_totals",
110
+ "design_hour_volume",
111
+ "dow_occurrences",
112
+ "drop_flagged",
113
+ "duplicate_keys",
114
+ "expand_volume",
115
+ "factors_from_result",
116
+ "group_factors",
117
+ "group_shares",
118
+ "growth_factor",
119
+ "highest_hours",
120
+ "k_factor",
121
+ "madt",
122
+ "nth_highest_hour",
123
+ "peak_hour_factor",
124
+ "read_factors_csv",
125
+ "read_hourly_csv",
126
+ "short_count_aadt",
127
+ "split_24h_blocks",
128
+ "t_critical_95",
129
+ "to_series",
130
+ "truck_share",
131
+ "write_factors_csv",
132
+ "write_hourly_csv",
133
+ "write_rows_csv",
134
+ ]
aadtkit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m aadtkit``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
aadtkit/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
aadtkit/aadt.py ADDED
@@ -0,0 +1,282 @@
1
+ """AADT computation for continuous count stations (CCS).
2
+
3
+ Three estimators are provided:
4
+
5
+ ``aadt_aashto``
6
+ The AASHTO method as published in the FHWA *Traffic Monitoring Guide*
7
+ (TMG 2013, Section 3.2.1, Step 7; adopted by FHWA and by the AASHTO
8
+ *Guidelines for Traffic Data Programs*)::
9
+
10
+ AADT = 1/7 * sum_i [ 1/12 * sum_j ( 1/n_ij * sum_k VOL_ijk ) ]
11
+
12
+ where ``VOL_ijk`` is the daily traffic of the k-th occurrence of day of
13
+ week *i* in month *j* and ``n_ij`` the number of such days with data. The
14
+ inner mean is the *monthly average day of week* (MADW).
15
+
16
+ ``aadt_hourly_weighted``
17
+ The hour-based procedure recommended by FHWA since 2016 (TMG 2022,
18
+ Section 3.1.4.7, Step 3)::
19
+
20
+ MADT_m = sum_j w_jm * sum_h (1/n_hjm * sum_i VOL_ihjm) / sum_j w_jm
21
+ AADT = sum_m d_m * MADT_m / sum_m d_m
22
+
23
+ with ``w_jm`` the number of times day of week *j* occurs in month *m* of
24
+ the calendar year (4 or 5) and ``d_m`` the number of days in month *m*.
25
+ Averaging at the hour level lets days with a few missing hours still
26
+ contribute, and the calendar weights remove the bias of giving every day
27
+ of week and every month the same weight.
28
+
29
+ ``aadt_simple``
30
+ Plain mean of all complete days (exact when a full year is available).
31
+
32
+ Days of week are indexed 0 = Monday ... 6 = Sunday (Python ``weekday()``).
33
+ Months are 1..12. Hourly series are mappings ``{hour-start datetime: volume}``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import calendar
39
+ import datetime as dt
40
+ from collections import defaultdict
41
+ from dataclasses import dataclass, field
42
+ from typing import Dict, List, Mapping, Optional, Tuple
43
+
44
+ from .errors import InsufficientDataError
45
+
46
+ __all__ = [
47
+ "AADTResult",
48
+ "DOW_NAMES",
49
+ "aadt_aashto",
50
+ "aadt_hourly_weighted",
51
+ "aadt_simple",
52
+ "daily_totals",
53
+ "dow_occurrences",
54
+ "madt",
55
+ ]
56
+
57
+ HourlySeries = Mapping[dt.datetime, float]
58
+ DailySeries = Mapping[dt.date, float]
59
+
60
+ #: Short names for day-of-week indices 0..6 (Monday first, as ``date.weekday()``).
61
+ DOW_NAMES: Tuple[str, ...] = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
62
+
63
+
64
+ @dataclass
65
+ class AADTResult:
66
+ """Result of a continuous-count AADT computation.
67
+
68
+ Attributes:
69
+ aadt: annual average daily traffic.
70
+ method: ``"aashto"``, ``"hourly_weighted"`` or ``"simple"``.
71
+ year: calendar year the estimate refers to (``None`` for ``simple``
72
+ over an arbitrary span).
73
+ madw: monthly average day-of-week volume keyed by ``(month, dow)``.
74
+ madt: monthly average daily traffic keyed by month, as used by the
75
+ method (unweighted mean of the seven MADW for ``aashto``,
76
+ calendar-weighted for ``hourly_weighted``).
77
+ n_days: number of days (or, for ``hourly_weighted``, the minimum
78
+ number of hour occurrences) behind each ``(month, dow)`` cell.
79
+ missing_cells: ``(month, dow)`` cells without data that were skipped.
80
+ """
81
+
82
+ aadt: float
83
+ method: str
84
+ year: Optional[int] = None
85
+ madw: Dict[Tuple[int, int], float] = field(default_factory=dict)
86
+ madt: Dict[int, float] = field(default_factory=dict)
87
+ n_days: Dict[Tuple[int, int], int] = field(default_factory=dict)
88
+ missing_cells: List[Tuple[int, int]] = field(default_factory=list)
89
+
90
+ def dow_average(self) -> Dict[int, float]:
91
+ """Annual average volume for each day of week (mean of its MADW over months)."""
92
+ acc: Dict[int, List[float]] = defaultdict(list)
93
+ for (_, j), v in self.madw.items():
94
+ acc[j].append(v)
95
+ return {j: sum(vs) / len(vs) for j, vs in sorted(acc.items())}
96
+
97
+
98
+ def _hour_start(ts: dt.datetime) -> dt.datetime:
99
+ return ts.replace(minute=0, second=0, microsecond=0)
100
+
101
+
102
+ def daily_totals(hourly: HourlySeries) -> Dict[dt.date, float]:
103
+ """Sum hourly volumes into daily totals, keeping only complete days.
104
+
105
+ A day is complete when all 24 hour-start slots 00..23 carry a value.
106
+ Incomplete days are dropped, never scaled up (TMG discourages imputation
107
+ of missing data, TMG 2013 Section 3.2.3).
108
+ """
109
+ hours: Dict[dt.date, Dict[int, float]] = defaultdict(dict)
110
+ for ts, vol in hourly.items():
111
+ h = _hour_start(ts)
112
+ slot = hours[h.date()]
113
+ slot[h.hour] = slot.get(h.hour, 0.0) + float(vol)
114
+ return {d: sum(s.values()) for d, s in sorted(hours.items()) if len(s) == 24}
115
+
116
+
117
+ def aadt_simple(daily: DailySeries) -> AADTResult:
118
+ """AADT as the plain mean of the daily totals supplied."""
119
+ if not daily:
120
+ raise InsufficientDataError("no complete days available")
121
+ vals = list(daily.values())
122
+ years = {d.year for d in daily}
123
+ year = years.pop() if len(years) == 1 else None
124
+ return AADTResult(aadt=sum(vals) / len(vals), method="simple", year=year)
125
+
126
+
127
+ def madt(daily: DailySeries) -> Dict[int, float]:
128
+ """Simple monthly average daily traffic (mean of the days in each month)."""
129
+ acc: Dict[int, List[float]] = defaultdict(list)
130
+ for d, v in daily.items():
131
+ acc[d.month].append(float(v))
132
+ return {m: sum(vs) / len(vs) for m, vs in sorted(acc.items())}
133
+
134
+
135
+ def _pick_year(dates: List[dt.date], year: Optional[int]) -> int:
136
+ if year is not None:
137
+ return year
138
+ years = sorted({d.year for d in dates})
139
+ if not years:
140
+ raise InsufficientDataError("no data")
141
+ if len(years) > 1:
142
+ raise ValueError(f"data span several years {years}; pass year=")
143
+ return years[0]
144
+
145
+
146
+ def aadt_aashto(
147
+ daily: DailySeries,
148
+ *,
149
+ year: Optional[int] = None,
150
+ allow_missing_cells: bool = False,
151
+ ) -> AADTResult:
152
+ """AADT by the AASHTO method from daily totals (TMG 2013, Section 3.2.1).
153
+
154
+ Args:
155
+ daily: complete-day totals, e.g. from :func:`daily_totals`.
156
+ year: calendar year to use; required if ``daily`` spans several years.
157
+ allow_missing_cells: the method needs at least one day for each of the
158
+ 84 month x day-of-week cells. When ``True`` empty cells are skipped:
159
+ a month is averaged over its available days of week and the year
160
+ over the months that have any data. This departs from the standard
161
+ and is recorded in ``missing_cells``.
162
+
163
+ Raises:
164
+ InsufficientDataError: a cell is empty and ``allow_missing_cells`` is
165
+ ``False``, or there is no data at all.
166
+ """
167
+ y = _pick_year(list(daily), year)
168
+ cells: Dict[Tuple[int, int], List[float]] = defaultdict(list)
169
+ for d, v in daily.items():
170
+ if d.year == y:
171
+ cells[(d.month, d.weekday())].append(float(v))
172
+ if not cells:
173
+ raise InsufficientDataError(f"no complete days in {y}")
174
+ missing = [(m, j) for m in range(1, 13) for j in range(7) if (m, j) not in cells]
175
+ if missing and not allow_missing_cells:
176
+ raise InsufficientDataError(
177
+ f"{len(missing)} of 84 month/day-of-week cells have no complete day "
178
+ f"(first: month {missing[0][0]}, {DOW_NAMES[missing[0][1]]}); "
179
+ "use allow_missing_cells=True to skip them"
180
+ )
181
+ madw = {k: sum(v) / len(v) for k, v in sorted(cells.items())}
182
+ # Monthly means of MADW, then mean over months (summation order is
183
+ # immaterial when all 84 cells are present).
184
+ per_month: Dict[int, List[float]] = defaultdict(list)
185
+ for (m, _), v in madw.items():
186
+ per_month[m].append(v)
187
+ madt_m = {m: sum(vs) / len(vs) for m, vs in sorted(per_month.items())}
188
+ if missing:
189
+ aadt = sum(madt_m.values()) / len(madt_m)
190
+ else:
191
+ # Literal TMG order: average over months for each DOW, then over DOW.
192
+ aadt = sum(sum(madw[(m, j)] for m in range(1, 13)) / 12.0 for j in range(7)) / 7.0
193
+ return AADTResult(
194
+ aadt=aadt,
195
+ method="aashto",
196
+ year=y,
197
+ madw=madw,
198
+ madt=madt_m,
199
+ n_days={k: len(v) for k, v in cells.items()},
200
+ missing_cells=missing,
201
+ )
202
+
203
+
204
+ def dow_occurrences(year: int, month: int) -> Dict[int, int]:
205
+ """Number of times each day of week (0=Mon) occurs in a calendar month (w_jm)."""
206
+ counts = dict.fromkeys(range(7), 0)
207
+ for day in range(1, calendar.monthrange(year, month)[1] + 1):
208
+ counts[dt.date(year, month, day).weekday()] += 1
209
+ return counts
210
+
211
+
212
+ def aadt_hourly_weighted(
213
+ hourly: HourlySeries,
214
+ *,
215
+ year: Optional[int] = None,
216
+ allow_missing_cells: bool = False,
217
+ ) -> AADTResult:
218
+ """AADT by the FHWA hour-based weighted AASHTO procedure (TMG 2022, 3.1.4.7).
219
+
220
+ For every month *m*, day of week *j* and hour *h* the volumes of all
221
+ occurrences with data are averaged; the 24 hourly means are summed to the
222
+ MADW; months weight days of week by their calendar occurrences ``w_jm``
223
+ and the year weights months by their length ``d_m``.
224
+
225
+ Args:
226
+ hourly: ``{hour-start datetime: volume}``; partial days are allowed.
227
+ year: calendar year; required when the data span several years.
228
+ allow_missing_cells: a (month, dow) cell is usable only if all 24 hours
229
+ have at least one occurrence. When ``True``, unusable cells are
230
+ dropped from the weights of their month (and months without any
231
+ usable cell from the year); otherwise an error is raised.
232
+ """
233
+ y = _pick_year([ts.date() for ts in hourly], year)
234
+ acc: Dict[Tuple[int, int, int], List[float]] = defaultdict(list)
235
+ # Sum duplicates of the same hour first (e.g. per-direction rows).
236
+ per_hour: Dict[dt.datetime, float] = defaultdict(float)
237
+ for ts, v in hourly.items():
238
+ h = _hour_start(ts)
239
+ if h.year == y:
240
+ per_hour[h] += float(v)
241
+ for h, v in per_hour.items():
242
+ acc[(h.month, h.weekday(), h.hour)].append(v)
243
+ if not acc:
244
+ raise InsufficientDataError(f"no hourly data in {y}")
245
+
246
+ madw: Dict[Tuple[int, int], float] = {}
247
+ n_occ: Dict[Tuple[int, int], int] = {}
248
+ missing: List[Tuple[int, int]] = []
249
+ for m in range(1, 13):
250
+ for j in range(7):
251
+ hours = [acc.get((m, j, hr)) for hr in range(24)]
252
+ if any(not x for x in hours):
253
+ missing.append((m, j))
254
+ continue
255
+ madw[(m, j)] = sum(sum(x) / len(x) for x in hours if x)
256
+ n_occ[(m, j)] = min(len(x) for x in hours if x)
257
+ if missing and not allow_missing_cells:
258
+ m0, j0 = missing[0]
259
+ raise InsufficientDataError(
260
+ f"{len(missing)} of 84 month/day-of-week cells lack some hour of day "
261
+ f"(first: month {m0}, {DOW_NAMES[j0]}); use allow_missing_cells=True"
262
+ )
263
+ madt_m: Dict[int, float] = {}
264
+ for m in range(1, 13):
265
+ w = dow_occurrences(y, m)
266
+ num = sum(w[j] * madw[(m, j)] for j in range(7) if (m, j) in madw)
267
+ den = sum(w[j] for j in range(7) if (m, j) in madw)
268
+ if den:
269
+ madt_m[m] = num / den
270
+ if not madt_m:
271
+ raise InsufficientDataError(f"no usable month in {y}")
272
+ d = {m: calendar.monthrange(y, m)[1] for m in madt_m}
273
+ aadt = sum(d[m] * madt_m[m] for m in madt_m) / sum(d.values())
274
+ return AADTResult(
275
+ aadt=aadt,
276
+ method="hourly_weighted",
277
+ year=y,
278
+ madw=madw,
279
+ madt=madt_m,
280
+ n_days=n_occ,
281
+ missing_cells=missing,
282
+ )
@@ -0,0 +1,105 @@
1
+ """FHWA 13-category vehicle classification and class shares.
2
+
3
+ Class definitions follow the FHWA 13-category vehicle classification scheme
4
+ reproduced in the *Traffic Monitoring Guide* (TMG 2022). The default aggregate groups follow HPMS reporting,
5
+ which records single-unit truck and bus volume (classes 4-7) and combination
6
+ truck volume (classes 8-13) separately; pass your own ``groups`` mapping to
7
+ use another aggregation.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from enum import IntEnum
13
+ from typing import Dict, Mapping, Optional, Sequence
14
+
15
+ __all__ = [
16
+ "CLASS_DESCRIPTIONS",
17
+ "DEFAULT_GROUPS",
18
+ "FHWAClass",
19
+ "class_shares",
20
+ "group_shares",
21
+ "truck_share",
22
+ ]
23
+
24
+
25
+ class FHWAClass(IntEnum):
26
+ """The 13 FHWA vehicle classes."""
27
+
28
+ MOTORCYCLE = 1
29
+ PASSENGER_CAR = 2
30
+ OTHER_2AXLE_4TIRE = 3
31
+ BUS = 4
32
+ SU_2AXLE_6TIRE = 5
33
+ SU_3AXLE = 6
34
+ SU_4PLUS_AXLE = 7
35
+ ST_4OR_FEWER_AXLE = 8
36
+ ST_5AXLE = 9
37
+ ST_6PLUS_AXLE = 10
38
+ MT_5OR_FEWER_AXLE = 11
39
+ MT_6AXLE = 12
40
+ MT_7PLUS_AXLE = 13
41
+
42
+
43
+ #: Short descriptions of the FHWA classes.
44
+ CLASS_DESCRIPTIONS: Dict[int, str] = {
45
+ 1: "Motorcycles",
46
+ 2: "Passenger cars",
47
+ 3: "Other two-axle, four-tire single-unit vehicles",
48
+ 4: "Buses",
49
+ 5: "Two-axle, six-tire, single-unit trucks",
50
+ 6: "Three-axle single-unit trucks",
51
+ 7: "Four or more axle single-unit trucks",
52
+ 8: "Four or fewer axle single-trailer trucks",
53
+ 9: "Five-axle single-trailer trucks",
54
+ 10: "Six or more axle single-trailer trucks",
55
+ 11: "Five or fewer axle multi-trailer trucks",
56
+ 12: "Six-axle multi-trailer trucks",
57
+ 13: "Seven or more axle multi-trailer trucks",
58
+ }
59
+
60
+ #: Default aggregation of classes (HPMS-style).
61
+ DEFAULT_GROUPS: Dict[str, Sequence[int]] = {
62
+ "motorcycles": (1,),
63
+ "passenger_vehicles": (2, 3),
64
+ "single_unit_truck_and_bus": (4, 5, 6, 7),
65
+ "combination_truck": (8, 9, 10, 11, 12, 13),
66
+ }
67
+
68
+
69
+ def _validate(counts: Mapping[int, float]) -> Dict[int, float]:
70
+ out: Dict[int, float] = {}
71
+ for c, n in counts.items():
72
+ ci = int(c)
73
+ if ci not in CLASS_DESCRIPTIONS:
74
+ raise ValueError(f"{c!r} is not an FHWA class 1-13")
75
+ if n < 0:
76
+ raise ValueError("class counts must be non-negative")
77
+ out[ci] = out.get(ci, 0.0) + float(n)
78
+ return out
79
+
80
+
81
+ def class_shares(counts: Mapping[int, float]) -> Dict[int, float]:
82
+ """Share (0-1) of each FHWA class 1-13; absent classes get 0."""
83
+ c = _validate(counts)
84
+ total = sum(c.values())
85
+ if total <= 0:
86
+ raise ValueError("total count is zero")
87
+ return {k: c.get(k, 0.0) / total for k in range(1, 14)}
88
+
89
+
90
+ def group_shares(
91
+ counts: Mapping[int, float], groups: Optional[Mapping[str, Sequence[int]]] = None
92
+ ) -> Dict[str, float]:
93
+ """Share of each class group (default :data:`DEFAULT_GROUPS`)."""
94
+ shares = class_shares(counts)
95
+ g = DEFAULT_GROUPS if groups is None else groups
96
+ return {name: sum(shares[int(k)] for k in classes) for name, classes in g.items()}
97
+
98
+
99
+ def truck_share(counts: Mapping[int, float], classes: Sequence[int] = range(4, 14)) -> float:
100
+ """Share of heavy vehicles, by default classes 4-13 (buses and trucks).
101
+
102
+ Use ``classes=range(5, 14)`` to exclude buses.
103
+ """
104
+ shares = class_shares(counts)
105
+ return sum(shares[int(k)] for k in classes)