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

Trip Generation

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"

Trip Production Rates

The sources of the data include the most recent Utah Household Travel Survey (HTS) from 2023, the HTS from 2012, and the base year model (BY 2023 Model).

areas = ["Region","Box Elder","Weber","Davis","Salt Lake","Utah"]
areasAvailable = areas.filter(a => tripRates.some(d => d.County === a))

metricMap = new Map([
  ["Trips per Household", "Trips per Household"],
  ["Trips per Person", "Trips per Person"]
])

purposeOrder = ["All","HBW","HBShp","HBOth","HBSch","NHBW","NHBNW","IX"]

viewof htsYearSelect = Inputs.select(["2023", "2012"], {
  value: "2023",
  label: "HTS Year"
})

viewof metricSelect = Inputs.select(metricMap, {
  value: "Trips per Household",
  label: "Metric:"
})
import {Legend} from "@d3/color-legend"

tripDumbbellColorScale = d3.scaleOrdinal(["HTS " + htsYearSelect, "Model"], [themeTarget, themeModel])
keyTripDumbbell = Legend(tripDumbbellColorScale, {title: "Data Source"})
tripGridBreakpoint = 900
tripGridGap = 20

function tripDumbbellChart(county) {
  const htsSource = "HTS " + htsYearSelect;

  // Reactive data -- always keep the selected HTS reference year (calib_run
  // == null), but only the selected calibration run's Model rows
  const filtered = tripRates
    .filter(d => d.County === county)
    .filter(d => d.calib_run == null || d.calib_run === globalCalibRun)
    .map(d => ({ ...d, "Data Source": String(d["Data Source"]).trim() }));

  // "All" (the county's total across every purpose) runs 4-8x larger than
  // any individual purpose -- including it would stretch the shared x-axis
  // so far that the individual purposes collapse together near zero. Shown
  // in the totals table below the grid instead (see tripTotalsRow).
  const purposes = purposeOrder.filter(p => p !== "All" && filtered.some(d => d.Purpose === p));

  const rows = purposes.map(p => {
    const hts = filtered.find(d => d.Purpose === p && d["Data Source"] === htsSource);
    const mod = filtered.find(d => d.Purpose === p && d["Data Source"] === "Model");
    const htsVal = hts?.[metricSelect];
    const modVal = mod?.[metricSelect];
    return {
      Purpose: p, HTS: htsVal, Model: modVal,
      pctDiff: (htsVal != null && modVal != null && htsVal !== 0) ? (modVal - htsVal) / htsVal : null
    };
  }).filter(d => d.HTS != null && d.Model != null);

  // Headroom for the +/-% labels past the rightmost dot, and enough margin
  // for them not to clip against the panel edge.
  const xMax = (d3.max(rows, d => Math.max(d.HTS, d.Model)) || 1) * 1.15;
  // Chart sits in one cell of the 2x3 grid (trip-dumbbell-grid below) --
  // account for the cell being a third of the container width above the
  // grid's breakpoint, and full width once the grid collapses to one
  // column, so the plotted width matches what CSS actually gives this chart.
  const chartWidth = width < tripGridBreakpoint ? width : (width - tripGridGap * 2) / 3;

  // A "-X%" label to the left of a dot sitting near x=0 would spill into
  // the y-axis label column (marginLeft) -- force those onto the right
  // instead, same as a dot where Model already reads to the right of HTS.
  const nearZero = xMax * 0.18;
  const labelsRight = rows.filter(d => d.Model >= d.HTS || d.Model < nearZero);
  const labelsLeft = rows.filter(d => d.Model < d.HTS && d.Model >= nearZero);

  const chart = Plot.plot({
    width: chartWidth,
    height: 260,
    marginLeft: 85,
    marginRight: 45,
    marginTop: 10,
    y: {domain: purposes, label: null},
    x: {label: metricSelect, domain: [0, xMax], grid: true, nice: true},
    marks: [
      Plot.link(rows, {
        x1: "HTS", x2: "Model", y: "Purpose",
        stroke: themeLinkGrey, strokeWidth: 3
      }),
      // HTS/Target drawn first, Model second, so the Model dot's ring always
      // sits on top where the two overlap -- same convention as the
      // Base-Model-vs-Final dumbbells on the CVM Update page.
      Plot.dot(rows, {
        x: "HTS", y: "Purpose", fill: themeTarget, r: 8,
        title: d => `${d.Purpose}\n${htsSource}: ${d3.format(",.2f")(d.HTS)}`
      }),
      Plot.dot(rows, {
        x: "Model", y: "Purpose", fill: themeModel, r: 6.5,
        title: d => `${d.Purpose}\nModel (${globalCalibRun}): ${d3.format(",.2f")(d.Model)}`
      }),
      Plot.text(labelsRight, {
        x: "Model", y: "Purpose", text: d => d3.format("+.1%")(d.pctDiff),
        dx: 10, textAnchor: "start", fontSize: 11, fontWeight: 600,
        fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
      }),
      Plot.text(labelsLeft, {
        x: "Model", y: "Purpose", text: d => d3.format("+.1%")(d.pctDiff),
        dx: -10, textAnchor: "end", fontSize: 11, fontWeight: 600,
        fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
      })
    ]
  });

  return html`<div>
    <div style="font-weight:600; text-align:center; margin-bottom:2px;">${county}</div>
    ${chart}
  </div>`;
}

// One row of "All"-purpose totals per county, left out of the dumbbell
// panels above (see tripDumbbellChart) since their magnitude would dominate
// the shared per-panel x-axis.
function tripTotalsRow(county) {
  const htsSource = "HTS " + htsYearSelect;
  const filtered = tripRates
    .filter(d => d.County === county && d.Purpose === "All")
    .filter(d => d.calib_run == null || d.calib_run === globalCalibRun)
    .map(d => ({ ...d, "Data Source": String(d["Data Source"]).trim() }));
  const hts = filtered.find(d => d["Data Source"] === htsSource);
  const mod = filtered.find(d => d["Data Source"] === "Model");
  const htsVal = hts?.[metricSelect];
  const modVal = mod?.[metricSelect];
  return {
    County: county, HTS: htsVal, Model: modVal,
    pctDiff: (htsVal != null && modVal != null && htsVal !== 0) ? (modVal - htsVal) / htsVal : null
  };
}

{
  const controls = html`<div style="display:flex; gap:16px; align-items:center; flex-wrap:wrap; margin-bottom:10px;"></div>`;
  controls.append(viewof htsYearSelect);
  controls.append(viewof metricSelect);

  const totalsRows = areasAvailable.map(tripTotalsRow);
  const fmt = d3.format(",.2f");

  return html`<div>
    <style>
      .trip-dumbbell-grid {
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 24px ${tripGridGap}px;
      }
      @media (max-width: ${tripGridBreakpoint}px) {
        .trip-dumbbell-grid {
          grid-template-columns: 1fr;
        }
      }
    </style>
    ${controls}
    <div class="trip-dumbbell-grid">
      ${areasAvailable.map(tripDumbbellChart)}
    </div>
    <div style="font-weight:600; margin-top:20px;">All Purposes (Total), by County</div>
    <table style="width:100%; border-collapse:collapse; font-size: 13px; margin-top: 6px;">
      <thead>
        <tr>
          <th style="text-align:left; padding:4px 6px; border-bottom:2px solid #999;">County</th>
          <th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">${"HTS " + htsYearSelect}</th>
          <th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">Model (${globalCalibRun})</th>
          <th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">% Difference</th>
        </tr>
      </thead>
      <tbody>
        ${totalsRows.map(r => html`<tr style="border-bottom: 1px solid lightgrey;">
          <td style="padding:4px 6px;">${r.County}</td>
          <td style="padding:4px 6px; text-align:right;">${r.HTS != null ? fmt(r.HTS) : "--"}</td>
          <td style="padding:4px 6px; text-align:right;">${r.Model != null ? fmt(r.Model) : "--"}</td>
          <td style="padding:4px 6px; text-align:right; color:${r.pctDiff != null && Math.abs(r.pctDiff) > 0.1 ? themeDanger : themeNeutral}; font-weight:600;">${r.pctDiff != null ? d3.format("+.1%")(r.pctDiff) : "--"}</td>
        </tr>`)}
      </tbody>
    </table>
  </div>`;
}
(a)
(b)
(c)
(d)
(e)
Figure 1: Trip Generation Rates (II + IX) by Purpose – HTS vs Model

Commercial Vehicle Trip Generation

Unlike person trips, there’s no household-travel-survey equivalent for commercial vehicles, so there’s no independent target to validate this against (see the Commercial Vehicle Model Update page’s “About the LOCUS Data” note, and the Trip Distribution page’s trip-length-frequency note). The chart below is a plain model summary – commercial-vehicle trips produced per job (CV trip generation is employment-driven, not household-driven), by county and vehicle type.

cvVehicleOrder = ["LT", "MD", "HV"]
cvVehicleLabel = new Map([
  ["LT", "Light Commercial Vehicles"],
  ["MD", "Medium Commercial Vehicles"],
  ["HV", "Heavy Commercial Vehicles"]
])
// Region is a sum of these, not a peer magnitude -- excluded from the
// per-county bars below (see cvHeadline for the region-wide total instead).
cvCountyOrder = ["Box Elder", "Weber", "Davis", "Salt Lake", "Utah"]
cvFiltered = cvTripGen
  .filter(d => d.calib_run === globalCalibRun && cvCountyOrder.includes(d.County))

Plot.plot({
  width: 840,
  height: 300,
  marginBottom: 40,
  y: {label: "Trips per Job", grid: true, nice: true},
  x: {label: null, domain: cvCountyOrder},
  fx: {label: null, domain: cvVehicleOrder, tickFormat: d => cvVehicleLabel.get(d)},
  marks: [
    Plot.barY(cvFiltered, {x: "County", y: "Trips per Job", fx: "Vehicle Type", fill: themeModel}),
    Plot.ruleY([0])
  ]
})
(a)
(b)
Figure 2: Commercial Vehicle Trip Generation by County (Model Only)
cvHeadline = cvTripGen.filter(d => d.calib_run === globalCalibRun && d.County === "Region")

html`<div style="display:flex; gap:16px; flex-wrap:wrap; margin-top:8px;">
  ${cvVehicleOrder.map(v => {
    const row = cvHeadline.find(d => d["Vehicle Type"] === v);
    return html`<div style="border:1px solid #e0dfdd; border-radius:8px; padding:10px 16px;">
      <div style="color:${themeNeutral}; font-size:0.9em;">${cvVehicleLabel.get(v)}</div>
      <div style="font-weight:600; font-size:1.1em;">${row ? d3.format(",.0f")(row.Trips) : "--"} daily trips</div>
      <div style="color:${themeNeutral}; font-size:0.85em;">${row ? d3.format(",.3f")(row["Trips per Job"]) : "--"} per job (Region)</div>
    </div>`;
  })}
</div>`
Household Disaggregation and Auto Ownership
Trip Distribution & HBW Destination Choice

© 2025 Wasatch Front Regional Council

  • Contact

  • License