WF-TDM Calibration
  • Follow Us
    • Facebook
    • X (Twitter)
    • Instagram
    • LinkedIn
    • YouTube
  • Source Code
  • Report a Bug
  1. Reports
  2. Trip Distribution & HBW Destination Choice
  • 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 Distribution & HBW Destination Choice

Trip Distribution & HBW Destination Choice

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 Distribution

The model used the 2012 Household Travel Survey to calibrate trip lengths for internal and external person trips. Trips were calibrated based on generalized cost trip length frequencies and validated to time and distance trip length frequencies and average trip lengths. The validation results are shown in Figure 1 and Figure 2. Trip lengths by time and distance met the validation targets and aligned closely with target data.

No target data was available for internal and external commercial vehicle calibration. CV trips were calibrated based on previous model estimation and validated relative to person work and other trip lengths.

Average Trip Length

viewof varSelect = Inputs.select(new Map([['Generalized Cost','GC'], ['Distance', 'Dist']]), {value: 'GC', label: "Variable"})

// No Purpose Set selector -- all purposes shown in one dumbbell chart below,
// grouped into their Internal/Commercial Vehicle/External sections by the
// same order gcdtime_Stats already sorted them into (see fig-ave-trip-length).
// PurpType is null for purposes outside all three groups (e.g. HBC, present
// in the underlying matrices but not part of `order`) -- the old purpSelect
// filter silently dropped these too (they never matched any dropdown value),
// so exclude them explicitly now that nothing else does.
dataLT = transpose(dataStats)
filtered_data = dataLT.filter(function(dataL) {
    return varSelect == dataL.Variable && dataL.calib_run === globalCalibRun && dataL.PurpType != null;
})

purpTypeLabel = new Map([
  ["InternalTripPurposes", "Internal Trip Purposes"],
  ["InternalTruckPurposes", "Internal Commercial Vehicle Purposes"],
  ["Externals", "External Trip Purposes"]
])

import {Legend, Swatches} from "@d3/color-legend"
import {howto, altplot} from "@d3/example-components"
  • Plot
  • Table

Purposes below are grouped, top to bottom, into Internal Trip Purposes, Internal Commercial Vehicle Purposes, and External Trip Purposes (a light rule marks each group boundary).

atlPurposes = [...new Set(filtered_data.map(d => d.Purpose))]

atlRows = atlPurposes.map(p => {
  const model = filtered_data.find(d => d.Purpose === p && d.DataSource === "Model");
  const target = filtered_data.find(d => d.Purpose === p && d.DataSource === "Target");
  const any = model ?? target;
  const modelVal = model ? model.AVG_TRIP_LEN : null;
  const targetVal = target ? target.AVG_TRIP_LEN : null;
  return {
    Purpose: p,
    PurposeLabel: p.replace(/^\d+-/, ""),
    PurpType: any?.PurpType,
    Model: modelVal,
    Target: targetVal,
    pctDiff: (modelVal != null && targetVal != null && targetVal !== 0) ? (modelVal - targetVal) / targetVal : null
  };
}).filter(d => d.Model != null || d.Target != null);

atlLinkRows = atlRows.filter(d => d.Model != null && d.Target != null);

// A boundary sits just above each row that starts a new PurpType group
// (skipping the very first row, which has no boundary above it) -- atlRows
// is already contiguous by group, from gcdtime_Stats' own sort order.
atlGroupBoundaries = atlRows
  .filter((d, i) => i > 0 && d.PurpType !== atlRows[i - 1].PurpType)
  .map(d => d.Purpose)

// Headroom for the +/-% labels past the rightmost dot.
atlXMax = (d3.max(atlRows, d => Math.max(d.Model ?? 0, d.Target ?? 0)) || 1) * 1.15

// 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 row where Model already reads to the right of Target.
atlNearZero = atlXMax * 0.18
atlLabelsRight = atlRows.filter(d => d.pctDiff != null && (d.Model >= d.Target || d.Model < atlNearZero))
atlLabelsLeft = atlRows.filter(d => d.pctDiff != null && d.Model < d.Target && d.Model >= atlNearZero)

Plot.plot({
  y: {domain: atlPurposes, label: null, tickFormat: p => atlRows.find(d => d.Purpose === p)?.PurposeLabel ?? p},
  x: {domain: [0, atlXMax], tickFormat: "s", grid: true, label: varSelect === "GC" ? "Generalized Cost" : "Distance"},
  color: {
    domain: ["Model", "Target"],
    range: [themeModel, themeTarget],
    legend: true
  },
  style: {fontSize: 16, fontFamily: "Inter, system-ui, sans-serif"},
  marginTop: 10,
  marginBottom: 40,
  marginRight: 55,
  marginLeft: 125,
  height: Math.max(atlPurposes.length * 28, 80),
  marks: [
    Plot.ruleY(atlGroupBoundaries, {y: p => p, dy: -14, stroke: themeNeutral, strokeOpacity: 0.4}),
    Plot.link(atlLinkRows, {x1: "Model", x2: "Target", y: "Purpose", stroke: themeLinkGrey, strokeWidth: 3}),
    // Target drawn first, Model second, so the Model dot's ring always sits
    // on top where the two overlap -- same convention as the CVM Update
    // page's Base-Model-vs-Final dumbbells.
    Plot.dot(atlRows.filter(d => d.Target != null), {
      x: "Target", y: "Purpose", fill: themeTarget, r: 7,
      title: d => `${d.PurposeLabel}\nTarget: ${d3.format(",.1f")(d.Target)}`
    }),
    Plot.dot(atlRows.filter(d => d.Model != null), {
      x: "Model", y: "Purpose", fill: themeModel, r: 5.5,
      title: d => `${d.PurposeLabel}\nModel: ${d3.format(",.1f")(d.Model)}`
    }),
    Plot.text(atlLabelsRight, {
      x: "Model", y: "Purpose", text: d => d3.format("+.1%")(d.pctDiff),
      dx: 9, textAnchor: "start", fontSize: 11, fontWeight: 600,
      fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
    }),
    Plot.text(atlLabelsLeft, {
      x: "Model", y: "Purpose", text: d => d3.format("+.1%")(d.pctDiff),
      dx: -9, textAnchor: "end", fontSize: 11, fontWeight: 600,
      fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
    }),
    Plot.ruleX([0])
  ]
})
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
(i)
Figure 1: Average Trip Length
Inputs.table(filtered_data)

Trip Length Frequency Validation

The following trip length frequency charts have been updated to exclude intrazonal trips. The distance charts only include data beyond 0, the time charts only include data beyond 4, and the general cost charts only include data beyond 4. Please note that these values were calculated at a mark where the majority of the trip would not be intrazonal trips. Intrazonal trips were analyzed separately.

optionsTripPurpBySet = ({
  "InternalTripPurposes": [
    ['Home-Based Work'                   , 'HBW'     ],
    ['Home-Based Shopping'               , 'HBShp'   ],
    ['Home-Based Other'                  , 'HBOth'   ],
    ['Home-Based School - Primary'       , 'HBSch_Pr'],
    ['Home-Based School - Secondary'     , 'HBSch_Sc'],
    ['Non-Home-Based Work'               , 'NHBW'    ],
    ['Non-Home-Based Non-Work'           , 'NHBNW'   ],
    ['Light CV'                          , 'LT'      ],
    ['Medium CV'                         , 'MD'      ],
    ['Heavy CV'                          , 'HV'      ]
  ],
  "InternalTruckPurposes": [
    ['Light CV - Door-to-Door'            , 'II_LT_D2D'],
    ['Light CV - Hub-and-Spoke'           , 'II_LT_HnS'],
    ['Light CV - Local'                   , 'II_LT_Lcl'],
    ['Light CV - Regional'                , 'II_LT_Reg'],
    ['Light CV - Long Haul'               , 'II_LT_LHl'],
    ['Light CV - Unknown'                 , 'II_LT_Unk'],
    ['Medium CV - Door-to-Door'           , 'II_MD_D2D'],
    ['Medium CV - Hub-and-Spoke'          , 'II_MD_HnS'],
    ['Medium CV - Local'                  , 'II_MD_Lcl'],
    ['Medium CV - Regional'               , 'II_MD_Reg'],
    ['Medium CV - Long Haul'              , 'II_MD_LHl'],
    ['Medium CV - Unknown'                , 'II_MD_Unk'],
    ['Heavy CV - Door-to-Door'            , 'II_HV_D2D'],
    ['Heavy CV - Hub-and-Spoke'           , 'II_HV_HnS'],
    ['Heavy CV - Local'                   , 'II_HV_Lcl'],
    ['Heavy CV - Regional'                , 'II_HV_Reg'],
    ['Heavy CV - Long Haul'               , 'II_HV_LHl'],
    ['Heavy CV - Unknown'                 , 'II_HV_Unk']
    // Medium/Heavy CV - Adjusted (II_MD_Adj/II_HV_Adj) removed -- no longer
    // modeled (the raw model output has never had these tabs; only the
    // static observed/target comparison file does -- see config/
    // framework.yaml's PA_AllPurp_GRAVITY.MTX tabs comment).
  ],
  "Externals": [
    ['Internal-to-External'              , 'IX'      ],
    ['External-to-Internal'              , 'XI'      ],
    ['Internal-to-External Medium CV'    , 'IX_MD'   ],
    ['External-to-Internal Medium CV'    , 'XI_MD'   ],
    ['Internal-to-External Heavy CV'     , 'IX_HV'   ],
    ['External-to-Internal Heavy CV'     , 'XI_HV'   ]
  ]
})

viewof vTlfPurpSet = Inputs.select(new Map([['Internal Trip Purposes', 'InternalTripPurposes'], ['Internal Commercial Vehicle Purposes', 'InternalTruckPurposes'], ['External Trip Purposes', 'Externals']]), {value: 'InternalTripPurposes', label: "Purpose Set:"})

optionsTripPurp = optionsTripPurpBySet[vTlfPurpSet]

viewof vTripPurp = Inputs.select(new Map(optionsTripPurp), {value: optionsTripPurp[0][1], label: "Trip Purpose:"})
viewof vTlfType = Inputs.select(new Map([['Generalized Cost','GC'],['Distance','Dist']]), {value: 'GC', label: "Variable:"})
viewof binSize = Inputs.range([1, 5], {value: 2, step: 1, label: "Bin Size:"})
freqT = transpose(freq)

// reuses globalCalibRun (the shared header selector, _calib_run_selector.qmd)
freqT_filtered_Tgt = freqT.filter(function(freqData) {
    return vTripPurp == freqData.TRIPPURP &&
    vTlfType == freqData.TLFTYPE &&
    freqData.calib_run === globalCalibRun &&
    'freqTgt' == freqData.freqSource;
})

freqT_filtered_Mod = freqT.filter(function(freqData) {
    return vTripPurp == freqData.TRIPPURP &&
    vTlfType == freqData.TLFTYPE &&
    freqData.calib_run === globalCalibRun &&
    'freqMod' == freqData.freqSource;
})
dataX = transpose(xvals)
filtered_x = dataX.filter(function(dx) {
    return vTripPurp == dx.Purpose &&
           vTlfType == dx.TLF;
})

maxXValue = filtered_x.length > 0 ? Math.max(...filtered_x.map(function(dx){
    return dx.maxX;
})) : 120;
minXValue = filtered_x.length > 0 ? Math.max(...filtered_x.map(function(dx){
    return dx.minX;
})) : 0;
colorScale = d3.scaleOrdinal()
  .domain(["Model", "Target"])
  .range([themeModel, themeTarget]);
key2 = Legend(colorScale, {title: "Data Source"})
  • Frequency
  • Cumulative
Plot.plot({
  grid: true,
  height: 420,
  x: {
    label: 'Distance / Cost',
    domain: [minXValue, maxXValue]
  },
  y: {
    label: 'Frequency',
    tickFormat: f => `${Math.round(f * 100)}%`
  },
  marginRight: 40,
  marginLeft: 60,
  marginTop: 40,
  marginBottom: 50,
  style: { fontSize: 15, fontFamily: "Inter, system-ui, sans-serif" },
  marks: [
    Plot.line(freqT_filtered_Mod, Plot.groupX({y: "sum"}, {
      x: d => Math.floor(d.BIN / binSize) * binSize,
      y: 'freq',
      stroke: themeModel,
      strokeWidth: 3
    })),
    Plot.line(freqT_filtered_Tgt, Plot.groupX({y: "sum"}, {
      x: d => Math.floor(d.BIN / binSize) * binSize,
      y: 'freq',
      stroke: themeTarget,
      strokeWidth: 3
    })),
    Plot.frame({ strokeOpacity: 0.2 })
  ]
});
Figure 2: Trip Length Frequency

Per-bin frequency can be jagged, especially for lower-volume purposes – the cumulative view below smooths past that bin-to-bin noise and makes it easy to compare, e.g., median trip length between Model and Target directly. Computed at the native bin resolution (not the Frequency tab’s adjustable Bin Size, which is a histogram-smoothing control a monotonic cumulative curve doesn’t need).

Curves start above 0% at the left edge (the intrazonal cutoff) rather than at 0% – some purposes (e.g. Light CV - Door-to-Door) have a large intrazonal share, excluded from this chart same as the Frequency tab; the starting height is that excluded share, so the curve still reaches 100% without rescaling the shape of the retained (non-intrazonal) distribution.

function toCdf(rows) {
  const sorted = [...rows].sort((a, b) => a.BIN - b.BIN);
  // freq was normalized against this purpose's full total before intrazonal
  // trips were filtered out of `rows` (see dfObsModelMerge4p) -- so the
  // retained bins' freq no longer sums to 1. The gap is exactly the
  // intrazonal share, already "complete" before this chart's own left edge
  // (minXValue, the intrazonal cutoff), so start the curve there instead of
  // at 0.
  const offset = 1 - d3.sum(sorted, d => d.freq);
  let cum = offset;
  const points = sorted.map(d => {
    cum += d.freq;
    return { BIN: d.BIN, cumFreq: cum };
  });
  return [{ BIN: minXValue, cumFreq: offset }, ...points];
}

cdfMod = toCdf(freqT_filtered_Mod)
cdfTgt = toCdf(freqT_filtered_Tgt)

Plot.plot({
  grid: true,
  height: 420,
  x: {
    label: 'Distance / Cost',
    domain: [minXValue, maxXValue]
  },
  y: {
    label: 'Cumulative Frequency',
    domain: [0, 1],
    tickFormat: f => `${Math.round(f * 100)}%`
  },
  marginRight: 40,
  marginLeft: 60,
  marginTop: 40,
  marginBottom: 50,
  style: { fontSize: 15, fontFamily: "Inter, system-ui, sans-serif" },
  marks: [
    Plot.ruleY([0.5], { stroke: themeNeutral, strokeOpacity: 0.4, strokeDasharray: "4,4" }),
    Plot.line(cdfMod, { x: "BIN", y: "cumFreq", stroke: themeModel, strokeWidth: 3 }),
    Plot.line(cdfTgt, { x: "BIN", y: "cumFreq", stroke: themeTarget, strokeWidth: 3 }),
    Plot.frame({ strokeOpacity: 0.2 })
  ]
});
(a)
(b)
(c)
(d)
Figure 3: Cumulative Trip Length Frequency

Intrazonal Trip Percentage

// reuses globalCalibRun (the shared header selector, _calib_run_selector.qmd)
dataIntraT = transpose(dataIntra).filter(d => d.calib_run === globalCalibRun)
  • Plot
  • Table
intraPurposes = [...new Set(dataIntraT.map(d => d.PURP))]

intraRows = intraPurposes.map(p => {
  const model = dataIntraT.find(d => d.PURP === p && d.DataSource === "Model");
  const target = dataIntraT.find(d => d.PURP === p && d.DataSource === "Target");
  const modelVal = model ? model.pct_intrazonal : null;
  const targetVal = target ? target.pct_intrazonal : null;
  return {
    PURP: p,
    PurposeLabel: p.replace(/^\d+-/, ""),
    Model: modelVal,
    Target: targetVal,
    pctDiff: (modelVal != null && targetVal != null && targetVal !== 0) ? (modelVal - targetVal) / targetVal : null
  };
}).filter(d => d.Model != null || d.Target != null);

intraLinkRows = intraRows.filter(d => d.Model != null && d.Target != null);

// Headroom for the +/-% labels past the rightmost dot.
intraXMax = (d3.max(intraRows, d => Math.max(d.Model ?? 0, d.Target ?? 0)) || 1) * 1.15

// 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 row where Model already reads to the right of Target.
intraNearZero = intraXMax * 0.18
intraLabelsRight = intraRows.filter(d => d.pctDiff != null && (d.Model >= d.Target || d.Model < intraNearZero))
intraLabelsLeft = intraRows.filter(d => d.pctDiff != null && d.Model < d.Target && d.Model >= intraNearZero)

Plot.plot({
  y: {domain: intraPurposes, label: null, tickFormat: p => intraRows.find(d => d.PURP === p)?.PurposeLabel ?? p},
  x: {domain: [0, intraXMax], tickFormat: "s", grid: true, label: "% Intrazonal"},
  color: {
    domain: ["Model", "Target"],
    range: [themeModel, themeTarget],
    legend: true
  },
  style: {fontSize: 16, fontFamily: "Inter, system-ui, sans-serif"},
  marginTop: 10,
  marginBottom: 40,
  marginRight: 55,
  marginLeft: 115,
  height: Math.max(intraPurposes.length * 28, 80),
  marks: [
    Plot.link(intraLinkRows, {x1: "Model", x2: "Target", y: "PURP", stroke: themeLinkGrey, strokeWidth: 3}),
    // Target drawn first, Model second, so the Model dot's ring always sits
    // on top where the two overlap -- same convention as the CVM Update
    // page's Base-Model-vs-Final dumbbells.
    Plot.dot(intraRows.filter(d => d.Target != null), {
      x: "Target", y: "PURP", fill: themeTarget, r: 7,
      title: d => `${d.PurposeLabel}\nTarget: ${d3.format(",.1f")(d.Target)}%`
    }),
    Plot.dot(intraRows.filter(d => d.Model != null), {
      x: "Model", y: "PURP", fill: themeModel, r: 5.5,
      title: d => `${d.PurposeLabel}\nModel: ${d3.format(",.1f")(d.Model)}%`
    }),
    Plot.text(intraLabelsRight, {
      x: "Model", y: "PURP", text: d => d3.format("+.1%")(d.pctDiff),
      dx: 9, textAnchor: "start", fontSize: 11, fontWeight: 600,
      fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
    }),
    Plot.text(intraLabelsLeft, {
      x: "Model", y: "PURP", text: d => d3.format("+.1%")(d.pctDiff),
      dx: -9, textAnchor: "end", fontSize: 11, fontWeight: 600,
      fill: d => Math.abs(d.pctDiff) > 0.1 ? themeDanger : themeNeutral
    }),
    Plot.ruleX([0])
  ]
})
(a)
(b)
(c)
(d)
(e)
(f)
(g)
(h)
Figure 4: Intrazonal trip percentage between model and target.
Inputs.table(dataIntraT)

Productions & Attractions

dc_final = {
  const data = transpose(trips_master_data).filter(d => d.calib_run === globalCalibRun);
  return data.map(d => ({
    ...d,
    tmeMod: d.mod,
    tmeObs: d.obs,
    tmeErrorPct: d.obs > 0 ? (d.mod - d.obs) / d.obs : 0
  })).filter(d => d.tmeObs > 0 || d.tmeMod > 0);
}

// 2. Selectors
viewof vGeo_final = Inputs.select(["District", "County"], {label: "Geography Level:", value: "District"})

// This dropdown updates its options based on what's available in the data
viewof vPurp_final = Inputs.select(
  Array.from(new Set(dc_final.map(d => d.PURP))).sort(),
  {label: "Trip Purpose:"}
)

// 3. Final Filtered Data
dc_filtered_final = dc_final.filter(d => d.Geo === vGeo_final && d.PURP === vPurp_final)

chartMax_final = d3.max(dc_filtered_final, d => Math.max(d.tmeObs, d.tmeMod)) * 1.05
// CHART 4a: Model vs Observed Trips
Plot.plot({
  grid: true,
  width: 460,
  height: 380,
  marginRight: 50,
  caption: html`<h4>4a. ${vPurp_final} Trips (${vGeo_final} Level)</h4>`,
  x: { label: "Target Trips", domain: [0, chartMax_final] },
  y: { label: "Modeled Trips", domain: [0, chartMax_final] },
  marks: [
    Plot.link([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
      x1: 0, y1: 0,
      x2: (k) => (k <= 1 ? chartMax_final : chartMax_final / k),
      y2: (k) => (k <= 1 ? chartMax_final * k : chartMax_final),
      strokeOpacity: (k) => k === 1 ? 1 : 0.2,
      stroke: "gray",
      strokeWidth: (k) => k === 1 ? 2 : 1
    }),
    // Labels for Fan Lines
    Plot.text([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
      x: (k) => (k <= 1 ? chartMax_final : chartMax_final / k),
      y: (k) => (k <= 1 ? chartMax_final * k : chartMax_final),
      text: (k) => k === 1 ? "Equal" : d3.format("+.0%")(k - 1),
      textAnchor: "start", dx: 6, fill: "gray", fontSize: 10
    }),
    Plot.dot(dc_filtered_final, {
      x: "tmeObs", y: "tmeMod", r: 3.5,
      fill: themeModel, fillOpacity: 0.5,
      tip: true,
      title: (d) =>
        `${d.Geo}: ${d.p_Loc} to ${d.a_Loc}\n` +
        `Target: ${d3.format(",.0f")(d.tmeObs)}\n` +
        `Modeled: ${d3.format(",.0f")(d.tmeMod)}`
    }),
    Plot.linearRegressionY(dc_filtered_final, {
      x: "tmeObs", y: "tmeMod",
      stroke: themeModel, strokeDasharray: "4 4"
    })
  ]
})
// CHART 4b: Percent Error
Plot.plot({
  grid: true,
  width: 460,
  height: 380,
  caption: html`<h4>4b. ${vPurp_final} Percent Error</h4>`,
  x: { label: "Target Trips", domain: [0, chartMax_final] },
  y: { label: "Percent Error", domain: [-2, 2], tickFormat: d3.format(".0%") },
  marks: [
    Plot.ruleY([0], { stroke: "#000", strokeWidth: 1.5 }),
    Plot.dot(dc_filtered_final, {
      x: "tmeObs", y: "tmeErrorPct", r: 3.5,
      fill: themeModel, fillOpacity: 0.5,
      tip: true,
      title: (d) =>
        `${d.Geo}: ${d.p_Loc} to ${d.a_Loc}\n` +
        `Target: ${d3.format(",.0f")(d.tmeObs)}\n` +
        `Error: ${d3.format("+.1%")(d.tmeErrorPct)}`
    }),
    // The Validation "Staircase"
    Plot.line([[0, 2.0], [1, 2.0], [1, 1.0], [2.5, 1.0], [2.5, 0.5], [5, 0.5], [5, 0.25], [10, 0.25], [10, 0.20], [25, 0.20], [25, 0.15], [50, 0.15], [50, 0.10], [chartMax_final, 0.10]],
      { stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" }),
    Plot.line([[0, -2.0], [1, -2.0], [1, -1.0], [2.5, -1.0], [2.5, -0.5], [5, -0.5], [5, -0.25], [10, -0.25], [10, -0.20], [25, -0.20], [25, -0.15], [50, -0.15], [50, -0.10], [chartMax_final, -0.10]],
      { stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" })
  ]
})

HBW Destination Choice

Production & Attractions by Model

dc = {
  const modT = transpose(dist_sum_mod).filter(d => d.calib_run === globalCalibRun);
  const obsT = transpose(dist_sum_obs);

  return modT.map(m => {
    const o = obsT.find(d =>
      d.p_DistLrg === m.p_DistLrg &&
      d.a_DistLrg === m.a_DistLrg &&
      d.Source === m.Source
    );

    if (!o || o.total_trips === 0) return null;

    return {
      p_DistLrg: m.p_DistLrg,
      a_DistLrg: m.a_DistLrg,
      Source: m.Source,
      tmeMod: m.total_trips,
      tmeObs: o.total_trips,
      // Added Percent Error calculation for the second chart
      tmeErrorPct: (m.total_trips - o.total_trips) / o.total_trips
    };
  }).filter(d => d !== null);
}

// Source Selection
viewof vSource = Inputs.select(
  Array.from(new Set(dc.map(d => d.Source))).sort(),
  { label: "Model Source:" }
)

// Filtered data based on selection
dc_filtered = dc.filter(d => d.Source === vSource)

// Shared Max for the Scatter Plot axes
chartMax = d3.max(dc_filtered, d => Math.max(d.tmeObs, d.tmeMod))
// CHART 1a: Model vs Observed Trips
Plot.plot({
  grid: true,
  width: 460,
  height: 380,
  marginRight: 50,
  caption: html`<h4>1a. Model vs Target Trips</h4>`,
  x: { label: "Target Trips", domain: [0, chartMax] },
  y: { label: "Modeled Trips", domain: [0, chartMax] },
  marks: [
    // Fan Reference Lines (+/- 10% increments)
    Plot.link([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
      x1: 0, y1: 0,
      x2: (k) => (k <= 1 ? chartMax : chartMax / k),
      y2: (k) => (k <= 1 ? chartMax * k : chartMax),
      strokeOpacity: (k) => k === 1 ? 1 : 0.2,
      stroke: "gray",
      strokeWidth: (k) => k === 1 ? 2 : 1
    }),
    // Labels for Fan Lines
    Plot.text([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
      x: (k) => (k <= 1 ? chartMax : chartMax / k),
      y: (k) => (k <= 1 ? chartMax * k : chartMax),
      text: (k) => k === 1 ? "Equal" : d3.format("+.0%")(k - 1),
      textAnchor: "start", dx: 6, fill: "gray", fontSize: 10
    }),
    // Data Points
    Plot.dot(dc_filtered, {
      x: "tmeObs",
      y: "tmeMod",
      r: 3, // Slightly larger dots are easier to hover
      fill: themeModel,
      fillOpacity: 0.5,
      // Add Tooltip data here:
      tip: true,
      title: (d) =>
        `District: ${d.p_DistLrg} to ${d.a_DistLrg}\n` +
        `Target: ${d3.format(",.0f")(d.tmeObs)}\n` +
        `Modeled: ${d3.format(",.0f")(d.tmeMod)}`
    }),
    // Regression Line
    Plot.linearRegressionY(dc_filtered, {
      x: "tmeObs", y: "tmeMod",
      stroke: themeModel, strokeDasharray: "4 4"
    })
  ]
})
// CHART 1b: Percent Error
Plot.plot({
  grid: true,
  width: 460,
  height: 380,
  caption: html`<h4>1b. Model vs Target Percent Error</h4>`,
  x: { label: "Target Trips", domain: [0, chartMax] },
  y: { label: "Percent Error", domain: [-2, 2], tickFormat: d3.format(".0%") },
  marks: [
    Plot.ruleY([0], { stroke: "#000", strokeWidth: 1.5 }),
    Plot.dot(dc_filtered, {
      x: "tmeObs",
      y: "tmeErrorPct",
      r: 3,
      fill: themeModel,
      fillOpacity: 0.5,
      // Add Tooltip data here:
      tip: true,
      title: (d) =>
        `District: ${d.p_DistLrg} to ${d.a_DistLrg}\n` +
        `Target: ${d3.format(",.0f")(d.tmeObs)}\n` +
        `Error: ${d3.format("+.1%")(d.tmeErrorPct)}`
    }),
    // The Validation "Staircase" (Targets)
    Plot.line([[0, 1.0], [500, 1.0], [500, 0.5], [1000, 0.5], [1000, 0.25], [chartMax, 0.25]],
      { stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" }),
    Plot.line([[0, -1.0], [500, -1.0], [500, -0.5], [1000, -0.5], [1000, -0.25], [chartMax, -0.25]],
      { stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" })
  ]
})

Production & Attractions by Vehicle Ownership & Income Category

vincs_list = Array.from(
  new Set(transpose(dist_sum_obs2).map(d => String(d.veh_inc).replace(/^HBW_/, "")))
).sort()

// Model Type Selection
viewof vSource_2 = Inputs.select(
  ["Destination Choice", "Gravity"],
  { label: "Model Type:", value: "Destination Choice" }
)

// Observed Data Selection
viewof vRemoveOutliers_2 = Inputs.toggle({
  label: "Remove outliers",
  value: true
})

// 2. DATA PROCESSING (Matches selected model source to selected observed data)
dc_2 = {
  const modT2 = transpose(dist_sum_mod2).filter(d => d.calib_run === globalCalibRun);
  const obsT2 = vRemoveOutliers_2
    ? transpose(dist_sum_obs2_no_outliers)
    : transpose(dist_sum_obs2);

  return modT2.map(m => {
    const o = obsT2.find(d =>
      d.p_DistLrg === m.p_DistLrg &&
      d.a_DistLrg === m.a_DistLrg &&
      d.veh_inc === m.veh_inc
    );

    if (!o || o.total_trips === 0) return null;

    return {
      p_DistLrg: m.p_DistLrg,
      a_DistLrg: m.a_DistLrg,
      veh_inc: m.veh_inc,
      veh_inc_clean: String(m.veh_inc).replace(/^HBW_/, ""),
      Source: m.Source,
      tmeMod: m.total_trips,
      tmeObs: o.total_trips,
      tmeErrorPct: (m.total_trips - o.total_trips) / o.total_trips
    };
  }).filter(d => d !== null);
}

// 3. FINAL FILTERING
dc_filtered_2 = dc_2.filter(d => d.Source === vSource_2)

// 4. NOTEBOOK-MATCHED METRICS
slope_through_origin = (rows) => {
  const denominator = d3.sum(rows, d => d.tmeObs * d.tmeObs);
  return denominator > 0
    ? d3.sum(rows, d => d.tmeObs * d.tmeMod) / denominator
    : 0;
}

wmape = (rows) => {
  const totalObs = d3.sum(rows, d => d.tmeObs);
  return totalObs > 0
    ? d3.sum(rows, d => Math.abs(d.tmeMod - d.tmeObs)) / totalObs
    : 0;
}

segment_summaries = vincs_list.map(seg => {
  const rows = dc_filtered_2.filter(d => d.veh_inc_clean === seg);
  return {
    seg,
    rows,
    slope: slope_through_origin(rows),
    wmape: wmape(rows),
    maxVal: d3.max(rows, d => Math.max(d.tmeObs, d.tmeMod)) || 1
  };
})

global_wmape_2 = wmape(dc_filtered_2)
avg_slope_2 = d3.mean(segment_summaries.filter(d => d.rows.length > 1), d => d.slope) || 0
obs_label_2 = vRemoveOutliers_2 ? "No Outliers" : "Standard"
segment_chart = (summary) => {
  const rows = summary.rows;
  const maxVal = summary.maxVal;
  const axisMax = maxVal * 1.1;
  const fanRatios = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3];
  const titleText = rows.length
    ? `${summary.seg} | Slope: ${d3.format(".2f")(summary.slope)} | Error: ${d3.format(".1%")(summary.wmape)}`
    : `${summary.seg} (No Data)`;

  return html`<div class="hbw-segment-chart">
    <h4>${titleText}</h4>
    ${Plot.plot({
      grid: true,
      width: 400,
      height: 315,
      marginLeft: 50,
      marginBottom: 42,
      x: { label: "Target Trips", domain: [0, axisMax] },
      y: { label: "Mod Trips", domain: [0, axisMax] },
      marks: [
        Plot.link(fanRatios, {
          x1: 0,
          y1: 0,
          x2: maxVal,
          y2: (r) => maxVal * r,
          stroke: "gray",
          strokeOpacity: (r) => r === 1 ? 1 : 0.25,
          strokeWidth: (r) => r === 1 ? 2 : 1,
          strokeDasharray: (r) => r === 1 ? null : "2 2"
        }),
        Plot.dot(rows, {
          x: "tmeObs",
          y: "tmeMod",
          r: 3,
          fill: themeModel,
          fillOpacity: 0.6,
          tip: true,
          title: (d) =>
            `District: ${d.p_DistLrg} to ${d.a_DistLrg}\n` +
            `Target: ${d3.format(",.0f")(d.tmeObs)}\n` +
            `Modeled: ${d3.format(",.0f")(d.tmeMod)}`
        }),
        Plot.line(
          [
            { x: 0, y: 0 },
            { x: maxVal, y: summary.slope * maxVal }
          ],
          {
            x: "x",
            y: "y",
            stroke: themeModel,
            strokeWidth: 2,
            strokeDasharray: "4 4"
          }
        )
      ]
    })}
  </div>`;
}

html`<div>
  <style>
    .hbw-segment-summary {
      font-weight: 600;
      margin: 0.75rem 0 1rem 0;
    }
    .hbw-segment-grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(360px, 1fr));
      gap: 0.75rem;
      align-items: start;
    }
    .hbw-segment-chart h4 {
      font-size: 0.95rem;
      margin: 0 0 0.25rem 0;
      line-height: 1.25;
    }
    @media (max-width: 960px) {
      .hbw-segment-grid {
        grid-template-columns: 1fr;
      }
    }
  </style>
  <div class="hbw-segment-summary">
    Run: ${vSource_2} | Target: ${obs_label_2} | Global WMAPE: ${d3.format(".1%")(global_wmape_2)} | Avg Slope: ${d3.format(".2f")(avg_slope_2)}
  </div>
  <div class="hbw-segment-grid">
    ${segment_summaries.map(segment_chart)}
  </div>
</div>`
Trip Generation
Mode Choice

© 2025 Wasatch Front Regional Council

  • Contact

  • License