WF-TDM Calibration
  • Follow Us
    • Facebook
    • X (Twitter)
    • Instagram
    • LinkedIn
    • YouTube
  • Source Code
  • Report a Bug
  1. Commercial Vehicle Model Update
  • TDM Calibration Reports
  • Reports
    • Household Disaggregation and Auto Ownership
    • Trip Generation
    • Trip Distribution & HBW Destination Choice
    • Mode Choice
    • Highway Assignment
  • Commercial Vehicle Model Update

Commercial Vehicle Model Update

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.
  function updateSidebarAvailability(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 orange
themeTarget = "#036fa8"      // WFRC core blue
themeTargetAlt = "#007426"   // WFRC core green
themeTargetLight = "#8ebed8" // Lighter tint of Target blue (#036fa8)
themeAccent = "#9d4879"      // WFRC RTP fuschia
themeNeutral = "#7f7a76"     // WFRC brand gray
themeDanger = "#d32f00"      // WFRC core red -- status color, pair with a label/icon, never color alone
themeLinkGrey = "#d8d7d6"
allRows = transpose(cvm_data)
vocOrder = ["D2D", "HnS", "Lcl", "Reg", "LHl", "Unk"]
extOrder = ["IX", "XI", "XX"]
vehicleOrder = ["LT", "MD", "HV"]
vehicleLabel = new Map([["LT", "Light Commercial Vehicles"], ["MD", "Medium Commercial Vehicles"], ["HV", "Heavy Commercial Vehicles"]])

rows = allRows.filter(d => d.calib_run === globalCalibRun)
fmtInt = d3.format(",.0f")
fmtPct = d3.format(",.1f")
fmtFactor = d3.format(",.2f")

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.

function headlineCard(veh) {
  const vehRows = rows.filter(d => d.vehicle_type === veh);
  function totals(metric) {
    const m = vehRows.filter(d => d.metric === metric);
    const factored = d3.sum(m, d => d.factored);
    const unfactored = d3.sum(m, d => d.unfactored);
    return { factored, unfactored, modelPct: factored ? unfactored / factored * 100 : 0, adjPct: factored ? (factored - unfactored) / factored * 100 : 0 };
  }
  const trips = totals("trips");
  const vmt = totals("vmt");

  const row = (label, t) => html`<div style="display:flex; justify-content:space-between; padding:3px 0;">
    <span style="color:${themeNeutral};">${label}</span>
    <span><strong>${fmtInt(t.factored)}</strong> &nbsp;<span style="color:${themeNeutral}; font-size:0.9em;">(${fmtPct(t.modelPct)}% model / ${t.adjPct >= 0 ? "+" : ""}${fmtPct(t.adjPct)}% adj)</span></span>
  </div>`;

  return html`<div style="border:1px solid #e0dfdd; border-radius:8px; padding:14px 18px; flex:1; min-width:240px;">
    <div style="font-weight:600; font-size:1.05em; margin-bottom:6px;">${vehicleLabel.get(veh)}</div>
    ${row("Daily Trips", trips)}
    ${row("Daily VMT", vmt)}
  </div>`;
}

html`<div style="display:flex; gap:16px; flex-wrap:wrap; margin-bottom:12px;">
  ${vehicleOrder.map(headlineCard)}
</div>`
(a)
(b)
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.

function iiExtTotal(veh, metric) {
  const m = rows.filter(d => d.vehicle_type === veh && d.metric === metric);
  const ii = d3.sum(m.filter(d => vocOrder.includes(d.component)), d => d.factored);
  const ext = d3.sum(m.filter(d => extOrder.includes(d.component)), d => d.factored);
  return { ii, ext, total: ii + ext };
}

html`
<table>
  <thead>
    <tr>
      ${["Vehicle Type", "Metric", "Internal (II)", "External (IX/XI/XX)", "Total Commercial Vehicle Activity"].map(d =>
        html`<th style='text-align:left; padding:5px;'>${d}</th>`)}
    </tr>
  </thead>
  <tbody>
    ${vehicleOrder.flatMap(veh => ["trips", "vmt"].map(metric => {
      const t = iiExtTotal(veh, metric);
      return html`<tr style='border-bottom: 1px solid lightgrey;'>
        <td style='padding:5px;'>${vehicleLabel.get(veh)}</td>
        <td style='padding:5px;'>${metric === "trips" ? "Daily Trips" : "Daily VMT"}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(t.ii)}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(t.ext)}</td>
        <td style='padding:5px; text-align:right;'><strong>${fmtInt(t.total)}</strong></td>
      </tr>`;
    }))}
    ${["trips", "vmt"].map(metric => {
      const totals = vehicleOrder.map(veh => iiExtTotal(veh, metric));
      const ii = d3.sum(totals, d => d.ii);
      const ext = d3.sum(totals, d => d.ext);
      return html`<tr style='border-top: 2px solid #999; font-weight:600;'>
        <td style='padding:5px;'>All Vehicle Types</td>
        <td style='padding:5px;'>${metric === "trips" ? "Daily Trips" : "Daily VMT"}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(ii)}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(ext)}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(ii + ext)}</td>
      </tr>`;
    })}
  </tbody>
</table>`
Table 1
(a)
(b)

Base Model vs. Calibration Adjustment, by Purpose

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"})
import {Legend} from "@d3/color-legend"

cvmColorScale = d3.scaleOrdinal(["Base Model", "Final (Calibrated)"], [themeNeutral, themeModel])
keyCvmPurpose = Legend(cvmColorScale, {title: ""})
function purposeDumbbell(veh) {
  const data = vocOrder.map(voc => {
    const r = rows.find(d => d.vehicle_type === veh && d.component === voc && d.metric === cvmMetric);
    return r ? { Purpose: voc, Model: r.unfactored, Final: r.factored, pctAdj: (r.factored - r.unfactored) / r.factored * 100 } : { Purpose: voc, Model: 0, Final: 0, pctAdj: 0 };
  });
  const xMax = d3.max(data, d => Math.max(d.Model, d.Final)) || 1;
  const xLabel = cvmMetric === "trips" ? "Daily Trips" : "Daily VMT";

  const chart = Plot.plot({
    width: 340,
    height: 220,
    marginLeft: 55,
    marginRight: 55,
    marginTop: 8,
    y: {domain: vocOrder, label: null},
    x: {label: xLabel, domain: [0, xMax], grid: true, nice: true, tickFormat: "~s"},
    marks: [
      Plot.link(data, {x1: "Model", x2: "Final", y: "Purpose", stroke: themeLinkGrey, strokeWidth: 3}),
      Plot.dot(data, {x: "Model", y: "Purpose", fill: themeNeutral, r: 7, title: d => `${d.Purpose}\nBase Model: ${fmtInt(d.Model)}`}),
      Plot.dot(data, {x: "Final", y: "Purpose", fill: themeModel, r: 7, title: d => `${d.Purpose}\nFinal: ${fmtInt(d.Final)}`}),
      Plot.text(data.filter(d => d.Final >= d.Model), {x: "Final", y: "Purpose", text: d => d3.format("+.0f")(d.pctAdj) + "%", dx: 10, textAnchor: "start", fontSize: 11, fontWeight: 600, fill: themeNeutral}),
      Plot.text(data.filter(d => d.Final < d.Model), {x: "Final", y: "Purpose", text: d => d3.format("+.0f")(d.pctAdj) + "%", dx: -10, textAnchor: "end", fontSize: 11, fontWeight: 600, fill: themeNeutral}),
    ],
  });
  return html`<div>
    <div style="font-weight:600; text-align:center; margin-bottom:2px;">${vehicleLabel.get(veh)}</div>
    ${chart}
  </div>`;
}

html`<div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:center;">${vehicleOrder.map(purposeDumbbell)}</div>`
(a)
(b)
Figure 2: Base Model vs. Final Calibrated – Internal (II) Trips/VMT by Vocation
html`
<table>
  <thead>
    <tr>
      ${["Vehicle Type", "Purpose", "Base Model", "Final (Calibrated)", "Adjustment Factor", "% Model / % Adjustment"].map(d =>
        html`<th style='text-align:left; padding:5px;'>${d}</th>`)}
    </tr>
  </thead>
  <tbody>
    ${vehicleOrder.flatMap(veh => vocOrder.map(voc => {
      const r = rows.find(d => d.vehicle_type === veh && d.component === voc && d.metric === cvmMetric);
      if (!r) return html``;
      return html`<tr style='border-bottom: 1px solid lightgrey;'>
        <td style='padding:5px;'>${vehicleLabel.get(veh)}</td>
        <td style='padding:5px;'>${voc}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(r.unfactored)}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(r.factored)}</td>
        <td style='padding:5px; text-align:right;'>${fmtFactor(r.effective_factor)}x</td>
        <td style='padding:5px; text-align:right;'>${fmtPct(r.unfactored / r.factored * 100)}% / ${(r.factored - r.unfactored) / r.factored >= 0 ? "+" : ""}${fmtPct((r.factored - r.unfactored) / r.factored * 100)}%</td>
      </tr>`;
    }))}
  </tbody>
</table>`

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.

function externalDumbbell(veh) {
  const data = extOrder.map(comp => {
    const r = rows.find(d => d.vehicle_type === veh && d.component === comp && d.metric === cvmMetric);
    return r ? { Purpose: comp, Model: r.unfactored, Final: r.factored, pctAdj: (r.factored - r.unfactored) / r.factored * 100 } : { Purpose: comp, Model: 0, Final: 0, pctAdj: 0 };
  });
  const xMax = d3.max(data, d => Math.max(d.Model, d.Final)) || 1;
  const xLabel = cvmMetric === "trips" ? "Daily Trips" : "Daily VMT";

  const chart = Plot.plot({
    width: 340,
    height: 130,
    marginLeft: 55,
    marginRight: 55,
    marginTop: 8,
    y: {domain: extOrder, label: null},
    x: {label: xLabel, domain: [0, xMax], grid: true, nice: true, tickFormat: "~s"},
    marks: [
      Plot.link(data, {x1: "Model", x2: "Final", y: "Purpose", stroke: themeLinkGrey, strokeWidth: 3}),
      Plot.dot(data, {x: "Model", y: "Purpose", fill: themeNeutral, r: 7, title: d => `${d.Purpose}\nUnadjusted: ${fmtInt(d.Model)}`}),
      Plot.dot(data, {x: "Final", y: "Purpose", fill: themeModel, r: 7, title: d => `${d.Purpose}\nFinal: ${fmtInt(d.Final)}`}),
      Plot.text(data, {x: "Final", y: "Purpose", text: d => d3.format("+.1f")(d.pctAdj) + "%", dx: 10, textAnchor: "start", fontSize: 11, fontWeight: 600, fill: themeNeutral}),
    ],
  });
  return html`<div>
    <div style="font-weight:600; text-align:center; margin-bottom:2px;">${vehicleLabel.get(veh)}</div>
    ${chart}
  </div>`;
}

html`<div><div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:center;">${["MD", "HV"].map(externalDumbbell)}</div>
<p style="text-align:center; color:${themeNeutral}; font-size:0.9em;">Light commercial vehicles (LT) have no external-station component -- see note above.</p></div>`
(a)
(b)
Figure 3: External (IX/XI/XX) Trips/VMT by Vehicle Type – Not CVM/LOCUS-Derived
html`
<table>
  <thead>
    <tr>
      ${["Vehicle Type", "Movement", "Before Station Adjustment", "Final (Calibrated)", "Adjustment Factor"].map(d =>
        html`<th style='text-align:left; padding:5px;'>${d}</th>`)}
    </tr>
  </thead>
  <tbody>
    ${["MD", "HV"].flatMap(veh => extOrder.map(comp => {
      const r = rows.find(d => d.vehicle_type === veh && d.component === comp && d.metric === cvmMetric);
      if (!r) return html``;
      return html`<tr style='border-bottom: 1px solid lightgrey;'>
        <td style='padding:5px;'>${vehicleLabel.get(veh)}</td>
        <td style='padding:5px;'>${comp}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(r.unfactored)}</td>
        <td style='padding:5px; text-align:right;'>${fmtInt(r.factored)}</td>
        <td style='padding:5px; text-align:right;'>${fmtFactor(r.effective_factor)}x</td>
      </tr>`;
    }))}
  </tbody>
</table>`

Methodology & Caveats

  • 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).
Highway Assignment

© 2025 Wasatch Front Regional Council

  • Contact

  • License