The mode choice model was calibrated to observed mode shares from the 2023 Utah Household Travel Survey and the 2024 Transit On-Board Survey. The results of this calibration effort are shown in Figure 1. All mode shares were calibrated to within 5% of observed data.
key =Legend(chart.scales.color, {title:"Data Source"})chart =GroupedBarChart(filtered_data, {x: d => d.Mode,y: d => d.Percent,z: d => d.DataSource,xDomain: xDomain,yLabel:"Percent",yFormat:".0%",yDomain: [0,1],zDomain: ['Model','Observed'],width:400,height:250,colors: ["#376092","#77933c"]})
Code
bEmtptyCell=1
Figure 1: Mode Share Calibration Results
Code
html`<br/><br/>`
Transit Trips and Boardings
Transit trips were validated to the 2024 Transit On-Board Survey and 2023 observed boarding data. To facilitate model calibration, transit trips, boardings, and transfers were validated by the model’s hierarchical mode. Boardings were also validated based on the mode where the boarding was actually observed. Transit validation results are shown in Figure 2.
Total transit trips and boardings were calibrated to within 5% of observed data (trips 0.5%, boardings -2.5%). Overall transfers were all within an acceptable range.
Transit trips and boardings by mode were calibrated to acceptable ranges for modes with significant ridership. Modes with low ridership were allowed to have a higher difference when compared to observed data if calibrating to increase base year accuracy resulted in too large alternative specific constants (i.e. over calibrating these modes). However, the following suggestions may help guide when using the model and interpreting model results:
BRT validation results were low (between 3.7% and -0.2%). However, only one BRT route (UVX) was available in 2019 to calibrate this mode. Partly due to this, additional rounds of calibration to improve BRT resulted in large constants. This in turn would have the effect of making the base year validation better but overpredicting BRT in future forecasts, particularly as there is significantly more BRT in future plan phases. The decision was made to allow BRT to show lower than expected ridership in the earlier years of the model in favor of more reasonable BRT future-year forecasts.
Core Route validation statistics are not applicable because the only Core Route operational in the base year (3500 S) was reclassified as a Local Bus. This adjustment removed all observed ridership from this category, resulting in undefined validation metrics. Consequently, the model is predicting trips for a mode structure preserved for future phases, while the observed dataset for this specific category is empty.
Express Bus trip and boarding validation results are higher than desired (-23.3% and -24.6%, respectively). However, Express Bus ridership in 2019 is not significant and Express Bus service is expected to decrease in future plan phases. Note that the model underpredicts overall boardings (-39.3%) largely due to the observed data showing trips in the downtown area are transferring from other modes (e.g. CRT) to use Express Bus more as a local downtown circulator. The model does not capture this behavior.
Code
html`<br/>`
Code
viewof bPlotSelect = Inputs.select(newMap([['Trips by Hierarchical Mode','Trips by Hierarchical Mode'], ['Boardings by Hierarchical Mode','Boardings by Hierarchical Mode'], ['Transfer Ratio by Hierarchical Mode','Transfer Ratio'], ['Boardings by Observed Mode','Boardings by Mode Surveyed']]), {value: nameGroup,label:"Category"})viewof metric = Inputs.radio(newMap([["Difference","Difference"], ["% Difference","% Difference"]]), {value:"Difference",label:"View:"})dataBLC =transpose(boardChart)filtered_bDataC = dataBLC.filter(function(dataBLC) {return bPlotSelect == dataBLC.Title&&"Value"== dataBLC.View&& dataBLC.Mode!=="Core Bus";// <--- EXCLUDE CORE BUS})dataBTT =transpose(boardTable)filtered_bDataT = dataBTT.filter(function(dataBTT) {return bPlotSelect == dataBTT.Title&& dataBTT.Mode!=="Core Bus";// <--- EXCLUDE CORE BUS})// Define FormattersformatInt = d3.format(",.0f") // 1,234formatRatio = d3.format(".2f") // 1.25formatPct = d3.format(".1%") // 12.5%// Helper to choose format based on contextfunctiongetFormat(col, val, title) {if (col ==="Mode") return val;if (col ==="% Difference") returnformatPct(val);// For Model, Observed, Difference: check if we are looking at Ratios or Tripsif (title ==="Transfer Ratio") returnformatRatio(val);returnformatInt(val);}
key2 =Legend(bChart.scales.color, {title:"Data Source"})bChart =GroupedBarChart(filtered_bDataC, {x: d => d.Mode,y: d => d.ViewValue,z: d => d.DataSource,// REMOVED 'Core Bus' from this listxDomain: ['Local Bus','Express Bus','BRT','LRT','CRT'],yLabel:"Value",// Conditionally format Y axis based on selectionyFormat: (bPlotSelect ==="Transfer Ratio") ?".2f":"s",zDomain: ['Model','Observed'],width:500,height:225,colors: ["#376092","#77933c"]})filtered_bData2 = dataBLC.filter(function(dataBLC) {return bPlotSelect == dataBLC.Title&& metric == dataBLC.View&& dataBLC.Mode!=="Core Bus";// <--- EXCLUDE CORE BUS})//https://observablehq.com/@d3/diverging-bar-chartimport {DivergingBarChart} from"@d3/diverging-bar-chart"html`<br/>`
Code
chart3 =DivergingBarChart(filtered_bData2, {x: d => d.ViewValue,y: d => d.Mode,xFormat: metric ==="Difference"?"+,d":"+.1%",// Integers for Diff, % for %DiffxLabel:"Model vs Observed Differences",height:200,colors: d3.schemeRdBu[3]})
Code
tbEmptyCell =1
Figure 2: Trips and Boardings by Mode Surveyed - Model vs. Observed Comparison
Code
html`<br/>`
Commuter Rail Station Boardings
The comparison of base year (2019) station-level boardings for commuter-rail transit (CRT) is found in Figure 3. CRT boardings were found to be higher than observed for Davis County and lower than observed for Utah County. An adjustment of 5 additional minutes to in-vehicle-time for trips to/from Davis County and 5 fewer minute to in-vehicle-time for Utah County was made to attempt to bring the model more in-line with observations.
Additional investigation was conducted into why Provo and Lehi were particularly low in the model. The findings did not turn up any obvious errors in the transit or model network. So, the conclusion is that further adjustments to CRT will be possible in the Mode Choice Update project that is currently being undertaken for the next release of the model.
Code
data =transpose(data_py)// 2. Create the Inputviewof access_select = Inputs.radio( ["All","Walk","Drive"], {label:"Access Mode",value:"All"})// 3. Filter Data based on selectionfiltered_data_boarding = data.filter(d => {if (access_select ==="All") returntrue;return d.Access_Type=== access_select;})
// 4. Render PlotPlot.plot({height:450,width:900,marginLeft:50,marginBottom:100,// Extra space for rotated labels// Global Style Adjustmentstyle: {backgroundColor:'transparent',fontSize:"0.85em"// Increases base font size by ~1.4-1.5x },// Facet X: The Station Namesfx: {padding:0.2,// Space between Station groupslabel:null,tickRotate:-45,// Inclined labels (bottom-up)tickSize:6,domain: sort_order // Enforce geographic sort order },// Inner X: The Source (Hidden axis labels)x: {axis:null,paddingOuter:0.1 },y: {grid:true,label:"Average Boardings",tickFormat:"s" },color: {legend:true,domain: ["On-Board Survey","Passenger Counts","Modeled v10 (Draft)"],range: ["#f28e2b","#59a14f","#4e79a7"] },marks: [ Plot.barY(filtered_data_boarding, Plot.groupX( { y:"sum" },// Sum boardings if multiple rows exist (e.g. Walk+Drive when 'Any') {x:"Source",// Separates by Source within Stationy:"Boardings",fill:"Source",fx:"Stop_Name",// Groups by Station// --- CUSTOM TOOLTIP CONFIGURATION ---tip: {format: {// 1. Format numbers with commas, no decimalsy: (d) => d.toLocaleString("en-US", {maximumFractionDigits:0}),// 2. Hide redundant fieldsfx:false,// Hide "Stop_Name" (it's already in the header)fill:false,// Hide "fill" (redundant with x/Source)x:true// Keep "Source" } } } ) ), Plot.ruleY([0]) ]})
Figure 3: 2023 Average Daily CRT Boardings by Station - Model vs Observed
Code
pivoted_table_data = {const current_sources =Array.from(newSet(filtered_data_boarding.map(d => d.Source))).sort();const rollup =newMap();for (const d of filtered_data_boarding) {if (!rollup.has(d.Stop_Name)) rollup.set(d.Stop_Name,newMap());const stopMap = rollup.get(d.Stop_Name);const currentVal = stopMap.get(d.Source) ||0; stopMap.set(d.Source, currentVal + d.Boardings); }const tableRows = sort_order.map(stop => {const row = { "Station Name": stop };for (const src of current_sources) {const stopMap = rollup.get(stop); row[src] = stopMap ? (stopMap.get(src) ||0) :0; }return row; });// Create Total Row (Keep as plain strings/numbers for now)const totalRow = { "Station Name":"Total" };for (const src of current_sources) { totalRow[src] = d3.sum(tableRows, d => d[src]); }return [...tableRows, totalRow];}// 2. Render the TableInputs.table(pivoted_table_data, {columns: ["Station Name",...Object.keys(pivoted_table_data[0] || {}).filter(k => k !=="Station Name") ],format: {// Bold the "Station Name" if it is the Total row"Station Name": d => d ==="Total"?html`<b>${d}</b>`: d,// Apply formatting and bolding to all dynamic source columns...Object.fromEntries(Object.keys(pivoted_table_data[0] || {}).filter(k => k !=="Station Name").map(k => [ k, (val, i, data) => {const isTotal = data[i]["Station Name"] ==="Total";const formatted = val.toLocaleString("en-US", {maximumFractionDigits:0});return isTotal ?html`<b>${formatted}</b>`: formatted; } ]) ) },layout:"auto",rows:21})