This file gives a future Claude session (or a returning one) full context on what this project is, what has been built, what decisions were made and why, and exactly where to pick up work. Read this before touching anything.
A two-part body of work for WFRC/MAG:
Part 1 — The framework itself (this repo, m:\GitHub\WF-TDM-Runs): a working
Python-based GitHub repository that manages Travel Demand Model (TDM) run sets —
sensitivity tests, project alternatives, validation runs, forecasting scenarios —
against a Cube Voyager TDM connected as a git submodule. Built and documented;
tdm/ now points at the real TDM (see "What's currently mocked vs. real"),
which surfaced a real-vs-mock incompatibility the test suite doesn't cover yet.
Part 2 — A presentation (_dev/tdm-run-management-framework-proposal.qmd +
_dev/styles.css): a Quarto RevealJS slide deck proposing the framework to
WFRC/MAG's analytics group, targeting pilot approval. 15 slides, WFRC brand
colors, designed for a mixed audience of analysts, developers, and leadership.
The immediate trigger was a 14-scenario non-motorized sensitivity study that took over a month — manually configuring each Control Center file, coordinating SE data inputs, tracking which model version ran which scenario, and reassembling scattered outputs for reporting. The framework was designed so that study could be repeated in a fraction of the time with a complete, publishable record.
Sits around the existing TDM (never modifies it). For each scenario run:
- Validates all config and override keys before touching anything
- Resolves and checks out the requested TDM git tag in the submodule — refuses on a dirty working tree
- Loads a baseline Control Center file from the TDM's
Scenarios/_default/library, layers run_set overrides then scenario overrides on top (resolving anyinput_filesrelative paths to absolute), fills in orchestrator-computed identity/path fields, writes_ControlCenter.yamlinto a fresh run folder - Invokes the TDM's fixed batch entry point with the control file path and scenario folder path as arguments
- Inventories all outputs, copies only the glob-selected subset (hard 100 MB/file ceiling) into the repo
- Writes
run_metadata.json— the source of truth for reporting
The TDM codebase, the _default/ library, how Cube Voyager runs internally,
or the Scenarios/ gitignored working folder convention.
- The TDM is Cube Voyager, run via one fixed batch entry point per version,
taking exactly two arguments: a Control Center file path and a scenario folder
path. That calling convention is captured in
config/framework.yamlexecution:— not hardcoded. - The
_ControlCenter.yamlthe orchestrator writes is plain YAML. The baseline.blockfiles it reads from the defaults library were assumed to also be plain YAML with a.blockextension — confirmed true for the mock TDM, but not true for the real TDM (see "What's currently mocked vs. real" below): the real1ControlCenter - BY_2019.blockis Cube Voyager's native indentedKEY = valueblock format with;comments, not YAML.controlcenter.py'sload_baseline()callsyaml.safe_load()on it directly and fails. This is the current top blocker for running anything through the CLI against the real TDM. - Input file selection (e.g.
WFRC_SEFile) and sensitivity knobs (e.g.HOT_Toll_Min) are both just keys in the same flat YAML file. There is exactly one override mechanism, not two. - Raw model outputs can be tens of gigabytes. They stay in the gitignored
Scenarios/working folder. Only small, deliberately selected files (CSVs, logs) get curated into the framework repo. - Cube Voyager is licensed per machine. Execution happens on a researcher's workstation or on-prem server. GitHub Actions is scoped to validation and reporting only — never model execution.
wf-tdm-runs/
├── tdm/ ← TDM git submodule (real TDM; see "What's currently mocked vs. real")
├── config/
│ ├── framework.yaml ← global settings
│ ├── local.example.yaml ← copy to local.yaml (gitignored) per machine
│ └── schemas/ ← JSON Schema for run_set, scenario, run_metadata
├── run_sets/
│ └── <run_set_id>/
│ ├── run_set.yaml ← config: shared tdm_ref/baseline/overrides
│ ├── scenarios/ ← config: one YAML file per scenario
│ │ └── <scenario_id>.yaml
│ ├── inputs/ ← prepped input files (e.g. SE CSVs); committed, not gitignored
│ ├── input_prep.ipynb ← input preparation notebook (committed; optional)
│ ├── <scripts folder>/ ← optional custom driver script (declared via driver_script
│ │ in run_set.yaml/scenario.yaml, e.g. hail-mary/), staged
│ │ (that one file, own filename kept) into the per-run
│ │ scenario folder every run -- falling back to the TDM's
│ │ own default driver script when not declared; any
│ │ companion/modified step scripts stay here and are
│ │ referenced from it by relative path (see ADR 0007)
│ ├── report_snapshot_script (declared in run_set.yaml, optional) ← freezes
│ │ this run set's report data before retirement
│ └── snapshot/ ← generated by `tdmruns snapshot-run-set`; small,
│ committed CSVs a retired run set's reports read
│ once runs/ curated outputs are purged
├── runs/ ← committed metadata + curated outputs only, whether
│ │ gathered by a CLI-driven run or import-manual-run(-set)
│ └── <run_set_id>/<scenario_id>/<run_id>/
│ ├── run_metadata.json ← execution_mode: "cli" or "manual"
│ └── outputs/
├── reports/ ← Quarto website
│ ├── _quarto.yml
│ ├── index.qmd ← auto-discovers CLI-run run sets from runs/;
│ │ run sets with custom pages (e.g. non-motorized-2023)
│ │ are linked in manually instead
│ ├── report_data.py ← shared data helpers (reads runs/ metadata)
│ ├── chart_utils.py ← shared Plotly chart styling (see below)
│ └── run_sets/
│ ├── <run_set_id>.qmd ← generic per-run-set page, data-driven from runs/
│ └── <run_set_id>/ ← custom per-run-set pages (e.g. slides.qmd +
│ summary.qmd for non-motorized-2023), reading the
│ latest curated outputs via report_data.py and
│ applying any report-specific filtering at render
│ time rather than pre-filtering a committed copy
├── src/tdmruns/ ← orchestrator CLI
│ ├── cli.py
│ ├── config.py
│ ├── controlcenter.py
│ ├── submodule.py
│ ├── execution.py
│ ├── outputs.py
│ ├── metadata.py
│ └── retirement.py ← snapshot-run-set / purge-run-set-outputs logic
├── bin/
│ └── RunModel.bat ← fixed batch entry point (config/framework.yaml
│ execution.entry_point); TDM-version-independent,
│ deliberately lives here and not in tdm/ (see
│ "What's currently mocked vs. real")
├── scripts/
│ ├── check_file_sizes.py ← CI backstop for 100 MB ceiling
│ └── validate_run_metadata.py ← CI schema + checksum validation
├── .github/workflows/
│ ├── validate-config.yml
│ ├── validate-run-metadata.yml
│ └── publish-report.yml
├── _dev/ ← presentation source (not part of the framework)
│ ├── tdm-run-management-framework-proposal.qmd
│ └── styles.css
├── tests/ ← pytest suite; fixtures currently stale against
│ the real submodule, see "What's currently mocked vs. real"
├── docs/architecture/ ← 6 ADRs
└── pyproject.toml
Every reports/run_sets/<id>/slides.qmd should call
chart_utils.use_slide_chart_defaults() once, in its setup cell (see
bring-work-trips-closer-to-home/slides.qmd or non-motorized-2023/slides.qmd
for the exact pattern: sys.path.insert(0, os.path.join('..', '..')) then
from chart_utils import use_slide_chart_defaults; use_slide_chart_defaults()).
This registers a Plotly template moving the legend to a horizontal band at
the top-left by default — Plotly Express's own default (top-right, outside
the plot) collides with Plotly's modebar icons (also top-right) on a
RevealJS deck's narrow, fixed-size canvas. This collision was independently
hand-fixed per-chart twice (non-motorized-2023, then
bring-work-trips-closer-to-home) before being centralized here — any new
slides.qmd should call this instead of re-discovering the same fix. A chart
needing a different offset (e.g. a taller multi-line title) can still pass
its own legend=dict(y=...) on top of the template's default; an explicit
per-chart value always wins over the template. summary.qmd pages (full-width
HTML, not a fixed slide canvas) don't have this collision and don't need it.
pip install -e .
tdmruns validate-config # validate all run_sets
tdmruns validate-config --run-set <id> # validate one run_set
tdmruns sync-tdm --run-set <id> # sync the submodule to tdm_ref
tdmruns sync-tdm --run-set <id> --scenario <id> # ...or a scenario's tdm_ref override
tdmruns prep-scenario --run-set <id> --scenario <id> # run prep_script only, no model execution
tdmruns run-set --run-set <id> # run all scenarios
tdmruns run-scenario --run-set <id> --scenario <id> # run one scenario
tdmruns run-scenario ... --force # re-run even if already successful
tdmruns import-manual-run --run-set <id> --scenario <id> [--scenario-folder <path>]
# curate outputs for a scenario run
# outside the CLI (see below)
tdmruns import-manual-run-set --run-set <id> # same, for every scenario in a run
# set (see below for folder resolution)
tdmruns snapshot-run-set --run-set <id> # freeze a report snapshot (see below)
tdmruns purge-run-set-outputs --run-set <id> # delete curated outputs once retired
tdmruns status # show latest result per scenariosync-tdm actually mutates the submodule (git fetch + checkout) to match
whatever tdm_ref is declared in config — it's not a dry-run preview. It
refuses on a dirty submodule tree, same guard run-scenario uses internally
before rendering anything.
import-manual-run(-set) exists because a scenario can be run manually
(Cube Voyager invoked directly, outside run-scenario) when a real CLI-driven
run isn't possible yet (see the .block-format blocker below) or isn't
desired. It applies the scenario's outputs.include selection and size
ceiling exactly like a real run would, curates into runs/<run_set>/<scenario>/ <run_id>/outputs/, and records run_metadata.json with execution_mode: "manual". --scenario-folder defaults to the scenario's declared
manual_scenario_folder (relative to the TDM submodule root) when omitted,
falling back further to the scenario_folder_template convention
(Scenarios/<run_set_id>/<scenario_id>) already used for CLI-driven runs if
the scenario doesn't declare one at all — a scenario whose raw folder happens
to follow that naming (e.g. bring-work-trips-closer-to-home's Closer00–Closer09)
doesn't need manual_scenario_folder declared; one is still required when the
raw folder's name departs from it (e.g. non-motorized-2023's
BY_2019_SensitivityTest_NN naming — see below).
It does not check out, fetch, or otherwise touch the TDM submodule — only
its current state is read for the record. There is no skip-if-unchanged
logic and no --force: every invocation creates a fresh timestamped run,
since running the command at all is already the deliberate signal to
(re-)gather outputs — the alternative (guessing staleness from the raw
folder's mtime) was tried and dropped as unnecessary complexity.
runs/ bloats as run_sets accumulate curated outputs — non-motorized-2023
alone was 491 MB across 13 scenarios, almost all of it in per-scenario
*_ZoneSummary_TripsByMode.csv files (~39 MB each) that the reports filter
down to a handful of columns/rows at render time. Once a run_set is done and
won't be re-run, most of that is redundant with what its reports actually
display. "If we need the data, we run the models again" is the accepted
tradeoff — purge-run-set-outputs deletes real, currently-committed files.
Two steps, deliberately separate (the first is safe and repeatable; the second is the irreversible-ish one):
tdmruns snapshot-run-set --run-set <id>— invokes the run set's declaredreport_snapshot_script(a plain Python script, subprocess-invoked exactly likeprep_script, with--run-set-dirand--snapshot-dirarguments), which reads whatever that run set's reports need fromruns/and writes small CSVs intorun_sets/<id>/snapshot/. Safe to re-run; overwrites any existing snapshot; deletes nothing. Re-render the reports afterward (they automatically prefer the snapshot once one exists — see below) to confirm they still match before ever purging.tdmruns purge-run-set-outputs --run-set <id>— refuses unlessrun_sets/<id>/snapshot/already exists and is populated. Deletes everyruns/<id>/**/outputs/directory's contents and marks each run'srun_metadata.jsonwithoutputs.retired: true/outputs.retired_at. The metadata JSON itself (TDM ref, overrides, checksums) is never deleted — it's the permanent, tiny audit trail of what once existed, even after the bytes are gone.scripts/validate_run_metadata.pyskips the on-disk checksum check for runs marked retired.
Reports don't get an "if retired" branch of their own — that's centralized
once in each run_set's own loader module, which checks
report_data.is_retired(run_set_id) (true once snapshot/ is populated) and
reads the frozen CSVs instead of live runs/ output when so. See
run_sets/non-motorized-2023/report_loader.py /
report_snapshot.py for the reference implementation: report_loader.py
factors out logic that used to be duplicated verbatim between
summary.qmd and slides.qmd, and its two leaf I/O functions
(load_scenario/load_se) are the only retirement-aware part — everything
else (aggregation, deltas, chart-ready tables) is unchanged and shared.
Known limitation: this mechanism only freezes what a run set's reports
read from runs/. Static reference data reports read directly from the
(gitignored) tdm/ working tree — non-motorized-2023's base-year (test_id 0)
CSV and the TAZ/district shapefiles — is always read live, never frozen. That
isn't runs/ bloat, so it's out of scope here, but it means a retired
report's base-year numbers and geography could in principle drift if the
tdm/ submodule later moves to a different ref for unrelated work. A future
extension could have report_snapshot_script freeze those pieces too if full
permanence is ever needed.
baseline .block file → run_set overrides + run_set input_files
→ scenario overrides + scenario input_files
→ local.yaml (machine values) → orchestrator identity fields
(ScenarioName, ScenarioDir, ParentDir — always win, always computed)
input_files entries in run_set or scenario YAML are relative file paths
(e.g. inputs/SE_S01.csv) resolved to absolute paths against the run_set
directory at runtime. This keeps scenario YAMLs machine-independent.
Every override key (including resolved input_files) is validated against the chosen baseline before execution. An unknown key is a hard failure before the model is touched.
tdm/submodule is now connected to the real TDM repo (https://github.com/WFRCAnalytics/WF-TDM-Development.git) — no longer the local mock.bin/RunModel.bat— the fixed batch entry pointconfig/framework.yamlexecution.entry_pointpoints at — now exists, and deliberately lives in this framework repo, not thetdm/submodule: it's a thin, TDM-version- independent wrapper that locates whatever driver script the orchestrator already staged into the scenario folder (glob on*.s, not a hardcoded_HailMaryname, matching the ADR 0007 assumption) and runs it through Cube Voyager,pushd-ing into the scenario folder first so the driver script's relativeREAD FILE = '..\..\..\2_ModelScripts\...'paths resolve correctly, then propagates Voyager's exit code back out. Voyager's install location is machine-local, not hardcoded in the bat file — it's read fromconfig/local.yaml's existingVoyager_EXEkey and passed through byexecution.invoke()as theVOYAGER_EXEenvironment variable; the bat file fails loudly if that's unset or points at a nonexistent path. Because of this,build_command()(src/tdmruns/execution.py) now resolvesexecution.entry_pointagainstrepo_root, nottdm_path. Still unconfirmed end-to-end — blocked on the next bullet, since the driver script's hardcodedREAD FILE = '0GeneralParameters.block'/'1ControlCenter.block'won't find anything in the scenario folder until that's fixed.control_center_defaults_dirinconfig/framework.yamlisScenarios/_default(singular, not_defaultsas earlier drafts of this doc said) — verified against the real submodule, which hastdm/Scenarios/_default/.- Blocker (still open):
tdmruns validate-configfails against the real TDM —1ControlCenter - BY_2019.blockin the real defaults library is Cube Voyager's native block format, not YAML (see the constraints note above).cli.py/controlcenter.pyhaven't been updated for this yet, so no real scenario has been run throughrun-scenario/run-set. Relatedly,controlcenter.write_block_file()currently writes plain YAML to_ControlCenter.yaml, but the driver script expects Cube block syntax in a file literally named1ControlCenter.block(plus a0GeneralParameters.blockneither this function nor anything else currently stages into the scenario folder) — fixing the read side (load_baseline()) without also fixing the write side won't be enough to actually run Cube. Until it's fixed, new run sets are executed manually (Cube Voyager invoked directly) and their outputs gathered withtdmruns import-manual-run(-set)(see CLI commands above) — a first-class, supported path now, not a one-off workaround. Seenon-motorized-2023below for the current example. - The test suite's fixtures still assume the old mock TDM layout (they try to
copy a
RunModel_stub.pythat no longer exists in the now-real submodule), sopytest tests/currently shows ~19 errors intest_config.py/test_integration.py/test_prep.py— pre-existing, unrelated to the block-file blocker above, and not something recent work introduced. - Quarto reporting (
reports/) renders successfully locally (quarto render reports/quarto preview reports) as of this session. GitHub Actions (publish-report.yml) installsgeopandas/plotlyand registers ajuly2025Jupyter kernel to match what the report.qmdfiles expect — not yet confirmed against a real GitHub Pages deploy.
- In-place sequential submodule checkout, not git worktrees — Cube runs in place inside its own checkout. Worktree isolation adds complexity for no immediate benefit. Deferred to a future PR if parallel execution is needed.
- One override mechanism —
_ControlCenter.yamlkeys are all just keys, whether they select input files or tune model parameters.input_filesin scenario YAML is syntactic sugar for file-path overrides with automatic path resolution; it merges into the same single override dict. - Driver script is staged every run, default or custom — a second, narrow
mechanism deliberately separate from overrides — every run stages a
driver script into its scenario folder: the TDM's own default
(
config/framework.yaml'sdefault_driver_script, currently_HailMary_1Subfolder.s, fromScenarios/_default/) unless a run_set or scenario declaresdriver_script(path to a custom copy, e.g.run_sets/<id>/hail-mary/_HailMary_1Subfolder_closer.s), staged keeping its own on-disk filename either way. The per-run scenario folder sits one directory level deeper below the TDM root thanScenarios/_default/does (Scenarios/<version>/<scenario_id>__<run_id>/vs.Scenarios/_default/) — exactly the depth_HailMary_1Subfolder.sis already written for (..\..\..\2_ModelScripts\..., three levels up, vs. plain_HailMary.s's two). Companion or modified step scripts are not auto-staged — they stay wherever the run_set keeps them and must be referenced from the custom driver script by a relative path computed back to that location. This swaps which code runs, not a parameter value, so it's not folded intooverrides/validate_overrides()— seedocs/architecture/0007-custom-driver-script.md.bin/RunModel.batnow exists and does glob for whatever driver script it finds staged in the scenario folder it's given (see "What's currently mocked vs. real" above); still unconfirmed end-to-end since it depends on the Control Center block-format blocker being fixed first. start_from_copyseeds a scenario's raw folder from a prior scenario's run — a third, narrow mechanism, orthogonal to overrides and driver scripts — a scenario may declarestart_from_copy: <scenario_id>(naming a sibling scenario in the same run set) to have its entire raw scenario folder copied from that scenario's most recent successful recorded run before this run's own Control Center/driver script are written — useful when a scenario's modification only affects a model step late in the pipeline, so upstream steps don't need to be recomputed. The source folder is resolved viametadata.latest_successful_run()'s recordedscenario_folder(works whether the source was run via the CLI or imported from a manual run), not a declaredmanual_scenario_folder— seedocs/architecture/0008-scenario-seeding.md.latest_successful_run()skips past any newer failed attempts to find the most recent success — an earlier version called plainlatest_run()and required that one to have succeeded, which wrongly blocked copying whenever a scenario's latest attempt failed for a reason unrelated to seeding (e.g. output curation tripping the size limit) even though an earlier attempt had succeeded. This mechanism only copies files; it never makes Cube Voyager skip a step — that's the analyst's owndriver_scriptlogic to write. Wired intorun-scenario/run-setonly (no standalone command), so it depends on the same block-format blocker as everything else routed through Cube, plus the source scenario needing a successful run first. Because the raw scenario folder is reused across every run attempt for a givenscenario_id(scenario_folder_templatehas norun_idcomponent), a scenario declaringstart_from_copyre-copies the source's entire folder — potentially tens of GB — on every one of its own retries too. A scenario may additionally declarelock_down_copy: trueonce its folder already holds the seeded state it needs, to suppress that repeated copy without discarding thestart_from_copydeclaration (kept as the record of where it was seeded from); it has no effect unlessstart_from_copyis also declared.- Input prep is manual, not automated — each run_set has an optional
input_prep.ipynbnotebook at its root that generates input files (e.g. SE CSVs) into itsinputs/folder. The framework does not run prep; analysts run it once before executing the run_set. - Curated outputs with a hard size ceiling — raw outputs stay gitignored.
Only a declared, glob-selected, size-checked subset enters the repo.
Checksums are computed only for that curated subset (at copy time), not for
every file the model produced — the full inventory (for the aggregate
count/byte-total in metadata) is stat()-only, since scenario folders
routinely hold thousands of files and tens of GB and nothing ever read the
per-file checksum for anything not selected (see
docs/architecture/0003-output-management.md's update note). - Success/failure is decided from the model's own completion log, not
Voyager's process exit code, when that log is available — reverses an
earlier deliberate choice.
src/tdmruns/model_log.pyparses<scenario_folder>\_Log\_RunTime.txt, written by the model scripts themselves via_TimeStamp_ModelSuccess.block/_TimeStamp_ModelCrashed.blockat:ENDMODEL/:ONERRORin the Hail Mary driver script. Real recorded runs ofbring-work-trips-closer-to-homeshowed this was necessary, not theoretical: Closer01's log shows two full, clean "TOTAL MODEL RUN TIME" completions with no crash marker, yet one of those attempts was recorded asstatus: "failed", exit_code: 1— Voyager's exit code disagreed with what the model itself reported it did. (The driver script also never callsExitafter:ONERROR, so the reverse — a crash that still exits 0 — is equally possible, not just the direction seen so far.)execution.py'sdecide_status()now prefers the log when a recognizable "TOTAL MODEL RUN TIME" entry is found for the current attempt (the file isAPPEND=Tand reused across every CLI-driven retry of a givenscenario_id, so only the text since the previous such entry — or file start — is read as this attempt's), and falls back to the exit code alone when no entry exists yet (e.g. Cube never started).run_metadata.json'sexecution.status_sourcerecords which signal won ("model_log"/"exit_code"), andexecution.model_logcarries the parsed outcome, crashed step (if any), and the model's own Beg/End/Run-Time strings — also the source for run-duration/crash-point detail in reports.execution.model_log.exit_code_mismatchistruewhenever the two signals disagreed, so a run can still be audited even though the log won. - Manual execution is a first-class path, not just a workaround —
import-manual-run(-set)curates outputs for a scenario run outside the CLI the same wayrun-scenariodoes after a real execution (same select/size-check/copy sequence), flattening curated files intooutputs/(no preserved subfolder structure) and taggingrun_metadata.jsonwithexecution_mode: "manual". It never touches the TDM submodule. It always creates a new run rather than trying to detect whether the raw folder changed since the last import (an mtime-based staleness check was tried and deliberately removed as unneeded complexity — every invocation is already a deliberate human action). - Flat JSON metadata as source of truth — one
run_metadata.jsonper run, committed, schema-versioned. No database. Quarto reads these directly. - CI scoped to validation and reporting — never model execution.
- Future capabilities (parallel runs, scheduled reruns, cross-version comparison, dashboards) are all deferred but attach cleanly to existing seams without redesign.
_dev/ has been deleted from the repo. The reason/outcome of the pilot
pitch isn't recorded here. The section below documents what the deck
contained for historical reference; none of these files exist anymore.
_dev/tdm-run-management-framework-proposal.qmd— 15-slide Quarto RevealJS deck_dev/styles.css— WFRC brand colors, must sit in the same folder as the.qmd
quarto render _dev/tdm-run-management-framework-proposal.qmd
# or for live preview:
quarto preview _dev/tdm-run-management-framework-proposal.qmd- Where we are today — non-motorized study anchor (14 scenarios, over a month)
- This isn't a process problem — it's a tooling gap
- The proposal — what the framework manages vs. what it doesn't touch
- How the two repositories relate — GitHub-level diagram (developer slide)
- Inside the framework repo — annotated folder tree (developer slide)
- It was built for our TDM specifically —
_defaults/, Control Center, batch entry point - What a run set looks like — example YAML configs (run_set.yaml + scenario.yaml)
- What running it looks like — incremental pipeline walkthrough
- What the record looks like — example
run_metadata.json - What gets published — GitHub Pages site structure and auto-discovery
- What changes for analysts — before/after comparison
- What stays the same — direct answer to "we already have a workflow"
- The pilot — scope, success criteria, what's required
- What we're asking for today — approval checklist
- Questions — anticipated objections with prepared answers
| Name | Hex | Used for |
|---|---|---|
| Navy | #1B3A5C |
Headings, body text, table headers, title |
| Teal | #1A8FAA |
Bold text, code borders, subtitle, footer |
| Amber | #F5A623 |
H2 underlines, blockquote border, progress bar |
| Light teal | #E8F4F8 |
Code backgrounds, table striping, blockquote bg |
- Base slide text: 80% of RevealJS default
- Bullet point text: 65% (of the already-scaled 80% base — effectively ~52%)
- Code blocks: 0.85em relative to base
Mixed: analysts/modelers + developers + some leadership. Two objections were anticipated and addressed directly in the deck:
- "We already have a workflow" — slide 12 ("What stays the same")
- "How do we know it works for our TDM?" — slide 6 (it was designed against our specific conventions) + slide 13 (that's what the pilot is for)
The ask is deliberately narrow: pilot approval for one run set, one lead analyst, 4–6 weeks, a review session at the end. Not a program commitment.
13-scenario non-motorized sensitivity study — repeat of the study that
originally took over a month. Renamed from non-motorized-2026 once the
actual model runs (base year 2019, results reported 2023) were completed and
folded into the repo; the old non-motorized-2026 run_set/report were deleted.
- TDM ref:
archive/non-motorized-sensitivity-tests(perrun_set.yamltdm_ref— notv1000-E3, which an earlier draft of this doc said). - Baseline:
1ControlCenter - BY_2019.block - Scenarios: S01–S13 (HH/EMP multipliers at smldst/smldst+taz scope, plus full SE_2050 and SE_2050_transit_corridors substitutions)
- SE prep: done —
run_sets/non-motorized-2023/inputs/SE_S01.csvthroughSE_S13.csvare already generated and committed (produced byinput_prep.ipynbat the run_set root). Each scenario'soverrides:pointsWFRC_SEFile/MAG_SEFilestraight at its file with a plain relative path — e.g.WFRC_SEFile: '..\..\..\run_sets\non-motorized-2023\inputs\SE_S01.csv'— rather than theinput_files:block (framework-resolved to an absolute path) these scenarios used until this session. The relative path works because the TDM's own model scripts readWFRC_SEFile/MAG_SEFileas a suffix appended to a fixed prefix (@ModelDir@\1_Inputs\2_SEData\, seetdm/2_ModelScripts/0_InputProcessing/b_SEProcessing/1_DemographicsAnalysis.s), so three..\climb back out of1_Inputs\2_SEData\to the repo root, the same relative-navigation trick_HailMary_1Subfolder.salready uses to reach2_ModelScripts\from the scenario folder. Note this is a plain single-quoted YAML scalar, not double-quoted — double quotes would treat\r(from..\run_sets\...) as a carriage-return escape and corrupt the path.input_files:itself is untouched as a mechanism (schema +config.pystill support it) for any run set that wants the absolute-path version; only non-motorized-2023 has stopped using it, for now. - Not run through
run-scenario/run-set. The block-file-parsing blocker above means these scenarios were run manually (Cube Voyager invoked directly, outside the framework). Each scenario YAML declares amanual_scenario_folder(e.g.Scenarios/non-motorized-2023/ BY_2019_SensitivityTest_01, relative to the TDM submodule root) pointing at that raw, gitignored output.run_set.yamlandS10.yaml/S11.yaml'soutputs.includeglob patterns are the real selection patterns — used both as documentation of what a CLI-driven run would curate, and as whatimport-manual-run(-set)actually applies today. - Outputs gathered via
tdmruns import-manual-run-set --run-set non-motorized-2023, which curates each scenario's raw, unfilteredoutputs.include-matched files intoruns/non-motorized-2023/S01–S13/ <run_id>/outputs/and writesrun_metadata.jsonwithexecution_mode: "manual". There used to be a separate, pre-filtered backfill underrun_sets/non-motorized-2023/data/outputs/(and a one-off script to produce it) — both were deleted once the reports were pointed atruns/directly;runs/is now the only copy of curated output for this run set. - Reporting pages (custom, not the generic
runs/-metadata-driven pattern):reports/run_sets/non-motorized-2023/slides.qmd(RevealJS deck) andsummary.qmd(detailed HTML writeup), both linked directly fromreports/index.qmd. Both importrun_sets/non-motorized-2023/report_loader.py(previously this data-loading/aggregation logic was duplicated verbatim between the two.qmdfiles; it's now factored into one shared module).report_loader.pycallsreport_data.py'slatest_run_per_scenario/curated_output_paths/is_retiredto resolve each scenario's most recently imported files (or, once this run set is retired, the frozenrun_sets/non-motorized-2023/snapshot/CSVs instead — see "Retiring a run set" above; this run set is the reference implementation for that mechanism, andreport_snapshot.pyis its declaredreport_snapshot_script, though it hasn't actually been retired/purged yet). The BY_2019 baseline (test_id 0) and the static TAZ/District shapefiles (tdm/1_Inputs/1_TAZ/...) are still read straight from the gitignoredtdm/working tree either way, since neither is scoped to a single scenario/run and isn't part of what retirement freezes (see the known limitation noted under "Retiring a run set").
SE files are referenced in scenario YAMLs directly under overrides:
(WFRC_SEFile/MAG_SEFile, relative paths like
'..\..\..\run_sets\non-motorized-2023\inputs\SE_S01.csv') — see the SE prep
bullet above for why, not via input_files.
Example run set for toll sensitivity testing. Scenarios defined, no runs executed.
In rough priority order:
1. Fix controlcenter.py's Control Center handling for the real TDM's .block format — both directions.
load_baseline() (src/tdmruns/controlcenter.py:23) calls yaml.safe_load()
directly on the baseline file, which works for the mock TDM but not the real
one — the real 1ControlCenter - BY_2019.block is Cube Voyager's native
indented KEY = value / ;-comment block format. This blocks
tdmruns validate-config and any real CLI-driven run. Separately,
write_block_file() writes plain YAML to a file named _ControlCenter.yaml,
but the driver script (READ FILE = '0GeneralParameters.block' /
'1ControlCenter.block', both relative to the scenario folder) expects Cube
block syntax under the literal name 1ControlCenter.block, plus a
0GeneralParameters.block that nothing currently stages into the scenario
folder at all (it's presumably an unmodified copy from Scenarios/_default/,
but that needs confirming). Fixing only the read side isn't enough to
actually run Cube end-to-end via bin/RunModel.bat (see "What's currently
mocked vs. real" above) — both sides need to agree on the real format before
a real scenario can run through run-scenario/run-set. Until it's fixed,
run sets have to be executed manually outside the framework and their outputs
gathered with tdmruns import-manual-run(-set), the way non-motorized-2023
is (declaring manual_scenario_folder per scenario).
2. Fix the test suite's fixtures.
tests/ fixtures still assume the old mock TDM layout (copying a
RunModel_stub.py that no longer exists now that tdm/ points at the real
repo) — ~19 errors in test_config.py / test_integration.py / test_prep.py.
Pre-existing, not caused by recent work, but blocks using pytest as a
signal until updated.
3. Once #1 is fixed, run tdmruns validate-config --run-set non-motorized-2023
against the real submodule to confirm S01–S13's override keys are valid, then
consider re-running the scenarios through run-scenario/run-set so this run
set no longer depends on manual_scenario_folder/import-manual-run-set —
though that path is now solid enough (flattened, checksummed, schema-validated)
that switching isn't urgent on its own.
4. Verify GitHub Actions actually deploys.
publish-report.yml was updated this session to install geopandas/plotly
and register a july2025 Jupyter kernel matching what the report .qmd files
declare — confirmed to render locally via quarto render reports, but not yet
confirmed against a real GitHub Pages deploy. Watch the first push's Actions
run for kernel/package issues that don't show up locally.
5. validate-config.yml / validate-run-metadata.yml workflows are
written but still unconfirmed against the real (possibly private) TDM repo —
if private, they'll need a deploy key or PAT to check out the submodule.
6. Exercise bin/RunModel.bat end-to-end once #1 is fixed.
bin/RunModel.bat now exists (lives in the framework repo, not tdm/ — see
"What's currently mocked vs. real" above): it globs the scenario folder for
whatever driver script src/tdmruns/driver_script.py staged there (default
_HailMary_1Subfolder.s or a run_set's custom one, see ADR 0007), pushds
into the scenario folder so the driver script's relative
..\..\..\2_ModelScripts\... paths resolve, and invokes Cube Voyager via
the VOYAGER_EXE env var (sourced from config/local.yaml's Voyager_EXE
by execution.invoke()). None of this has been run against real Cube
Voyager yet — it can't produce a working run until #1's block-format fix
lands, since the driver script's READ FILE = '1ControlCenter.block' /
'0GeneralParameters.block' won't find anything until then. Once #1 is
fixed, verify: the bat file actually locates and runs the staged driver
script, and VOYAGER_EXE resolves correctly on a real workstation.
Success/failure now prefers the model's own _Log\_RunTime.txt completion
report over Voyager's process exit code (%ERRORLEVEL% right after
start /w, propagated by RunModel.bat and read by execution.invoke()) —
see the "Success/failure is decided from the model's own completion log"
architecture decision above. Falls back to the exit code alone when no
recognizable log entry exists yet.
7. start_from_copy is now exercisable for bring-work-trips-closer-to-home.
Closer00 (Closer01/Closer02/Closer03 all declare start_from_copy: Closer00) has recorded successful runs now that RunModel.bat works
end-to-end, so the copy source resolves. Fixed a real bug in
scenario_seed.seed() this session: it used to require the single most
recent recorded run to have status: "success", so Closer00's frequent
unrelated re-run failures (block-format/exit-code issues while iterating,
output curation tripping the 45 MB ceiling) would wrongly block Closer01/02/03
from copying, even with an earlier Closer00 success on record. It now calls
the new metadata.latest_successful_run(), which skips past newer failures
to find the most recent success. Also added lock_down_copy: true (scenario
YAML), since the raw scenario folder is reused across every retry of a given
scenario_id — without it, every Closer01 retry would re-shutil.copytree()
Closer00's entire raw folder (currently ~34 GB) again. Declare it on
Closer01/02/03 once each has been seeded once and doesn't need Closer00's
folder re-copied on further retries. Covered by new unit tests in
tests/test_scenario_seed.py; still not exercised end-to-end via a live
run-scenario invocation against the real TDM.