import io
import os
import re
import zipfile
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import pyproj
import requests
from dotenv import load_dotenv
from scipy import stats
from sklearn.linear_model import LinearRegression
load_dotenv()
PROJECT_CRS = "EPSG:3566"
# Year boundaries that recur as control-flow thresholds throughout the pipeline.
# (Distinct from the per-station judgment-call years inside the hardcoded
# reference tables below, e.g. selected_forecast_df — those are literal data,
# not thresholds, so they stay as plain literals.)
YEARS = {
"HISTORIC_START": 1981, # first year with any HPMS/UDOT AADT data
"HISTORIC_END": 2023, # last year treated as confirmed historic AADT
"HISTORIC_FILL_CUTOFF": 2024, # archive fallback is allowed through this year
"TRUCK_PROJECTION_SPLIT": 2025, # truck volume switches from %-split to AADT regression
"PIPELINE_END": 2060, # last year in the exported forecast grid
"EXPORT_START": 2010, # results/external_year_vol.csv is filtered to Year >= this
}External Station Traffic Forecasts
Single-document version of the external-station forecasting pipeline, originally migrated from 5 sequential notebooks (1-Get-Historic-AADT through 5-Export-Data) as a straight format port. Step 5.5 (CCS-derived truck split) was added afterward and is a genuine methodology change layered on top of that port — see below.
How the final AADT for each external station is assembled, by year:
- 1981–2022: historic AADT from UDOT/HPMS, matched to each station by route + milepost.
- 2023–2026: gap-filled by
sophisticated_fill— interpolated/extrapolated from the linear-trend forecast points below. - 2027 onward: a per-station OLS linear trend (Step 3), fit over a forecaster-selected historic window and adjusted with hand-tuned corrections (Step 4).
How the truck/passenger split is assembled, by year — note this is a two-layer process: Steps 5 and 5.5 both run for every row, with 5.5 always having the final say.
- Step 5 computes an “existing”
PctTrk_SU/PctTrk_MUbaseline for every year: UDOT’s historicSUTRK/CUTRKpercentages through 2024, then a per-station regression of truck volume against AADT (not year) for 2025 onward — seeproject_future_volumes. - Step 5.5 then overrides or rescales that baseline for every year 1981–2060, not just the forecast years:
- Stations matched to a usable CCS (Continuous Count Station) vehicle-length count — via
data/External Stations - CCS Connections.csv— get a single fixed percentage derived from that station’s own length-bin distribution, replacing the Step 5 series outright for all years. - Stations without a usable CCS match instead keep the Step 5 series but rescale it by a constant regional adjustment ratio, keyed by the station’s road Functional Class (derived from the WFv1000 master network), falling back to a statewide “All Stations” ratio for Local/Unpaved roads.
- Stations matched to a usable CCS (Continuous Count Station) vehicle-length count — via
PASSENGERis always the remainder:AADT - TRUCK_SU - TRUCK_MU, computed after Step 5.5 so it reflects whichever split won.
Setup
def fetch_github(
url: str, mode: str = "private", token_env_var: str = "GITHUB_TOKEN"
) -> requests.Response:
"""Fetch content from GitHub repositories."""
if mode not in ["public", "private"]:
raise ValueError(f"mode must be 'public' or 'private', got '{mode}'")
if mode == "public":
response = requests.get(url, timeout=30)
else:
token = os.getenv(token_env_var)
if not token:
raise ValueError(
f"GitHub token not found in environment variable '{token_env_var}'. "
f"Check your .env file has: {token_env_var}=your_token_here"
)
headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github.v3.raw",
}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
return response
def format_external_label(df: pd.DataFrame) -> pd.Series:
"""Builds the 'Ext # <id> - <name>' label used across plots/exports."""
return (
"Ext # "
+ df["externalid"].fillna(0).astype(int).astype(str)
+ " - "
+ df["name"].fillna("")
)Step 1: Historic AADT (was 1-Get-Historic-AADT)
externals_df = pd.read_csv("params/externals.csv")
aadt_df = pd.read_csv(
"data/AADTHistory 2023.xlsx - UnroundedAADT2023.csv", low_memory=False
)
external_segment_link = pd.read_csv("params/externals-segments-link.csv")
# Manual Override: make it closer to the CCS station #-319
external_segment_link.loc[external_segment_link["externalid"] == 3620, "segid"] = (
"0189_013.1" # Alternative: 0189_009.9 # Original: 0189_014.3
)
# This is the single source of truth for the link + override — reused as-is in Step 5.# Melt AADT columns
aadt_year_cols = [col for col in aadt_df.columns if re.match(r"AADT\d{4}$", col)]
aadt_melted = aadt_df.melt(
id_vars=["STATION", "RouteID", "BeginPoint", "EndPoint", "Section_Length", "DESC"],
value_vars=aadt_year_cols,
var_name="year",
value_name="AADT",
)
aadt_melted["year"] = aadt_melted["year"].str.extract(r"(\d{4})").astype(int)
# Extract route number
aadt_melted["route"] = (
aadt_melted["RouteID"].str.extract(r"^(\d{4})")[0].dropna().astype(int)
)
aadt_melted = aadt_melted[["route", "BeginPoint", "EndPoint", "year", "AADT"]]
aadt_melted.columns = ["route", "mp_begin", "mp_end", "year", "AADT"]
aadt_melted| route | mp_begin | mp_end | year | AADT | |
|---|---|---|---|---|---|
| 0 | 6.0 | 0.000 | 46.0380 | 2023 | 457.0 |
| 1 | 6.0 | 46.038 | 77.5560 | 2023 | 409.0 |
| 2 | 6.0 | 77.556 | 82.8970 | 2023 | 586.0 |
| 3 | 6.0 | 82.897 | 83.9110 | 2023 | 2189.0 |
| 4 | 6.0 | 83.911 | 87.6940 | 2023 | 4012.0 |
| ... | ... | ... | ... | ... | ... |
| 196376 | 3468.0 | 0.000 | 5.1248 | 1981 | 0.0 |
| 196377 | 3469.0 | 0.000 | 6.9300 | 1981 | 0.0 |
| 196378 | 3470.0 | 0.000 | 1.0394 | 1981 | 0.0 |
| 196379 | 3478.0 | 0.000 | 2.0404 | 1981 | 0.0 |
| 196380 | 3483.0 | 0.000 | 1.7030 | 1981 | 0.0 |
196381 rows × 5 columns
# get external route, mp
external_segment_link[["route", "milepost"]] = external_segment_link[
"segid"
].str.split("_", expand=True)
external_segment_link["route"] = external_segment_link["route"].astype(int)
external_segment_link["milepost"] = external_segment_link["milepost"].astype(float)
external_segment_link| externalid | segid | route | milepost | |
|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1082 | 0.00 |
| 1 | 3602 | 0013_006.5 | 13 | 6.50 |
| 2 | 3603 | 1112_000.0 | 1112 | 0.00 |
| 3 | 3604 | 0015_368.1 | 15 | 368.10 |
| 4 | 3605 | 0038_003.2 | 38 | 3.20 |
| 5 | 3606 | 0091_010.1 | 91 | 10.10 |
| 6 | 3607 | 3462_002.8 | 3462 | 2.80 |
| 7 | 3608 | 0039_008.7 | 39 | 8.70 |
| 8 | 3609 | 0084_087.8 | 84 | 87.80 |
| 9 | 3610 | 2688_005.5 | 2688 | 5.50 |
| 10 | 3611 | 0201_000.0 | 201 | 0.00 |
| 11 | 3612 | 0080_101.2 | 80 | 101.20 |
| 12 | 3613 | 0065_008.4 | 65 | 8.40 |
| 13 | 3614 | 0080_138.9 | 80 | 138.90 |
| 14 | 3615 | 0190_015.75 | 190 | 15.75 |
| 15 | 3616 | 1828_004.5 | 1828 | 4.50 |
| 16 | 3617 | 0006_140.3 | 6 | 140.30 |
| 17 | 3618 | 0073_015.5 | 73 | 15.50 |
| 18 | 3619 | 3108_000.0 | 3108 | 0.00 |
| 19 | 3620 | 0189_013.1 | 189 | 13.10 |
| 20 | 3621 | 2865_019.4 | 2865 | 19.40 |
| 21 | 3622 | 2863_000.0 | 2863 | 0.00 |
| 22 | 3623 | 0006_216.2 | 6 | 216.20 |
| 23 | 3624 | 0096_006.2 | 96 | 6.20 |
| 24 | 3625 | 2495_000.0 | 2495 | 0.00 |
| 25 | 3626 | 0089_296.0 | 89 | 296.00 |
| 26 | 3627 | 1822_000.0 | 1822 | 0.00 |
| 27 | 3628 | 0015_233.2 | 15 | 233.20 |
| 28 | 3629 | 1826_004.9 | 1826 | 4.90 |
# Define full list of years
years_full_df = pd.DataFrame(
{"year": list(range(YEARS["HISTORIC_START"], YEARS["HISTORIC_END"] + 1))}
)
# Create full cartesian product: segments × years
full_index_df = external_segment_link[["externalid", "segid"]].merge(
years_full_df, how="cross"
)
full_index_df| externalid | segid | year | |
|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1981 |
| 1 | 3601 | 1082_000.0 | 1982 |
| 2 | 3601 | 1082_000.0 | 1983 |
| 3 | 3601 | 1082_000.0 | 1984 |
| 4 | 3601 | 1082_000.0 | 1985 |
| ... | ... | ... | ... |
| 1242 | 3629 | 1826_004.9 | 2019 |
| 1243 | 3629 | 1826_004.9 | 2020 |
| 1244 | 3629 | 1826_004.9 | 2021 |
| 1245 | 3629 | 1826_004.9 | 2022 |
| 1246 | 3629 | 1826_004.9 | 2023 |
1247 rows × 3 columns
# Merge external_segment_link and aadt_melted with left join
matched_df = external_segment_link.merge(aadt_melted, on=["route"], how="left").query(
"mp_begin < milepost + .01 <= mp_end"
)
matched_df| externalid | segid | route | milepost | mp_begin | mp_end | year | AADT | |
|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1082 | 0.0 | 0.0 | 12.348 | 2023.0 | 817.0 |
| 4 | 3601 | 1082_000.0 | 1082 | 0.0 | 0.0 | 12.348 | 2022.0 | 783.0 |
| 8 | 3601 | 1082_000.0 | 1082 | 0.0 | 0.0 | 12.348 | 2021.0 | 792.0 |
| 12 | 3601 | 1082_000.0 | 1082 | 0.0 | 0.0 | 12.348 | 2020.0 | 739.0 |
| 16 | 3601 | 1082_000.0 | 1082 | 0.0 | 0.0 | 12.348 | 2019.0 | 740.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 33529 | 3629 | 1826_004.9 | 1826 | 4.9 | 0.0 | 4.944 | 1985.0 | 0.0 |
| 33532 | 3629 | 1826_004.9 | 1826 | 4.9 | 0.0 | 4.944 | 1984.0 | 0.0 |
| 33535 | 3629 | 1826_004.9 | 1826 | 4.9 | 0.0 | 4.944 | 1983.0 | 0.0 |
| 33538 | 3629 | 1826_004.9 | 1826 | 4.9 | 0.0 | 4.944 | 1982.0 | 0.0 |
| 33541 | 3629 | 1826_004.9 | 1826 | 4.9 | 0.0 | 4.944 | 1981.0 | 0.0 |
989 rows × 8 columns
full_set_df = pd.merge(
full_index_df, matched_df, how="left", on=["externalid", "segid", "year"]
)[["externalid", "segid", "year", "AADT"]].assign(
AADT=lambda d: d["AADT"].replace(0, np.nan)
)
full_set_df| externalid | segid | year | AADT | |
|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1981 | NaN |
| 1 | 3601 | 1082_000.0 | 1982 | NaN |
| 2 | 3601 | 1082_000.0 | 1983 | NaN |
| 3 | 3601 | 1082_000.0 | 1984 | NaN |
| 4 | 3601 | 1082_000.0 | 1985 | NaN |
| ... | ... | ... | ... | ... |
| 1242 | 3629 | 1826_004.9 | 2019 | 3158.0 |
| 1243 | 3629 | 1826_004.9 | 2020 | 3155.0 |
| 1244 | 3629 | 1826_004.9 | 2021 | 3382.0 |
| 1245 | 3629 | 1826_004.9 | 2022 | 3345.0 |
| 1246 | 3629 | 1826_004.9 | 2023 | 3489.0 |
1247 rows × 4 columns
full_set_df.to_csv("intermediate/external-historic-aadt.csv", index=False)Step 2: Previous Forecasts v900 (was 2-Prepare-Previous-Forecasts)
previous_forecasts_df = pd.read_csv("data/external-forecasts-v900.csv")
previous_forecasts_df = previous_forecasts_df.melt(
id_vars=["TAZ"], var_name="year", value_name="AADT"
)
previous_forecasts_df["forecast"] = "v900"
previous_forecasts_df.rename(columns={"TAZ": "externalid"}, inplace=True)
previous_forecasts_df| externalid | year | AADT | forecast | |
|---|---|---|---|---|
| 0 | 3601 | 2020 | 750.0 | v900 |
| 1 | 3602 | 2020 | 9570.0 | v900 |
| 2 | 3603 | 2020 | 1480.0 | v900 |
| 3 | 3604 | 2020 | 28390.0 | v900 |
| 4 | 3605 | 2020 | 3840.0 | v900 |
| ... | ... | ... | ... | ... |
| 140 | 3625 | 2060 | 200.0 | v900 |
| 141 | 3626 | 2060 | 5250.0 | v900 |
| 142 | 3627 | 2060 | 450.0 | v900 |
| 143 | 3628 | 2060 | 54260.0 | v900 |
| 144 | 3629 | 2060 | 3660.0 | v900 |
145 rows × 4 columns
previous_forecasts_df.to_csv("intermediate/previous-forecasts.csv", index=False)Step 3: Linear Forecasts (was 3-Prepare-Linear-Forecasts)
# list of years for which to produce linear forecasts
future_years = [2027, 2032, 2036, 2046, 2055, 2060]# One projection group per "Since <year>" starting point, all ending at
# HISTORIC_END with no excluded years -- a pure derived sequence, unlike
# selected_forecast_df/manual_adj_df below, which are genuine per-station
# judgment calls and stay as literal tables.
df_proj_groups_linear = pd.DataFrame(
[
[f"Since {year}", year, YEARS["HISTORIC_END"], {}]
for year in range(YEARS["HISTORIC_START"], YEARS["HISTORIC_END"])
],
columns=("pg_name", "pg_year_from", "pg_year_to", "pg_years_exclude"),
)
df_proj_groups_linear| pg_name | pg_year_from | pg_year_to | pg_years_exclude | |
|---|---|---|---|---|
| 0 | Since 1981 | 1981 | 2023 | {} |
| 1 | Since 1982 | 1982 | 2023 | {} |
| 2 | Since 1983 | 1983 | 2023 | {} |
| 3 | Since 1984 | 1984 | 2023 | {} |
| 4 | Since 1985 | 1985 | 2023 | {} |
| 5 | Since 1986 | 1986 | 2023 | {} |
| 6 | Since 1987 | 1987 | 2023 | {} |
| 7 | Since 1988 | 1988 | 2023 | {} |
| 8 | Since 1989 | 1989 | 2023 | {} |
| 9 | Since 1990 | 1990 | 2023 | {} |
| 10 | Since 1991 | 1991 | 2023 | {} |
| 11 | Since 1992 | 1992 | 2023 | {} |
| 12 | Since 1993 | 1993 | 2023 | {} |
| 13 | Since 1994 | 1994 | 2023 | {} |
| 14 | Since 1995 | 1995 | 2023 | {} |
| 15 | Since 1996 | 1996 | 2023 | {} |
| 16 | Since 1997 | 1997 | 2023 | {} |
| 17 | Since 1998 | 1998 | 2023 | {} |
| 18 | Since 1999 | 1999 | 2023 | {} |
| 19 | Since 2000 | 2000 | 2023 | {} |
| 20 | Since 2001 | 2001 | 2023 | {} |
| 21 | Since 2002 | 2002 | 2023 | {} |
| 22 | Since 2003 | 2003 | 2023 | {} |
| 23 | Since 2004 | 2004 | 2023 | {} |
| 24 | Since 2005 | 2005 | 2023 | {} |
| 25 | Since 2006 | 2006 | 2023 | {} |
| 26 | Since 2007 | 2007 | 2023 | {} |
| 27 | Since 2008 | 2008 | 2023 | {} |
| 28 | Since 2009 | 2009 | 2023 | {} |
| 29 | Since 2010 | 2010 | 2023 | {} |
| 30 | Since 2011 | 2011 | 2023 | {} |
| 31 | Since 2012 | 2012 | 2023 | {} |
| 32 | Since 2013 | 2013 | 2023 | {} |
| 33 | Since 2014 | 2014 | 2023 | {} |
| 34 | Since 2015 | 2015 | 2023 | {} |
| 35 | Since 2016 | 2016 | 2023 | {} |
| 36 | Since 2017 | 2017 | 2023 | {} |
| 37 | Since 2018 | 2018 | 2023 | {} |
| 38 | Since 2019 | 2019 | 2023 | {} |
| 39 | Since 2020 | 2020 | 2023 | {} |
| 40 | Since 2021 | 2021 | 2023 | {} |
| 41 | Since 2022 | 2022 | 2023 | {} |
# import historic AADT (created in step 1)
df_historic_aadt = pd.read_csv("intermediate/external-historic-aadt.csv")
df_historic_aadt| externalid | segid | year | AADT | |
|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1981 | NaN |
| 1 | 3601 | 1082_000.0 | 1982 | NaN |
| 2 | 3601 | 1082_000.0 | 1983 | NaN |
| 3 | 3601 | 1082_000.0 | 1984 | NaN |
| 4 | 3601 | 1082_000.0 | 1985 | NaN |
| ... | ... | ... | ... | ... |
| 1242 | 3629 | 1826_004.9 | 2019 | 3158.0 |
| 1243 | 3629 | 1826_004.9 | 2020 | 3155.0 |
| 1244 | 3629 | 1826_004.9 | 2021 | 3382.0 |
| 1245 | 3629 | 1826_004.9 | 2022 | 3345.0 |
| 1246 | 3629 | 1826_004.9 | 2023 | 3489.0 |
1247 rows × 4 columns
Linear forecasts with assist from ChatGPT: https://chat.openai.com/share/d127492a-ad78-4f45-afd0-50e29069db1a
def fit_linear_forecast(
filtered_group: pd.DataFrame,
pg_name: str,
pg_year_from: int,
future_years: list[int],
) -> tuple[np.ndarray, str] | None:
"""
Fits an OLS trend (AADT ~ year) on `filtered_group` and predicts
`pg_year_from` plus each of `future_years`.
Returns (forecast_array, proj_grp_used) if there are at least 2 valid
(non-NaN) historic points to fit a line through; otherwise None, leaving
the "no data" fallback to the caller.
"""
X = filtered_group["year"].values.reshape(-1, 1)
y = filtered_group["AADT"].values
valid_mask = ~np.isnan(X.flatten()) & ~np.isnan(y)
X_valid = X[valid_mask]
y_valid = y[valid_mask]
if len(X_valid) < 2:
return None
model = LinearRegression()
model.fit(X_valid, y_valid)
aadt = model.predict(np.array([pg_year_from] + future_years).reshape(-1, 1))
aadt = np.rint(aadt).astype(int)
return aadt, pg_name# Initialize a list to store the individual result DataFrames
forecast_results_list = []
# Initialize a set to track which externalids have already been assigned 'No Data'
no_data_externalids = set()
# Open the error file
with open("intermediate/linear-forecasts-errors.txt", "w") as err_file:
# Loop through the projection groups
for _, row in df_proj_groups_linear.iterrows():
pg_name = row["pg_name"]
pg_year_from = row["pg_year_from"]
pg_year_to = row["pg_year_to"]
pg_years_exclude = set(row["pg_years_exclude"])
# Group by externalid and segid and iterate through the groups
for (externalid, segid), group in df_historic_aadt.groupby(
["externalid", "segid"]
):
# Filter the data according to the projection group criteria
filtered_group = group[
(group["year"] >= pg_year_from) & (group["year"] <= pg_year_to)
]
filtered_group = filtered_group[
~filtered_group["year"].isin(pg_years_exclude)
]
fit_result = fit_linear_forecast(
filtered_group, pg_name, pg_year_from, future_years
)
if fit_result is not None:
aadt, proj_grp_used = fit_result
else:
# "No Data" is recorded once per externalid: the first
# projection group (in "Since <year>" order, oldest first)
# that lacks 2+ historic points wins; every later group for
# the same externalid is skipped entirely via this memo set,
# since a shorter window can't fix a lack of data.
if externalid in no_data_externalids:
continue # Skip this segid
error_msg = f"No valid data for externalid: {externalid}, segid: {segid}, Projection Group: {pg_name}. Filling zeros."
err_file.write(error_msg + "\n")
aadt = np.zeros(len([pg_year_from] + future_years), dtype=int)
proj_grp_used = "No Data"
no_data_externalids.add(externalid)
# Create a dictionary to store results for this group
result_dict = {
"externalid": externalid,
"segid": segid,
"PROJ_GRP": proj_grp_used,
}
result_dict.update(
{
year: forecast
for year, forecast in zip([pg_year_from] + future_years, aadt)
}
)
# Convert the dictionary to a DataFrame and add to the list
result_df = pd.DataFrame([result_dict])
result_df_melt = result_df.melt(
id_vars=["externalid", "segid", "PROJ_GRP"],
var_name="year",
value_name="linear_forecast",
)
forecast_results_list.append(result_df_melt)
# Concatenate all the individual result DataFrames
forecast_results = pd.concat(forecast_results_list, ignore_index=True)
forecast_results| externalid | segid | PROJ_GRP | year | linear_forecast | |
|---|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | Since 1981 | 1981 | -26 |
| 1 | 3601 | 1082_000.0 | Since 1981 | 2027 | 890 |
| 2 | 3601 | 1082_000.0 | Since 1981 | 2032 | 989 |
| 3 | 3601 | 1082_000.0 | Since 1981 | 2036 | 1069 |
| 4 | 3601 | 1082_000.0 | Since 1981 | 2046 | 1268 |
| ... | ... | ... | ... | ... | ... |
| 6799 | 3629 | 1826_004.9 | Since 2022 | 2032 | 4785 |
| 6800 | 3629 | 1826_004.9 | Since 2022 | 2036 | 5361 |
| 6801 | 3629 | 1826_004.9 | Since 2022 | 2046 | 6801 |
| 6802 | 3629 | 1826_004.9 | Since 2022 | 2055 | 8097 |
| 6803 | 3629 | 1826_004.9 | Since 2022 | 2060 | 8817 |
6804 rows × 5 columns
forecast_results.to_csv("results/linear-forecasts.csv", index=False)Step 4: Finalize Forecasts (was 4-Finalize-Forecasts)
linear_forecast_df = pd.read_csv("results/linear-forecasts.csv")
externals_df = pd.read_csv("params/externals.csv")
previous_forecasts_df = pd.read_csv("intermediate/previous-forecasts.csv")
external_xy_df = pd.read_csv("data/external-x-y.csv")
# Create a lookup dictionary
name_lookup = externals_df.set_index("externalid")["name"].to_dict()
# Map names to the forecast df
linear_forecast_df["name"] = linear_forecast_df["externalid"].map(name_lookup)
linear_forecast_df["external"] = format_external_label(linear_forecast_df)
# Map names to the previous-forecasts df (used later for plotting/reference only)
previous_forecasts_df["name"] = previous_forecasts_df["externalid"].map(name_lookup)
previous_forecasts_df["external"] = format_external_label(previous_forecasts_df)
previous_forecasts_df = previous_forecasts_df[
previous_forecasts_df["year"] > YEARS["HISTORIC_END"]
]selected_forecast_df = pd.DataFrame(
[
[3601, "Since 2003", "as early as there is data"],
[
3602,
"Since 1981",
"as early as there is data, and trying not to get too high when using 2002 or higher",
],
[
3603,
"Since 2016",
"trying to split difference between forecast using earlier data and forecast using 2020+ data",
],
[
3604,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[3605, "Since 2002", "earlier data jumps areound too much"],
[
3606,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[3607, "Since 2007", "data does not look consistent till 2007"],
[
3608,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[
3609,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[3610, "No Data", ""],
[
3611,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[
3612,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[3613, "Since 2009", "pre 2009 data is much higher than post 2009 data"],
[
3614,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[
3615,
"Since 2007",
"erratic data. 2007 tries to split difference between 2004-2012 data and 2013-2023 data",
],
[3616, "Since 2006", "data pretty consistent since 2006"],
[3617, "Since 2011", "data pretty consistent since 2011"],
[
3618,
"Since 2007",
"data pretty consistent since 2007, but forecast from 1981 is much higher than 2007-2023 data",
],
[3619, "No Data", ""],
[
3620,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time",
],
[3621, "No Data", ""],
[3622, "No Data", ""],
[
3623,
"Since 1981",
"as early as there is data, trend looks pretty consistent over time. outlier in 1983 does not change the trend",
],
[
3624,
"Since 1990",
"data pretty consistent since 2017, but puts forecast too high",
],
[3625, "No Data", ""],
[
3626,
"Since 1981",
"as early as there is data, despite some variation early on, trend looks pretty consistent over time",
],
[3627, "No Data", ""],
[
3628,
"Since 1984",
"as early as there is data, trend looks pretty consistent over time except for past two years, keep eye on it",
],
[3629, "Since 2011", "data pretty consistent since 2011"],
],
columns=["externalid", "PROJ_GRP", "linear_forecast_notes"],
)
linear_forecast_selected_df = pd.merge(
selected_forecast_df, linear_forecast_df, on=["externalid", "PROJ_GRP"]
)
linear_forecast_selected_df| externalid | PROJ_GRP | linear_forecast_notes | segid | year | linear_forecast | name | external | |
|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2003 | 412 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge |
| 1 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2027 | 890 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge |
| 2 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2032 | 989 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge |
| 3 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2036 | 1069 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge |
| 4 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2046 | 1268 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 198 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2032 | 4248 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms |
| 199 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2036 | 4588 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms |
| 200 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2046 | 5437 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms |
| 201 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2055 | 6202 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms |
| 202 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2060 | 6627 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms |
203 rows × 8 columns
def custom_round(x: float) -> float:
"""Rounds to the nearest 10/50/100/500/1000, coarser at larger magnitudes."""
if x < 100:
return round(x / 10) * 10
elif x < 1000:
return round(x / 50) * 50
elif x < 10000:
return round(x / 100) * 100
elif x < 100000:
return round(x / 500) * 500
else:
return round(x / 1000) * 1000# Example manual adjustment data
manual_adj_df = pd.DataFrame(
[
[3602, 2027, 500],
[3602, 2032, 300],
[3602, 2036, 200],
[3602, 2046, 100],
[3605, 2027, 100],
[3608, 2027, 100],
[3612, 2027, 4000],
[3612, 2032, 2000],
[3612, 2036, 1000],
[3613, 2027, 50],
[3613, 2032, 0],
[3613, 2036, 0],
[3613, 2046, 0],
[3613, 2055, 0],
[3615, 2027, -2050], # adjust down to prior forecast
[3615, 2032, -2200], # adjust down to prior forecast
[3615, 2036, -2300], # adjust down to prior forecast
[3615, 2046, -2600], # adjust down to prior forecast
[3615, 2055, -2750], # adjust down to prior forecast
[3615, 2060, -2900], # adjust down to prior forecast
[3620, 2027, 500],
[3623, 2027, 500],
[3623, 2032, 500],
[3623, 2036, 500],
[3626, 2027, 300],
[3626, 2032, 200],
[3626, 2036, 100],
[3610, 2027, 100],
[3610, 2032, 100],
[3610, 2036, 100],
[3610, 2046, 150],
[3610, 2055, 150],
[3610, 2060, 200],
[3619, 2027, 450],
[3619, 2032, 450],
[3619, 2036, 500],
[3619, 2046, 550],
[3619, 2055, 550],
[3619, 2060, 600],
[3621, 2027, 350],
[3621, 2032, 350],
[3621, 2036, 400],
[3621, 2046, 400],
[3621, 2055, 450],
[3621, 2060, 450],
[3622, 2027, 150],
[3622, 2032, 150],
[3622, 2036, 150],
[3622, 2046, 200],
[3622, 2055, 200],
[3622, 2060, 200],
[3624, 2027, 600],
[3624, 2032, 400],
[3624, 2036, 400],
[3624, 2046, 300],
[3624, 2055, 100],
[3624, 2060, 0],
[3625, 2027, 150],
[3625, 2032, 150],
[3625, 2036, 150],
[3625, 2046, 200],
[3625, 2055, 200],
[3625, 2060, 200],
[3627, 2027, 350],
[3627, 2032, 350],
[3627, 2036, 400],
[3627, 2046, 400],
[3627, 2055, 450],
[3627, 2060, 450],
],
columns=["externalid", "year", "manual_adj"],
)
# Filter only needed years
forecast_df = linear_forecast_selected_df[
linear_forecast_selected_df["year"] >= YEARS["HISTORIC_END"]
]
# Merge manual adjustments
final_forecast_df = pd.merge(
forecast_df, manual_adj_df, on=["externalid", "year"], how="left"
)
# Compute final_forecast using manual adjustments where available
final_forecast_df["final_forecast"] = final_forecast_df[
"linear_forecast"
] + final_forecast_df["manual_adj"].fillna(0)
# Apply custom rounding
final_forecast_df["final_forecast"] = final_forecast_df["final_forecast"].apply(
custom_round
)
final_forecast_df| externalid | PROJ_GRP | linear_forecast_notes | segid | year | linear_forecast | name | external | manual_adj | final_forecast | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2027 | 890 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge | NaN | 900 |
| 1 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2032 | 989 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge | NaN | 1000 |
| 2 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2036 | 1069 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge | NaN | 1100 |
| 3 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2046 | 1268 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge | NaN | 1300 |
| 4 | 3601 | Since 2003 | as early as there is data | 1082_000.0 | 2055 | 1447 | FAR-1082 Bird Refuge | Ext # 3601 - FAR-1082 Bird Refuge | NaN | 1400 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 169 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2032 | 4248 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms | NaN | 4200 |
| 170 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2036 | 4588 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms | NaN | 4600 |
| 171 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2046 | 5437 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms | NaN | 5400 |
| 172 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2055 | 6202 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms | NaN | 6200 |
| 173 | 3629 | Since 2011 | data pretty consistent since 2011 | 1826_004.9 | 2060 | 6627 | FAR-1826 South Ridge Farms | Ext # 3629 - FAR-1826 South Ridge Farms | NaN | 6600 |
174 rows × 10 columns
# Export data for the externals-app Shiny app.
# Define transformer from UTM Zone 12N (EPSG:32612) to WGS84 Lat/Lon (EPSG:4326)
transformer = pyproj.Transformer.from_crs("EPSG:32612", "EPSG:4326", always_xy=True)
# Apply transformation to create 'lon' and 'lat' columns
external_xy_df["lon"], external_xy_df["lat"] = transformer.transform(
external_xy_df["x_utm12n"].values,
external_xy_df["y_utm12n"].values,
)
aadt_df = pd.read_csv("intermediate/external-historic-aadt.csv")
aadt_df["name"] = aadt_df["externalid"].map(name_lookup)
aadt_df["external"] = format_external_label(aadt_df)
# historic.csv: actual historic AADT by station/year (used as the "observed" series).
aadt_df.drop(columns=["external", "segid", "name"]).dropna().astype(int).to_csv(
"externals-app/data/historic.csv", index=False
)
# forecasts-previous.csv: the prior (v9.0) forecast, kept for before/after comparison.
previous_forecasts_df.drop(columns=["external", "forecast", "name"]).rename(
columns={"AADT": "previous_forecast"}
).sort_values(by=["externalid", "year"]).astype(int).to_csv(
"externals-app/data/forecasts-previous.csv", index=False
)
# externals.csv: station locations (lat/lon) for the app's map view.
external_xy_df.to_csv("externals-app/data/externals.csv", index=False)
# forecasts.csv: the current final forecast (linear trend + manual adjustments + rounding).
final_forecast_df.drop(
columns=["external", "name", "linear_forecast_notes", "segid", "PROJ_GRP"]
).fillna(0).astype(int).to_csv("externals-app/data/forecasts.csv", index=False)
# linear-forecasts.csv: the selected linear-trend line (one projection group per
# station, via selected_forecast_df above) that the app draws as the forecast trend.
linear_forecast_selected_df[linear_forecast_selected_df["linear_forecast"] > 0].drop(
columns=["PROJ_GRP", "linear_forecast_notes", "segid", "name", "external"]
).fillna(0).astype(int).to_csv("externals-app/data/linear-forecasts.csv", index=False)final_forecast_df.to_csv("results/final_forecast_df.csv", index=False)Step 5: Export Data (was 5-Export-Data)
Helper functions
def sophisticated_fill(group: pd.DataFrame) -> pd.Series:
"""Interpolates AADT_Forecast gaps and back-extrapolates from the earliest trend."""
# A. PREPARE DATA
s = group.set_index("Year")["AADT_Forecast"].astype(float)
# B. INTERPOLATION (Fill gaps between known years)
s_interp = s.interpolate(method="linear", limit_area="inside")
# C. BACKWARD EXTRAPOLATION (Fill 2023-2026)
valid_years = s.dropna().index.sort_values()
if len(valid_years) >= 2:
y1, y2 = valid_years[0], valid_years[1]
val1, val2 = s.loc[y1], s.loc[y2]
if y2 != y1:
slope = (val2 - val1) / (y2 - y1)
# Find years before the first known data point
pre_years = s_interp.index[s_interp.index < y1]
for yr in pre_years:
if pd.isna(s_interp.loc[yr]):
extrapolated_val = val1 + slope * (yr - y1)
s_interp.loc[yr] = max(0, extrapolated_val)
# Fill any remaining edges
s_final = s_interp.bfill().ffill()
# D. RE-ALIGNMENT
return pd.Series(s_final.loc[group["Year"]].values, index=group.index)def project_future_volumes(
group: pd.DataFrame,
value_col: str,
year_col: str,
independent_col: str,
split_year: int = YEARS["TRUCK_PROJECTION_SPLIT"],
) -> pd.Series:
"""
Projects future volumes using Linear Regression (y = mx + b).
Logic:
1. If data varies: Calculate proper Slope (m) and Intercept (b) using OLS regression.
2. If AADT is constant (Edge Case): Fallback to a Ratio Slope (b=0) to allow
growth, as a unique m & b cannot be calculated from a single point.
"""
# 1. Identify Historic Data for Training
hist_mask = (group[year_col] < split_year) & (group[value_col].notna())
# 2. If insufficient historic data, forward fill the last known value
if hist_mask.sum() < 2:
last_known = (
group.loc[group[year_col] < split_year, value_col].iloc[-1]
if hist_mask.any()
else 0
)
group.loc[group[year_col] >= split_year, value_col] = last_known
return group[value_col]
# 3. Prepare Data
x_hist = group.loc[hist_mask, independent_col].values
y_hist = group.loc[hist_mask, value_col].values
# 4. Check for Constant AADT (Edge Case where Regression Fails)
if np.min(x_hist) == np.max(x_hist):
# Fallback: Assume Intercept is 0 and calculate Slope based on Average Ratio.
mean_x = np.mean(x_hist)
if mean_x == 0:
slope = 0
else:
slope = np.mean(y_hist) / mean_x
intercept = 0
else:
# 5. Standard Calculation (The "Proper" Way)
slope, intercept, _, _, _ = stats.linregress(x_hist, y_hist)
# 6. Predict Future
future_mask = group[year_col] >= split_year
x_future = group.loc[future_mask, independent_col].values
predicted = slope * x_future + intercept
group.loc[future_mask, value_col] = np.maximum(predicted, 0)
return group[value_col]Input data
forecast_results = pd.read_csv("results/final_forecast_df.csv")
forecast_results[["externalid", "year", "final_forecast"]]| externalid | year | final_forecast | |
|---|---|---|---|
| 0 | 3601 | 2027 | 900 |
| 1 | 3601 | 2032 | 1000 |
| 2 | 3601 | 2036 | 1100 |
| 3 | 3601 | 2046 | 1300 |
| 4 | 3601 | 2055 | 1400 |
| ... | ... | ... | ... |
| 169 | 3629 | 2032 | 4200 |
| 170 | 3629 | 2036 | 4600 |
| 171 | 3629 | 2046 | 5400 |
| 172 | 3629 | 2055 | 6200 |
| 173 | 3629 | 2060 | 6600 |
174 rows × 3 columns
gdf_master_segments = gpd.read_file(
"zip://data/updated-traffic-factors/Master_Segs_withFactors_20251120.zip"
).to_crs(PROJECT_CRS)
gdf_master_segments| SEGID | BMP | EMP | DISTANCE | CO_FIPS | PLANAREA | AADT2023 | AADT2022 | AADT2021 | AADT2020 | ... | FAC_WIN | FAC_SPR | FAC_SUM | FAC_FAL | FAC_MAXMO | FAC_MAX | FACMANADJ | SUTRUCKS | CUTRUCKS | geometry | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0006_000.0 | 0.000 | 0.665 | 0.666641 | 27 | UDOT | 457.0 | 441.0 | 474.0 | 430.0 | ... | 0.8769 | 1.0071 | 1.0496 | 1.0664 | 10 | 1.1275 | 0 | 0.2496 | 0.2324 | LINESTRING (916692.404 6835614.744, 920182.189... |
| 1 | 0006_000.7 | 0.665 | 16.022 | 15.369839 | 27 | UDOT | 457.0 | 441.0 | 474.0 | 430.0 | ... | 0.8769 | 1.0071 | 1.0496 | 1.0664 | 10 | 1.1275 | 0 | 0.2496 | 0.2324 | LINESTRING (920182.189 6835168.248, 923166.425... |
| 2 | 0006_016.0 | 16.022 | 46.017 | 30.001961 | 27 | UDOT | 457.0 | 441.0 | 474.0 | 430.0 | ... | 0.8769 | 1.0071 | 1.0496 | 1.0664 | 10 | 1.1275 | 0 | 0.2496 | 0.2324 | LINESTRING (999430.463 6834408.584, 999447.155... |
| 3 | 0006_046.0 | 46.017 | 60.218 | 14.194306 | 27 | UDOT | 409.0 | 395.0 | 424.0 | 385.0 | ... | 0.8769 | 1.0071 | 1.0496 | 1.0664 | 10 | 1.1275 | 0 | 0.1751 | 0.3338 | LINESTRING (1143701.911 6830874.803, 1145024.9... |
| 4 | 0006_060.2 | 60.218 | 77.545 | 17.323237 | 27 | UDOT | 409.0 | 395.0 | 424.0 | 385.0 | ... | 0.8769 | 1.0071 | 1.0496 | 1.0664 | 10 | 1.1275 | 0 | 0.1751 | 0.3338 | LINESTRING (1206604.946 6871212.763, 1206701.0... |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 9252 | WFRC_8489 | 0.000 | 0.000 | 0.505514 | 35 | WFRC | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.9411 | 0.9924 | 1.0246 | 1.0419 | 8 | 1.0510 | 0 | 0.1086 | 0.0565 | LINESTRING (1518588.41 7388436.516, 1518656.54... |
| 9253 | WFRC_8490 | 0.000 | 0.000 | 0.737896 | 35 | WFRC | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.9411 | 0.9924 | 1.0246 | 1.0419 | 8 | 1.0510 | 0 | 0.1086 | 0.0565 | LINESTRING (1521238.95 7388420.585, 1522418.15... |
| 9254 | WFRC_8491 | 0.000 | 0.000 | 0.265495 | 35 | WFRC | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.9411 | 0.9924 | 1.0246 | 1.0419 | 8 | 1.0510 | 0 | 0.1086 | 0.0565 | LINESTRING (1525103.396 7388831.353, 1525935.1... |
| 9255 | WFRC_8492 | 0.000 | 0.000 | 0.444725 | 35 | WFRC | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.9519 | 1.0137 | 1.0141 | 1.0203 | 5 | 1.0436 | 0 | 0.1052 | 0.0432 | LINESTRING (1527315.01 7447662.776, 1527326.78... |
| 9256 | WFRC_8493 | 0.000 | 0.000 | 0.583552 | 35 | WFRC | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.9060 | 1.0249 | 1.0414 | 1.0278 | 5 | 1.0822 | 0 | 0.1244 | 0.0925 | LINESTRING (1507812.247 7347416.236, 1507019.7... |
9257 rows × 82 columns
# Read Processed UDOT AADT Data directly from GitHub repo
response = fetch_github(
"https://raw.githubusercontent.com/WFRCAnalytics/DATA-UDOT-AADT-Processing/refs/heads/main/_output/udot_aadt_trkpct_data.csv",
mode="private",
)
df_aadt_udot = pd.read_csv(io.StringIO(response.text))
df_aadt_udot| Station | RouteID | BeginPoint | EndPoint | SectionLength | DESC_ | YEAR | AADT | SUTRK | CUTRK | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 001-0010 | 0015PM | 109.029 | 112.071 | 3.042 | SR 160 South Beaver Milford | 1981 | 4125.0 | 0.029041 | 0.222085 |
| 1 | 001-0010 | 0015PM | 109.029 | 112.071 | 3.042 | SR 160 South Beaver Milford | 1982 | 4325.0 | 0.029041 | 0.222085 |
| 2 | 001-0010 | 0015PM | 109.029 | 112.071 | 3.042 | SR 160 South Beaver Milford | 1983 | 4635.0 | 0.029041 | 0.222085 |
| 3 | 001-0010 | 0015PM | 109.029 | 112.071 | 3.042 | SR 160 South Beaver Milford | 1984 | 4810.0 | 0.029041 | 0.222085 |
| 4 | 001-0010 | 0015PM | 109.029 | 112.071 | 3.042 | SR 160 South Beaver Milford | 1985 | 5120.0 | 0.029041 | 0.222085 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 199535 | 057-1530 | 3424PM | 0.553 | 1.306 | 0.753 | 9th St (Rt 3426) via Polk Ave - Sheridan Dr | 2020 | 2219.0 | 0.000000 | 0.000000 |
| 199536 | 057-1530 | 3424PM | 0.553 | 1.306 | 0.753 | 9th St (Rt 3426) via Polk Ave - Sheridan Dr | 2021 | 2405.0 | 0.000000 | 0.000000 |
| 199537 | 057-1530 | 3424PM | 0.553 | 1.306 | 0.753 | 9th St (Rt 3426) via Polk Ave - Sheridan Dr | 2022 | 2429.0 | 0.000000 | 0.000000 |
| 199538 | 057-1530 | 3424PM | 0.553 | 1.306 | 0.753 | 9th St (Rt 3426) via Polk Ave - Sheridan Dr | 2023 | 2463.0 | 0.000000 | 0.000000 |
| 199539 | 057-1530 | 3424PM | 0.553 | 1.306 | 0.753 | 9th St (Rt 3426) via Polk Ave - Sheridan Dr | 2024 | 2517.0 | 0.000000 | 0.000000 |
199540 rows × 10 columns
Reusing external_segment_link from Step 1 rather than re-reading it — same file, same CCS-319 override, so there’s a single place that logic can be edited.
external_segment_link| externalid | segid | route | milepost | |
|---|---|---|---|---|
| 0 | 3601 | 1082_000.0 | 1082 | 0.00 |
| 1 | 3602 | 0013_006.5 | 13 | 6.50 |
| 2 | 3603 | 1112_000.0 | 1112 | 0.00 |
| 3 | 3604 | 0015_368.1 | 15 | 368.10 |
| 4 | 3605 | 0038_003.2 | 38 | 3.20 |
| 5 | 3606 | 0091_010.1 | 91 | 10.10 |
| 6 | 3607 | 3462_002.8 | 3462 | 2.80 |
| 7 | 3608 | 0039_008.7 | 39 | 8.70 |
| 8 | 3609 | 0084_087.8 | 84 | 87.80 |
| 9 | 3610 | 2688_005.5 | 2688 | 5.50 |
| 10 | 3611 | 0201_000.0 | 201 | 0.00 |
| 11 | 3612 | 0080_101.2 | 80 | 101.20 |
| 12 | 3613 | 0065_008.4 | 65 | 8.40 |
| 13 | 3614 | 0080_138.9 | 80 | 138.90 |
| 14 | 3615 | 0190_015.75 | 190 | 15.75 |
| 15 | 3616 | 1828_004.5 | 1828 | 4.50 |
| 16 | 3617 | 0006_140.3 | 6 | 140.30 |
| 17 | 3618 | 0073_015.5 | 73 | 15.50 |
| 18 | 3619 | 3108_000.0 | 3108 | 0.00 |
| 19 | 3620 | 0189_013.1 | 189 | 13.10 |
| 20 | 3621 | 2865_019.4 | 2865 | 19.40 |
| 21 | 3622 | 2863_000.0 | 2863 | 0.00 |
| 22 | 3623 | 0006_216.2 | 6 | 216.20 |
| 23 | 3624 | 0096_006.2 | 96 | 6.20 |
| 24 | 3625 | 2495_000.0 | 2495 | 0.00 |
| 25 | 3626 | 0089_296.0 | 89 | 296.00 |
| 26 | 3627 | 1822_000.0 | 1822 | 0.00 |
| 27 | 3628 | 0015_233.2 | 15 | 233.20 |
| 28 | 3629 | 1826_004.9 | 1826 | 4.90 |
# Prior published version of external_year_vol.csv — used below as a fallback source
# for AWDT_FAC and historic AADT/truck-% where the primary sources have gaps.
df_external_year_archive = pd.read_csv(r"archive/v920/external_year_vol.csv")
df_external_year_archive| ;Idx_WF | WF_Ext | Year | AWDT | PASS_VOL | TRUCK_MD | TRUCK_HV | AWDT_FAC | AADT | PASSENGER | TRUCK_SU | TRUCK_MU | PctTrk_SU | PctTrk_MU | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 36012010 | 3601 | 2010 | 492 | 321 | 105 | 66 | 0.956 | 515 | 336 | 110 | 69 | 0.214 | 0.135 |
| 1 | 36012011 | 3601 | 2011 | 487 | 317 | 104 | 66 | 0.956 | 510 | 332 | 109 | 69 | 0.214 | 0.135 |
| 2 | 36012012 | 3601 | 2012 | 558 | 364 | 119 | 75 | 0.956 | 585 | 381 | 125 | 79 | 0.214 | 0.135 |
| 3 | 36012013 | 3601 | 2013 | 577 | 377 | 123 | 77 | 0.956 | 605 | 395 | 129 | 81 | 0.214 | 0.135 |
| 4 | 36012014 | 3601 | 2014 | 587 | 382 | 126 | 79 | 0.956 | 615 | 400 | 132 | 83 | 0.214 | 0.135 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 1474 | 36292056 | 3629 | 2056 | 3409 | 2291 | 637 | 481 | 0.984 | 3464 | 2328 | 647 | 489 | 0.187 | 0.141 |
| 1475 | 36292057 | 3629 | 2057 | 3457 | 2323 | 646 | 488 | 0.984 | 3513 | 2361 | 656 | 496 | 0.187 | 0.141 |
| 1476 | 36292058 | 3629 | 2058 | 3505 | 2356 | 654 | 495 | 0.984 | 3562 | 2394 | 665 | 503 | 0.187 | 0.141 |
| 1477 | 36292059 | 3629 | 2059 | 3553 | 2388 | 663 | 502 | 0.984 | 3611 | 2427 | 674 | 510 | 0.187 | 0.141 |
| 1478 | 36292060 | 3629 | 2060 | 3602 | 2421 | 672 | 509 | 0.984 | 3660 | 2460 | 683 | 517 | 0.187 | 0.141 |
1479 rows × 14 columns
Prepare data
Step 1: Initialize final dataframe structure
df_external_year = (
pd.MultiIndex.from_product(
[
external_segment_link["externalid"].unique(),
range(YEARS["HISTORIC_START"], YEARS["PIPELINE_END"] + 1),
],
names=["WF_Ext", "Year"],
)
.to_frame(index=False)
.reset_index(drop=True)
)
# Create Index String
df_external_year[";Idx_WF"] = df_external_year["WF_Ext"].astype(
str
) + df_external_year["Year"].astype(str)
# Map Metadata
df_external_year["segid"] = df_external_year["WF_Ext"].map(
external_segment_link.set_index("externalid")["segid"]
)
df_external_year["route"] = df_external_year["segid"].str.split("_").str[0] + "PM"
df_external_year["milepost"] = pd.to_numeric(
df_external_year["segid"].str.split("_").str[1], errors="coerce"
)
# Map Station Name
df_external_year["Ext_Name"] = df_external_year["WF_Ext"].map(
forecast_results[["externalid", "external"]]
.drop_duplicates()
.set_index("externalid")["external"]
)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | |
|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms |
2320 rows × 7 columns
Step 2: AWDT factors (with fallback)
# Primary: From Master Segments
df_external_year["AWDT_FAC"] = df_external_year["segid"].map(
gdf_master_segments.set_index("SEGID")["FAC_WDAVG"]
)
# Fallback: From Archive (Match WF_Ext + Year)
archive_fac_map = df_external_year_archive.set_index(["WF_Ext", "Year"])["AWDT_FAC"]
df_external_year = df_external_year.set_index(["WF_Ext", "Year"])
df_external_year["AWDT_FAC"] = df_external_year["AWDT_FAC"].fillna(archive_fac_map)
df_external_year = df_external_year.reset_index()
# Clean up: Replace 0s with 1 (or NA) to prevent math errors, assuming 1 if missing
df_external_year["AWDT_FAC"] = df_external_year["AWDT_FAC"].replace(0, 1).fillna(1)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | |
|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 |
2320 rows × 8 columns
Step 3: Historic data integration (1981 - 2022)
# 1. Merge UDOT Data based on Year and Route
udot_subset = df_aadt_udot[
["YEAR", "RouteID", "BeginPoint", "EndPoint", "AADT", "SUTRK", "CUTRK"]
].copy()
df_merged = df_external_year.merge(
udot_subset, left_on=["Year", "route"], right_on=["YEAR", "RouteID"], how="left"
)
# 2. Filter for segment matching
# Primary Check: Does the milepost fall strictly INSIDE the segment?
df_merged["is_inside"] = (df_merged["milepost"] >= df_merged["BeginPoint"]) & (
df_merged["milepost"] < df_merged["EndPoint"]
)
# Fallback Check: Distance to the BeginPoint (in case of edge cases/missing endpoints)
df_merged["distance"] = (df_merged["BeginPoint"] - df_merged["milepost"]).abs()
# Sort so that rows where 'is_inside' is True float to the top.
df_sorted = df_merged.sort_values(by=["is_inside", "distance"], ascending=[False, True])
# 3. Aggregate: Take the best match
df_historic_matches = df_sorted.groupby([";Idx_WF"]).first().reset_index()
# Update the main df with these matches
cols_to_update = {"AADT": "AADT_Historic", "SUTRK": "PctTrk_SU", "CUTRK": "PctTrk_MU"}
for src, dest in cols_to_update.items():
df_external_year[dest] = df_external_year[";Idx_WF"].map(
df_historic_matches.set_index(";Idx_WF")[src]
)
# 4. Fallback: Fill Historic Gaps from Archive
mask_historic_fill = df_external_year["Year"] <= YEARS["HISTORIC_FILL_CUTOFF"]
fallback_cols = ["AADT", "PctTrk_SU", "PctTrk_MU"]
target_cols = ["AADT_Historic", "PctTrk_SU", "PctTrk_MU"]
for archive_col, target_col in zip(fallback_cols, target_cols):
archive_map = df_external_year_archive.set_index(["WF_Ext", "Year"])[archive_col]
fallback_series = pd.Series(
df_external_year.set_index(["WF_Ext", "Year"]).index.map(archive_map),
index=df_external_year.index,
)
df_external_year.loc[mask_historic_fill, target_col] = df_external_year.loc[
mask_historic_fill, target_col
].fillna(fallback_series)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | PctTrk_MU | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN |
2320 rows × 11 columns
Step 4: Forecast data & AADT hybridization
# 1. Map Forecast Results (Sparse data: 2027, 2032, ... 2060 etc.)
df_external_year["AADT_Forecast"] = (
df_external_year.set_index(["WF_Ext", "Year"])
.index.map(forecast_results.set_index(["externalid", "year"])["final_forecast"])
.astype(float)
)
# Extrapolate/Back-cast Forecast (Fill 2023-2026 using trend from 2027+)
forecast_mask = df_external_year["Year"] >= YEARS["HISTORIC_END"]
df_external_year.loc[forecast_mask, "AADT_Forecast"] = (
df_external_year[forecast_mask]
.copy()
.groupby("WF_Ext", group_keys=False)[["Year", "AADT_Forecast"]]
.apply(sophisticated_fill)
)
# Create Combined AADT Column (The "Final" Column)
# Logic: Use Historic if < YEARS["HISTORIC_END"], else use Forecast
df_external_year["AADT"] = np.where(
df_external_year["Year"] < YEARS["HISTORIC_END"],
df_external_year["AADT_Historic"],
df_external_year["AADT_Forecast"],
)
df_external_year["AADT"] = (
pd.to_numeric(df_external_year["AADT"], errors="coerce").round().astype("Int64")
)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | PctTrk_MU | AADT_Forecast | AADT | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN | 6280.0 | 6280 |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN | 6360.0 | 6360 |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN | 6440.0 | 6440 |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN | 6520.0 | 6520 |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | NaN | NaN | 6600.0 | 6600 |
2320 rows × 13 columns
Step 5: Truck volume projections
# A. Calculate Base Historic Volumes (Rows < 2025)
df_external_year["TRUCK_SU"] = (
df_external_year["AADT"] * df_external_year["PctTrk_SU"].fillna(0)
).where(df_external_year["PctTrk_SU"].notna())
df_external_year["TRUCK_MU"] = (
df_external_year["AADT"] * df_external_year["PctTrk_MU"].fillna(0)
).where(df_external_year["PctTrk_MU"].notna())
# B. Project Future Volumes (Rows >= 2025)
# split_year=2025 lines up with mask_historic_fill (<= 2024) above: historic
# truck percentages are trusted through 2024, so 2025+ switches to regressing
# truck volume against AADT instead. TRUCK_SU and TRUCK_MU are independent
# regressions (both only depend on Year/AADT, not on each other), so both are
# computed in a single per-WF_Ext groupby pass rather than two.
def project_future_truck_volumes(
group: pd.DataFrame, split_year: int = YEARS["TRUCK_PROJECTION_SPLIT"]
) -> pd.DataFrame:
"""Projects TRUCK_SU and TRUCK_MU forward from `split_year` for one WF_Ext group."""
group = group.copy()
group["TRUCK_SU"] = project_future_volumes(
group, "TRUCK_SU", "Year", independent_col="AADT", split_year=split_year
)
group["TRUCK_MU"] = project_future_volumes(
group, "TRUCK_MU", "Year", independent_col="AADT", split_year=split_year
)
return group[["TRUCK_SU", "TRUCK_MU"]]
df_external_year[["TRUCK_SU", "TRUCK_MU"]] = df_external_year.groupby(
"WF_Ext", group_keys=False
).apply(project_future_truck_volumes, include_groups=False)
# Round Truck Volumes to Integers
df_external_year["TRUCK_SU"] = (
pd.to_numeric(df_external_year["TRUCK_SU"]).round().astype("Int64")
)
df_external_year["TRUCK_MU"] = (
pd.to_numeric(df_external_year["TRUCK_MU"]).round().astype("Int64")
)
# C. Back-Calculate Future Percentages
mask_future = df_external_year["Year"] >= YEARS["TRUCK_PROJECTION_SPLIT"]
df_external_year.loc[mask_future, "PctTrk_SU"] = (
df_external_year.loc[mask_future, "TRUCK_SU"]
/ df_external_year.loc[mask_future, "AADT"]
)
df_external_year.loc[mask_future, "PctTrk_MU"] = (
df_external_year.loc[mask_future, "TRUCK_MU"]
/ df_external_year.loc[mask_future, "AADT"]
)
# D. Global Rounding (Applies to ALL rows: Historic & Forecast)
df_external_year["PctTrk_SU"] = df_external_year["PctTrk_SU"].astype(float).round(4)
df_external_year["PctTrk_MU"] = df_external_year["PctTrk_MU"].astype(float).round(4)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | PctTrk_MU | AADT_Forecast | AADT | TRUCK_SU | TRUCK_MU | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6280.0 | 6280 | 0 | 0 |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6360.0 | 6360 | 0 | 0 |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6440.0 | 6440 | 0 | 0 |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6520.0 | 6520 | 0 | 0 |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6600.0 | 6600 | 0 | 0 |
2320 rows × 15 columns
Step 5.5: CCS-derived / regionally-adjusted truck split (all years)
Steps A-D above give each station’s “existing” truck-split percentage for every year (historic UDOT SUTRK/CUTRK where available, regression-projected for Year >= YEARS["TRUCK_PROJECTION_SPLIT"]). This step now overrides or rescales that series, for every year 1981-2060, station by station:
- Matched stations (have a CCS site with usable Tue/Wed/Thu length-bin count data): replace
PctTrk_SU/PctTrk_MUwith one fixed percentage derived from that station’s own vehicle-length distribution, held constant across all years. - Unmatched stations: keep the existing (already year-varying) percentage series, but rescale it by a constant regional adjustment ratio, keyed by the station’s road’s Functional Class. Stations whose Functional Class is Local or Unpaved (no ratio defined for either) fall back to the statewide “All Stations” ratio.
Matched-station CCS split
# Combine both CCS extracts' length-bin count data (WFRC_260202.zip is the original
# WFRC-county extract; 20260728_UDOT_CCS is a small supplemental pull covering a few
# sites outside that coverage, e.g. 307 and 615).
with zipfile.ZipFile("data/WFRC_260202.zip") as z:
with z.open("JKLP_Length_WFRC_2023_Sept_Nov.csv") as f:
jklp_a = pd.read_csv(f)
# The supplemental extract is a raw DB export with a trailing "N rows selected." footer.
jklp_b = pd.read_csv(
"data/20260728_UDOT_CCS/JKLP_Length_WFRC_2023_Sept_Nov.csv",
skipfooter=2,
engine="python",
)
jklp_b["SITE"] = jklp_b["SITE"].astype(int)
jklp_all = pd.concat([jklp_a, jklp_b], ignore_index=True)
jklp_all["abs_site"] = jklp_all["SITE"].abs()
jklp_all["weekday"] = pd.to_datetime(jklp_all["DATE_ONLY"]).dt.day_name()
jklp_tue_wed_thu = jklp_all[jklp_all["weekday"].isin(["Tuesday", "Wednesday", "Thursday"])]
# Length bin -> LT(=Passenger)/MD/HV %, pre-collapsed (no FHWA-13-class intermediate step).
split_factors = pd.read_csv("data/LT_MD_HV_Split_Factors_2025.csv").dropna(
subset=["Length Bin"]
).set_index("Length Bin")
ccs_connections = pd.read_csv("data/External Stations - CCS Connections.csv")
def compute_ccs_split(
site_candidates: list[int], jklp_df: pd.DataFrame, split_factors_df: pd.DataFrame
) -> tuple[int, float, float, float] | None:
"""
Tries each candidate CCS SITE id in order (a station may list more than
one candidate site); returns (site_used, LT%, MD%, HV%) from the first
one with usable Tue/Wed/Thu length-bin count data, or None if none of
the candidates have any.
"""
for site in site_candidates:
by_bin = jklp_df.loc[jklp_df["abs_site"] == site].groupby("LENGTH_DEF")["COUNT"].sum()
if by_bin.sum() <= 0:
continue
lt = md = hv = 0.0
for bin_name, count in by_bin.items():
if bin_name not in split_factors_df.index:
continue
lt += count * split_factors_df.loc[bin_name, "LT"] / 100
md += count * split_factors_df.loc[bin_name, "MD"] / 100
hv += count * split_factors_df.loc[bin_name, "HV"] / 100
total = lt + md + hv
if total > 0:
return site, lt / total, md / total, hv / total
return None
matched_pct = {}
for _, row in ccs_connections.iterrows():
site_str = row["SITE"]
if pd.isna(site_str) or str(site_str).strip() == "":
continue
candidates = [int(float(s.strip())) for s in str(site_str).split(",")]
result = compute_ccs_split(candidates, jklp_tue_wed_thu, split_factors)
if result is not None:
_, lt_pct, md_pct, hv_pct = result
matched_pct[int(row["N"])] = {"MD": md_pct, "HV": hv_pct}
matched_pct{3606: {'MD': np.float64(0.06361746125939362),
'HV': np.float64(0.07374354638507385)},
3608: {'MD': np.float64(0.07110812283272898),
'HV': np.float64(0.02000357349906918)},
3609: {'MD': np.float64(0.05903544234668708),
'HV': np.float64(0.22682773235967182)},
3612: {'MD': np.float64(0.045269547236972886),
'HV': np.float64(0.10275848270535058)},
3614: {'MD': np.float64(0.05967990691219469),
'HV': np.float64(0.10012126083565437)},
3617: {'MD': np.float64(0.06668134306281431),
'HV': np.float64(0.0761972208526444)},
3620: {'MD': np.float64(0.14604709705960156),
'HV': np.float64(0.06068312833315179)},
3626: {'MD': np.float64(0.07838720924864032),
'HV': np.float64(0.04144919157502055)},
3628: {'MD': np.float64(0.060398702531700106),
'HV': np.float64(0.17646955601772002)}}
Functional Class lookup (for unmatched stations)
def ft_to_functional_class(ft: int) -> str | None:
"""Maps a WFv1000 master-net FT_2023 facility-type code to the ratio
table's Functional Class categories, per the UDOT/WFRC FT code legend."""
if ft == 2:
return "Principal Arterial"
if ft == 3:
return "Minor Arterial"
if ft in (4, 5):
return "Collector"
if ft == 6:
return "Local"
if ft == 7:
return "Unpaved"
if 12 <= ft <= 15:
return "Expressway"
if 20 <= ft <= 42:
return "Freeway"
return None
master_net_nodes = gpd.read_file(
"data/WFv1000_MasterNet_20260430/WFv1000_MasterNet_20260430_Node.shp"
)
master_net_links = gpd.read_file(
"data/WFv1000_MasterNet_20260430/WFv1000_MasterNet_20260430_Link.shp"
)
external_node_ids = master_net_nodes.loc[master_net_nodes["EXTERNAL"] == 1, "N"].tolist()
connector_links = master_net_links[master_net_links["EXTERNAL"] == 1]
# Each external node's own link is a dummy centroid connector (FT_2023 == 1 always) --
# the real Functional Class comes from the link one hop further, at the far-side node.
functional_class_by_external = {}
for n in external_node_ids:
connectors = connector_links[(connector_links["A"] == n) | (connector_links["B"] == n)]
far_nodes = set(connectors["A"]).union(connectors["B"]) - {n}
ft_values = set()
for far in far_nodes:
real_links = master_net_links[
((master_net_links["A"] == far) | (master_net_links["B"] == far))
& ~((master_net_links["A"] == n) | (master_net_links["B"] == n))
]
ft_values.update(real_links["FT_2023"].dropna().unique().tolist())
classes = {ft_to_functional_class(ft) for ft in ft_values} - {None}
# A station with 0 or >1 distinct classes falls back to "All Stations" below.
functional_class_by_external[int(n)] = classes.pop() if len(classes) == 1 else None
functional_class_by_external{3601: 'Collector',
3602: 'Minor Arterial',
3603: 'Collector',
3604: 'Freeway',
3605: 'Minor Arterial',
3606: 'Principal Arterial',
3607: 'Collector',
3608: 'Minor Arterial',
3609: 'Freeway',
3610: 'Local',
3611: 'Expressway',
3612: 'Freeway',
3613: 'Unpaved',
3614: 'Freeway',
3615: 'Minor Arterial',
3616: 'Collector',
3617: 'Principal Arterial',
3618: 'Principal Arterial',
3619: 'Collector',
3620: 'Expressway',
3621: 'Unpaved',
3622: 'Unpaved',
3623: 'Expressway',
3624: 'Expressway',
3625: 'Unpaved',
3626: 'Expressway',
3627: 'Unpaved',
3628: 'Freeway',
3629: 'Collector'}
Regional adjustment ratio lookup
reclass_ratio = pd.read_csv("data/CCS_Reclass_Ratio_2025_2003.csv").set_index(
"Functional Class"
)
all_stations_ratio = reclass_ratio.loc["All Stations"]
def ratio_for_class(functional_class: str | None) -> pd.Series:
"""Looks up the MD/HV ratio for a Functional Class, falling back to the
statewide "All Stations" ratio for Local/Unpaved (neither has its own
row) or any unrecognized/ambiguous class."""
if functional_class is None or functional_class not in reclass_ratio.index:
return all_stations_ratio
row = reclass_ratio.loc[functional_class]
return row if pd.notna(row["MD Ratio (2025/2003)"]) else all_stations_ratioApply: override matched stations, rescale unmatched stations
for ext_id, pct in matched_pct.items():
mask = df_external_year["WF_Ext"] == ext_id
df_external_year.loc[mask, "PctTrk_SU"] = pct["MD"]
df_external_year.loc[mask, "PctTrk_MU"] = pct["HV"]
for ext_id in df_external_year["WF_Ext"].unique():
if ext_id in matched_pct:
continue
ratio = ratio_for_class(functional_class_by_external.get(ext_id))
mask = df_external_year["WF_Ext"] == ext_id
df_external_year.loc[mask, "PctTrk_SU"] *= ratio["MD Ratio (2025/2003)"]
df_external_year.loc[mask, "PctTrk_MU"] *= ratio["HV Ratio (2025/2003)"]
df_external_year["PctTrk_SU"] = df_external_year["PctTrk_SU"].astype(float).round(4)
df_external_year["PctTrk_MU"] = df_external_year["PctTrk_MU"].astype(float).round(4)
# Recompute TRUCK_SU/TRUCK_MU from the (possibly overridden/rescaled) percentages,
# for every year -- the percentage is now the single source of truth throughout.
df_external_year["TRUCK_SU"] = (
(df_external_year["AADT"] * df_external_year["PctTrk_SU"]).round().astype("Int64")
)
df_external_year["TRUCK_MU"] = (
(df_external_year["AADT"] * df_external_year["PctTrk_MU"]).round().astype("Int64")
)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | PctTrk_MU | AADT_Forecast | AADT | TRUCK_SU | TRUCK_MU | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6280.0 | 6280 | 0 | 0 |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6360.0 | 6360 | 0 | 0 |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6440.0 | 6440 | 0 | 0 |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6520.0 | 6520 | 0 | 0 |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6600.0 | 6600 | 0 | 0 |
2320 rows × 15 columns
Step 6: Passenger & weekly counts
df_external_year["PASSENGER"] = (
(
df_external_year["AADT"]
- (
df_external_year["TRUCK_SU"].fillna(0)
+ df_external_year["TRUCK_MU"].fillna(0)
)
)
.round()
.astype("Int64")
)
df_external_year["AWDT"] = (
(df_external_year["AADT"] * df_external_year["AWDT_FAC"]).round().astype("Int64")
)
df_external_year["PASS_VOL"] = (
(df_external_year["PASSENGER"] * df_external_year["AWDT_FAC"])
.round()
.astype("Int64")
)
df_external_year["TRUCK_MD"] = (
(df_external_year["TRUCK_SU"] * df_external_year["AWDT_FAC"])
.round()
.astype("Int64")
)
df_external_year["TRUCK_HV"] = (
(df_external_year["TRUCK_MU"] * df_external_year["AWDT_FAC"])
.round()
.astype("Int64")
)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | PctTrk_MU | AADT_Forecast | AADT | TRUCK_SU | TRUCK_MU | PASSENGER | AWDT | PASS_VOL | TRUCK_MD | TRUCK_HV | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | 0.0 | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6280.0 | 6280 | 0 | 0 | 6280 | 6534 | 6534 | 0 | 0 |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6360.0 | 6360 | 0 | 0 | 6360 | 6617 | 6617 | 0 | 0 |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6440.0 | 6440 | 0 | 0 | 6440 | 6700 | 6700 | 0 | 0 |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6520.0 | 6520 | 0 | 0 | 6520 | 6783 | 6783 | 0 | 0 |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | 0.0 | 6600.0 | 6600 | 0 | 0 | 6600 | 6867 | 6867 | 0 | 0 |
2320 rows × 20 columns
Step 7: Vintage labeling
df_external_year["Vintage"] = np.where(
df_external_year["Year"]
<= df_external_year["WF_Ext"].map(
df_external_year.dropna(subset=["AADT_Historic"])
.groupby("WF_Ext")["Year"]
.max()
),
"Historic",
"Forecast",
)
df_external_year| WF_Ext | Year | ;Idx_WF | segid | route | milepost | Ext_Name | AWDT_FAC | AADT_Historic | PctTrk_SU | ... | AADT_Forecast | AADT | TRUCK_SU | TRUCK_MU | PASSENGER | AWDT | PASS_VOL | TRUCK_MD | TRUCK_HV | Vintage | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3601 | 1981 | 36011981 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | ... | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Historic |
| 1 | 3601 | 1982 | 36011982 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | ... | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Historic |
| 2 | 3601 | 1983 | 36011983 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | ... | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Historic |
| 3 | 3601 | 1984 | 36011984 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | ... | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Historic |
| 4 | 3601 | 1985 | 36011985 | 1082_000.0 | 1082PM | 0.0 | Ext # 3601 - FAR-1082 Bird Refuge | 1.0000 | 0.0 | 0.0 | ... | NaN | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | Historic |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2315 | 3629 | 2056 | 36292056 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | ... | 6280.0 | 6280 | 0 | 0 | 6280 | 6534 | 6534 | 0 | 0 | Forecast |
| 2316 | 3629 | 2057 | 36292057 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | ... | 6360.0 | 6360 | 0 | 0 | 6360 | 6617 | 6617 | 0 | 0 | Forecast |
| 2317 | 3629 | 2058 | 36292058 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | ... | 6440.0 | 6440 | 0 | 0 | 6440 | 6700 | 6700 | 0 | 0 | Forecast |
| 2318 | 3629 | 2059 | 36292059 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | ... | 6520.0 | 6520 | 0 | 0 | 6520 | 6783 | 6783 | 0 | 0 | Forecast |
| 2319 | 3629 | 2060 | 36292060 | 1826_004.9 | 1826PM | 4.9 | Ext # 3629 - FAR-1826 South Ridge Farms | 1.0404 | NaN | 0.0 | ... | 6600.0 | 6600 | 0 | 0 | 6600 | 6867 | 6867 | 0 | 0 | Forecast |
2320 rows × 21 columns
Export final results
Path("results").mkdir(parents=True, exist_ok=True)
(
df_external_year[
[
";Idx_WF",
"WF_Ext",
"Year",
"AWDT",
"PASS_VOL",
"TRUCK_MD",
"TRUCK_HV",
"AWDT_FAC",
"AADT",
"PASSENGER",
"TRUCK_SU",
"TRUCK_MU",
"PctTrk_SU",
"PctTrk_MU",
]
][
(df_external_year["Year"] >= YEARS["EXPORT_START"])
&
# External 3611 link doesn't currently exist, so it shouldn't be forecasted.
# We'll have to assert a shift from External 3612 to 3611 using USTM model output from UDOT.
(df_external_year["WF_Ext"] != 3611)
].to_csv("results/external_year_vol.csv", index=False)
)