The Commercial Vehicle Model (CVM) generates daily Light (LT), Medium (MD), and Heavy (HV) commercial-vehicle trips for six vocations – Door-to-Door (D2D), Hub-and-Spoke (HnS), Local (Lcl), Regional (Reg), Long-Haul (LHl), and Unknown (Unk) – from trip-rate models built on LOCUS commercial-vehicle data. Those raw rates rarely match observed commercial-vehicle activity exactly, so a set of calibration factors are layered on top, by county and (for MD/HV) by vocation. This page breaks down, for the selected calibration run, how much of the region’s final commercial-vehicle trips and VMT trace back to the raw LOCUS-based rates (“base model”) versus how much comes from that calibration adjustment – and separately explains what falls outside the CVM entirely: the external (X) end of any trip that crosses the region boundary.
About the LOCUS Data
UDOT has licensed commercial-vehicle origin-destination (OD) data from LOCUS that was geo-referenced to the region’s TAZs and external stations, made available to WFRC as part of a 2025 Wasatch Front TDM Truck Model Update effort. That project used the licensed LOCUS OD data, alongside segment-level roadway volumes with medium- and heavy-duty commercial-vehicle percentages and a base- and horizon-year commercial-vehicle trip table extracted from the Utah Statewide Travel Demand Model (USTM), to re-estimate and calibrate the CVM’s internal (II) trip generation and distribution parameters against 2023 base-year observed commercial-vehicle activity. Per the LOCUS-derived CV TG Rates.csv rate table, that re-estimation also reaches the internal-zone side of IX/XI trip generation (see “Within-region distribution” further down this page). Consistent with the project’s scope, though, USTM continues to supply XX trip tables and the external end zones for IX/XI trips; LOCUS data doesn’t reach those.
A note on vehicle-class definitions. LOCUS classifies commercial vehicles by gross vehicle weight, while UDOT’s roadway count data – including the Continuous Count Station (CCS) adjustments described below – classifies by vehicle configuration: single-unit (SU) vs. multi-unit (MU). The two schemes don’t always agree. A short, rigid single-unit vehicle like a dump truck or concrete mixer can carry a gross vehicle weight well into LOCUS’s “Heavy” bucket while still counting as UDOT’s shorter single-unit (“Medium”) category at a count station. Vehicles like these are a possible source of any mismatch between the CVM’s LOCUS-derived Medium/Heavy split and UDOT’s own Medium/Heavy percentages.
viewof globalCalibRun = {const options = calib_runs_ojs;if (options.length===0) {return Inputs.select([], {label:"Calibration Run:",disabled:true}); }// Default to the run with the highest numeric ID (e.g. "C51" over "C50"),// not just the last entry in `options` -- that list comes from a plain// alphabetical sort (see list_available_runs in _validation_scripts.py),// which only happens to match numeric order today because both IDs have// the same digit count ("C9" would sort after "C50" alphabetically despite// being numerically earlier).const runNumber = id => {const m =/(\d+)/.exec(id);return m ?parseInt(m[1],10) :-Infinity; };const latestRun = options.reduce((best, id) =>runNumber(id) >runNumber(best) ? id : best, options[0]);const stored =window.localStorage.getItem("tdmcalib:selectedRun");const initial = options.includes(stored) ? stored : latestRun;const input = Inputs.select(options, {label:"Calibration Run:",value: initial});const selectEl = input.querySelector("select"); input.classList.add("calib-run-bar");// Sidebar links for pages that don't have data for every run (currently// just report/cvm-update.qmd, which is only ever generated for the model// version's reference run -- not planned for every run the way the other// report pages are) get greyed out -- rather than the run dropdown itself// shrinking to that page's subset -- so the dropdown's options stay// identical everywhere on the site. The link stays clickable. If the// current page happens to be cvm-update.qmd itself, this also swaps its// own #cvm-content/#cvm-empty-message visibility -- both elements are// absent on every other page, so this is a no-op there.functionupdateSidebarAvailability(run) {const available = cvm_update_available_runs_ojs.includes(run);const link =document.querySelector('#quarto-sidebar a[href$="cvm-update.html"]');if (link) {const item = link.closest(".sidebar-item") ?? link; item.classList.toggle("sidebar-item-no-run-data",!available); link.title= available ?"":`No commercial-vehicle factoring data for ${run}`; }const content =document.getElementById("cvm-content");const emptyMsg =document.getElementById("cvm-empty-message");if (content) content.style.display= available ?"":"none";if (emptyMsg) emptyMsg.style.display= available ?"none":""; }updateSidebarAvailability(initial); input.addEventListener("input", () => {window.localStorage.setItem("tdmcalib:selectedRun", input.value);updateSidebarAvailability(input.value); });// Quarto's own OJS runtime places this cell's returned node into its// normal in-page output slot right after this function returns -- moving// it into the header/sidebar synchronously, before that happens, would// just get undone a moment later. Deferring to the next tick runs this// after that placement, so the move actually sticks.setTimeout(() => {const navbarBrand =document.querySelector("#quarto-header .navbar-brand-container");if (navbarBrand) { input.classList.remove("calib-run-bar"); input.classList.add("calib-run-navbar"); navbarBrand.appendChild(input);return; }const sidebar =document.querySelector("#quarto-sidebar");const menu = sidebar?.querySelector(".sidebar-menu-container");if (!sidebar ||!menu) return;// no navbar brand container or docked sidebar -- stays inline as a .calib-run-bar// Inputs.select renders the label text and <select> on the same line by// default -- force them onto separate lines to match the sidebar's// stacked item style. Insert relative to the select's actual parent// (not assumed to be the <label>, which varies by Inputs version) so// this doesn't throw if they aren't nested. selectEl?.parentNode.insertBefore(document.createElement("br"), selectEl); input.classList.remove("calib-run-bar"); input.classList.add("calib-run-sidebar"); sidebar.insertBefore(input, menu); },0);return input;}
themeModel ="#ea7b00"// WFRC core orangethemeTarget ="#036fa8"// WFRC core bluethemeTargetAlt ="#007426"// WFRC core greenthemeTargetLight ="#8ebed8"// Lighter tint of Target blue (#036fa8)themeAccent ="#9d4879"// WFRC RTP fuschiathemeNeutral ="#7f7a76"// WFRC brand graythemeDanger ="#d32f00"// WFRC core red -- status color, pair with a label/icon, never color alonethemeLinkGrey ="#d8d7d6"
CVM factoring is only ever produced for this model version’s reference calibration run (currently C70, WF TDM v10.0 Beta.2) – not run for every calibration run the way the other report pages are, and not planned to be. Picking a different run below shows a plain “no information” notice instead of this page’s charts.
No information was generated for this calibration run.
Total Commercial Vehicle Activity
Trips and VMT below cover all commercial-vehicle movement – internal (II), plus any trip with one or both ends outside the region (IX/XI/XX). Each card splits that total into what the raw model would have produced on its own (“base model”) versus the net effect of every calibration factor applied on top (“adjustment”) – a negative adjustment means calibration is scaling the raw estimate down, not up.
Figure 1: Total Commercial Vehicle Trips and VMT by Vehicle Type
The table below splits that same total into its two sources – internal (II) trips from the CVM rate model versus external (IX/XI/XX) trips from the gateway-station process described further down this page – using each run’s final, fully-calibrated values.
Each purpose below is one of the six LOCUS-derived vocations, internal (II) trips only – the only place a purpose-specific calibration factor applies (see the section below for IX/XI/XX, which are calibrated by a different, vocation-blind mechanism entirely). The dot marked “Base Model” is what the raw LOCUS trip-rate model alone would produce; “Final (Calibrated)” is this run’s actual output after every calibration factor is applied. The percent label is the calibration factor’s net effect.
viewof cvmMetric = Inputs.radio(["trips","vmt"], {value:"trips",label:"Metric:",format: d => d ==="trips"?"Trip Generation":"VMT"})
What’s Not Part of the CVM: External (X-End) Trips
Not every commercial-vehicle trip touching our region comes from the CVM rate model above. Any trip with an external end – IX (internal origin, external destination), XI (external origin, internal destination), or XX (passing through, both ends external) – has its volume set independently of that rate model, though the CVM’s own data still shapes part of where those trips land:
Trip generation: overall AADT at each external gateway station is forecast from HPMS segment AADT volumes, then split into medium- and heavy-duty commercial-vehicle volumes using base-year UDOT medium-/heavy-duty commercial-vehicle percentages – themselves adjusted using revised vehicle-length classifications at nearby Continuous Count Stations (CCS). Not a LOCUS-derived rate model.
Within-region distribution (which internal zone originates an IX trip, or receives an XI trip): shaped by the same land-use-based rate table (CV TG Rates.csv, LOCUS-derived) used for internal CVM trips – but for shape only. Those zone-level values are immediately rescaled so the region-wide IX/XI total matches the AADT-derived external total from above, so LOCUS data has no influence on how much IX/XI volume exists, only where within the region it starts or ends.
Gateway/route pattern (which external station an IX/XI trip uses, and for XX, which entry-and-exit gateway pair): a separate historical subarea-extraction pattern, not LOCUS/CVM data and not a live run of the Utah Statewide model.
The only calibration applied to these trips is a per-station, per-vehicle-type multiplier (calibFac_Ext_MD_<station> / calibFac_Ext_HV_<station>) that scales a station’s whole commercial-vehicle volume before splitting it into IX/XI/XX shares – there’s no vocation dimension here (LOCUS’s D2D/HnS/Lcl/Reg/LHl/Unk split doesn’t apply), and no LT component at all (Light commercial vehicles are folded into the passenger-vehicle volume at external stations, which get their own separate calibFac_Ext_PL_<station> multiplier). These factors are derived by comparing the resulting link volumes, once forecast, against each station’s CCS Fall 2023 weekday average volume – the difference becomes the adjustment factor, so like the rest of the CVM’s calibration they’re anchored to actual observed station counts rather than a demand-rate model.
Trip generation figures are exact: tdm/2_ModelScripts/2_TripGen/2_TripGen_CV.s multiplies each zone’s raw rate-model production by a county x vehicle-type (x vocation, for MD/HV) calibration factor, in place – so the “base model” value here is the same production divided back out by that factor, not an estimate.
VMT figures use an approximation: the calibration factor is unfactored from the origin (production) zone of each trip only. A trip’s destination zone – and, for IX trips, its external end – carries its own factor too, which isn’t undone here. This tends to pull the VMT adjustment slightly toward zero relative to what a full two-sided treatment would show. Treat the VMT split as directionally right, not to the same precision as trip generation.
Produced by scripts/cv_purpose_factoring.py (run manually per calibration run; see that script’s module docstring for the full mechanism and scripts/cv_effective_factors.py for the underlying per-zone factor reconstruction).