- Version 10.0.0
- Calibration - C22
- 4 Highway Assignment (C22)
Highway Assignment
Volumes
The validation results for the Highway Assignment portion of the model are shown in this section. The observed data for 2023 volumes is taken from the Utah Department of Transportation (UDOT) Average Annual Daily Traffic (AADT) History and associated with their respective model segments. The traffic model data is taken from segment summary report for the 2023 base year model: Summary_SEGID.csv. The results are divided into three sections:
- Summary Comparison
- Detailed Comparison
- Map Comparison
Summary Comparison
The summary comparison shows region and county-wide differences between model and observed for Average Daily Volume and Vehicle-Miles Traveled (VMT) by vehicle type. The values for Box Elder and Weber counties are only the portions within the MPO planning area. Validation was checked comparing the average daily volume at the region and county levels. Figure 1, below, contains an interactive view of model vs observed differences by roadway class and vehicle type.
Code
viewof bSummaryFTCLASS = Inputs.select(new Map([['All Roadways','All Roadways'], ['Freeway','Freeway'], ['Principal Arterial','Principal Arterial'], ['Minor Arterial', 'Minor Arterial'], ['Collector', 'Collector']]), {value: 'All Roadways', label: "Roadway Class:"})
viewof bSummaryVehType = Inputs.select(new Map([['All Vehicles','All'], ['Cars + Light CV','PCLT'], ['Medium CV','MD'], ['Heavy CV','HV']]), {value: 'All', label: "Vehicle Type:"})
viewof bSummaryDiffType = Inputs.select(new Map([['Percent Difference','DiffPct'], ['Difference','Diff']]), {value: 'DiffPct', label: "Display:"})Code
volDiffLongT = transpose(volDiffLong)
vmtDiffLongT = transpose(vmtDiffLong)
volDiffLongT_filtered = volDiffLongT.filter(function(dataL) {
return bSummaryFTCLASS == dataL.FTCLASS &&
bSummaryVehType == dataL.vehType &&
(('vol' + bSummaryDiffType) == dataL.View);
})
vmtDiffLongT_filtered = vmtDiffLongT.filter(function(dataL) {
return bSummaryFTCLASS == dataL.FTCLASS &&
bSummaryVehType == dataL.vehType &&
(('vmt' + bSummaryDiffType) == dataL.View);
})
vvp = transpose(vvpct)
vvpL = transpose(vvpctLong)
vvaL = transpose(vvabsLong)
vvaLR = transpose(vvabsLongR)Code
import {DivergingBarChart} from "@d3/diverging-bar-chart"
function getXDomainVol(bSummaryDiffType) {
if (bSummaryDiffType === "Diff") {
return [max_abs_value_volDiff * -1, max_abs_value_volDiff];
} else {
//return [max_abs_value_volDiffPct * -1, max_abs_value_volDiff]; // -100% to 100%
return [-100, 100]
}
}
function getXDomainVmt(bSummaryDiffType) {
if (bSummaryDiffType === "Diff") {
return [max_abs_value_vmtDiff * -1, max_abs_value_vmtDiff];
} else {
//return [max_abs_value_vmtDiffPct * -1, max_abs_value_vmtDiff]; // -100% to 100%
return [-1,1]
}
}Code
chartVolDiff = DivergingBarChart(volDiffLongT_filtered, {
x: d => d.ViewValue,
y: d => d.CO_FIPS,
xFormat: bSummaryDiffType === "Diff" ? "+,d" : "+.1%",
xLabel: "Model vs Observed Differences",
width: 440,
xDomain: bSummaryDiffType === "Diff" ? [max_abs_value_volDiff * -1, max_abs_value_volDiff] : [-1, 1], //[max_abs_value_volDiffPct * -1, max_abs_value_volDiffPct],
yDomain: ['Region','Box Elder County - WFRC','Weber County - WFRC','Davis County','Salt Lake County','Utah County'],
colors: d3.schemeRdBu[3]
})Code
chartVmtDiff = DivergingBarChart(vmtDiffLongT_filtered, {
x: d => d.ViewValue,
y: d => d.CO_FIPS,
xFormat: bSummaryDiffType === "Diff" ? "+,d" : "+.1%",
xLabel: "Model vs Observed Differences",
width: 440,
xDomain: bSummaryDiffType === "Diff" ? [max_abs_value_vmtDiff * -1, max_abs_value_vmtDiff] : [-1, 1], //[max_abs_value_vmtDiffPct * -1, max_abs_value_vmtDiffPct],
yDomain: ['Region','Box Elder County - WFRC','Weber County - WFRC','Davis County','Salt Lake County','Utah County'],
colors: d3.schemeRdBu[3]
})At the region level model volume is 1.7% higher than observed volume. The four more urban counties (Weber, Davis, Salt Lake, and Davis) were all within approximately 5% of observed volumes with Salt Lake County being the closest. Weber and Davis were slightly lower and Utah County was slightly higher. Box Elder County is more rural than the other counties. Box Elder model volumes are about 10% lower than observed. Time did not allow for further calibration of the volumes in Box Elder area to account for the larger differences.
One important observation at the Collector and All Vehicles level is that Utah County shows a much higher difference than the other counties. Upon further investigation of observed Collector volumes in Utah County, many roadway segments had very low volumes compared to what was expected. Utah County is one of the highest growth areas in the region. For this reason, we expect that the observed count data may be underrepresenting actual volumes. We also anticipate observed volumes in Utah County to improve in the near-term. Within the last several years, a large investment in continuous count station in Utah County has been made. The new counters will add additional information to generate observed volumes for all roadway segments.
The largest differences in model vs observed volumes occur in the Medium CV and Heavy CV vehicle types. A good amount of time was spent attempting to bring model commercial vehicle volumes closer to observed. However, due to the limited data sources for commercial vehicle information, further need to investigate observed commercial vehicle volumes, and a desire to not over-calibrate the model, further calibration was stopped. CV modeling remains a future priority for model improvement.
Detailed Comparison
The model vs observed details in this section are presented by volume and Vehicle-Miles Traveled (VMT) through the comparison of model and observed data facility type by region and also by county. Figure 2 allows for the interactive visual comparison of model and observed values for the region and each county for all vehicles, cars, Medium CV, and Heavy CV. The comparisons are shown in four different types of charts and tables:
- Average Daily Volume by Roadway Class (2a): The daily volume is averaged across all segments within their respective geography and vehicle type.
- Total VMT by Roadway Class (2b): For each segment*, the daily volume is multiplied by segment distance and then summed across all segments within their respective geography and vehicle type.
- Model vs Count Segment Volume (2c): This is a scatter plot of segment daily volume with the x-axis as the observed volume and the y-axis as the model volume. The red line shows the location of where model and observed volumes are equal. The dashed blue line shows a least-squares linear regression. The further the blue line moved away from the red line, the further the model is from observed.
- Segment Percent Error (2d): This is a scatter plot showing the amount of error (percent difference) between the observed volume and the model volume. The observed volume is the x-axis and the percent error is the y-axis. The red lines are a bounding box that shows the control target. As volume increases, it is expected that the percent error should decrease.
Code
viewof bCountySelect = Inputs.select(new Map([['Region',99], ['Box Elder County - WFRC',3], ['Weber County - WFRC',57], ['Davis County',11], ['Salt Lake County',35], ['Utah County',49]]), {value: 'All', label: "Geography:"})
viewof bVehType = Inputs.select(new Map([['All Vehicles', 'All'], ['Cars + Light CV', 'PCLT'], ['Medium CV', 'MD'], ['Heavy CV','HV']]), {value: 'All', label: "Vehicle Type:"})
sortOrder = ['Freeway', 'Principal Arterial', 'Minor Arterial', 'Collector', 'All Roadways'];
volT = transpose(vol)
vmtT = transpose(vmt)
filtered_volData = volT.filter(function(dataL) {
return bCountySelect == dataL.CO_FIPS &&
bVehType == dataL.vehType;
}).sort((a, b) => sortOrder.indexOf(a.FTCLASS) - sortOrder.indexOf(b.FTCLASS));
filtered_vmtData = vmtT.filter(function(dataL){
return bCountySelect == dataL.CO_FIPS &&
bVehType == dataL.vehType;
}).sort((a, b) => sortOrder.indexOf(a.FTCLASS) - sortOrder.indexOf(b.FTCLASS));
volTL = transpose(volLong)
vmtTL = transpose(vmtLong)
filtered_volDataL = volTL.filter(function(dataL) {
return bCountySelect == dataL.CO_FIPS &&
bVehType == dataL.vehType;
}).sort((a, b) => sortOrder.indexOf(a.FTCLASS) - sortOrder.indexOf(b.FTCLASS));
filtered_vmtDataL = vmtTL.filter(function(dataL){
return bCountySelect == dataL.CO_FIPS &&
bVehType == dataL.vehType;
}).sort((a, b) => sortOrder.indexOf(a.FTCLASS) - sortOrder.indexOf(b.FTCLASS));
allvehplotT = transpose(allvehplot)
filtered_allvehplotData = allvehplotT.filter(function(dataL) {
return bCountySelect == dataL.CO_FIPS &&
bVehType == dataL.vehType;
});Code
function formatNumber(value, isPercentage=false) {
if (typeof value === 'undefined') {
return ''; // or return a default value or message
}
if (isPercentage) {
return (Number(value) * 100).toFixed(1) + '%';
}
return Number(value.toFixed(0)).toLocaleString();
}
widthsVol = ['100px', '52px', '70px', '70px', '73px', '73px', '63px', '63px']; // Define the widths
widthsVmt = ['100px', '88px', '88px', '88px', '88px'];Code
html`
<h4>2a. Average Daily Volume by Roadway Class</h4>
<table>
<thead>
<tr>
${["Roadway Class", "# Segs", "Volume", "Observed", "Difference", "Percent Difference", "RMSE", "Percent RMSE"].map((d, i) => {
return html`<th style='text-align: ${i === 0 ? "left" : "right"}; padding: 5px; width: ${widthsVol[i]};'>${d}</th>`;
})}
</tr>
</thead>
<tbody>
${filtered_volData.map(row => {
const isBold = row['FTCLASS'] === 'All Roadways';
return html`<tr style='border-bottom: 1px solid lightgrey;'>
${["FTCLASS", "numSegs", "AWDT_Mod", "AWDT_Obs", "volDiff", "volDiffPct", "volRmse", "volRmsePct"].map((d, i) => {
// Check if the current cell is one of the numeric columns that need formatting
let formattedValue;
if (i === 5 || i === 7) {
formattedValue = formatNumber(row[d], true); // True for percentage formatting
} else if ((i >= 1 && i <= 4) || i==6) {
formattedValue = formatNumber(row[d]);
} else {
formattedValue = row[d];
}
return html`<td style='text-align: ${i === 0 ? "left" : "right"}; padding: 5px; font-weight: ${isBold ? 'bold' : 'normal'};'>${formattedValue}</td>`;
})}
</tr>`;
})}
</tbody>
</table>`Code
keyVol = Legend(bChartVol.scales.color, {title: "Data Source"})
bChartVol = GroupedBarChart(filtered_volDataL, {
x: d => d.FTCLASS,
y: d => d.ViewValue,
z: d => d.DataSource,
xDomain: ['Freeway','Principal Arterial','Minor Arterial','Collector','All Roadways'],
yLabel: "Average Volume (thousands)",
zDomain: ['Model','Observed'],
width: 320,
height: 175,
colors: ["#376092", "#77933c"]
})Code
html`
<h4>2b. Total Daily VMT by Roadway Class</h4>
<table>
<thead>
<tr>
${["Roadway Class", "Model", "Observed", "Difference", "Percent Difference"].map((d, i) => {
return html`<th style='text-align: ${i === 0 ? "left" : "right"}; padding: 5px; width: ${widthsVmt[i]};'>${d}</th>`;
})}
</tr>
</thead>
<tbody>
${filtered_vmtData.map(row => {
const isBold = row['FTCLASS'] === 'All Roadways';
return html`<tr style='border-bottom: 1px solid lightgrey;'>
${["FTCLASS", "VMT_Mod", "VMT_Obs", "vmtDiff", "vmtDiffPct"].map((d, i) => {
// Check if the current cell is one of the numeric columns that need formatting
let formattedValue;
if (i === 4 || i === 6) {
formattedValue = formatNumber(row[d], true); // True for percentage formatting
} else if ((i >= 1 && i <= 3) || i==5) {
formattedValue = formatNumber(row[d]);
} else {
formattedValue = row[d];
}
return html`<td style='text-align: ${i === 0 ? "left" : "right"}; padding: 5px; font-weight: ${isBold ? 'bold' : 'normal'};'>${formattedValue}</td>`;
})}
</tr>`;
})}
</tbody>
</table>`Code
keyVmt = Legend(bChartVmt.scales.color, {title: "Data Source"})
bChartVmt = GroupedBarChart(filtered_vmtDataL, {
x: d => d.FTCLASS,
y: d => d.ViewValue,
z: d => d.DataSource,
xDomain: ['Freeway','Principal Arterial','Minor Arterial','Collector','All Roadways'],
yLabel: "Total VMT (millions)",
zDomain: ['Model','Observed'],
width: 320,
height: 175,
colors: ["#376092", "#77933c"]
})Code
Code
Plot.plot({
grid: true,
width: 460,
height: 300,
marginRight: 40,
x: {
label: "Observed Volume (thousands)",
domain: [0, maxVal]
},
y: {
label: "Model Volume (thousands)",
domain: [0, maxVal]
},
marks: [
Plot.dot(filtered_allvehplotData, {
x: "AWDT_Obs",
y: "AWDT_Mod",
r: 1,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
stroke: "none"
}),
Plot.link([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
x1: 0,
y1: 0,
x2: maxVal,
y2: (k) => maxVal * k,
strokeOpacity: (k) => k === 1 ? 1 : 0.2,
stroke: "gray",
strokeWidth: (k) => k === 1 ? 2 : 1.5
}),
Plot.text([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
x: maxVal,
y: (k) => maxVal * k,
text: ((f) => (k) => k === 1 ? "Equal" : f(k - 1))(d3.format("+.0%")),
textAnchor: "start",
dx: 6
}),
// New top-aligned labels
Plot.text([1.4, 1.3, 1.2, 1.1, 1, 0.1, 0.2, 0.3, 0.4], {
x: (k2) => maxVal * 0.8 * (1-k2),
y: maxVal,
text: ((f) => (k2) => k2 === 1 ? "" : f(1-k2))(d3.format("+.0%")), //(k2) => d3.format("+.0%")(1-k2),
textAnchor: "middle",
dy: -10, // Adjusts position slightly above the top of the plot
dx: 392
}),
Plot.linearRegressionY(filtered_allvehplotData, {
x: "AWDT_Obs",
y: "AWDT_Mod",
stroke: "rgb(80, 116, 230)",
strokeDasharray: "4 4", // This creates a dashed line pattern,
strokeWidth: 2
})
]
})Code
Plot.plot({
grid: true,
width: 460,
height: 300,
marginRight: 40,
x: {
label: "Observed Volume (thousands)",
domain: [0, maxVal]
},
y: {
label: "Percent Error",
domain: [-2, 2],
tickFormat: d3.format(".0%")
},
marks: [
Plot.dot(filtered_allvehplotData, {
x: "AWDT_Obs",
y: "volErrorPct",
r: 1,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
stroke: "none"
}),
Plot.ruleY([2], {
x1: 0,
x2: 1,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([1], {
y1: 1,
y2: 2,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([1], {
x1: 1,
x2: 2.5,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([2.5], {
y1: 0.5,
y2: 1.0,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([0.5], {
x1: 2.5,
x2: 5,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([5], {
y1: 0.25,
y2: 0.50,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([0.25], {
x1: 5,
x2: 10,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([10], {
y1: 0.20,
y2: 0.25,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([0.20], {
x1: 10,
x2: 25,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([25], {
y1: 0.15,
y2: 0.20,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([0.15], {
x1: 25,
x2: 50,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([50], {
y1: 0.10,
y2: 0.15,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([0.10], {
x1: 50,
x2: 300,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-2], {
x1: 0,
x2: 1,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([1], {
y1: -1,
y2: -2,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-1], {
x1: 1,
x2: 2.5,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([2.5], {
y1: -0.5,
y2: -1.0,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-0.5], {
x1: 2.5,
x2: 5,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([5], {
y1: -0.25,
y2: -0.50,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-0.25], {
x1: 5,
x2: 10,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([10], {
y1: -0.20,
y2: -0.25,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-0.20], {
x1: 10,
x2: 25,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([25], {
y1: -0.15,
y2: -0.20,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-0.15], {
x1: 25,
x2: 50,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleX([50], {
y1: -0.10,
y2: -0.15,
stroke: "gray",
strokeWidth: 2
}),
Plot.ruleY([-0.10], {
x1: 50,
x2: 300,
stroke: "gray",
strokeWidth: 2
})
]
})As shown in Figure 2 (Region, All Vehicles), the volume and VMT of all vehicles at the region-wide level closely matches the validation targets. Volume for all roadways is only 1.7% higher than observed and VMT for all roadways is only 1.5% higher than observed.
As shown in Figure 2 (Region, Medium CV) and Figure 2 (Region, Heavy CV), the model currently overpredicts Medium and Heavy CV. A good amount of effort was spent attempting to bring model commercial vehicle volumes closer to observed. However, due to commercial vehicle data limitations and other model resource considerations, further calibration was stopped. CV modeling remains a future priority for model improvement.
In addition to the charts, the interactive map in Figure 3 allows for exploring segment-level model vs observed volume differences by vehicle type. Blue represents model volume lower than observed, and red represents model volume higher than observed.
Code
viewof mapMetric = Inputs.select(
new Map([
["All Vehicles", "Total"],
["Cars + Light CV", "PCLT"],
["Medium CV", "MD"],
["Heavy CV", "HV"]
]),
{ value: "Total", label: "Select Vehicle Class:" }
)
viewof mapType = Inputs.radio(
[ "Percent", "Absolute" ],
{ value: "Percent", label: "Difference Type:" }
)Code
Code
{
// 1. Determine Column Name based on selection
// e.g., "Diff_Total" (Absolute) or "PctDiff_Total" (Percent)
const prefix = mapType === "Percent" ? "PctDiff_" : "Diff_";
const activeCol = prefix + mapMetric;
// 2. Define Bins based on Type
let bins;
if (mapType === "Percent") {
// Bins for Percentages (-50%, -25%, -10%, +10%, +25%, +50%)
bins = [-0.5, -0.25, -0.1, 0.1, 0.25, 0.5];
} else {
// Bins for Absolute Volumes
const useLargeBins = ["Total", "PCLT"].includes(mapMetric);
bins = useLargeBins
? [-15000, -7500, -2500, 2500, 7500, 15000]
: [-5000, -1500, -500, 500, 1500, 5000];
}
// Colors
const colors = [
"#2166ac", "#4393c3", "#92c5de", // Blues
"#f7f7f7", // Neutral
"#f4a582", "#d6604d", "#b2182b" // Reds
];
const getColor = (val) => {
if (val < bins[0]) return colors[0];
if (val < bins[1]) return colors[1];
if (val < bins[2]) return colors[2];
if (val < bins[3]) return colors[3];
if (val < bins[4]) return colors[4];
if (val < bins[5]) return colors[5];
return colors[6];
};
// Setup Container
const container = document.createElement("div");
container.style.height = "600px";
container.style.width = "100%";
const map = LM.map(container).setView([40.7608, -111.8910], 9);
LM.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO',
maxZoom: 19
}).addTo(map);
// GeoJSON Layer
if (typeof vol_diff_geojson !== "undefined" && vol_diff_geojson && vol_diff_geojson.features) {
LM.geoJSON(vol_diff_geojson, {
style: (feature) => {
const val = feature.properties[activeCol]; // Use dynamic column
const ft = feature.properties.FTCLASS;
let weight = 1;
if (ft === 'Freeway' || ft === 'Expressway') { weight = 4; }
else if (ft === 'Principal Arterial') { weight = 3; }
else if (ft === 'Minor Arterial') { weight = 2; }
return {
color: getColor(val),
weight: weight,
opacity: 0.8
};
},
onEachFeature: (feature, layer) => {
const p = feature.properties;
const val = p[activeCol];
const sign = val > 0 ? "+" : "";
// Format display string
let valStr;
if (mapType === "Percent") {
valStr = (val * 100).toFixed(1) + "%";
} else {
valStr = Math.round(val).toLocaleString();
}
layer.bindPopup(
`<strong>Segment: ${p.SEGID}</strong><br/>` +
`Class: ${p.FTCLASS}<br/>` +
`Diff: ${sign}${valStr}`
);
}
}).addTo(map);
}
// Legend
const legend = LM.control({ position: "bottomright" });
legend.onAdd = function() {
const div = LM.DomUtil.create("div", "info legend");
div.style.backgroundColor = "white";
div.style.padding = "6px";
div.style.borderRadius = "4px";
div.style.textAlign = "left";
let labels = [`<strong>${mapType === "Percent" ? "% Diff" : "Diff"} (Mod - Obs)</strong><br>`];
// Helper to format legend labels
const fmt = (n) => mapType === "Percent" ? (n * 100) + "%" : n;
labels.push(`<i style="background:${colors[0]}; width:12px; height:12px; display:inline-block;"></i> < ${fmt(bins[0])}`);
for (let i = 0; i < bins.length - 1; i++) {
labels.push(
`<i style="background:${colors[i+1]}; width:12px; height:12px; display:inline-block;"></i> ${fmt(bins[i])} to ${fmt(bins[i+1])}`
);
}
labels.push(`<i style="background:${colors[6]}; width:12px; height:12px; display:inline-block;"></i> > ${fmt(bins[5])}`);
div.innerHTML = labels.join("<br>");
return div;
};
legend.addTo(map);
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
});
resizeObserver.observe(container);
return container;
}Looking at the All Vehicles map, the model volumes are lower than observed for by more than 7,500 vehicles per day for the east side of I-215 and by more than 15,000 vehicles per day for I-15 through northern Utah County. Model volumes are higher than observed volumes by more than 15,000 vehicles for I-15 in southern Salt Lake County and for I-15 in Utah County between Springville and Spanish Fork. When looking at these areas by vehicle type, volumes for both Medium CV and Heavy CV are slightly greater than observed. Overall, the volume differences between model and observed are relatively minor.
The lower arterial model vs observed volumes of Heavy CV on 9000 South in Salt Lake County was further investigated. The Heavy CV observed volume for this roadway seemed much higher than expected for this roadway. The lower volumes are likely due to the observed data and not anything in the model.
Observed Traffic Counts (CCS)

Code
import { aq, op } from '@uwdata/arquero'
import { Plot } from '@observablehq/plot'
// 2. LOAD DATA
// Use transpose() to convert the column-oriented Python data to row-oriented objects
data_raw = transpose(data_py)
// 3. DASHBOARD CONTROLS
viewof dashboard_ctrls = {
// 1. Define the Form Object (Same as before)
const form = Inputs.form({
group_by: Inputs.select(
["FTCLASS", "ATYPENAME", "COUNTY_NAME", "PERIOD", "VEHICLE_TYPE"],
{label: "Group Vars:", value: "FTCLASS"}
),
filter_county: Inputs.select(
["All"].concat(Array.from(new Set(data_raw.map(d => d.COUNTY_NAME))).sort()),
{label: "Filter County:", value: "All"}
),
filter_ftclass: Inputs.select(
["All"].concat(Array.from(new Set(data_raw.map(d => d.FTCLASS))).sort()),
{label: "Filter FTClass:", value: "All"}
),
filter_atype: Inputs.select(
["All"].concat(Array.from(new Set(data_raw.map(d => d.ATYPENAME))).sort()),
{label: "Filter AType:", value: "All"}
),
filter_period: Inputs.select(
["All", "AM", "MD", "PM", "EV"],
{label: "Filter Period:", value: "All"}
),
filter_vehicle: Inputs.select(
["All", "Auto", "SUT", "CUT"],
{label: "Filter Vehicle:", value: "All"}
)
});
// 2. Apply Custom CSS Layout
// This turns the container into a Grid with 3 flexible columns
form.style.display = "grid";
form.style.gridTemplateColumns = "repeat(auto-fit, minmax(250px, 1fr))";
form.style.gap = "10px";
form.style.alignItems = "flex-end"; // Aligns inputs to the bottom if labels vary in height
form.style.marginBottom = "20px"; // Adds spacing below the controls
return form;
}
// 4. DATA PROCESSING
dash_data = {
const { group_by, filter_county, filter_ftclass, filter_atype, filter_period, filter_vehicle } = dashboard_ctrls;
let base = aq.from(data_raw)
.params({
fc: filter_county,
ff: filter_ftclass,
fa: filter_atype,
fp: filter_period,
fv: filter_vehicle,
gb: group_by
})
.filter((d, $) => $.fc === "All" || d.COUNTY_NAME === $.fc)
.filter((d, $) => $.ff === "All" || d.FTCLASS === $.ff)
.filter((d, $) => $.fa === "All" || d.ATYPENAME === $.fa)
.filter((d, $) => $.fp === "All" || d.PERIOD === $.fp)
.filter((d, $) => $.fv === "All" || d.VEHICLE_TYPE === $.fv)
// A. Scatter Data (Station Level)
// Aggregating by MATCHED_SEGID ensures one point per segment per grouping
let scatter = base
.groupby( "MATCHED_SEGID", group_by)
.rollup({
Stations: op.any("SITE"),
Obs_Total: op.sum('OBSERVED'),
Mod_Total: op.sum('MODELED')
})
.derive({
Label: (d, $) => d[$.gb],
Obs_K: d => d.Obs_Total / 1000,
Mod_K: d => d.Mod_Total / 1000,
Pct_Error: d => d.Obs_Total > 0 ? (d.Mod_Total - d.Obs_Total) / d.Obs_Total : 0
})
// B. Summary Data (Aggregate Level)
let summary_base = base
.groupby(group_by)
.rollup({
Segments: op.count(),
Stations: op.distinct('SITE'),
Obs_Total: op.sum('OBSERVED'),
Mod_Total: op.sum('MODELED'),
Diff_Sq_Sum: d => op.sum(op.pow(d.MODELED - d.OBSERVED, 2))
});
let total_row = base
.rollup({
Segments: op.count(),
Stations: op.distinct('SITE'),
Obs_Total: op.sum('OBSERVED'),
Mod_Total: op.sum('MODELED'),
Diff_Sq_Sum: d => op.sum(op.pow(d.MODELED - d.OBSERVED, 2))
})
.derive({ [group_by]: d => "TOTAL" });
// Define custom sort orders
const sort_orders = {
'FTCLASS': ['Freeway', 'Expressway', 'Principal Arterial', 'Minor Arterial', 'Collector'],
'ATYPENAME': ['Urban', 'Suburban', 'Transition', 'Rural'],
'COUNTY_NAME': ['Box Elder', 'Weber', 'Davis', 'Salt Lake', 'Utah'],
'PERIOD': ['AM', 'MD', 'PM', 'EV'],
'VEHICLE_TYPE': ['Auto', 'SUT', 'CUT']
};
let summary = summary_base
.derive({
Difference: d => d.Mod_Total - d.Obs_Total,
Percent_Difference: d => (d.Mod_Total - d.Obs_Total) / d.Obs_Total,
RMSE: d => Math.sqrt(d.Diff_Sq_Sum / d.Segments),
Percent_RMSE: d => d.Obs_Total > 0 ? Math.sqrt(d.Diff_Sq_Sum / d.Segments) / (d.Obs_Total / d.Segments) : 0,
Label: (d, $) => d[$.gb]
})
.concat(total_row.derive({
Difference: d => d.Mod_Total - d.Obs_Total,
Percent_Difference: d => (d.Mod_Total - d.Obs_Total) / d.Obs_Total,
RMSE: d => Math.sqrt(d.Diff_Sq_Sum / d.Segments),
Percent_RMSE: d => d.Obs_Total > 0 ? Math.sqrt(d.Diff_Sq_Sum / d.Segments) / (d.Obs_Total / d.Segments) : 0,
Label: (d, $) => d[$.gb]
}));
// Apply custom sorting
const order = sort_orders[group_by] || [];
if (order.length > 0) {
// Convert to array, sort, then back to arquero table
let summary_array = summary.objects();
summary_array.sort((a, b) => {
if (a.Label === "TOTAL") return 1;
if (b.Label === "TOTAL") return -1;
const aIndex = order.indexOf(a.Label);
const bIndex = order.indexOf(b.Label);
const aOrder = aIndex >= 0 ? aIndex : 999;
const bOrder = bIndex >= 0 ? bIndex : 999;
return aOrder - bOrder;
});
summary = aq.from(summary_array);
} else {
// Just ensure TOTAL is at the end
let summary_array = summary.objects();
summary_array.sort((a, b) => {
if (a.Label === "TOTAL") return 1;
if (b.Label === "TOTAL") return -1;
return 0;
});
summary = aq.from(summary_array);
}
return { scatter, summary }
}
// 5. HELPER: TRENDLINES DATA
trendlines = {
const max_k = dash_data.scatter.rollup({ max: op.max("Mod_K") }).get("max") || 1;
const lines = [
{ slope: 1.4, label: "+40%" },
{ slope: 1.3, label: "+30%" },
{ slope: 1.2, label: "+20%" },
{ slope: 1.1, label: "+10%" },
{ slope: 1.0, label: "Equal" },
{ slope: 0.9, label: "-10%" },
{ slope: 0.8, label: "-20%" },
{ slope: 0.7, label: "-30%" },
{ slope: 0.6, label: "-40%" }
];
return lines.flatMap(l => [
{ x: 0, y: 0, label: l.label, slope: l.slope },
{ x: max_k, y: max_k * l.slope, label: l.label, slope: l.slope }
]);
}
// 6. HELPER: ERROR BOUNDS
error_bounds = {
const max_k = dash_data.scatter.rollup({ max: op.max("Obs_K") }).get("max") || 1;
const bounds = [
{ x: 0, y_pos: 2.0, y_neg: -2.0 },
{ x: 1, y_pos: 2.0, y_neg: -2.0 },
{ x: 1, y_pos: 1.0, y_neg: -1.0 },
{ x: 2.5, y_pos: 1.0, y_neg: -1.0 },
{ x: 2.5, y_pos: 0.5, y_neg: -0.5 },
{ x: 5, y_pos: 0.5, y_neg: -0.5 },
{ x: 5, y_pos: 0.25, y_neg: -0.25 },
{ x: 10, y_pos: 0.25, y_neg: -0.25 },
{ x: 10, y_pos: 0.20, y_neg: -0.20 },
{ x: 25, y_pos: 0.20, y_neg: -0.20 },
{ x: 25, y_pos: 0.15, y_neg: -0.15 },
{ x: 50, y_pos: 0.15, y_neg: -0.15 },
{ x: 50, y_pos: 0.10, y_neg: -0.10 },
{ x: Math.max(max_k, 250), y_pos: 0.10, y_neg: -0.10 }
];
return bounds;
}
// 7. HELPER: Color-blind friendly palette (Okabe-Ito)
palette = ["#E69F00", "#56B4E9", "#009E73", "#CC79A7", "#0072B2", "#D55E00", "#F0E442", "#999999"]- Model vs Observed Volumes (000s)
- Model vs Observed Percent Error
- Aggregate Comparison & Statistics
- Interactive Mapping
Code
max_obs_k = dash_data.scatter.rollup({ max: op.max("Obs_K") }).get("max") || 1
max_mod_k = dash_data.scatter.rollup({ max: op.max("Mod_K") }).get("max") || 1
max_vol_k = Math.max(max_obs_k, max_mod_k)
Plot.plot({
height: 400,
aspectRatio: 1,
grid: true,
x: { label: "Observed Volume (000s)", domain: [0, max_vol_k * 1.05] },
y: { label: "Modeled Volume (000s)", domain: [0, max_vol_k * 1.05] },
style: { backgroundColor: 'transparent' },
color: { legend: true, label: dashboard_ctrls.group_by, range: palette },
marks: [
Plot.line(trendlines, {
x: "x", y: "y", z: "slope",
stroke: "currentColor", strokeOpacity: 0.2, strokeDasharray: "4"
}),
Plot.text(trendlines.filter(d => d.x > 0), {
x: "x", y: "y", text: "label",
dx: 5, fill: "currentColor", fillOpacity: 0.5, textAnchor: "start"
}),
Plot.line(trendlines.filter(d => d.slope === 1.0), {
x: "x", y: "y", stroke: "currentColor", strokeOpacity: 0.5, strokeWidth: 1.5
}),
Plot.linearRegressionY(dash_data.scatter, {
x: "Obs_K", y: "Mod_K", stroke: "rgb(80, 116, 230)",
strokeDasharray: "4 4", strokeWidth: 2
}),
Plot.dot(dash_data.scatter, {
x: "Obs_K", y: "Mod_K", fill: "Label",
stroke: "white", strokeOpacity: 0.5, r: 4,
title: d => `Station: ${d.Stations}\nGroup: ${d.Label}\nObs: ${d.Obs_Total.toLocaleString()}\nMod: ${d.Mod_Total.toLocaleString()}\nError: ${(d.Pct_Error*100).toFixed(1)}%`
})
]
})Code
// md`#### 2. Model vs Observed Percent Error`
Plot.plot({
height: 400,
grid: true,
x: { label: "Observed Volume (000s)", domain: [0, max_obs_k * 1.05] },
y: { label: "Percent Error", tickFormat: "+%", domain: [-2, 2] },
style: { backgroundColor: 'transparent' },
color: { legend: true, label: dashboard_ctrls.group_by, range: palette },
marks: [
Plot.ruleY([0], { stroke: "currentColor", strokeWidth: 1.5, strokeOpacity: 0.5 }),
Plot.line(error_bounds, { x: "x", y: "y_pos", stroke: "gray", strokeWidth: 2, strokeOpacity: 0.4 }),
Plot.line(error_bounds, { x: "x", y: "y_neg", stroke: "gray", strokeWidth: 2, strokeOpacity: 0.4 }),
Plot.dot(dash_data.scatter, {
x: "Obs_K", y: "Pct_Error", fill: "Label",
stroke: "white", strokeOpacity: 0.5, r: 4,
title: d => `Station: ${d.Stations}\nObs: ${d.Obs_Total.toLocaleString()}\nError: ${(d.Pct_Error*100).toFixed(1)}%`
})
]
})Code
bar_data = dash_data.summary.fold(["Obs_Total", "Mod_Total"], { as: ["Metric", "Volume"] })
.derive({ Metric_Label: d => d.Metric === "Obs_Total" ? "Observed" : "Modeled" })
Plot.plot({
marginLeft: 80,
height: Math.max(400, dash_data.summary.numRows() * 40),
color: { domain: ["Observed", "Modeled"], range: ["#77933c", "#376092"], legend: true, label: "Data Source" },
style: { backgroundColor: 'transparent' },
x: { label: "Total Volume", grid: true, tickFormat: "s" },
y: { axis: null },
fy: { label: null, axis: "left" },
marks: [
Plot.barX(bar_data, {
x: "Volume", y: "Metric_Label", fy: "Label", fill: "Metric_Label",
title: d => `${d.Metric_Label}: ${d.Volume.toLocaleString()}`,
sort: { fy: "x", reduce: "max", order: "descending" }
}),
Plot.text(bar_data, {
x: "Volume", y: "Metric_Label", fy: "Label",
text: d => d.Volume.toLocaleString(),
dx: 5, textAnchor: "start", fill: "currentColor", fontSize: 10
})
]
})Code
Code
stations_geo = stations_py
segments_geo = segments_py
// 3. DRAW REACTIVE MAP
map_display = {
// A. PREPARE DATA
const scatter_array = dash_data.scatter.objects();
// Create Lookup Map: MATCHED_SEGID -> Data Object
const data_map = new Map(scatter_array.map(d => [String(d.MATCHED_SEGID).trim(), d]));
// Valid IDs sets for filtering geometry
const valid_seg_ids = new Set(data_map.keys());
// --- COLOR LOGIC ---
const current_group = dashboard_ctrls.group_by;
const enable_color = ["FTCLASS", "ATYPENAME", "COUNTY_NAME"].includes(current_group);
const unique_labels = Array.from(new Set(scatter_array.map(d => d.Label))).sort();
const color_map = new Map(unique_labels.map((l, i) => [l, palette[i % palette.length]]));
// B. SETUP CONTAINER
const container = document.createElement("div");
container.style.height = "650px";
container.style.width = "100%";
container.style.borderRadius = "8px";
container.style.border = "1px solid #ccc";
container.style.zIndex = "1";
// C. INITIALIZE MAP
const map = L.map(container).setView([40.7608, -111.8910], 9);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO',
maxZoom: 19
}).addTo(map);
// --- HELPER FUNCTIONS ---
function highlightSegment(segid) {
if (!segid) return;
const clean_segid = String(segid).trim();
segmentLayer.eachLayer(layer => {
if (layer._segid === clean_segid) {
layer.setStyle({ color: "#00e5ff", weight: 8, opacity: 1.0 });
layer.bringToFront();
}
});
}
function highlightStations(segid) {
if (!segid) return;
const clean_segid = String(segid).trim();
stationLayer.eachLayer(layer => {
if (layer._matched_segid === clean_segid) {
// Use setRadius for the size increase
layer.setRadius(6);
layer.setStyle({
color: "#00e5ff",
weight: 3,
fillColor: "#00e5ff",
fillOpacity: 1.0
});
layer.bringToFront();
}
});
}
function resetAll() {
segmentLayer.eachLayer(l => segmentLayer.resetStyle(l));
stationLayer.eachLayer(l => {
stationLayer.resetStyle(l);
// Explicitly reset the radius to your default of 4
if (typeof l.setRadius === "function") {
l.setRadius(4);
}
});
}
// D. ADD SEGMENTS
const filtered_segments = {
type: "FeatureCollection",
features: segments_geo.features.filter(f => f.properties.SEGID && valid_seg_ids.has(String(f.properties.SEGID).trim()))
};
const segmentLayer = L.geoJSON(filtered_segments, {
style: (feature) => {
const segid = String(feature.properties.SEGID).trim();
const stats = data_map.get(segid);
let finalColor = "#376092";
if (enable_color && stats) finalColor = color_map.get(stats.Label) || finalColor;
return { color: finalColor, weight: 4, opacity: 0.9 };
},
onEachFeature: (feature, layer) => {
const segid = String(feature.properties.SEGID).trim();
layer._segid = segid;
const stats = data_map.get(segid);
const group_label = stats ? stats.Label : "N/A";
const stations_label = stats ? stats.Stations : "N/A";
layer.bindPopup(`<div style="font-family:sans-serif; font-size:12px;"><b>Segment: ${segid}</b><br>Group: ${group_label}<br>Stations: ${stations_label}</div>`);
layer.on('mouseover', () => {
resetAll();
layer.setStyle({ color: "#00e5ff", weight: 4, opacity: 1.0 });
layer.bringToFront();
highlightStations(segid);
});
layer.on('mouseout', () => resetAll());
}
}).addTo(map);
// E. ADD STATIONS
const filtered_sites = {
type: "FeatureCollection",
features: stations_geo.features.filter(f => f.properties.MATCHED_SEGID && valid_seg_ids.has(String(f.properties.MATCHED_SEGID).trim()))
};
const stationLayer = L.geoJSON(filtered_sites, {
pointToLayer: (feature, latlng) => {
// Look up stats using MATCHED_SEGID from GeoJSON
const segid = String(feature.properties.MATCHED_SEGID).trim();
const stats = data_map.get(segid);
let finalColor = "#77933c";
if (enable_color && stats) finalColor = color_map.get(stats.Label) || finalColor;
return L.circleMarker(latlng, { radius: 4, fillColor: finalColor, color: "#1e1e1e", weight: 1, opacity: 0.9, fillOpacity: 0.9 });
},
onEachFeature: (feature, layer) => {
const site_id = String(feature.properties.SITE).trim();
const segid = String(feature.properties.MATCHED_SEGID).trim();
layer._site_id = site_id;
layer._matched_segid = segid;
const stats = data_map.get(segid);
layer.on('mouseover', () => {
resetAll();
if (segid) highlightSegment(segid);
});
layer.on('mouseout', () => resetAll());
if (stats) {
const diffColor = stats.Pct_Error > 0 ? '#d9534f' : '#5cb85c';
layer.bindPopup(`<div style="font-family: sans-serif; min-width: 160px;">
<strong>Station(s): ${stats.Stations}</strong><hr style="margin:4px 0; border:0; border-top:1px solid #eee;">
Group: <b>${stats.Label}</b><br/>
Observed: <b>${Math.round(stats.Obs_Total).toLocaleString()}</b><br/>
Modeled: ${Math.round(stats.Mod_Total).toLocaleString()}<br/>
Diff: <span style="color:${diffColor}; font-weight:bold;">${(stats.Pct_Error * 100).toFixed(1)}%</span><br/>
<span style="color:#999; font-size:11px;">Seg: ${segid}</span>
<br/><span style="color:#999; font-size:10px;">(This dot: Site ${site_id})</span>
</div>`);
}
}
}).addTo(map);
// F. ADD LEGEND
if (enable_color && unique_labels.length > 0) {
const legend = L.control({ position: 'bottomright' });
legend.onAdd = function(map) {
const div = L.DomUtil.create('div', 'info legend');
div.style.backgroundColor = 'white';
div.style.padding = '10px';
div.style.borderRadius = '5px';
div.style.boxShadow = '0 0 15px rgba(0,0,0,0.2)';
div.style.fontSize = '12px';
div.style.lineHeight = '18px';
// Add title
const title_text = current_group === 'FTCLASS' ? 'Functional Class' :
current_group === 'ATYPENAME' ? 'Area Type' :
current_group === 'COUNTY_NAME' ? 'County' :
current_group;
div.innerHTML = `<strong>${title_text}</strong><br>`;
// Define sort orders for legend
const legend_orders = {
'FTCLASS': ['Freeway', 'Expressway', 'Principal Arterial', 'Minor Arterial', 'Collector'],
'ATYPENAME': ['Urban', 'Suburban', 'Transition', 'Rural'],
'COUNTY_NAME': ['Box Elder', 'Weber', 'Davis', 'Salt Lake', 'Utah']
};
// Sort labels according to custom order
const sorted_labels = [...unique_labels];
const order = legend_orders[current_group];
if (order) {
sorted_labels.sort((a, b) => {
const aIndex = order.indexOf(a);
const bIndex = order.indexOf(b);
const aOrder = aIndex >= 0 ? aIndex : 999;
const bOrder = bIndex >= 0 ? bIndex : 999;
return aOrder - bOrder;
});
}
// Add color boxes for each label
sorted_labels.forEach(label => {
const color = color_map.get(label);
div.innerHTML +=
`<div style="margin: 4px 0;">
<span style="display:inline-block; width:18px; height:18px; background-color:${color}; margin-right:5px; vertical-align:middle; border:1px solid #999;"></span>
<span style="vertical-align:middle;">${label}</span>
</div>`;
});
return div;
};
legend.addTo(map);
}
// G. SAFETY & RESIZING
// 1. If mouse leaves the map container entirely, clear highlights (Fixes "stuck when leaving map")
container.addEventListener('mouseleave', () => resetAll());
// 2. Resize observer for Tabset support
const resizeObserver = new ResizeObserver(() => {
map.invalidateSize();
if (filtered_sites.features.length > 0) {
map.fitBounds(stationLayer.getBounds(), { padding: [50, 50] });
}
});
resizeObserver.observe(container);
return container;
}Summary Statistics
Code
// md`#### 5. Summary Statistics`
// 5. Summary Statistics
{
// Create a custom table with bold TOTAL row
const table_data = dash_data.summary.objects();
return Inputs.table(table_data, {
columns: ["Label", "Stations", "Obs_Total", "Mod_Total", "Difference", "Percent_Difference", "RMSE", "Percent_RMSE"],
header: {
Label: (() => {
const gb = dashboard_ctrls.group_by;
if (gb === 'FTCLASS') return 'Functional Class';
if (gb === 'ATYPENAME') return 'Area Type';
if (gb === 'COUNTY_NAME') return 'County';
if (gb === 'PERIOD') return 'Time of Day';
if (gb === 'VEHICLE_TYPE') return 'Vehicle Type';
return gb;
})(),
Stations: "Stations",
Obs_Total: "Observed",
Mod_Total: "Modeled",
Difference: "Diff",
Percent_Difference: "Pct Diff",
RMSE: "RMSE",
Percent_RMSE: "Pct RMSE"
},
format: {
Label: (d, i) => table_data[i].Label === "TOTAL" ? html`<strong>${d}</strong>` : d,
Stations: (d, i) => {
const formatted = d.toLocaleString();
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
Obs_Total: (d, i) => {
const formatted = Math.round(d).toLocaleString();
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
Mod_Total: (d, i) => {
const formatted = Math.round(d).toLocaleString();
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
Difference: (d, i) => {
const formatted = Math.round(d).toLocaleString();
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
Percent_Difference: (d, i) => {
const formatted = (d * 100).toFixed(1) + "%";
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
RMSE: (d, i) => {
const formatted = d.toLocaleString(undefined, {maximumFractionDigits: 0});
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
},
Percent_RMSE: (d, i) => {
const formatted = (d * 100).toFixed(1) + "%";
return table_data[i].Label === "TOTAL" ? html`<strong>${formatted}</strong>` : formatted;
}
},
layout: "auto"
});
}2023 Daily Volume Analysis
The interactive chart below displays the 2023 daily traffic volume trend for a selected Continuous Count Station (CCS).
- Black Line: Daily CCS Volume
- Green Dashed: HPMS AADT
- Blue Dashed: Modeled Volume
- Shaded Area: Calibration Period (Sept 1 - Nov 15)
Code
Code
viewof groupVar = Inputs.select(
["FTCLASS", "COUNTY_NAME", "ATYPENAME"],
{label: "1. Group By:", value: "FTCLASS"}
)
viewof filterVal = Inputs.select(
["All"].concat(uniqueGroups),
{label: "2. Filter Group:", value: "All"}
)
viewof selectedStation = Inputs.select(
filteredStations,
{label: "3. Select Station:"}
)Code
sortOrders = ({
"FTCLASS": ['Freeway', 'Expressway', 'Principal Arterial', 'Minor Arterial', 'Collector'],
"ATYPENAME": ['Urban', 'Suburban', 'Transition', 'Rural'],
"COUNTY_NAME": ['Box Elder', 'Weber', 'Davis', 'Salt Lake', 'Utah']
})
// Calculate unique groups with custom sorting
uniqueGroups = {
// Get unique values
const vals = daily_data.map(d => d[groupVar]).filter((v, i, a) => a.indexOf(v) === i);
// Get the specific sort order for the selected variable
const order = sortOrders[groupVar];
if (order) {
// Sort based on the index in the 'order' array
return vals.sort((a, b) => {
const idxA = order.indexOf(a);
const idxB = order.indexOf(b);
// Items found in the list get their index; items not found get pushed to the end (999)
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
} else {
// Fallback to alphabetical if no order is defined
return vals.sort();
}
}
filteredStations = daily_data
.filter(d => filterVal === "All" || d[groupVar] === filterVal)
.map(d => d.SITE)
.filter((v, i, a) => a.indexOf(v) === i)
.sort()
{
// ─── Local Processing ────────────────────────────────────────────────────────
// We use 'const' here so these variables don't conflict with other parts of the report
const stationData = daily_data.filter(d => d.SITE === selectedStation);
const refs = stationData.length > 0 ? stationData[0] : {HPMS_AADT: 0, MODEL_VOL: 0};
const stationHasData = stationData.length > 0;
// ─── Configuration ───────────────────────────────────────────────────────────
const palette = {
ccs: "#2c3e50", // Dark Slate
hpms: "#77933c", // Green
model: "#376092", // Blue
cal: "#f39c12" // Orange
};
const yMaxVol = stationHasData
? Math.max(d3.max(stationData, d => d.DAILY_VOL), refs.HPMS_AADT, refs.MODEL_VOL) * 1.1
: 1000;
// ─── The Plot ────────────────────────────────────────────────────────────────
return Plot.plot({
title: `Station ${selectedStation} Daily Volume Analysis`,
subtitle: "Comparison of 2023 Observed Daily Counts vs. HPMS AADT and Modeled Volume",
width: Math.min(width, 960),
height: 450,
marginLeft: 60,
marginBottom: 40,
style: {
fontSize: "12px",
fontFamily: "system-ui, -apple-system, sans-serif",
backgroundColor: "white"
},
color: {
domain: ["Daily CCS Volume", "HPMS AADT", "Modeled Volume"],
range: [palette.ccs, palette.hpms, palette.model],
legend: true
},
x: {
label: null,
tickFormat: "%B",
domain: [new Date("2023-01-01"), new Date("2023-12-31")]
},
y: {
label: "Daily Volume",
grid: true,
domain: [0, yMaxVol],
tickFormat: "s"
},
marks: [
// 1. Calibration Period
Plot.rectX([{start: new Date("2023-09-01"), end: new Date("2023-11-15")}], {
x1: "start", x2: "end",
y1: 0, y2: yMaxVol,
fill: palette.cal,
fillOpacity: 0.08
}),
Plot.text([{x: new Date("2023-10-08"), y: yMaxVol}], {
x: "x", y: "y",
text: d => "Calibration Period",
fill: palette.cal,
dy: 15,
fontWeight: "bold",
fontSize: 11
}),
// 2. Reference Lines
Plot.ruleY([refs.HPMS_AADT], {
stroke: () => "HPMS AADT",
strokeWidth: 2,
strokeDasharray: "6 4"
}),
Plot.ruleY([refs.MODEL_VOL], {
stroke: () => "Modeled Volume",
strokeWidth: 2,
strokeDasharray: "6 4"
}),
// 3. Main Data Line
Plot.line(stationData, {
x: "Date",
y: "DAILY_VOL",
stroke: () => "Daily CCS Volume",
strokeWidth: 1.0
}),
// 4. Tooltip
Plot.tip(stationData, Plot.pointerX({
x: "Date",
y: "DAILY_VOL",
title: d => `📅 ${d.Date.toLocaleDateString()}\n🚗 CCS: ${d.DAILY_VOL.toLocaleString()}\n📏 HPMS: ${Math.round(refs.HPMS_AADT).toLocaleString()}\n🔵 Model: ${Math.round(refs.MODEL_VOL).toLocaleString()}`
}))
]
});
}HOT Lane Validation
Code
viewof selected_var = Inputs.select(new Map([["VPH", "Vph"], ["VOL", "Vol"]]), {label: "Variable", value: "Vph"})
viewof selected_period = Inputs.select(["AM", "MD", "PM", "EV", 'DY'], {label: "Period", value: "AM"})
viewof selected_direction = Inputs.select(["D1", "D2", "Both"], {label: "Direction", value: "D1"})
viewof plot_type = Inputs.radio(["Absolute", "Relative"], {label: "Plot Type", value: "Absolute"})Code
plot_data = {
// Quarto passes data as columns; `transpose` converts it to standard row objects
const raw_data = transpose(traffic_data);
// 1. Filter the data based on dropdowns
const filtered = raw_data.filter(d =>
d.Period === selected_period &&
d.DIRECTION === selected_direction
);
// 2. Group by CO_SEG and calculate all our stats
const groups = {};
filtered.forEach(d => {
if (!groups[d.CO_SEG]) {
groups[d.CO_SEG] = {
obs_values: [],
mod_val: d[`Mod_${selected_var}`],
county_idx: d.COUNTY_ORDER_IDX,
seg_num: d.SEG_NUM_SORT
};
}
groups[d.CO_SEG].obs_values.push(d[`Obs_${selected_var}`]);
});
// 3. Flatten the grouped data back out and calculate Differences
let result = [];
Object.keys(groups).forEach(seg => {
const g = groups[seg];
// Calculate the observed mean for this segment
const sum = g.obs_values.reduce((a, b) => a + b, 0);
const mean = sum / g.obs_values.length;
// Build a row for every single observation point
g.obs_values.forEach(obs => {
result.push({
CO_SEG: seg,
obs_val: obs,
mean_obs: mean,
mod_val: g.mod_val,
diff_from_mean: obs - mean,
model_diff: g.mod_val - mean,
COUNTY_ORDER_IDX: g.county_idx,
SEG_NUM_SORT: g.seg_num
});
});
});
// 4. Native Sorting (County Ascending -> Seg Num Descending)
result.sort((a, b) => {
if (a.COUNTY_ORDER_IDX !== b.COUNTY_ORDER_IDX) {
return a.COUNTY_ORDER_IDX - b.COUNTY_ORDER_IDX;
}
return b.SEG_NUM_SORT - a.SEG_NUM_SORT;
});
return result;
}
//#endregion
// ---------------------------------------------------------
// 4. RENDER PLOT (Faceted Violin Plots)
// ---------------------------------------------------------
Plot.plot({
width: width,
height: 600,
marginBottom: 120,
marginLeft: 60,
grid: true,
color: {
legend: true,
domain: ["Modeled", "Observed Mean", "Observed Distribution"],
range: ["#376092", "#77933c", "#c8d6a3"]
},
fx: {
label: "Segment",
domain: [...new Set(plot_data.map(d => d.CO_SEG))],
tickRotate: -45
},x: { axis: null },
y: {
label: plot_type === "Absolute" ? selected_var : `Modeled - Observed Mean (${selected_var})`,
nice: true
},
marks: [
// --- MODE 1: ABSOLUTE ---
plot_type === "Absolute" ? [
// The Violin Plot
Plot.areaX(plot_data, Plot.binY({ x1: bin => -bin.length, x2: "count" }, {
y: "obs_val", fx: "CO_SEG", fill: "#c8d6a3", fillOpacity: 0.7, curve: "basis", thresholds: 15
})),
// Observed Mean Dot
Plot.dot(plot_data, {
y: "mean_obs", fx: "CO_SEG", x: 0, fill: "#77933c", r: 4.5, symbol: "circle",
// UNIFIED TOOLTIP (Shows all info)
title: d => `Segment: ${d.CO_SEG}\nObserved Mean: ${Math.round(d.mean_obs)}\nModeled Value: ${Math.round(d.mod_val)}\nDifference: ${Math.round(d.mod_val - d.mean_obs)}`,
tip: true
}),
// Model Value Dot
Plot.dot(plot_data, {
y: "mod_val", fx: "CO_SEG", x: 0, fill: "#376092", r: 5, stroke: "white",
// UNIFIED TOOLTIP (Exact same as above, so it doesn't matter which dot you hit!)
title: d => `Segment: ${d.CO_SEG}\nObserved Mean: ${Math.round(d.mean_obs)}\nModeled Value: ${Math.round(d.mod_val)}\nDifference: ${Math.round(d.mod_val - d.mean_obs)}`,
tip: true
})
] :
// --- MODE 2: RELATIVE (LOLLIPOP) ---
[
// Center Zero Line
Plot.ruleY([0], {stroke: "#3b491e", strokeWidth: 1.5}),
// Gray Stem
Plot.link(plot_data, {
fx: "CO_SEG", x1: 0, x2: 0, y1: 0, y2: "model_diff", stroke: "gray", strokeOpacity: 0.4, strokeWidth: 1.5
}),
// The Relative Violin Plot
Plot.areaX(plot_data, Plot.binY({ x1: bin => -bin.length, x2: "count" }, {
y: "diff_from_mean", fx: "CO_SEG", fill: "#c8d6a3", fillOpacity: 0.7, curve: "basis", thresholds: 15
})),
// Observed Mean Anchor Dot
Plot.dot(plot_data, {
y: 0, fx: "CO_SEG", x: 0, fill: "#77933c", r: 4.5, symbol: "circle",
// UNIFIED TOOLTIP
title: d => `Segment: ${d.CO_SEG}\nObserved Mean: ${Math.round(d.mean_obs)}\nModeled Value: ${Math.round(d.mod_val)}\nDifference: ${Math.round(d.model_diff)}`,
tip: true
}),
// Model Difference Dot
Plot.dot(plot_data, {
y: "model_diff", fx: "CO_SEG", x: 0, fill: "#376092", r: 5, stroke: "white",
// UNIFIED TOOLTIP
title: d => `Segment: ${d.CO_SEG}\nObserved Mean: ${Math.round(d.mean_obs)}\nModeled Value: ${Math.round(d.mod_val)}\nDifference: ${Math.round(d.model_diff)}`,
tip: true
})
]
]
})Code
map_display2 = {
// 1. RE-AGGREGATE DATA FOR MAP
const raw = transpose(traffic_data);
const filtered = raw.filter(d => d.Period === selected_period && d.DIRECTION === selected_direction);
const map_stats = new Map();
filtered.forEach(d => {
if (!map_stats.has(d.SEGID)) {
map_stats.set(d.SEGID, { obs_values: [], mod_val: d[`Mod_${selected_var}`] });
}
map_stats.get(d.SEGID).obs_values.push(d[`Obs_${selected_var}`]);
});
for (let [segid, stats] of map_stats.entries()) {
const sum = stats.obs_values.reduce((a, b) => a + b, 0);
stats.mean_obs = sum / stats.obs_values.length;
stats.diff = stats.mod_val - stats.mean_obs;
stats.pct_err = stats.mean_obs === 0 ? 0 : (stats.diff / stats.mean_obs) * 100;
}
// 2. SETUP MAP CONTAINER
const container = document.createElement("div");
container.style.height = "600px";
container.style.width = "100%";
container.style.borderRadius = "8px";
container.style.border = "1px solid #ccc";
container.style.zIndex = "1";
// ---> THE MAGIC BULLET: Put the box on the webpage BEFORE drawing the map <---
yield container;
// 3. INITIALIZE MAP (Now it knows its true size!)
const map = L.map(container).setView([40.7608, -111.8910], 9);
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap © CARTO'
}).addTo(map);
// 4. DRAW SEGMENTS
const valid_seg_ids = new Set(map_stats.keys());
const filtered_segments = {
type: "FeatureCollection",
features: segments_geo_hot.features.filter(f => f.properties.SEGID && valid_seg_ids.has(String(f.properties.SEGID).trim()))
};
const segmentLayer = L.geoJSON(filtered_segments, {
style: (feature) => {
const segid = String(feature.properties.SEGID).trim();
const stats = map_stats.get(segid);
let finalColor = "#999999";
if (stats) {
if (stats.pct_err > 15) finalColor = "#d9534f";
else if (stats.pct_err < -15) finalColor = "#376092";
else finalColor = "#77933c";
}
return { color: finalColor, weight: 6, opacity: 0.9 };
},
onEachFeature: (feature, layer) => {
const segid = String(feature.properties.SEGID).trim();
const stats = map_stats.get(segid);
if (stats) {
const diffColor = stats.diff > 0 ? '#d9534f' : '#376092';
layer.bindPopup(`<div style="font-family: sans-serif; min-width: 180px;">
<strong>Segment: ${segid}</strong><hr style="margin:4px 0; border:0; border-top:1px solid #eee;">
Observed Mean: <b>${Math.round(stats.mean_obs).toLocaleString()}</b><br/>
Modeled Value: <b>${Math.round(stats.mod_val).toLocaleString()}</b><br/>
Diff: <span style="color:${diffColor}; font-weight:bold;">${Math.round(stats.diff).toLocaleString()}
(${stats.pct_err > 0 ? '+' : ''}${stats.pct_err.toFixed(1)}%)</span>
</div>`);
}
layer.on('mouseover', function() {
this.setStyle({ color: "#00e5ff", weight: 9, opacity: 1.0 });
this.bringToFront();
});
layer.on('mouseout', function() {
segmentLayer.resetStyle(this);
});
}
}).addTo(map);
// 4. DRAW STATIONS (CCS Points)
// Filter out any stations that don't belong to the current segments
const filtered_stations = {
type: "FeatureCollection",
features: stations_geo_hot.features.filter(f => f.properties.SEGID && valid_seg_ids.has(String(f.properties.SEGID).trim()))
};
const stationLayer = L.geoJSON(filtered_stations, {
pointToLayer: (feature, latlng) => {
// Look up the performance stats for this station's segment
const segid = String(feature.properties.SEGID).trim();
const stats = map_stats.get(segid);
// Match the color of the segment
let finalColor = "#1e1e1e";
if (stats) {
if (stats.pct_err > 15) finalColor = "#d9534f"; // Red
else if (stats.pct_err < -15) finalColor = "#376092"; // Blue
else finalColor = "#77933c"; // Green
}
// Draw a nice crisp circle
return L.circleMarker(latlng, {
radius: 6,
fillColor: finalColor,
color: "#ffffff", // White border to make the dot pop off the map
weight: 2,
opacity: 1.0,
fillOpacity: 1.0
});
},
onEachFeature: (feature, layer) => {
const segid = String(feature.properties.SEGID).trim();
const station = String(feature.properties.STATION).trim();
const stats = map_stats.get(segid);
if (stats) {
const diffColor = stats.diff > 0 ? '#d9534f' : '#376092';
layer.bindPopup(`<div style="font-family: sans-serif; min-width: 180px;">
<strong>Station: ${station}</strong><br>
<span style="color:#666; font-size:11px;">Segment: ${segid}</span>
<hr style="margin:4px 0; border:0; border-top:1px solid #eee;">
Observed Mean: <b>${Math.round(stats.mean_obs).toLocaleString()}</b><br/>
Modeled Value: <b>${Math.round(stats.mod_val).toLocaleString()}</b><br/>
Diff: <span style="color:${diffColor}; font-weight:bold;">${Math.round(stats.diff).toLocaleString()}
(${stats.pct_err > 0 ? '+' : ''}${stats.pct_err.toFixed(1)}%)</span>
</div>`);
}
// Add a cool hover effect where the dot grows when you mouse over it
layer.on('mouseover', function() {
this.setStyle({ radius: 9, weight: 3 });
this.bringToFront();
});
layer.on('mouseout', function() {
stationLayer.resetStyle(this);
});
}
}).addTo(map);
if (filtered_segments.features.length > 0) {
map.fitBounds(segmentLayer.getBounds(), { padding: [30, 30] });
}
// 5. TABSET RESIZE & ZOOM FIX
const resizeObserver = new ResizeObserver(() => {
// Wait 100ms for the tab animation to open completely
setTimeout(() => {
// 1. Tell Leaflet its true 600px size
map.invalidateSize();
// 2. NOW calculate the zoom to fit the segments perfectly!
if (filtered_segments.features.length > 0) {
map.fitBounds(segmentLayer.getBounds(), { padding: [30, 30] });
}
}, 100);
});
resizeObserver.observe(container);
return container;
}Average Travel Time
The model’s average travel time was compared to observed data between (how many) various origin and destination locations throughout the model space. Observed travel times came from the Google API for various times throughout 2023. All observed data was collected on Tuesday through Thursday. Due to a data collection issue, observed average travel times were only available for the WFRC area. Model data came from the final network skims that report travel times between every TAZ by period.
The validation results for average travel time are shown in the following figures. Looking at the EV period and knowing that evening speeds are similar to freeflow, we can deduce that in general the model’s freeflow speeds are about 10% faster than observed. In addition, a pattern exists in the AM, MD, and PM periods where shorter trips (under 20 minutes) have shorter travel times than observed and longer trips (30-60 minutes) have longer travel times than observed. This suggests that the volume-delay function (VDF) curves are slightly too aggressive on higher end facility types (freeways and arterials). Overall, while these charts show an acceptable range of error, improvements to freeflow speeds and to the VDF curves are adjustments we will consider making in future models.
Code
Code
calculateSlope = (data, xKey, yKey) => {
const sumX = d3.sum(data, d => d[xKey]);
const sumY = d3.sum(data, d => d[yKey]);
const sumXY = d3.sum(data, d => d[xKey] * d[yKey]);
const sumX2 = d3.sum(data, d => d[xKey] ** 2);
return sumXY / sumX2;
};
slope = calculateSlope(timeT_filtered, "tmeObs", "tmeMod");
// Step 2: Define the end point for the regression line based on the slope
xMax = 60;
yMax = slope * xMax;
html`<h4>Model vs Observed Times</h4>`Code
Plot.plot({
grid: true,
width: 460,
height: 300,
marginRight: 40,
x: {
label: "Observed Time (minutes)",
domain: [0, xMax],
ticks: 8,
tickFormat: d3.format(".0f")
},
y: {
label: "Model Time (minutes)",
domain: [0, xMax]
},
marks: [
Plot.dot(timeT_filtered, {
x: "tmeObs",
y: "tmeMod",
r: 1,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
stroke: "none"
}),
Plot.line([{ x: 0, y: 0 }, { x: xMax, y: yMax }], {
x: "x",
y: "y",
stroke: "rgb(80, 116, 230)",
strokeDasharray: "4 4",
strokeWidth: 2
}),
Plot.line([{ x: 0, y: 0 }, { x: xMax, y: xMax }], {
x: "x",
y: "y",
stroke: "gray",
strokeWidth: 1,
strokeDasharray: "2 2" // optional dashed line style for the x=y line
})
]
})