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/__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
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)
|