diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index eea237f..6b7c669 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -7,7 +7,36 @@ on:
workflow_dispatch:
jobs:
- pytest:
+ pytest-fast:
+ runs-on: windows-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.12"]
+
+ steps:
+ - name: Check out repo
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+
+ - name: Sync locked dependencies
+ run: uv sync --locked --group dev
+
+ - name: Run correctness lint
+ if: matrix.python-version == '3.12'
+ run: uv run ruff check .
+
+ - name: Run fast tests
+ run: uv run pytest --basetemp .pytest_tmp -m "not full_export"
+
+ pytest-full-export:
runs-on: windows-latest
steps:
@@ -22,8 +51,8 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v4
- - name: Sync dependencies
- run: uv sync
+ - name: Sync locked dependencies
+ run: uv sync --locked --group dev
- - name: Run tests
- run: uv run --with pytest pytest --basetemp .pytest_tmp
+ - name: Run exhaustive export tests
+ run: uv run pytest --basetemp .pytest_tmp -m full_export
diff --git a/.gitignore b/.gitignore
index 4d94b01..9debff0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,7 +69,6 @@ instance/
.scrapy
# Sphinx documentation
-docs/_build/
# PyBuilder
.pybuilder/
@@ -225,4 +224,23 @@ tmp*/
*.parquet
*.json
-plans/
\ No newline at end of file
+plans/
+
+# Local runtime and diagnostic artifacts
+/artifacts/
+/artifacts_2/
+/logs/
+/.manual_logging_test/
+/.posit/publish/
+/codex_tmp_check/
+/config_1.yaml
+/configs/artifacts/
+/configs/artifacts_2/
+/configs/scratch.py
+/configs/skimjoin_odot_series15.yaml
+/configs/will*.yaml
+/pytest-output.txt
+/raw_school_dropoff.ipynb
+/prepared_table_filter_example.ipynb
+/scratch.ipynb
+/test
diff --git a/README.md b/README.md
index 767661a..04b8812 100644
--- a/README.md
+++ b/README.md
@@ -15,16 +15,17 @@ Install dependencies with `uv`:
uv sync --locked
```
-If `uv sync` fails because of a hardlink issue, retry with:
+Notebook tooling is optional; install it only when working with the repository's
+notebooks:
```bash
-uv sync --locked --link-mode=copy
+uv sync --locked --group notebooks
```
-Activate the environment:
+If `uv sync` fails because of a hardlink issue, retry with:
```bash
-.\.venv\Scripts\activate
+uv sync --locked --link-mode=copy
```
Create a project-specific config:
@@ -36,19 +37,21 @@ Copy-Item config.yaml local_config.yaml
Edit `local_config.yaml`, then run the app with that config:
```bash
-python run.py --config local_config.yaml
+uv run activitysim-viz --config local_config.yaml
```
-By default, `python run.py` follows `pipeline.steps` from the loaded config when no explicit step flags are supplied. The shipped example config defaults to `summarize` + `dashboard`, so a normal run will reuse summary caches when possible, rebuild them when needed, and then start the live dashboard on [http://localhost:5006](http://localhost:5006).
+By default, `activitysim-viz` follows `pipeline.steps` from the loaded config when no explicit step flags are supplied. The shipped example config defaults to `summarize` + `dashboard`, so a normal run will reuse summary caches when possible, rebuild them when needed, and then start the live dashboard on [http://localhost:5006](http://localhost:5006).
## Dashboard Pages
Dashboard pages now use one shared authoring model:
-- page modules export `PAGE = DashboardPageDefinition(...)`
-- page classes subclass `DashboardPage`
-- page-local controls are registered with `selector(...)`
+- page classes use `@dashboard_page(...)` and subclass `DashboardPage`
+- dropdowns use `select(...)`; custom widgets use `selector(...)`
+- dynamic selectors declare an option provider and default policy
- refreshable regions are registered with `section(...)`
+- large pages compose related selectors and sections with `feature(...)`
+- repeated chart transforms use `query(...)` without page-authored cache keys
- live refresh and export metadata both derive from those registrations
The main shared page-helper modules live under `dashboard/helpers/`:
@@ -59,7 +62,11 @@ The main shared page-helper modules live under `dashboard/helpers/`:
- `time_distance_helpers.py`
- `comparison_helpers.py`
-If you are adding or refactoring a page, read [docs/adding-dashboard-pages.md](docs/adding-dashboard-pages.md) first. For the broader runtime picture, see [docs/architecture.md](docs/architecture.md).
+If you are adding or refactoring a page, start with the
+[dashboard page recipes](wiki/33-dashboard-page-recipes.md) and
+[figures/widgets guide](wiki/32-figures-and-widgets.md). The
+[dashboard extension cookbook](wiki/45-dashboard-extension-cookbook.md) covers
+the complete contributor path.
## Config Setup
@@ -67,7 +74,7 @@ The repo ships with `config.yaml` as a template. In practice, most people should
1. Copy `config.yaml` to `local_config.yaml` or another machine-specific file.
2. Update the `runs` section to point at real ActivitySim output folders.
-3. Update `skimjoin.distance_skim`, `zones`, and `files` if your model layout differs from the defaults.
+3. Update `prepare.distance_skim`, `zones`, and `files` if your model layout differs from the defaults.
4. Run with `--config your_file.yaml`.
The canonical config layout is organized around a few top-level sections:
@@ -87,12 +94,12 @@ segment: ...
dashboard: ...
display: ...
skimjoin: ...
+extensions: ...
```
-Older keys such as `processor.*`, `summaries.*`, `visualizer.*`, top-level
-`dashboard_labels`, and top-level `run_colors` are still supported for
-compatibility, but they now emit deprecation warnings and normalize into the
-canonical schema above.
+Removed keys such as `processor.*`, `summaries.*`, `visualizer.*`, top-level
+`dashboard_labels`, and top-level `run_colors` now fail validation and name the
+canonical replacement. Unknown keys also fail instead of being silently ignored.
The minimum useful config is usually:
@@ -179,6 +186,26 @@ skimjoined and then optionally filtered or otherwise post-processed outside this
repo. When a run uses `prepared_table_map`, the workflow loads those prepared
tables directly and does not rerun raw prepare or integrated skimjoin for that run.
+If a run already has dashboard-ready summary tables, point directly at those
+files with `runs[*].summary_table_map`:
+
+```yaml
+runs:
+ - label: Summary Only Demo
+ summary_table_map:
+ population_totals: path\to\summaries\population_totals.csv
+ traffic_count_comparisons: path\to\summaries\traffic_count_comparisons.parquet
+```
+
+`summary_table_map` uses registered summary IDs as keys, accepts explicit
+`.csv` or `.parquet` paths, and resolves relative paths from the config file
+directory. Mapped summaries are expected to already use the dashboard's canonical
+columns. During summarize they override the listed generated summaries; missing
+summaries can still be generated from raw/prepared inputs when those inputs exist.
+Some registered summary IDs are external/demo-only and are not generated by
+default for raw/prepared runs, which avoids writing `__empty__` cache CSVs just
+to make those IDs available to `summary_table_map`.
+
Integrated skim enrichment can now be selected per run without forcing one
shared skimjoin config for every skim structure. Keep the explicit skimjoin
YAML logic in separate files, then choose the file and optional project-input
@@ -212,6 +239,7 @@ Skimjoin override rules:
- `runs[*].skimjoin.config_path` overrides global `skimjoin.config_path`.
- `runs[*].skimjoin.skim_files` overrides the selected skimjoin config's `project.skim_files`.
- `runs[*].skimjoin.network_los_file` overrides the selected skimjoin config's `project.network_los_file`.
+- `skimjoin.failure_policy` defaults to `record`; use `error` when skimjoin failures must stop a validation or batch run.
- If a run omits `runs[*].skimjoin`, it uses the global skimjoin settings exactly as before.
Recommended rule of thumb:
@@ -286,10 +314,11 @@ Important path rules:
- `root` is resolved relative to the config file if you give a relative path.
- The prepared cache is created automatically next to `root` as `prepared_cache/`.
- `runs[*].dir` should point at an ActivitySim output directory.
-- `skimjoin.distance_skim.file` may be absolute, or relative to each run directory.
+- `prepare.distance_skim.file` may be absolute, or relative to each run directory.
- File entries under `files` can be bare stems like `final_trips` or explicit filenames like `final_trips.csv`.
- `runs[*].file_map` uses the same filename rules as `files`, but applies only to that run.
- `runs[*].prepared_table_map` must use explicit `.parquet` or `.csv` paths and resolves relative paths from the config file directory.
+- `runs[*].summary_table_map` must use registered summary IDs with explicit `.parquet` or `.csv` paths and resolves relative paths from the config file directory.
- `prepare.output.file_format` controls how standard prepared caches are written; supported values are `parquet` and `csv`, with `parquet` as the default.
- `prepare.validation.relationship_checks` controls prepared-table foreign-key validation. Use `warn` to log inconsistencies and continue, `error` to fail the run, or `off` to skip the checks.
- `dashboard.export.output_path`, when relative, is resolved from `root`.
@@ -302,45 +331,56 @@ These are the sections most people need to touch:
|---|---|
| `root` | Where summary caches are stored |
| `pipeline` | Default workflow steps, dashboard mode, and overwrite behavior |
-| `runs` | Run directories, display labels, and optional per-run skim, raw file-map, custom prepared-table map, and weight overrides |
-| `skimjoin.distance_skim` | Default distance skim file and matrix name used by summaries |
+| `runs` | Run directories, display labels, and optional per-run skim, raw file-map, custom prepared-table map, custom summary-table map, and weight overrides |
+| `prepare.distance_skim` | Default distance skim file and matrix name used by summaries |
| `zones` | MAZ/TAZ settings for skim joins and zone normalization |
| `files` | Default ActivitySim output file stems or filenames used unless a run overrides them |
| `columns` | Column aliases when outputs use non-default names |
| `prepare.output.file_format` | On-disk format for prepared caches written by the normal prepare workflow |
| `prepare.validation.relationship_checks` | Whether cross-table prepared-key validation is disabled, warns, or errors |
+| `prepare.student_types` | Optional school/university enrollment definitions for shadow pricing pages |
| `dashboard.title` | Title used in the live dashboard and HTML export |
+| `dashboard.include_notes` | Show per-plot and per-table calculation notes in the live dashboard and HTML export (default: `true`) |
| `dashboard.live.pages` | Ordered list of live pages/groups to show |
| `dashboard.export` | Export-only output path, page selection, and selector-state controls |
| `display.run_colors` | Plot colors by run |
| `display.labels` | Presentation-only labels and ordering for dashboard/export |
-| `summarize.weighting_modes` | Which cache variants to build: `weighted`, `unweighted`, or both |
-| `summarize.geography` | Optional district/county/zone grouping |
+| `weighting.modes` | Named weighting alternatives backed by household, person, and/or trip columns |
+| `extensions` | Advanced importable weighting calculations and their summary-affecting settings |
+| `summarize.weighting_modes` | Ordered built-in, declarative, or custom weighting-mode IDs to build |
+| `summarize.failure_policy` | `record` keeps failed summaries visible as diagnostics; `error` stops immediately on a builder exception |
+| `summarize.geography` | Optional configured district/county/zone mappings |
| `summarize.pnr_tour_modes` | Which tour modes count as park-and-ride in summary builders |
| `summarize.group_*_tour_purposes` | Summary-time purpose regrouping switches |
-| `summary_categories` | Summary-affecting category normalization/regrouping |
+| `summarize.category_normalization` | Summary-affecting category normalization/regrouping |
| `modes` | Optional mode ordering and grouped mode display |
-| `person_types` | Optional display labels for `ptype` values |
-| `student_types` | Optional school/university enrollment definitions for shadow pricing pages |
+| `display.labels.person_type` | Optional display labels for `ptype` values |
Weighting rules:
- If a run sets `hh_weight_col`, `person_weight_col`, or `trip_weight_col`, those are used.
- Otherwise, if a `sample_rate` column is available, weights are derived from it.
- Otherwise, weights default to `1`.
+- `weighting.modes` can select additional prepared household, person, and trip columns as named alternatives without replacing the primary `weighted` mode.
+
+Geography summary notes:
-Legacy config notes:
+- Summaries may emit `all_geographies` total rows independently of the geography config.
+- Native prepared geographies such as `home_taz`, `home_county`, and `home_mpo` may appear whenever those columns are available in prepared data, even when `summarize.geography.enabled: false`.
+- `summarize.geography` controls additional mapped geography aggregations, such as `home_geo__school_district`, `work_geo__county`, or `land_use_geo__district`.
+
+Removed config notes:
- Prefer the canonical top-level schema: `root`, `pipeline`, `dashboard`, `display`, `summarize`, `segment`, and `skimjoin`.
-- Older keys such as `processor.root`, `summaries.weighting_modes`, `visualizer.dashboard_pages`, and top-level `run_colors` are still supported for compatibility, but now log deprecation warnings.
+- Older keys such as `processor.root`, `summaries.weighting_modes`, `visualizer.dashboard_pages`, top-level `run_colors`, top-level `summary_categories`, and top-level `student_types` are rejected with their canonical replacement.
Geography note:
-- `geography.enabled: false` now disables both the older geography mapping behavior and the newer `geography.aggregations` derived columns. If you want aggregation-based geography summaries, `geography.enabled` must be `true`.
+- `summarize.geography.enabled: false` disables mapped geography aggregation columns. Set it to `true` for aggregation-based geography summaries.
Category config note:
-- Use `summary_categories` when a mapping changes summary values, grouping membership, or canonical category values.
+- Use `summarize.category_normalization` when a mapping changes summary values, grouping membership, or canonical category values.
- Use `display.labels` when a change is cosmetic and should only affect dashboard/export labels or ordering.
## Live Pages And Export Pages
@@ -362,6 +402,8 @@ Grouped page ids support either the whole group or specific child pages. For exa
```yaml
dashboard:
+ # Set to false to omit all per-plot and per-table calculation notes.
+ include_notes: true
live:
pages:
- overview
@@ -383,7 +425,8 @@ Notes:
- A plain group id like `tour_summaries` behaves like the group's default selection.
- `raw_trip_demo` is disabled by default and requests prepared trip tables, so keep it out unless you explicitly want that behavior.
-For HTML export, you can further narrow the exported page set and selector states:
+For HTML export, start with the live page set and override selector states or
+parts as needed:
```yaml
dashboard:
@@ -407,9 +450,12 @@ dashboard:
Rules worth remembering:
- If `dashboard.live.pages` is omitted, the app uses its built-in default page set.
-- If `dashboard.export.pages` is omitted, export mirrors the live page set.
+- Export always starts from the live page set. Entries under
+ `dashboard.export.pages` modify matching pages; they are not an allow-list.
- Export selector requests accept `default`, `all`, or a list of explicit values.
-- `dashboard.export.exclude_pages` and `exclude_groups` remove pages from export without changing the live dashboard.
+- Set a page override's `enabled` to `false`, or use
+ `dashboard.export.exclude_pages` / `exclude_groups`, to remove pages from
+ export without changing the live dashboard.
## Run Modes
@@ -439,8 +485,9 @@ Common commands:
Behavior details:
-- `--from-csvs` is cache-only: it will not rebuild missing summaries.
+- `--from-csvs` is cache-only: it reads visualizer summary-cache directories with manifests, not loose summary CSVs.
- `--from-csvs path\to\cache1 path\to\cache2` lets you point directly at specific summary cache directories.
+- Use `runs[*].summary_table_map` when you have loose dashboard-ready summary files instead of visualizer cache directories.
- `--dashboard` by itself is valid when summary caches already exist for the configured runs.
- During summarize, the app will reuse prepared cache when possible and rebuild from raw outputs only when needed.
- `--refresh-prepared-cache` deletes the selected runs' prepared-cache directories first, then disables prepared-cache reuse for that invocation.
@@ -502,7 +549,7 @@ python run.py --config local_config.yaml ^
--run-skim C:\path\to\base_skims.omx C:\path\to\build_skims.omx
```
-Use `null`, `None`, or an empty string in `--run-skim` to fall back to the configured `skimjoin.distance_skim.file`.
+Use `null`, `None`, or an empty string in `--run-skim` to fall back to the configured `prepare.distance_skim.file`.
## Codebase Map
@@ -521,6 +568,11 @@ activitysim_visualizer/
| |-- app.py
| |-- export/
| |-- page_base.py
+| |-- page_declarations.py
+| |-- page_diagnostics.py
+| |-- page_features.py
+| |-- page_lifecycle.py
+| |-- page_navigation.py
| |-- page_definitions.py
| |-- page_registry.py
| |-- state.py
@@ -528,26 +580,40 @@ activitysim_visualizer/
`-- tests/
```
-## Contributor Docs
+## Documentation
-Contributor-oriented docs live under [`docs/`](docs/):
+The main user and contributor documentation lives in the
+[`wiki/`](wiki/00-home.md) chapter set. Start with:
-- [`docs/architecture.md`](docs/architecture.md)
-- [`docs/summary-workflow.md`](docs/summary-workflow.md)
-- [`docs/adding-summaries.md`](docs/adding-summaries.md)
-- [`docs/adding-dashboard-pages.md`](docs/adding-dashboard-pages.md)
-- [`docs/plotting-summary-tables.md`](docs/plotting-summary-tables.md)
-- [`docs/export_html_schema.md`](docs/export_html_schema.md)
-- [`docs/export_html_contributor_guide.md`](docs/export_html_contributor_guide.md)
+- [Getting Started](wiki/10-getting-started.md)
+- [Architecture](wiki/01-architecture.md)
+- [Configuration Reference](wiki/13-configuration-reference.md)
+- [Output Processor](wiki/20-output-processor.md)
+- [Output Visualizer](wiki/30-output-visualizer.md)
+- [Developer Workflows](wiki/40-developer-workflows.md)
+- [Data Extension Cookbook](wiki/41-data-extension-cookbook.md)
+- [Config, Columns, and Labels](wiki/42-config-column-label-cookbook.md)
+- [Weighting and Hosting Extensions](wiki/43-weighting-hosting-extensions.md)
+- [Summary Function Cookbook](wiki/44-summary-function-cookbook.md)
+- [Dashboard Extension Cookbook](wiki/45-dashboard-extension-cookbook.md)
+- [Testing](wiki/46-testing.md)
+- [Troubleshooting](wiki/90-troubleshooting.md)
-If you are new to the codebase, start with `docs/architecture.md`, then `docs/summary-workflow.md`.
+The wiki is the sole documentation source. Add or revise a wiki chapter instead
+of creating a parallel documentation tree.
## Documentation Maintenance Checklist
When behavior changes, update docs in the same change:
-- New config key or config behavior: update this README and any affected workflow guide.
-- New summary contract or registration pattern: update `docs/adding-summaries.md`.
-- New page, selector, or export behavior: update `docs/adding-dashboard-pages.md`.
-- New export payload/runtime behavior: update `docs/export_html_schema.md` and `docs/export_html_contributor_guide.md`.
-- Architecture or runtime-flow changes: update `docs/architecture.md` or `docs/summary-workflow.md`.
+- New config key or config behavior: update chapters 11 and 13.
+- New summary declaration or contract: update chapter 23 and regenerate catalogs.
+- New page, selector, or plotting behavior: update chapters 31 through 33 and regenerate catalogs.
+- New export payload/runtime behavior: update chapter 34.
+- Architecture or runtime-flow changes: update chapters 12, 20, and 30 as applicable.
+
+## Tests
+
+See [Developer Workflows](wiki/40-developer-workflows.md) for the normal test
+loop and [Testing](wiki/46-testing.md) for the fast/full split and
+offline-export boundary.
diff --git a/config.yaml b/config.yaml
index 7df024e..ede454f 100644
--- a/config.yaml
+++ b/config.yaml
@@ -7,14 +7,32 @@ log_level: INFO
pipeline:
steps:
- - prepare
- - skimjoin
- - segment
- summarize
- dashboard
- dashboard_mode: live # live | export | host
+ # Add `prepare`, `skimjoin`, or `segment` when those explicit stages are
+ # needed. Summarize automatically prepares data when no valid cache exists.
+ dashboard_mode: live # none | live | export | host
overwrite: false
+# Optional named alternatives backed by columns retained in prepared tables.
+# weighting:
+# modes:
+# calibrated:
+# label: Calibrated
+# columns:
+# households: calibrated_hh_weight
+# persons: calibrated_person_weight
+# trips: calibrated_trip_weight
+
+# Advanced trusted calculations only. Each importable module defines
+# register_weighting_modes(registry); settings are available on Config and enter
+# summary cache identity.
+# extensions:
+# modules: [my_project.weighting]
+# settings:
+# calibrated:
+# multiplier: 1.0
+
# ---------------------------------------------------------------------------
# ActivitySim output file names
# Use stems (no extension) for automatic format detection.
@@ -49,7 +67,7 @@ runs:
- dir: path\to\activitysim\output\run1
label: Base
# skimjoin:
- # config_path: configs/example_skimjoin.yaml
+ # config_path: example_skimjoin_config.yaml
# skim_files:
# - C:\path\to\model_skims\*.omx
# - C:\path\to\model_skims\maz_stop_walk.csv
@@ -128,8 +146,10 @@ prepare:
# Settings for the `skimjoin` step, to be used as defaults.
# ---------------------------------------------------------------------------
skimjoin:
+ failure_policy: record # record | error
+ create_hypothetical_skim_tables: false
defaults:
- config_path: configs/example_skimjoin.yaml
+ config_path: example_skimjoin_config.yaml
skim_files:
- C:\path\to\model_skims\*.omx
- C:\path\to\model_skims\maz_stop_walk.csv
@@ -141,7 +161,7 @@ skimjoin:
# ---------------------------------------------------------------------------
segment:
dashboard:
- segmentation_type: person_sex
+ segmentation_type: signup_platform
visibility: segments_only # full_only | segments_only | full_and_segments
definitions:
signup_platform:
@@ -169,10 +189,16 @@ segment:
# ---------------------------------------------------------------------------
summarize:
weighting_modes: [weighted, unweighted]
+ failure_policy: record # record | error
pnr_tour_modes:
- PNR_TRANSIT
+ # Controls additional mapped geography aggregations only. Summaries may still
+ # emit all_geographies totals, and native prepared home geographies such as
+ # home_taz, home_county, and home_mpo can appear when those columns exist.
geography:
enabled: false
+ # Configured mappings create columns such as home_geo__district,
+ # work_geo__county, or land_use_geo__district.
# landuse_col: COUNTY
# mapping:
# 1: County 1
@@ -188,6 +214,7 @@ summarize:
# ---------------------------------------------------------------------------
dashboard:
title: "ActivitySim Comparison Visualizer"
+ include_notes: true
enable_maz_geographies: false
live:
pages:
@@ -200,6 +227,7 @@ dashboard:
- validation
export:
output_path: exports/dashboard.html
+ # `pages` overrides matching live pages; it is not an inclusion list.
# dashboard:
# weighting: [unweighted]
# pages:
@@ -212,8 +240,18 @@ dashboard:
# enabled: false
# school_table:
# enabled: false
+ # Reserved for a future hosted-dashboard implementation. These settings are
+ # validated but intentionally ignored by the current runtime.
+ # host:
+ # account: my-connect-cloud-account
+ # app_id: 12345
+ # title: ActivitySim Comparison Visualizer
+ # verify: true
display:
+ missing_data_display: card # card | blank
+ # bar_hover_mode: all # closest | all
+ # density_hover_mode: all # closest | all
labels:
person_type:
mapping:
diff --git a/dashboard/__init__.py b/dashboard/__init__.py
index e112ec0..f228d87 100644
--- a/dashboard/__init__.py
+++ b/dashboard/__init__.py
@@ -3,11 +3,19 @@
from dashboard.data_access import (
DashboardPreparedRunProvider,
DashboardSummarySeries,
+ PageData,
+ RunTables,
)
from dashboard.state import DashboardState
+from dashboard.page_base import DashboardPage
+from dashboard.page_definitions import dashboard_page
__all__ = [
"DashboardPreparedRunProvider",
+ "DashboardPage",
"DashboardState",
"DashboardSummarySeries",
+ "PageData",
+ "RunTables",
+ "dashboard_page",
]
diff --git a/dashboard/app.py b/dashboard/app.py
index 7740203..dc998d8 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -5,12 +5,7 @@
import panel as pn
from dashboard import DashboardState
-from dashboard.components import (
- build_run_legend_panes,
- set_percent_mode,
- set_run_colors,
- set_run_label_order,
-)
+from dashboard.rendering import RenderContext, run_legend_panes
from dashboard.page_registry import (
build_dashboard_prepared_run_provider,
build_registered_live_pages,
@@ -18,7 +13,7 @@
)
from dashboard.page_base import GroupedDashboardPage
from processor.models import RunData
-from processor.summarize.cache import SummaryRun
+from processor.summarize.cache_types import SummaryRun
from runtime.config import Config
pn.extension("plotly", "tabulator", sizing_mode="stretch_width")
@@ -39,17 +34,17 @@ def build_dashboard(
summary_runs: list[SummaryRun] | None = None,
) -> pn.template.FastListTemplate:
"""Assemble the full Panel dashboard from a list of (label, RunData) tuples."""
- set_run_colors(config.run_colors)
prepared_run_provider = build_dashboard_prepared_run_provider(prepared_runs, config)
state = DashboardState(
summary_runs=summary_runs,
weighting_modes=config.weighting_modes,
+ weighting_definitions=config.weighting_mode_definitions,
+ config=config,
prepared_run_provider=prepared_run_provider,
dashboard_segmentation_type=config.segmentation.dashboard.segmentation_type,
default_segmentation_visibility=config.segmentation.dashboard.visibility,
)
run_labels = state.run_labels
- set_run_label_order(run_labels)
weight_mode = pn.widgets.RadioButtonGroup(
name="Weighting",
@@ -70,7 +65,6 @@ def build_dashboard(
# if static_export:
# weight_mode.disabled = True
# value_mode.disabled = True
- # set_percent_mode(True)
# pages = build_registered_live_pages(state, config)
# for page in pages:
# page.mark_stale()
@@ -141,7 +135,7 @@ def _on_value_change(event) -> None:
sidebar_items = [
pn.pane.Markdown("## Runs Loaded"),
- *build_run_legend_panes(run_labels),
+ *run_legend_panes(RenderContext.from_dashboard(config, state)),
pn.layout.Divider(),
pn.pane.Markdown("## Display Options"),
pn.pane.HTML(
diff --git a/dashboard/calculation_notes.py b/dashboard/calculation_notes.py
new file mode 100644
index 0000000..f3fa0a0
--- /dev/null
+++ b/dashboard/calculation_notes.py
@@ -0,0 +1,311 @@
+"""Shared calculation-note content and rendering helpers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import lru_cache
+import html
+from pathlib import Path
+from typing import Mapping
+
+import panel as pn
+import yaml
+
+
+CALCULATION_NOTES_PATH = Path(__file__).with_name("calculation_notes.yaml")
+
+CALCULATION_NOTE_STYLESHEET = """
+.calculation-note-block {
+ margin: 4px 0 12px;
+}
+.calculation-note-summary {
+ margin: 3px 0 0;
+ color: inherit;
+ font-size: 13px;
+ line-height: 1.45;
+}
+.calculation-note {
+ margin: 0;
+ border: 1px solid #dbe3ec;
+ border-radius: 10px;
+ background: #f8fafc;
+ color: #334155;
+ overflow: hidden;
+}
+.calculation-note summary {
+ padding: 9px 12px;
+ cursor: pointer;
+ color: #334155;
+ font-size: 13px;
+ font-weight: 650;
+ user-select: none;
+}
+.calculation-note summary:hover {
+ background: #f1f5f9;
+}
+.calculation-note[open] summary {
+ border-bottom: 1px solid #dbe3ec;
+}
+.calculation-note-content {
+ padding: 10px 14px 12px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.calculation-note-content p {
+ margin: 0 0 8px;
+}
+.calculation-note-content p:last-child,
+.calculation-note-content ul:last-child {
+ margin-bottom: 0;
+}
+.calculation-note-content .calculation-note-formula {
+ margin-top: 8px;
+}
+.calculation-note-formula code {
+ white-space: normal;
+}
+.calculation-note-section {
+ margin-top: 8px;
+}
+.calculation-note-section ul {
+ margin: 3px 0 0;
+ padding-left: 20px;
+}
+.calculation-note-section p {
+ margin: 3px 0 0;
+}
+.calculation-note-view {
+ margin-top: 6px;
+}
+"""
+
+
+@dataclass(frozen=True)
+class CalculationNote:
+ """Validated calculation-note content loaded from YAML."""
+
+ note_id: str
+ label: str
+ summary: str
+ formula: str | None
+ details: tuple[tuple[str, tuple[str, ...]], ...]
+ method_explanation: str | None = None
+ method_text: str | None = None
+ sources: tuple[str, ...] = ()
+ source_filters: tuple[str, ...] = ()
+
+
+def _nonempty_text(value: object, *, field: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"Calculation note {field} must be a non-empty string.")
+ return value.strip()
+
+
+def _parse_note(
+ note_id: str,
+ raw_note: object,
+ methods: Mapping[str, str],
+) -> CalculationNote:
+ if not isinstance(raw_note, dict):
+ raise ValueError(f"Calculation note {note_id!r} must be a mapping.")
+ allowed_fields = {
+ "label",
+ "summary",
+ "formula",
+ "details",
+ "method",
+ "method_text",
+ "sources",
+ "source_filters",
+ }
+ unexpected = sorted(set(raw_note) - allowed_fields)
+ if unexpected:
+ raise ValueError(
+ f"Calculation note {note_id!r} has unsupported fields: "
+ + ", ".join(unexpected)
+ )
+
+ label = _nonempty_text(
+ raw_note.get("label", "Notes"),
+ field=f"{note_id!r}.label",
+ )
+ summary = _nonempty_text(
+ raw_note.get("summary"), field=f"{note_id!r}.summary"
+ )
+ raw_formula = raw_note.get("formula")
+ formula = (
+ None
+ if raw_formula is None
+ else _nonempty_text(raw_formula, field=f"{note_id!r}.formula")
+ )
+
+ raw_method = raw_note.get("method", "grouped_counts")
+ method_name = _nonempty_text(raw_method, field=f"{note_id!r}.method")
+ try:
+ method_explanation = methods[method_name]
+ except KeyError as exc:
+ raise ValueError(
+ f"Calculation note {note_id!r} references unknown method "
+ f"{method_name!r}."
+ ) from exc
+
+ raw_method_text = raw_note.get("method_text")
+ method_text = (
+ None
+ if raw_method_text is None
+ else _nonempty_text(raw_method_text, field=f"{note_id!r}.method_text")
+ )
+
+ raw_sources = raw_note.get("sources", [])
+ if not isinstance(raw_sources, list) or not raw_sources:
+ raise ValueError(
+ f"Calculation note {note_id!r}.sources must be a non-empty list."
+ )
+ sources = tuple(
+ _nonempty_text(source, field=f"{note_id!r}.sources item")
+ for source in raw_sources
+ )
+
+ raw_source_filters = raw_note.get("source_filters", [])
+ if not isinstance(raw_source_filters, list):
+ raise ValueError(
+ f"Calculation note {note_id!r}.source_filters must be a list."
+ )
+ source_filters = tuple(
+ _nonempty_text(item, field=f"{note_id!r}.source_filters item")
+ for item in raw_source_filters
+ )
+
+ raw_details = raw_note.get("details", {})
+ if not isinstance(raw_details, dict):
+ raise ValueError(f"Calculation note {note_id!r}.details must be a mapping.")
+ details: list[tuple[str, tuple[str, ...]]] = []
+ for raw_heading, raw_items in raw_details.items():
+ heading = _nonempty_text(
+ raw_heading, field=f"{note_id!r}.details heading"
+ )
+ if not isinstance(raw_items, list) or not raw_items:
+ raise ValueError(
+ f"Calculation note {note_id!r}.details.{heading} must be a "
+ "non-empty list."
+ )
+ items = tuple(
+ _nonempty_text(item, field=f"{note_id!r}.details.{heading} item")
+ for item in raw_items
+ )
+ details.append((heading, items))
+
+ return CalculationNote(
+ note_id=note_id,
+ label=label,
+ summary=summary,
+ formula=formula,
+ details=tuple(details),
+ method_explanation=method_explanation,
+ method_text=method_text,
+ sources=sources,
+ source_filters=source_filters,
+ )
+
+
+@lru_cache(maxsize=None)
+def load_calculation_notes(
+ path: str | Path = CALCULATION_NOTES_PATH,
+) -> Mapping[str, CalculationNote]:
+ """Load and validate the calculation-note registry."""
+ note_path = Path(path)
+ with note_path.open("r", encoding="utf-8") as stream:
+ raw_registry = yaml.safe_load(stream)
+ if not isinstance(raw_registry, dict) or set(raw_registry) != {"methods", "notes"}:
+ raise ValueError(
+ "Calculation notes YAML must contain top-level 'methods' and 'notes' mappings."
+ )
+ raw_methods = raw_registry["methods"]
+ if not isinstance(raw_methods, dict) or not raw_methods:
+ raise ValueError("Calculation notes YAML 'methods' must be a non-empty mapping.")
+ methods = {
+ _nonempty_text(name, field="method name"): _nonempty_text(
+ explanation, field=f"method {name!r}"
+ )
+ for name, explanation in raw_methods.items()
+ }
+ raw_notes = raw_registry["notes"]
+ if not isinstance(raw_notes, dict) or not raw_notes:
+ raise ValueError("Calculation notes YAML 'notes' must be a non-empty mapping.")
+
+ notes: dict[str, CalculationNote] = {}
+ for raw_note_id, raw_note in raw_notes.items():
+ note_id = _nonempty_text(raw_note_id, field="id")
+ notes[note_id] = _parse_note(note_id, raw_note, methods)
+ return notes
+
+
+def get_calculation_note(note_id: str) -> CalculationNote:
+ """Return a configured note or raise a useful error for an unknown id."""
+ notes = load_calculation_notes()
+ try:
+ return notes[note_id]
+ except KeyError as exc:
+ available = ", ".join(sorted(notes))
+ raise KeyError(
+ f"Unknown calculation note {note_id!r}. Available notes: {available}"
+ ) from exc
+
+
+def render_calculation_note_html(note: CalculationNote) -> str:
+ """Render one validated note as dependency-free native HTML details."""
+ sections = [
+ "
",
+ "
",
+ f"{html.escape(note.label)}
",
+ "",
+ "
"
+ "
Summary:"
+ f"
{html.escape(note.summary)}
"
+ "
",
+ ]
+ if note.method_text:
+ sections.append(
+ "
"
+ "
Method:"
+ f"
{html.escape(note.method_text)}
"
+ "
"
+ )
+ if note.formula:
+ sections.append(
+ "
Formula: "
+ f"{html.escape(note.formula)}
"
+ )
+ if note.source_filters:
+ rendered_items = "".join(
+ f"
{html.escape(item)}" for item in note.source_filters
+ )
+ sections.append(
+ "
"
+ "
Summary filters / eligibility:"
+ f"
"
+ "
"
+ )
+ if note.sources:
+ rendered_sources = "".join(
+ f"
{html.escape(source)}" for source in note.sources
+ )
+ sections.append(
+ "
"
+ "
Summary Tables Used:"
+ f"
"
+ "
"
+ )
+ sections.extend(["
", " ", "
"])
+ return "".join(sections)
+
+
+def calculation_note(note_id: str) -> pn.pane.HTML:
+ """Build a collapsible note shared by the live dashboard and HTML export."""
+ return pn.pane.HTML(
+ render_calculation_note_html(get_calculation_note(note_id)),
+ sizing_mode="stretch_width",
+ stylesheets=[CALCULATION_NOTE_STYLESHEET],
+ )
diff --git a/dashboard/calculation_notes.yaml b/dashboard/calculation_notes.yaml
new file mode 100644
index 0000000..da1c174
--- /dev/null
+++ b/dashboard/calculation_notes.yaml
@@ -0,0 +1,1159 @@
+# Central registry for calculation notes shown in both the live dashboard and
+# standalone HTML exports. Each note requires a summary and prepared source;
+# method classifies the summary approach for validation and authoring. Optional
+# method_text is visualization-specific prose shown in the expanded note;
+# omitting it avoids repeating a generic method already conveyed by the summary.
+# source_filters records only eligibility/exclusion rules applied while building
+# the summary. Legacy detail sections may remain as authoring context but are
+# intentionally not rendered.
+methods:
+ totals: >-
+ The summary function sums the applicable final weights across all prepared
+ records, producing one total per run and metric. In unweighted mode it uses
+ record counts; the dashboard performs the base-run comparison afterward.
+ grouped_counts: >-
+ The summary function starts with prepared model records, drops records that
+ lack a required grouping value, groups the remaining records by the listed
+ categories, and sums finalweight. In unweighted mode it counts records
+ instead. The dashboard then filters, labels, and compares those grouped rows.
+ rates_and_averages: >-
+ The summary function creates a weighted numerator and denominator for every
+ group, then divides the two. This preserves the correct result when groups
+ are combined; the dashboard does not average already-calculated percentages.
+ counts_and_rates: >-
+ The summary functions first create weighted category totals. For a rate or
+ average they also retain the matching weighted denominator, so combined
+ groups are recomputed from totals rather than by averaging displayed rates.
+ binned_values: >-
+ The summary function calculates or reads one numeric value per record,
+ removes unusable values, assigns the remaining values to fixed bins, and
+ sums finalweight in each bin. Unweighted mode counts records in each bin.
+ descriptive_statistics: >-
+ The summary function removes missing or non-finite values, then calculates
+ valid-record count, missing and zero shares, minimum, maximum, mean, median,
+ and standard deviation for each configured skim family and component.
+ aligned_comparison: >-
+ The summary function aligns modeled and comparison values on their shared
+ geography or validation key before calculating totals, differences, ratios,
+ or residuals. Unmatched or invalid keys are retained or excluded according
+ to that summary's validation rules.
+ vmt: >-
+ The summary function calculates VMT at the trip or network-link level, applies
+ the applicable expansion weight and occupancy adjustment, then groups and
+ sums the resulting VMT by the requested geography and travel attributes.
+ direct_records: >-
+ This view uses a prepared record table instead of a pre-aggregated summary.
+ It applies the displayed filters to those records and performs the grouping,
+ binning, or descriptive calculation when the view is rendered.
+ supplied_validation: >-
+ The summary function standardizes a configured validation table into the
+ dashboard's run, category, and value fields. The dashboard displays and
+ filters those supplied totals rather than reconstructing them from trips.
+ supplied_aggregation: >-
+ The summary function validates and standardizes a configured validation
+ table. The dashboard then selects the requested metric columns and sums
+ them into the categories shown in this visualization.
+notes:
+ overview.kpis:
+ method: totals
+ method_text: Each KPI is summed independently from its prepared run-level population or VMT total for the current run and weighting mode.
+ sources: [population_totals, auto_vmt_totals]
+ summary: KPI cards show run-level population, household, tour, trip, stop, and auto VMT totals.
+ source_filters:
+ - The Stops KPI counts only trip records marked as intermediate stops.
+ details:
+ Source data:
+ - Person, household, tour, trip, and stop totals come from the population totals summary.
+ - Auto VMT comes from the auto VMT totals summary.
+ Important details:
+ - Weighting follows the dashboard Weighting control.
+
+ overview.demographics:
+ sources: [person_type_distribution, household_size_distribution]
+ summary: The charts compare person counts by person type and household counts by household size across runs.
+ details:
+ Aggregation:
+ - Counts are read from the person-type and household-size distribution summaries.
+ - Household sizes of five or more are combined into a 5+ category for display.
+ Display:
+ - Count mode shows weighted or unweighted totals; Percent mode divides each category by its run total.
+
+ raw_trip_demo.trip_modes:
+ method: direct_records
+ method_text: This page reads prepared trip rows directly, removes rows without a trip mode, and totals the remaining rows by mode using trip weights when available.
+ sources: [prepared trips table]
+ summary: Prepared trip records are grouped by trip mode to produce one trip-mode distribution for each run.
+ formula: mode total = sum of final trip weights, or row count when finalweight is unavailable
+ source_filters:
+ - Trips without a trip mode are excluded.
+ details:
+ Source data:
+ - This page reads the prepared trips table directly rather than a precomputed summary.
+ Filters and display:
+ - Trips with a missing trip mode are excluded and the remaining mode categories are sorted for display.
+
+ joint_travel.frequency:
+ method_text: Joint tours are counted within each household to form its joint-tour-frequency pattern, then each household contributes its household weight once to the matching pattern.
+ sources: [jtf_distribution]
+ summary: Household counts are grouped by joint-tour-frequency pattern.
+ source_filters:
+ - Joint-tour patterns are built only from tours categorized as joint.
+ details:
+ Aggregation:
+ - The joint-tour-frequency summary supplies the household count for each pattern.
+ - Percent mode divides each pattern count by all household-pattern counts in the run.
+ Filters:
+ - Hide No Joint Tours removes that category after percentages have been calculated against the full household total.
+
+ joint_travel.details:
+ sources: [joint_tours_by_household_size, joint_tour_party_size_distribution, joint_tour_composition_by_party_size]
+ summary: These charts describe joint tours by household size, party size, and composition for the selected party-size slice.
+ details:
+ Aggregation:
+ - Joint-tour counts are grouped independently by household size and party size.
+ - Composition counts are filtered to the selected party size and then grouped by composition category.
+ Display:
+ - Missing size categories are completed with zeros so runs use the same category axis.
+ - Percent mode normalizes each displayed distribution within a run.
+
+ joint_travel.participation:
+ sources: [household_jtp_by_household_size_and_jtf, person_jtp_by_household_size]
+ summary: Participation charts compare household joint-tour patterns and person participation across household sizes.
+ details:
+ Aggregation:
+ - Household participation is filtered to the selected household size and grouped by joint-tour-frequency category.
+ - Person participation is grouped by household size using the prepared person participation totals.
+ Display:
+ - Percent mode uses the applicable household or person total as the denominator for each run.
+
+ individual_choices.summary:
+ sources: [license_holding_status_distribution, bicycle_comfort_level_distribution, transit_pass_ownership_by_person_type, transit_subsidy_by_person_type]
+ summary: The four charts show license holding, bicycle comfort, transit-pass ownership, and transit-subsidy status for the selected person type.
+ details:
+ Aggregation:
+ - Each chart filters its prepared summary to the selected person type and groups person counts by the displayed choice category.
+ - Selecting all person types sums category counts across person types.
+ Display:
+ - Missing configured categories are shown as zero so runs remain comparable.
+ - Percent mode divides each category by the applicable person total.
+
+ vehicle_ownership.ownership:
+ sources: [auto_ownership_distribution, autonomous_vehicle_ownership_totals]
+ summary: This section combines household auto-ownership counts with the total number of autonomous vehicles owned.
+ details:
+ Aggregation:
+ - Auto ownership is filtered to the selected household size and grouped by household vehicle count.
+ - The autonomous-vehicle KPI sums the autonomous vehicle ownership total for each run.
+ Display:
+ - Percent mode normalizes the auto-ownership distribution within the selected household-size group.
+
+ vehicle_ownership.vehicle_mix:
+ sources: [vehicle_age_distribution, vehicle_fuel_type_distribution, vehicle_body_type_distribution]
+ summary: Vehicle records are grouped separately by vehicle age, fuel type, and body type.
+ details:
+ Aggregation:
+ - Each chart uses its corresponding vehicle distribution summary and sums vehicle counts by category.
+ Display:
+ - Configured category labels and ordering are applied consistently across runs.
+ - Percent mode divides each category by all vehicles represented in that chart and run.
+
+ mandatory_location.remote_work:
+ method: counts_and_rates
+ method_text: Work-from-home percentages are recomputed from weighted worker numerators and denominators within each geography; telecommute categories are separate weighted worker totals.
+ sources: [work_from_home_rate_by_geography, telecommute_frequency_distribution]
+ summary: The charts compare work-from-home rates and telecommute-frequency distributions for the selected geography.
+ formula: work-from-home percent = work-from-home workers ÷ all workers × 100
+ details:
+ Aggregation:
+ - Geography filters are applied before worker counts and telecommute categories are displayed.
+ - Telecommute values represent workers who do not work from home, grouped by frequency.
+ Display:
+ - Geography names and telecommute categories use configured labels and ordering.
+
+ mandatory_location.distance_distributions:
+ method: binned_values
+ method_text: Each eligible person is assigned the prepared home-to-mandatory-destination distance, placed in one distance bin, and counted with that person's weight.
+ sources: [work_location_distance_distribution_by_geography, school_location_distance_distribution_by_geography, university_location_distance_distribution_by_geography]
+ summary: Work, school, and university distance summaries are filtered to the selected home geography and displayed as distance-bin distributions.
+ details:
+ Aggregation:
+ - Each chart uses its purpose-specific mandatory location distance summary.
+ - Counts in matching distance bins are summed after applying the geography filter.
+ Filters and display:
+ - The distance-range controls limit the bins shown without changing the underlying summary totals.
+ - Percent mode normalizes the displayed purpose distribution within each run.
+
+ mandatory_location.worker_geography:
+ method: counts_and_rates
+ method_text: Worker totals are split by the prepared internal/external flag. The external share uses those totals as its numerator and denominator, while workplace zones are totaled only for external workers.
+ sources: [internal_external_worker_by_geography, external_worker_workplace_locations]
+ summary: The table compares internal and external workers, while the chart shows where external workers are assigned workplaces.
+ formula: external worker percent = external workers ÷ (internal workers + external workers) × 100
+ details:
+ Aggregation:
+ - Worker counts are filtered to the selected home geography for the internal/external table.
+ - External workplace counts are grouped by workplace location for the chart.
+ Display:
+ - Percent mode normalizes the external workplace categories within each run.
+
+ mandatory_location.distance_table:
+ method: rates_and_averages
+ method_text: For each purpose and geography, the summary divides the weighted sum of mandatory-tour distance by the corresponding weighted eligible-person total.
+ sources: [average_mandatory_tour_distance_by_purpose_and_geography]
+ summary: Average mandatory-tour distances are compared across runs for the selected geography and tour purpose.
+ formula: percent difference = (comparison average − base-run average) ÷ base-run average × 100
+ details:
+ Aggregation:
+ - Mandatory average-distance rows are filtered by geography and arranged by tour purpose.
+ Important details:
+ - The first loaded run supplies the base average for each row.
+
+ shadow_pricing.workplace_plot:
+ method: aligned_comparison
+ method_text: Modeled workers are totaled by workplace geography, joined to the employment target for the same geography, and converted to a modeled-minus-target residual before binning.
+ sources: [workplace_shadow_pricing_residual_histogram]
+ summary: The workplace histogram shows how modeled employment differs from the shadow-pricing target across geographies.
+ formula: residual = modeled employment − target employment
+ source_filters:
+ - Modeled counts include only workers with a known workplace zone; target rows require a non-missing employment count.
+ - A full geography join is used, so a missing modeled or target side is retained and filled with zero.
+ details:
+ Aggregation:
+ - Geography residuals are filtered to the selected geography level and counted in residual bins.
+ Important details:
+ - A residual of zero indicates an exact match; positive values are modeled overages and negative values are shortages.
+
+ shadow_pricing.workplace_table:
+ method: aligned_comparison
+ method_text: One row is produced per workplace geography after joining the modeled worker total to its employment target; the residual is modeled employment minus target employment.
+ sources: [workplace_shadow_pricing_residuals]
+ summary: The table lists target employment, modeled employment, and their residual for each geography at the selected level.
+ formula: residual = modeled employment − target employment
+ source_filters:
+ - Modeled counts include only workers with a known workplace zone; target rows require a non-missing employment count.
+ - A full geography join is used, so a missing modeled or target side is retained and filled with zero.
+ details:
+ Display:
+ - Friendly geography identifiers and names are added when configured.
+ - MAZ rows can be suppressed by the dashboard geography settings.
+
+ shadow_pricing.school_plot:
+ method: aligned_comparison
+ method_text: Modeled students of the selected type are totaled by school geography, joined to the matching enrollment target, and converted to a modeled-minus-target residual before binning.
+ sources: [school_shadow_pricing_residual_histogram]
+ summary: The school histogram shows how modeled enrollment differs from the target for the selected student type and geography level.
+ formula: residual = modeled enrollment − target enrollment
+ source_filters:
+ - Modeled counts include only students with a known school zone and student type; target rows require both student type and enrollment count.
+ - A full geography-and-student-type join retains unmatched target or modeled rows and fills the missing side with zero.
+ details:
+ Aggregation:
+ - Residuals are filtered by student type and geography level, then counted in residual bins.
+ Important details:
+ - A residual of zero indicates an exact match; positive values are modeled overages and negative values are shortages.
+
+ shadow_pricing.school_table:
+ method: aligned_comparison
+ method_text: One row is produced per school geography after joining modeled enrollment for the selected student type to its target; the residual is modeled enrollment minus target enrollment.
+ sources: [school_shadow_pricing_residuals]
+ summary: The table lists target enrollment, modeled enrollment, and their residual by geography for the selected student type.
+ formula: residual = modeled enrollment − target enrollment
+ source_filters:
+ - Modeled counts include only students with a known school zone and student type; target rows require both student type and enrollment count.
+ - A full geography-and-student-type join retains unmatched target or modeled rows and fills the missing side with zero.
+ details:
+ Filters and display:
+ - Student type and geography-level filters are applied before display.
+ - Friendly geography identifiers and names are added when configured.
+
+ daily_activity_pattern.summary:
+ method: counts_and_rates
+ method_text: Activity-pattern and frequency charts total eligible persons by choice. Tour and trip rates instead divide weighted activity totals by the matching weighted person population.
+ sources: [daily_activity_pattern_by_person_type, mandatory_tour_frequency_by_person_type, nonmandatory_tour_frequency_by_person_type, tour_rates_by_person_type_and_tour_purpose, trip_rates_by_person_type_and_trip_purpose]
+ summary: These charts summarize daily activity pattern, mandatory and non-mandatory tour frequency, and daily tour and trip rates for the selected person type.
+ formula: tour or trip rate = weighted tours or trips for a purpose ÷ weighted person-days
+ details:
+ Aggregation:
+ - Distribution charts group person counts by the applicable activity or frequency category.
+ - Rate charts aggregate purpose totals and divide by the matching person-type population.
+ Display:
+ - Selecting all person types recomputes totals using the person-type population weights.
+ - Percent mode applies to distributions; rate charts remain rates per person-day.
+
+ escorted_tours.static:
+ method: counts_and_rates
+ method_text: Status and stop charts are weighted category totals. Household escort shares and students-per-tour values are recalculated from their prepared weighted numerators and denominators.
+ sources: [student_school_escort_status_by_direction, student_households_by_student_count, households_with_school_escorting_by_student_count_and_direction, schoolkids_per_escorted_tour_by_student_count_and_direction, adult_escort_event_stop_distribution]
+ summary: These charts describe escort status, escorting households, students per escorted tour, and chauffeur stop counts without using the page direction selector.
+ details:
+ Aggregation:
+ - Student escort status is grouped by escort type for outbound, inbound, and both directions.
+ - Household escort rates divide escorting households by all student households for each student-count category.
+ - Students-per-tour values are averages from escorted-tour totals; chauffeur tour legs are grouped by stop-count category and segment.
+ Display:
+ - Configured escort, student-count, and stop-count category order is applied across runs.
+
+ escorted_tours.directional:
+ method: binned_values
+ method_text: Eligible chauffeur tours or trips are split by escort direction; person types are totaled directly, while usable distances are assigned to the prepared distance bins.
+ sources: [adult_escorted_tours_by_person_type_and_direction, adult_escorted_tour_distance_distribution_by_direction, adult_escorted_trip_distance_distribution_by_direction]
+ summary: The directional charts compare chauffeur tours by person type and chauffeur tour and trip distance for the selected escort direction.
+ details:
+ Aggregation:
+ - Chauffeur tour counts are filtered by direction and grouped by chauffeur person type.
+ - Tour and trip distances are filtered by direction and summed into distance bins.
+ Filters and display:
+ - Distance-range controls limit the bins shown.
+ - Percent mode normalizes each displayed distribution within a run.
+
+ tour_purpose.distributions:
+ sources: [tour_category_distribution, tour_purpose_distribution]
+ summary: Tours are grouped separately by broad tour category and detailed tour purpose.
+ details:
+ Aggregation:
+ - Tour counts come from the tour-category and tour-purpose distribution summaries.
+ Display:
+ - Configured category labels and ordering are applied across runs.
+ - Percent mode divides each category by all tours in its distribution.
+
+ tour_mode.distribution:
+ sources: [tour_mode_by_tour_purpose_and_auto_sufficiency]
+ summary: Tour counts are filtered by purpose and grouped by tour mode for each auto-sufficiency segment.
+ details:
+ Aggregation:
+ - Zero-auto, auto-deficient, and auto-sufficient tour counts are taken from separate columns and shown as coordinated views.
+ Filters and display:
+ - Hide Drive Alone removes the configured drive-alone modes from the displayed categories.
+ - Percent mode normalizes tour modes within each auto-sufficiency segment and run.
+
+ tour_mode.allocated_vehicles:
+ sources: [allocated_vehicle_age_by_occupancy, allocated_vehicle_fuel_type_by_occupancy, allocated_vehicle_body_type_by_occupancy]
+ summary: Vehicles allocated to tours are filtered by occupancy and grouped by age, fuel type, and body type.
+ details:
+ Aggregation:
+ - Each chart sums allocated vehicle counts within its characteristic categories for the selected occupancy.
+ Display:
+ - Configured category labels and ordering are used across runs.
+ - Percent mode divides each characteristic category by all allocated vehicles in that chart.
+
+ tour_time.distributions:
+ method: binned_values
+ sources: [tour_time_of_day_by_tour_purpose]
+ summary: Tour start, end, and duration counts are filtered by tour purpose and displayed in time bins.
+ details:
+ Aggregation:
+ - Departure and arrival tours are grouped by clock-time bin; duration tours are grouped by duration bin.
+ Display:
+ - Clock-time bins use a 3:00 a.m. day boundary for ordering.
+ - Percent mode normalizes each of the three distributions independently within a run.
+
+ tour_distance.distribution:
+ method: binned_values
+ method_text: Tours with a usable distance are restricted to the selected purpose, assigned to a tour-distance bin, and summed with tour weights within each bin.
+ sources: [tour_distance_by_tour_purpose]
+ summary: Tours are filtered by purpose and counted in tour-distance bins.
+ source_filters:
+ - Tours without a tour purpose or usable tour distance are excluded.
+ details:
+ Aggregation:
+ - Weighted or unweighted tour counts are summed within each distance bin.
+ Filters and display:
+ - Distance-range controls limit the bins shown without recomputing the source summary.
+ - Percent mode divides each displayed bin by the tour total for the selected purpose.
+
+ tour_distance.averages:
+ method: rates_and_averages
+ method_text: The summary retains weighted tour-distance totals and weighted tour counts by purpose, then divides those totals to produce each run's average before comparing it with the base run.
+ sources: [average_nonmandatory_tour_distance_by_purpose_and_geography]
+ summary: This table compares weighted average non-mandatory tour distance by purpose with the first loaded run.
+ formula: percent difference = (comparison average − base-run average) ÷ base-run average × 100
+ source_filters:
+ - Only non-mandatory tours with a person, tour purpose, usable distance, and weight are included.
+ details:
+ Aggregation:
+ - Average-distance summaries are filtered to the selected geography level and geography.
+ Important details:
+ - The first loaded run supplies the base average for each tour-purpose row.
+
+ tour_stop_frequency.distributions:
+ sources: [tour_stop_frequency_by_tour_purpose, atwork_subtour_frequency_distribution]
+ summary: Tours are grouped by outbound and inbound stop frequency for the selected purpose; at-work subtours are grouped separately by subtour frequency.
+ details:
+ Aggregation:
+ - Direction-specific tour counts are filtered by tour purpose and grouped by stop-frequency category.
+ - At-work subtour counts use the dedicated at-work frequency summary.
+ Display:
+ - Percent mode normalizes each distribution independently within a run.
+
+ internal_external_tours.tables:
+ sources: [internal_external_nonmandatory_tour_frequency_by_home_geography, external_nonmandatory_tour_locations]
+ summary: The tables compare non-mandatory tour frequency by internal/external status and list external tour locations for the selected geography level.
+ details:
+ Aggregation:
+ - Tour-frequency rows are grouped by home geography and internal/external classification.
+ - External-location rows report the prepared external destinations and associated tour totals.
+ Display:
+ - Friendly geography identifiers and names are inserted when configured.
+
+ internal_external_tours.frequency_table:
+ method_text: Eligible non-mandatory tours are linked to their person's home geography, split by internal/external tour status, and summed with tour weights for each table row.
+ sources: [internal_external_nonmandatory_tour_frequency_by_home_geography]
+ summary: This table reports weighted non-mandatory tour frequency by home geography and internal/external tour status.
+ source_filters:
+ - Only non-mandatory tours with a person and known internal/external status are included.
+
+ internal_external_tours.location_table:
+ method_text: Eligible external non-mandatory tours are grouped by their external destination identifier and summed with tour weights.
+ sources: [external_nonmandatory_tour_locations]
+ summary: This table reports weighted external non-mandatory tours by destination.
+ source_filters:
+ - Only non-mandatory tours flagged as external and having a known destination are included.
+
+ park_and_ride.residual_plot:
+ method: aligned_comparison
+ method_text: Weighted park-and-ride tours are totaled by lot, joined to that lot's land-use capacity, and converted to a demand-minus-capacity residual; the residuals are then counted in histogram bins.
+ sources: [park_and_ride_location_residual_histogram]
+ summary: The histogram shows the distribution of modeled park-and-ride tour demand minus lot capacity across geographies.
+ formula: residual = modeled park-and-ride tours − park-and-ride capacity
+ source_filters:
+ - Only configured park-and-ride tour modes with a known park-and-ride zone and tour weight are included.
+ - Every modeled lot must match a land-use row with a non-missing capacity; otherwise the summary is unavailable.
+ details:
+ Aggregation:
+ - Residuals are filtered to the selected geography level and counted in residual bins.
+ Important details:
+ - Positive residuals indicate demand above capacity; negative residuals indicate unused capacity.
+
+ park_and_ride.residual_table:
+ method: aligned_comparison
+ method_text: Weighted park-and-ride tours are totaled by lot and joined to land-use capacity so that each table row can report capacity, demand, and their demand-minus-capacity residual.
+ sources: [park_and_ride_location_residuals]
+ summary: The table lists park-and-ride capacity, modeled tour demand, and the resulting residual by geography.
+ formula: residual = modeled park-and-ride tours − park-and-ride capacity
+ source_filters:
+ - Only configured park-and-ride tour modes with a known park-and-ride zone and tour weight are included.
+ - Every modeled lot must match a land-use row with a non-missing capacity; otherwise the summary is unavailable.
+ details:
+ Filters and display:
+ - Rows are filtered to the selected geography level and augmented with configured geography names.
+ - MAZ rows can be suppressed by the dashboard geography settings.
+
+ trip_stop_purpose.distributions:
+ sources: [trip_purpose_distribution, stop_destination_purpose_by_tour_purpose]
+ summary: The charts compare trip purposes and stop-destination purposes, optionally within a selected tour-purpose segment.
+ details:
+ Aggregation:
+ - Trip counts are grouped by trip purpose and stop counts are grouped by stop destination purpose.
+ - Selecting a tour purpose filters both source summaries before grouping.
+ Display:
+ - Percent mode normalizes the trip and stop distributions independently within a run.
+
+ trip_stop_purpose.trip_purpose:
+ method_text: Eligible trips are grouped by their trip-purpose category and summed with trip weights.
+ sources: [trip_purpose_distribution]
+ summary: This plot sums trip weights by trip purpose.
+ source_filters:
+ - Trips without a trip purpose are excluded.
+
+ trip_stop_purpose.stop_purpose:
+ method_text: Intermediate-stop records are restricted to the selected tour purpose, grouped by the stop destination's trip-purpose category, and summed with trip weights.
+ sources: [stop_destination_purpose_by_tour_purpose]
+ summary: This plot sums intermediate-stop weights by destination purpose within tour purpose.
+ source_filters:
+ - Only records marked as intermediate stops and having both a tour purpose and trip purpose are included.
+
+ trip_mode.distributions:
+ method_text: Eligible trips are restricted to the selected tour purpose and, for segmented charts, tour mode; trip weights are then summed by trip mode within each requested slice.
+ sources: [trip_mode_by_tour_purpose_and_tour_mode]
+ summary: Trips are filtered by tour purpose and grouped by trip mode, with additional charts available for each tour-mode segment.
+ source_filters:
+ - Trips without a tour purpose, tour mode, or trip mode are excluded.
+ details:
+ Aggregation:
+ - The overall chart sums trip counts across tour modes; segment charts retain the selected tour-mode grouping.
+ Filters and display:
+ - Hide Auto Modes removes configured auto trip-mode categories after filtering.
+ - Percent mode divides each trip mode by the applicable trip total within its chart.
+
+ trip_stop_time.distributions:
+ method: binned_values
+ sources: [trip_departure_time_by_purpose]
+ summary: Trip and stop departures are filtered by tour purpose and counted in clock-time bins.
+ details:
+ Aggregation:
+ - Trip departures and intermediate-stop departures use separate count columns from the same time-of-day summary.
+ Display:
+ - Clock time is ordered from 3:00 a.m. through the following early morning.
+ - Percent mode normalizes trip and stop departure distributions independently.
+
+ trip_stop_time.trips:
+ method: binned_values
+ method_text: Eligible trips are restricted to the selected purpose, assigned to the configured bin containing their departure time, and summed with trip weights.
+ sources: [trip_departure_time_by_purpose]
+ summary: This plot sums trip weights by departure-time bin.
+ source_filters:
+ - Trips must have a purpose and a departure bin within the configured model day.
+
+ trip_stop_time.stops:
+ method: binned_values
+ method_text: Intermediate stops are restricted to the selected purpose, assigned to the configured bin containing their departure time, and summed with trip weights.
+ sources: [trip_departure_time_by_purpose]
+ summary: This plot sums intermediate-stop weights by departure-time bin.
+ source_filters:
+ - Only records marked as intermediate stops, with a purpose and a departure bin within the configured model day, are included.
+
+ trip_stop_distance.distributions:
+ method: binned_values
+ sources: [trip_distance_by_purpose, stop_out_of_direction_distance_by_tour_purpose]
+ summary: The charts count trips by trip distance and stops by out-of-direction distance for the selected tour purpose.
+ details:
+ Aggregation:
+ - Weighted or unweighted counts are summed within the distance bins supplied by each summary.
+ Filters and display:
+ - Distance-range controls limit the bins shown for both charts.
+ - Percent mode normalizes the trip and stop distributions independently.
+
+ trip_stop_distance.trip_distance:
+ method: binned_values
+ method_text: Eligible trips are restricted to the selected tour purpose, assigned to a bin using origin-to-destination trip distance, and summed with trip weights.
+ sources: [trip_distance_by_purpose]
+ summary: This plot bins weighted trips by origin-to-destination distance.
+ source_filters:
+ - Trips without a tour purpose or usable trip distance are excluded.
+
+ trip_stop_distance.out_of_direction:
+ method: binned_values
+ method_text: Intermediate stops are restricted to the selected tour purpose, assigned to a bin using their prepared out-of-direction distance, and summed with trip weights.
+ sources: [stop_out_of_direction_distance_by_tour_purpose]
+ summary: This plot bins weighted intermediate stops by out-of-direction distance.
+ source_filters:
+ - Only records marked as intermediate stops with a tour purpose and usable out-of-direction distance are included.
+
+ parking_location.scatter:
+ method: aligned_comparison
+ method_text: Weighted parked trips are totaled by parking MAZ, parking spaces are totaled from land use by the same MAZ, and the two totals are full-joined with missing values filled by zero.
+ sources: [parking_locations, prepared land_use table]
+ summary: This plot compares modeled parked-trip totals with parking capacity after joining both measures by parking MAZ.
+ source_filters:
+ - Trips without a positive parking-zone identifier are excluded.
+
+ parking_location.table:
+ method: aligned_comparison
+ method_text: Weighted parked trips are totaled by parking MAZ, parking spaces are totaled from land use by the same MAZ, and the two totals are full-joined with missing values filled by zero.
+ sources: [parking_locations, prepared land_use table]
+ summary: This table lists modeled parked-trip totals and parking capacity after joining both measures by parking MAZ.
+ source_filters:
+ - Trips without a positive parking-zone identifier are excluded.
+
+ parking_location.comparison:
+ method: aligned_comparison
+ sources: [parking_locations, prepared land_use table]
+ summary: Parking capacity from prepared land use is joined to modeled parked-trip totals by MAZ; the same joined rows feed the scatter plot and table.
+ details:
+ Aggregation:
+ - Parking spaces are summed by MAZ and joined to modeled parking-location trip counts.
+ - Missing capacity or trip totals after the full join are filled with zero.
+ Display:
+ - The scatter plot compares capacity with trips parked; the table exposes the underlying joined values.
+
+ trip_skims.summary_table:
+ method: descriptive_statistics
+ method_text: For each skim component and mode, the summary scans the prepared values once to calculate valid count, missing and zero shares, minimum, maximum, mean, median, and standard deviation.
+ sources: [skimjoin_trip_component_stats]
+ summary: The table reports descriptive statistics for the selected trip skim family and scenario.
+ details:
+ Aggregation:
+ - Prepared skim statistics provide the valid count, missing share, zero share, minimum, maximum, mean, median, and standard deviation for each component and mode.
+ Filters:
+ - Family and scenario selectors choose the skim components and whether chosen-mode or all-record values are summarized.
+
+ trip_skims.distribution:
+ method: direct_records
+ method_text: Usable prepared skim values in the selected range are assigned to bins and totaled with trip weights; unweighted mode counts trip rows instead.
+ sources: [prepared trip skim columns]
+ summary: Prepared trip skim values are filtered by scenario, component, mode, and value range, then grouped into a distribution.
+ source_filters:
+ - Records without a usable value for the selected skim component are excluded.
+ details:
+ Aggregation:
+ - Values are binned and each bin is summed using trip weights when weighted mode is active, otherwise record counts.
+ Filters and display:
+ - The minimum and maximum controls restrict included values; Reset restores the available range.
+
+ tour_skims.summary_table:
+ method: descriptive_statistics
+ method_text: For each directional skim component and mode, the summary calculates valid count, missing and zero shares, minimum, maximum, mean, median, and standard deviation from prepared tour values.
+ sources: [skimjoin_tour_component_stats]
+ summary: The table reports descriptive statistics for the selected tour skim family, direction, and scenario.
+ details:
+ Aggregation:
+ - Prepared skim statistics provide the valid count, missing share, zero share, minimum, maximum, mean, median, and standard deviation for each component and mode.
+ Filters:
+ - Family, direction, and scenario selectors choose the displayed summary slice.
+
+ tour_skims.distributions:
+ method: direct_records
+ method_text: Usable outbound and inbound skim values are binned separately and totaled with tour weights; unweighted mode counts tour rows instead.
+ sources: [prepared tour skim columns]
+ summary: Prepared tour skim values are filtered separately for outbound and inbound direction, then grouped into distributions.
+ source_filters:
+ - Records without a usable value for the selected directional skim component are excluded.
+ details:
+ Aggregation:
+ - Values are binned and each bin is summed using tour weights when weighted mode is active, otherwise record counts.
+ Filters and display:
+ - Scenario, component, mode, and direction-specific range controls determine the included records and values.
+
+ regional_validation.flows:
+ method: aligned_comparison
+ method_text: Duplicate origin-destination pairs are summed within the observed and modeled sources, then the two matrices are joined by origin and destination before the selected cell-by-cell comparison is calculated.
+ sources: [county_flows_validation_summary, county_flows_joja_validation_summary, commuting_flows]
+ summary: Observed and modeled origin-destination flow matrices are aligned by geography pair and displayed beside a comparison matrix.
+ source_filters:
+ - Modeled flows include only workers with known home and workplace zones.
+ - Flow rows without both an origin and destination geography are excluded.
+ details:
+ Aggregation:
+ - Observed flows come from the selected validation matrix; modeled commuters come from the commuting-flows summary.
+ - Duplicate origin-destination pairs are summed before the two matrices are joined.
+ Comparison:
+ - The selected metric is computed cell by cell from aligned observed and modeled values.
+ - Include Totals adds origin, destination, and grand-total cells to the matrices.
+
+ transit_validation.boardings:
+ method: grouped_counts
+ method_text: Prepared boarding values are restricted to the selected transit technology and summed by operator; rows missing any required grouping field or boarding value do not contribute.
+ sources: [transit_boardings_by_operator_and_technology]
+ summary: Transit boardings are filtered by technology and grouped by operator.
+ source_filters:
+ - Records without an operator, transit technology, or boarding value are excluded.
+ details:
+ Aggregation:
+ - Boardings for matching operator and technology rows are summed within each run.
+ Display:
+ - Operator categories are aligned across runs and displayed using the summary labels.
+ - Percent mode divides each operator by total boardings in the selected technology slice.
+
+ transit_validation.transfer_rate:
+ method: rates_and_averages
+ method_text: Boardings and linked transit trips are totaled for each operator, technology, and access mode, then boardings are divided by linked trips; values are not averaged across already-calculated operator rates.
+ sources: [transit_transfer_rate]
+ summary: Transfer rate is shown as average boardings per linked transit trip by operator for the selected technology and access mode.
+ formula: transfer rate = transit boardings ÷ linked transit trips
+ source_filters:
+ - Records without an operator, transit technology, or access mode are excluded.
+ details:
+ Filters:
+ - Technology and access-mode filters are applied before operator values are displayed.
+ Important details:
+ - A value of 1.0 represents no transfer on average; values above 1.0 reflect additional boardings per linked trip.
+
+ traffic.facility_summary:
+ method: aligned_comparison
+ method_text: Daily observed and modeled volumes are paired by count-location ID and grouped by facility type. Location count, percent RMSE, and R-squared are calculated from those paired daily values.
+ sources: [count_location_counts_validation_summary, count_location_volumes_validation_summary, count_location_scatter_validation_summary, count_location_fit_validation_summary]
+ summary: The table summarizes daily count-location validation statistics by facility type.
+ details:
+ Aggregation:
+ - Daily observed and modeled volumes are paired by count location and grouped by facility type.
+ - The table reports location count, percent RMSE, and R-squared using the prepared fit summary when available.
+ Important details:
+ - This overview always uses unfiltered daily totals; the controls below apply to other traffic charts.
+
+ traffic.count_locations:
+ method: aligned_comparison
+ method_text: After period and facility filters are applied, one observed count and one modeled volume are paired by count-location ID; each successfully paired location becomes one scatter point.
+ sources: [count_location_counts_validation_summary, count_location_volumes_validation_summary, count_location_scatter_validation_summary, count_location_fit_validation_summary]
+ summary: The scatter plot pairs observed traffic counts with modeled count-location volumes for the selected period and facility type.
+ details:
+ Aggregation:
+ - Observed and modeled values are joined by count-location identifier after applying the selected filters.
+ - A one-to-one reference line is always shown; a fitted regression line is added when fit coefficients are available.
+ Display:
+ - Each point is one validated count location.
+
+ traffic.link_volume:
+ method: supplied_aggregation
+ method_text: The selected AM, midday, PM, or daily link-volume column is read from the prepared link summary, then link values are summed within facility type.
+ sources: [link_validation_summary]
+ summary: Modeled link volume is summed by facility type for the selected time period.
+ formula: facility-type volume = sum of selected-period volume across links in that facility type
+ details:
+ Aggregation:
+ - AM, MD, PM, or daily volume is selected from the corresponding link-volume column before grouping.
+ Display:
+ - Facility-type codes are displayed using configured category labels and ordering.
+
+ traffic.top_count_locations:
+ method: aligned_comparison
+ method_text: Observed and modeled values are paired by count-location ID, sorted from highest to lowest modeled volume, and truncated to the requested number of locations.
+ sources: [count_location_counts_validation_summary, count_location_volumes_validation_summary]
+ summary: The table lists the count locations with the highest modeled volume for the selected period.
+ details:
+ Aggregation:
+ - Observed counts and modeled volumes are joined by count-location identifier.
+ - Rows are sorted by modeled volume in descending order and limited to the selected Top N value.
+ Display:
+ - Location and facility metadata are retained when available.
+
+ traffic.screenlines:
+ method: aligned_comparison
+ method_text: Records are first summed by screenline, direction, and count period; observed and modeled totals with the same three-part key are then paired for the scatter plot.
+ sources: [screenline_flow_comparisons]
+ summary: The scatter plot compares observed and modeled screenline flows.
+ source_filters:
+ - Screenline records without an identifier, direction, count period, or observed volume are excluded before matching modeled flows.
+ details:
+ Aggregation:
+ - Flow records are grouped by screenline, direction, and count period before observed and modeled volumes are paired.
+ Display:
+ - Each point represents one comparable screenline-direction-period record.
+
+ vmt.overview:
+ method: vmt
+ method_text: Each travel category is summed from its own prepared VMT summary. Category share divides that category by the sum of all available category totals for the run.
+ sources: [auto_vmt_by_home_geography_income_hhsize_time_period, non_motorized_vmt_by_home_geography_income_hhsize_time_period, external_vmt_validation_summary, commercial_vehicle_vmt_validation_summary]
+ summary: The table compares total VMT and share of combined VMT for personal auto, non-motorized, external, and commercial travel.
+ formula: category share = category VMT ÷ sum of all available VMT categories × 100
+ source_filters:
+ - Personal-auto totals use records with a usable configured distance source; non-motorized totals include only walk, bicycle, and e-bike trips with usable distances.
+ details:
+ Aggregation:
+ - Each category total is summed from its prepared VMT summary for the current run and weighting mode.
+ Important details:
+ - Categories whose summaries are unavailable contribute zero to the displayed total.
+
+ vmt.personal_auto:
+ method: vmt
+ method_text: Each eligible auto trip contributes its distance times final trip weight, divided by vehicle occupancy so shared-ride passenger records do not each contribute a full vehicle trip.
+ sources: [auto_vmt_by_home_geography_income_hhsize_time_period]
+ summary: Personal auto VMT estimates vehicle miles traveled by weighted auto trips and allocates shared-ride travel across vehicle occupants.
+ formula: trip distance × final trip weight ÷ vehicle occupancy
+ source_filters:
+ - When a prepared auto skim distance is available, records without that distance are excluded.
+ - Otherwise, only configured auto-mode trips with a usable origin-to-destination distance are included.
+ details:
+ Included records:
+ - Trips with a usable auto distance and final trip weight.
+ - Auto modes are reported separately when mode information is available.
+ Grouping and filters:
+ - Results are grouped by the selected home geography, income segment, household size, time period, or mode.
+ - When geography is the breakdown, the chart shows the 25 geographies with the highest VMT.
+ Important details:
+ - Vehicle occupancy uses the number of tour participants when available and otherwise defaults to one.
+ - Daily summary rows are preferred when all time periods are selected.
+
+ vmt.non_motorized:
+ method: vmt
+ method_text: Each eligible walk, bicycle, or e-bike trip contributes its mode-appropriate distance times final trip weight; these weighted miles are then summed by the selected breakdown.
+ sources: [non_motorized_vmt_by_home_geography_income_hhsize_time_period]
+ summary: Non-motorized miles traveled are weighted trip miles for walk, bicycle, and e-bike trips.
+ formula: trip distance × final trip weight
+ source_filters:
+ - Only walk, bicycle, and e-bike trips with a usable mode-appropriate distance are included.
+ details:
+ Included records:
+ - Walk, bicycle, and e-bike trips with a usable distance and final trip weight.
+ Grouping and filters:
+ - Results are grouped by the selected home geography, income segment, household size, time period, or mode.
+ - When geography is the breakdown, the chart shows the 25 geographies with the highest mileage.
+ Important details:
+ - The distance source recorded in the prepared summary is used; this may be a trip distance or a configured skim distance.
+
+ vmt.external_travel:
+ method: supplied_aggregation
+ method_text: The requested trip or VMT measure is selected from the prepared external-travel summary, filtered on the nondisplayed dimension, and summed into the displayed time-period or purpose categories.
+ sources: [external_trip_validation_summary, external_vmt_validation_summary]
+ summary: The chart filters the prepared external-travel summary and compares either trips or VMT by time period or trip purpose.
+ details:
+ Aggregation:
+ - Values for the selected metric are summed into the displayed time-period or trip-purpose categories.
+ - Selecting a specific time period or purpose filters the other dimension before aggregation.
+ Important details:
+ - VMT and trip totals come from separate prepared validation summaries.
+ - Percent display shows each displayed category as a share of the applicable total.
+
+ vmt.commercial_travel:
+ method: supplied_aggregation
+ method_text: The requested trip or VMT columns are selected from the prepared commercial-vehicle summary, filtered on the nondisplayed dimension, and summed into the displayed period or vehicle categories.
+ sources: [commercial_vehicle_validation_summary, commercial_vehicle_vmt_validation_summary]
+ summary: The chart filters the prepared commercial-vehicle summary and compares either trips or VMT by time period or vehicle type.
+ details:
+ Aggregation:
+ - Values are summed across the commercial-vehicle columns into the displayed time-period or vehicle-type categories.
+ - Selecting a specific time period or vehicle type filters the other dimension before aggregation.
+ Important details:
+ - VMT and trip totals come from separate prepared validation summaries.
+ - Percent display shows each displayed category as a share of the applicable total.
+
+ vmt.bicycle:
+ method: vmt
+ method_text: Prepared bicycle VMT values are grouped by their supplied facility type and summed; this view does not recalculate trip distances from raw bicycle trips.
+ sources: [bicycle_vmt_by_facility_type]
+ summary: Bicycle VMT is read from the prepared bicycle-facility summary and displayed by facility type.
+ source_filters:
+ - Records without a facility type or bicycle-VMT value are excluded.
+ details:
+ Aggregation:
+ - Bicycle VMT is grouped by the facility type supplied in the summary.
+ Important details:
+ - Percent display uses the percentage values supplied with the prepared summary.
+
+ overview.percent_difference:
+ method: rates_and_averages
+ method_text: Run-level totals are aligned by metric, and each comparison run is measured against the same metric in the first loaded run; the base run is therefore zero by definition.
+ sources: [population_totals, auto_vmt_totals]
+ summary: This table compares each run's population, households, tours, trips, stops, and auto VMT with the first loaded run.
+ formula: percent difference = (comparison run − base run) ÷ base run × 100
+ source_filters:
+ - Stops include only trip records marked as intermediate stops.
+
+ overview.person_type:
+ method_text: Prepared persons with a person type are grouped by that type and summed with person weights; unweighted mode counts person records instead.
+ sources: [person_type_distribution]
+ summary: This plot sums person weights by person type.
+
+ overview.household_size:
+ method_text: Prepared households are assigned to their household-size category, sizes of five or more are recoded to 5+, and household weights are summed within each category.
+ sources: [household_size_distribution]
+ summary: This plot sums household weights by household size, combining households of five or more people into 5+.
+
+ daily_activity.activity_pattern:
+ method_text: Prepared persons with a coordinated daily activity pattern are grouped by that pattern and summed with person weights.
+ sources: [daily_activity_pattern_by_person_type]
+ summary: This plot sums person weights by coordinated daily activity pattern.
+ source_filters:
+ - Persons without a daily activity pattern are excluded.
+
+ daily_activity.mandatory_tour_frequency:
+ method_text: Eligible persons are grouped by their mandatory-tour-frequency choice and summed with person weights; zero or missing choices are not included.
+ sources: [mandatory_tour_frequency_by_person_type]
+ summary: This plot sums person weights by mandatory-tour-frequency choice.
+ source_filters:
+ - Only persons with a mandatory-tour-frequency choice greater than zero are included.
+
+ daily_activity.nonmandatory_tour_frequency:
+ method_text: Non-mandatory tours are counted for each person, that person-level tour count becomes the frequency category, and person weights are summed within the resulting categories.
+ sources: [nonmandatory_tour_frequency_by_person_type]
+ summary: This plot sums person weights by the number of individual non-mandatory tours.
+ source_filters:
+ - Only tours categorized as non-mandatory contribute to the tour count.
+
+ daily_activity.tour_rate:
+ method: rates_and_averages
+ method_text: Weighted tours are totaled by purpose and person type, then divided by the weighted number of people in that person-type group; combined groups are recalculated from totals.
+ sources: [tour_rates_by_person_type_and_tour_purpose]
+ summary: This plot divides weighted tours of each purpose by the weighted person population.
+ formula: tour rate = weighted tours ÷ weighted persons
+ source_filters:
+ - Tours without a person or tour purpose and persons without a person type or weight are excluded.
+
+ daily_activity.trip_rate:
+ method: rates_and_averages
+ method_text: Weighted trips are totaled by purpose and person type, then divided by the weighted number of people in that person-type group; combined groups are recalculated from totals.
+ sources: [trip_rates_by_person_type_and_trip_purpose]
+ summary: This plot divides weighted trips of each purpose by the weighted person population.
+ formula: trip rate = weighted trips ÷ weighted persons
+ source_filters:
+ - Trips without a person or trip purpose and persons without a person type are excluded.
+
+ joint_travel.household_size:
+ method_text: Households appearing on at least one joint tour are identified once, joined to household size, and summed with household weights so multiple joint tours do not duplicate a household.
+ sources: [joint_tours_by_household_size]
+ summary: This plot sums the weights of households with at least one joint tour by household size.
+ source_filters:
+ - Only tours categorized as joint are used to identify participating households.
+
+ joint_travel.party_size:
+ method_text: Participants are counted for each joint tour, party sizes of five or more are recoded to 5+, and tour weights are summed within each party-size category.
+ sources: [joint_tour_party_size_distribution]
+ summary: This plot sums joint-tour weights by number of participants, combining party sizes of five or more into 5+.
+ source_filters:
+ - Only tours categorized as joint are included.
+
+ joint_travel.composition:
+ method_text: Each eligible joint tour retains its participant count and prepared party-composition category; tour weights are then summed for each party-size and composition combination.
+ sources: [joint_tour_composition_by_party_size]
+ summary: This plot sums joint-tour weights by party composition for the selected party size.
+ source_filters:
+ - Only joint tours with a non-missing composition and participant count are included.
+
+ joint_travel.person_participation:
+ method_text: People participating in one or more joint tours are identified once, joined to household size, and summed with person weights within each household-size category.
+ sources: [person_jtp_by_household_size]
+ summary: This plot compares the weighted number of people participating in one or more joint tours by household size.
+ source_filters:
+ - A participant must have a positive number of joint tours and a known household size.
+
+ joint_travel.household_participation:
+ method: rates_and_averages
+ method_text: Within each household-size group, weighted households are totaled by joint-tour-count category and divided by the weighted total number of eligible households in that size group.
+ sources: [household_jtp_by_household_size_and_jtf]
+ summary: This plot shows the share of households in each joint-tour-count category by household size.
+ formula: household share = weighted households in category ÷ weighted households in household-size group
+ source_filters:
+ - Only households with at least two people are included.
+
+ individual_choices.license:
+ method_text: Persons age 16 or older with a known license status are grouped by that status and summed with person weights.
+ sources: [license_holding_status_distribution]
+ summary: This plot sums person weights by driver's-license status.
+ source_filters:
+ - Only persons age 16 or older with a known license status are included.
+
+ individual_choices.bicycle_comfort:
+ method_text: Persons with a bicycle-comfort response are grouped by the prepared comfort level and summed with person weights.
+ sources: [bicycle_comfort_level_distribution]
+ summary: This plot sums person weights by bicycle comfort level.
+ source_filters:
+ - Persons without a bicycle comfort value are excluded.
+
+ individual_choices.transit_pass:
+ method_text: Persons with a known transit-pass ownership value are grouped by ownership status and person type, then summed with person weights.
+ sources: [transit_pass_ownership_by_person_type]
+ summary: This plot sums person weights by transit-pass ownership status.
+ source_filters:
+ - Persons without a transit-pass ownership value are excluded.
+
+ individual_choices.transit_subsidy:
+ method_text: Workers with a known transit-subsidy value are grouped by subsidy status and person type, then summed with person weights.
+ sources: [transit_subsidy_by_person_type]
+ summary: This plot sums worker weights by transit-subsidy status.
+ source_filters:
+ - Only workers with a known transit-subsidy value are included.
+
+ vehicle_ownership.auto_ownership:
+ method_text: Households are grouped jointly by household size and number of owned vehicles, and household weights are summed for each combination.
+ sources: [auto_ownership_distribution]
+ summary: This plot sums household weights by number of owned vehicles for the selected household size.
+
+ vehicle_ownership.autonomous_vehicle_kpi:
+ method: totals
+ method_text: Households whose autonomous-vehicle ownership flag is true are selected and their household weights are summed once per household.
+ sources: [autonomous_vehicle_ownership_totals]
+ summary: This indicator sums household weights for households that own an autonomous vehicle.
+ source_filters:
+ - Only households whose autonomous-vehicle ownership flag is true are included.
+
+ vehicle_ownership.vehicle_age:
+ method_text: Prepared vehicle records with a usable age are grouped by vehicle age and summed with their associated vehicle weights.
+ sources: [vehicle_age_distribution]
+ summary: This plot sums vehicle weights by vehicle age.
+ source_filters:
+ - Vehicles without an age value are excluded.
+
+ vehicle_ownership.vehicle_fuel:
+ method_text: Prepared vehicle records with a usable fuel type are grouped by fuel category and summed with their associated vehicle weights.
+ sources: [vehicle_fuel_type_distribution]
+ summary: This plot sums vehicle weights by fuel type.
+ source_filters:
+ - Vehicles without a fuel-type value are excluded.
+
+ vehicle_ownership.vehicle_body:
+ method_text: Prepared vehicle records with a usable body type are grouped by body category and summed with their associated vehicle weights.
+ sources: [vehicle_body_type_distribution]
+ summary: This plot sums vehicle weights by body type.
+ source_filters:
+ - Vehicles without a body-type value are excluded.
+
+ mandatory_location.work_from_home:
+ method: rates_and_averages
+ method_text: Within each home geography, the weighted number of workers flagged as working from home is divided by the weighted total number of workers.
+ sources: [work_from_home_rate_by_geography]
+ summary: This plot divides the weighted number of workers who work from home by all weighted workers in each home geography.
+ formula: work-from-home rate = weighted work-from-home workers ÷ weighted workers
+ source_filters:
+ - Only workers with a known home zone are included.
+
+ mandatory_location.telecommute:
+ method_text: Workers whose usual workplace is not home are grouped by their nonblank telecommute-frequency category and summed with worker weights.
+ sources: [telecommute_frequency_distribution]
+ summary: This plot sums worker weights by telecommute-frequency category.
+ source_filters:
+ - Only workers with a nonblank telecommute-frequency value are included.
+ - Workers whose usual workplace is home are excluded.
+
+ mandatory_location.work_distance:
+ method: binned_values
+ method_text: Eligible workers are assigned the distance from home to their workplace zone, placed in a distance bin, and summed with person weights within each bin.
+ sources: [work_location_distance_distribution_by_geography]
+ summary: This plot bins weighted workers by distance from home to their assigned workplace.
+ source_filters:
+ - Only workers assigned to a workplace zone greater than zero are included.
+
+ mandatory_location.university_distance:
+ method: binned_values
+ method_text: Eligible university students are assigned the distance from home to their school zone, placed in a distance bin, and summed with person weights within each bin.
+ sources: [university_location_distance_distribution_by_geography]
+ summary: This plot bins weighted university students by distance from home to their assigned school.
+ source_filters:
+ - Only students assigned to a school zone greater than zero and coded as person type 3 are included.
+
+ mandatory_location.school_distance:
+ method: binned_values
+ method_text: Eligible school students are assigned the distance from home to their school zone, placed in a distance bin, and summed with person weights within each bin.
+ sources: [school_location_distance_distribution_by_geography]
+ summary: This plot bins weighted school students by distance from home to their assigned school.
+ source_filters:
+ - Only students assigned to a school zone greater than zero and coded as person type 6 or higher are included.
+
+ mandatory_location.worker_status_table:
+ method: rates_and_averages
+ method_text: Weighted workers are totaled separately by internal/external status within home geography; external share divides the external total by the combined internal and external total.
+ sources: [internal_external_worker_by_geography]
+ summary: This table reports weighted internal and external worker totals and the external-worker share by home geography.
+ source_filters:
+ - Only workers with a known external-worker flag and home zone are included.
+
+ mandatory_location.external_workplace:
+ method_text: Workers flagged as external are grouped by their assigned external workplace zone and summed with person weights.
+ sources: [external_worker_workplace_locations]
+ summary: This plot sums external-worker weights by assigned external workplace zone.
+ source_filters:
+ - Only workers flagged as external workers with a known external workplace zone are included.
+
+ mandatory_location.average_distance:
+ method: rates_and_averages
+ method_text: For each eligible purpose and geography, weighted distance is summed and divided by the corresponding weighted record total; run differences are calculated only after those averages are formed.
+ sources: [average_mandatory_tour_distance_by_purpose_and_geography]
+ summary: This table reports weighted average mandatory-tour distance and compares each run with the first loaded run.
+ formula: average distance = weighted tour distance ÷ weighted tours
+ source_filters:
+ - Work rows include workers assigned to a workplace zone greater than zero.
+ - University rows include students assigned to a school zone greater than zero and coded as person type 3.
+ - School rows include students assigned to a school zone greater than zero and coded as person type 6 or higher.
+
+ escorted_tours.student_status:
+ method_text: Students with explicit outbound and inbound escort labels are grouped by the status for the direction named in the plot, then summed with student weights.
+ sources: [student_school_escort_status_by_direction]
+ summary: This plot sums student weights by school-escort status for the direction named in the plot title.
+ source_filters:
+ - Students must have nonblank outbound and inbound school-escort labels.
+
+ escorted_tours.households:
+ method: rates_and_averages
+ method_text: Student households are totaled by student count, and the households with a nonblank escort label for the named direction are divided by the matching student-household total.
+ sources: [student_households_by_student_count, households_with_school_escorting_by_student_count_and_direction]
+ summary: This plot shows the weighted share of student households that escort for the direction named in the plot title.
+ formula: escorting-household share = weighted escorting households ÷ weighted student households
+ source_filters:
+ - A household is counted for a direction only when its corresponding school-escort label is nonblank.
+
+ escorted_tours.students_per_tour:
+ method: rates_and_averages
+ method_text: The weighted number of escorted students is totaled for the named direction and divided by the corresponding weighted number of escorted tours.
+ sources: [schoolkids_per_escorted_tour_by_student_count_and_direction]
+ summary: This plot reports the weighted average number of escorted students per escorted tour.
+ formula: students per tour = weighted escorted students ÷ weighted escorted tours
+ source_filters:
+ - Tours must match the direction named in the plot and have a known number of escorted students.
+
+ escorted_tours.chauffeur_stops:
+ method_text: Eligible escort events are counted for each chauffeur tour in the named direction, and chauffeur-tour weights are summed by the resulting pickup or drop-off count.
+ sources: [adult_escort_event_stop_distribution]
+ summary: This plot sums chauffeur-tour weights by the number of pickup or drop-off escort events.
+ source_filters:
+ - Outbound events are drop-offs with an explicit outbound escort label; inbound events are pickups with an explicit inbound escort label.
+
+ escorted_tours.chauffeur_person_type:
+ method_text: Escorted tours with a known chauffeur are restricted to the selected direction, grouped by chauffeur person type, and summed with tour weights.
+ sources: [adult_escorted_tours_by_person_type_and_direction]
+ summary: This plot sums escorted-tour weights by chauffeur person type for the selected direction.
+ source_filters:
+ - Tours must have a known chauffeur person type and an explicit escort label for the selected direction.
+
+ escorted_tours.tour_distance:
+ method: binned_values
+ method_text: Eligible chauffeur tours in the selected direction are assigned to a tour-distance bin and summed with tour weights within each bin.
+ sources: [adult_escorted_tour_distance_distribution_by_direction]
+ summary: This plot bins weighted chauffeur tours by tour distance for the selected direction.
+ source_filters:
+ - Tours must have a usable tour distance and an explicit escort label for the selected direction.
+
+ escorted_tours.trip_distance:
+ method: binned_values
+ method_text: Trips belonging to eligible chauffeur tours in the selected direction are assigned to a trip-distance bin and summed with trip weights within each bin.
+ sources: [adult_escorted_trip_distance_distribution_by_direction]
+ summary: This plot bins weighted chauffeur trips by trip distance for the selected direction.
+ source_filters:
+ - Trips must have a usable distance and belong to a tour with an explicit escort label for the selected direction.
+
+ tour_purpose.category:
+ method_text: Tours with a broad category are grouped by that category and summed with tour weights.
+ sources: [tour_category_distribution]
+ summary: This plot sums tour weights by broad tour category.
+ source_filters:
+ - Tours without a tour category are excluded.
+
+ tour_purpose.purpose:
+ method_text: Tours with a detailed purpose are grouped by that purpose and summed with tour weights.
+ sources: [tour_purpose_distribution]
+ summary: This plot sums tour weights by detailed tour purpose.
+ source_filters:
+ - Tours without a tour purpose are excluded.
+
+ tour_mode.mode:
+ method_text: Tours are restricted to the purpose and auto-sufficiency segment named in the plot, grouped by tour mode, and summed with tour weights.
+ sources: [tour_mode_by_tour_purpose_and_auto_sufficiency]
+ summary: This plot sums tour weights by mode for the purpose and auto-sufficiency segment named in the plot title.
+ source_filters:
+ - Tours without a purpose, tour mode, or household auto-sufficiency value are excluded.
+
+ tour_mode.vehicle_age:
+ method_text: Tours with a usable allocated-vehicle code are split by occupancy, the vehicle age is decoded from that allocation, and the associated weights are summed by age.
+ sources: [allocated_vehicle_age_by_occupancy]
+ summary: This plot sums the weights of vehicles allocated to tours by vehicle age and occupancy.
+ source_filters:
+ - Tours without an allocated vehicle code, or whose code cannot supply body, age, and fuel components, are excluded.
+
+ tour_mode.vehicle_fuel:
+ method_text: Tours with a usable allocated-vehicle code are split by occupancy, the fuel type is decoded from that allocation, and the associated weights are summed by fuel type.
+ sources: [allocated_vehicle_fuel_type_by_occupancy]
+ summary: This plot sums the weights of vehicles allocated to tours by fuel type and occupancy.
+ source_filters:
+ - Tours without an allocated vehicle code, or whose code cannot supply body, age, and fuel components, are excluded.
+
+ tour_mode.vehicle_body:
+ method_text: Tours with a usable allocated-vehicle code are split by occupancy, the body type is decoded from that allocation, and the associated weights are summed by body type.
+ sources: [allocated_vehicle_body_type_by_occupancy]
+ summary: This plot sums the weights of vehicles allocated to tours by body type and occupancy.
+ source_filters:
+ - Tours without an allocated vehicle code, or whose code cannot supply body, age, and fuel components, are excluded.
+
+ tour_time.departure:
+ method: binned_values
+ method_text: Eligible tours are restricted to the selected purpose, assigned to the configured bin containing their departure time, and summed with tour weights.
+ sources: [tour_time_of_day_by_tour_purpose]
+ summary: This plot sums tour weights by tour departure-time bin.
+ source_filters:
+ - Tours without a purpose or with a time bin outside the configured model day are excluded.
+
+ tour_time.arrival:
+ method: binned_values
+ method_text: Eligible tours are restricted to the selected purpose, assigned to the configured bin containing their arrival time, and summed with tour weights.
+ sources: [tour_time_of_day_by_tour_purpose]
+ summary: This plot sums tour weights by tour arrival-time bin.
+ source_filters:
+ - Tours without a purpose or with a time bin outside the configured model day are excluded.
+
+ tour_time.duration:
+ method: binned_values
+ method_text: Elapsed duration is calculated from each eligible tour's start and end periods, assigned to a duration bin, and summed with tour weights.
+ sources: [tour_time_of_day_by_tour_purpose]
+ summary: This plot sums tour weights by elapsed tour-duration bin.
+ source_filters:
+ - Tours without a purpose or with a time bin outside the configured model day are excluded.
+
+ tour_stop_frequency.direction:
+ method_text: Tours are restricted to the selected purpose, grouped by the outbound or inbound stop count named in the plot, and summed with tour weights.
+ sources: [tour_stop_frequency_by_tour_purpose]
+ summary: This plot sums tour weights by stop count for the direction named in the plot title.
+ source_filters:
+ - Tours without a tour category are excluded.
+
+ tour_stop_frequency.atwork:
+ method_text: Eligible mandatory work tours are grouped by their at-work subtour-frequency choice, and the associated person weights are summed within each choice.
+ sources: [atwork_subtour_frequency_distribution]
+ summary: This plot sums person weights by at-work sub-tour-frequency choice.
+ source_filters:
+ - Only mandatory work tours with a known at-work sub-tour-frequency value are included.
diff --git a/dashboard/components.py b/dashboard/components.py
deleted file mode 100644
index f7c7aed..0000000
--- a/dashboard/components.py
+++ /dev/null
@@ -1,609 +0,0 @@
-"""Shared chart builders, layout helpers, and color palette for ActivitySim visualizer."""
-
-from __future__ import annotations
-import html
-import math
-import panel as pn
-import plotly.graph_objects as go
-import polars as pl
-import numpy as np
-
-# Color palette for multiple runs (colorblind-friendly)
-_DEFAULT_RUN_COLORS = [
- "#4E79A7",
- "#F28E2B",
- "#59A14F",
- "#E15759",
- "#76B7B2",
- "#EDC948",
- "#B07AA1",
- "#9C755F",
-]
-RUN_COLORS = list(_DEFAULT_RUN_COLORS)
-RUN_LABEL_ORDER: list[str] = []
-_DISPLAY_PERCENT_MODE = False
-
-
-def _percent_mode(as_percent: bool | None) -> bool:
- return _DISPLAY_PERCENT_MODE if as_percent is None else bool(as_percent)
-
-
-def run_color(idx: int) -> str:
- return RUN_COLORS[idx % len(RUN_COLORS)]
-
-
-def run_color_for_label(label: str, fallback_idx: int) -> str:
- label_str = str(label)
- if label_str in RUN_LABEL_ORDER:
- return run_color(RUN_LABEL_ORDER.index(label_str))
- return run_color(fallback_idx)
-
-
-def set_run_colors(colors: list[str] | None) -> None:
- global RUN_COLORS
- RUN_COLORS = list(colors) if colors else list(_DEFAULT_RUN_COLORS)
-
-
-def set_run_label_order(labels: list[str] | None) -> None:
- global RUN_LABEL_ORDER
- RUN_LABEL_ORDER = [str(label) for label in (labels or [])]
-
-
-def set_percent_mode(enabled: bool) -> None:
- global _DISPLAY_PERCENT_MODE
- _DISPLAY_PERCENT_MODE = bool(enabled)
-
-
-def build_run_legend_entries(run_labels: list[str]) -> list[dict[str, str]]:
- """Return ordered run legend entries with the configured display colors."""
- return [
- {"label": str(label), "color": run_color(index)}
- for index, label in enumerate(run_labels)
- ]
-
-
-def run_legend_item_html(label: str, color: str) -> str:
- """Return the shared HTML used for one run legend item."""
- safe_label = html.escape(str(label))
- safe_color = html.escape(str(color), quote=True)
- return (
- f''
- f'{safe_label}
'
- )
-
-
-def build_run_legend_panes(run_labels: list[str]) -> list[pn.pane.HTML]:
- """Return sidebar-ready panes for the configured run legend."""
- return [
- pn.pane.HTML(run_legend_item_html(entry["label"], entry["color"]))
- for entry in build_run_legend_entries(run_labels)
- ]
-
-
-def _layout(
- fig: go.Figure,
- title: str,
- xaxis_title: str,
- yaxis_title: str,
- height: int,
- barmode: str | None = None,
-) -> None:
- layout_kwargs = dict(
- title=dict(text=title, x=0.01, xanchor="left", y=0.98, yanchor="top"),
- height=height,
- xaxis_title=xaxis_title,
- yaxis_title=yaxis_title,
- legend=dict(orientation="h", yanchor="bottom", y=1.12, x=0),
- margin=dict(l=60, r=20, t=90, b=90),
- title_font=dict(size=16),
- font=dict(family="Inter, Segoe UI, Arial, sans-serif", size=12),
- hoverlabel=dict(font=dict(size=12)),
- )
- if barmode is not None:
- layout_kwargs["barmode"] = barmode
- fig.update_layout(**layout_kwargs)
- fig.update_xaxes(automargin=True, tickangle=-20)
- fig.update_yaxes(automargin=True)
- fig.update_traces(marker_line_width=0.4)
-
-
-def bar_chart(
- data_list: list[tuple[str, pl.DataFrame]],
- x_col: str,
- y_col: str,
- title: str = "",
- xaxis_title: str = "",
- yaxis_title: str = "Count",
- barmode: str = "group",
- pct_col: str | None = None,
- height: int = 400,
- as_percent: bool | None = None,
- xaxis_categoryarray: list[object] | None = None,
-) -> pn.pane.Plotly:
- """
- Create a grouped bar chart comparing multiple runs.
-
- Args:
- data_list: list of (run_label, DataFrame) tuples
- x_col: column for x-axis categories
- y_col: column for y-axis values
- title: chart title
- xaxis_title: x-axis label
- yaxis_title: y-axis label
- barmode: 'group' or 'stack'
- pct_col: optional column for percentage values (shown in hover)
- height: chart height in pixels
- """
- def _align_categories(
- df: pl.DataFrame,
- categories: list[object],
- ) -> pl.DataFrame:
- if x_col not in df.columns:
- return df
- category_frame = pl.DataFrame({x_col: categories}, schema={x_col: pl.Utf8})
- aligned = category_frame.join(
- df.with_columns(pl.col(x_col).cast(pl.Utf8)),
- on=x_col,
- how="left",
- )
- fill_exprs = []
- if y_col in aligned.columns:
- fill_exprs.append(pl.col(y_col).fill_null(0.0).cast(pl.Float64).alias(y_col))
- return aligned.with_columns(fill_exprs) if fill_exprs else aligned
-
- fig = go.Figure()
- percent_mode = _percent_mode(as_percent)
- category_order: list[object] = []
- for i, (label, df) in enumerate(data_list):
- if df is None or len(df) == 0:
- continue
- if xaxis_categoryarray is not None:
- df = _align_categories(df, [str(value) for value in xaxis_categoryarray])
- color = run_color_for_label(label, i)
- x = df[x_col].to_list()
- for value in x:
- if value not in category_order:
- category_order.append(value)
- y = np.array(df[y_col].to_list(), dtype=float)
- if percent_mode and y.sum() > 0:
- y = y / y.sum() * 100.0
- y_list = y.tolist()
- yy_title = f"Percent of {yaxis_title} (%)" if percent_mode else yaxis_title
- hover = [
- f"{label}
{xaxis_title or x_col}: {xi}
{yy_title}: {yi:,.1f}"
- for xi, yi in zip(x, y_list)
- ]
- fig.add_trace(
- go.Bar(
- name=label,
- x=x,
- y=y_list,
- marker_color=color,
- hovertemplate="%{customdata}",
- customdata=hover,
- )
- )
- _layout(
- fig,
- title,
- xaxis_title,
- f"Percent of {yaxis_title} (%)" if percent_mode else yaxis_title,
- height,
- barmode=barmode,
- )
- final_category_order = xaxis_categoryarray or category_order
- if final_category_order:
- fig.update_xaxes(
- type="category",
- categoryorder="array",
- categoryarray=final_category_order,
- )
- return pn.pane.Plotly(fig, sizing_mode="stretch_width")
-
-
-def line_chart(
- data_list: list[tuple[str, pl.DataFrame]],
- x_col: str,
- y_col: str,
- title: str = "",
- xaxis_title: str = "",
- yaxis_title: str = "",
- height: int = 350,
- as_percent: bool | None = None,
-) -> pn.pane.Plotly:
- """Create an overlaid line chart for density/profile comparisons."""
- fig = go.Figure()
- percent_mode = _percent_mode(as_percent)
- for i, (label, df) in enumerate(data_list):
- if df is None or len(df) == 0:
- continue
- color = run_color_for_label(label, i)
- y = np.array(df[y_col].to_list(), dtype=float)
- if percent_mode and y.sum() > 0:
- y = y / y.sum() * 100.0
- fig.add_trace(
- go.Scatter(
- name=label,
- x=df[x_col].to_list(),
- y=y.tolist(),
- mode="lines",
- line=dict(color=color, width=2),
- )
- )
- _layout(
- fig,
- title,
- xaxis_title,
- f"Percent of {yaxis_title} (%)" if percent_mode else yaxis_title,
- height,
- )
- return pn.pane.Plotly(fig, sizing_mode="stretch_width")
-
-
-def density_chart(
- data_list: list[tuple[str, pl.DataFrame]],
- x_col: str,
- y_col: str,
- title: str = "",
- xaxis_title: str = "",
- yaxis_title: str = "Frequency",
- normalize: bool = False,
- height: int = 350,
- as_percent: bool | None = None,
- xaxis_range: tuple[float, float] | None = None,
- xaxis_categoryarray: list[object] | None = None,
- xaxis_tickvals: list[object] | None = None,
- xaxis_ticktext: list[str] | None = None,
-) -> pn.pane.Plotly:
- """
- Create an overlaid density/histogram line chart.
- If normalize=True, convert to proportions (sum=100%).
- """
- fig = go.Figure()
- percent_mode = _percent_mode(as_percent)
- for i, (label, df) in enumerate(data_list):
- if df is None or len(df) == 0:
- continue
- color = run_color_for_label(label, i)
- x = df[x_col].to_list()
- y = np.array(df[y_col].to_list(), dtype=float)
- if (percent_mode or normalize) and y.sum() > 0:
- y = y / y.sum() * 100
- fig.add_trace(
- go.Scatter(
- name=label,
- x=x,
- y=y.tolist(),
- mode="lines",
- line=dict(color=color, width=2),
- fill="tozeroy",
- fillcolor=(
- color.replace(")", ",0.1)").replace("rgb", "rgba")
- if "rgb" in color
- else None
- ),
- )
- )
- _layout(
- fig,
- title,
- xaxis_title,
- f"Percent of {yaxis_title} (%)" if (percent_mode or normalize) else yaxis_title,
- height,
- )
- if xaxis_range is not None:
- fig.update_xaxes(range=[float(xaxis_range[0]), float(xaxis_range[1])])
- if xaxis_categoryarray is not None:
- fig.update_xaxes(
- type="category",
- categoryorder="array",
- categoryarray=xaxis_categoryarray,
- )
- if xaxis_tickvals is not None:
- tick_kwargs = {"tickmode": "array", "tickvals": xaxis_tickvals}
- if xaxis_ticktext is not None:
- tick_kwargs["ticktext"] = xaxis_ticktext
- fig.update_xaxes(**tick_kwargs)
- return pn.pane.Plotly(fig, sizing_mode="stretch_width")
-
-
-# TODO: Consider changing to a grid of plots
-def scatter_chart(
- data_list: list[tuple[str, pl.DataFrame]],
- x_col: str,
- y_col: str,
- title: str = "",
- xaxis_title: str = "",
- yaxis_title: str = "",
- height: int = 400,
- drop_zero_y: bool = False,
-) -> pn.pane.Plotly:
- """Create a scatterplot comparing multiple runs."""
- fig = go.Figure()
-
- for i, (label, df) in enumerate(data_list):
- if df is None or len(df) == 0:
- continue
- if drop_zero_y and y_col in df.columns:
- df = df.filter(pl.col(y_col).fill_null(0) != 0)
- if len(df) == 0:
- continue
- color = run_color_for_label(label, i)
-
- x = df[x_col].to_list()
- y = df[y_col].to_list()
-
- hover = [
- f"{label}
{xaxis_title or x_col}: {xi:,.1f}
{yaxis_title or y_col}: {yi:,.1f}"
- for xi, yi in zip(x, y)
- ]
-
- fig.add_trace(
- go.Scatter(
- name=label,
- x=x,
- y=y,
- mode="markers",
- marker=dict(
- color=color,
- size=8,
- line=dict(width=0.4),
- ),
- hovertemplate="%{customdata}",
- customdata=hover,
- )
- )
-
- _layout(
- fig,
- title,
- xaxis_title,
- yaxis_title,
- height,
- )
-
- return pn.pane.Plotly(fig, sizing_mode="stretch_width")
-
-
-def kpi_box(
- label: str, values: list[tuple[str, float]], format_fn=None, icon: str = ""
-) -> pn.viewable.Viewable:
- """
- Create a KPI value box showing the metric for each run.
-
- Args:
- label: metric name
- values: list of (run_label, value) tuples
- format_fn: optional formatter function
- """
- if format_fn is None:
- format_fn = lambda v: f"{v:,.0f}"
- max_v = max([v for _, v in values], default=0) or 1
-
- items = []
- for i, (run_label, val) in enumerate(values):
- color = run_color_for_label(run_label, i)
- mini = int((float(val) / max_v) * 100)
- items.append(
- pn.pane.HTML(
- f"""
-
{run_label}
-
{format_fn(val)}
-
-
"""
- )
- )
-
- return pn.Card(
- *items,
- title=f"{icon} {label}" if icon else label,
- sizing_mode="stretch_width",
- min_width=260,
- styles={"border-radius": "10px"},
- )
-
-
-def _to_pandas(df: pl.DataFrame):
- """Convert Polars DataFrame to pandas, casting categoricals to strings first."""
- cat_cols = [c for c in df.columns if df[c].dtype == pl.Categorical]
- if cat_cols:
- df = df.with_columns([pl.col(c).cast(pl.Utf8) for c in cat_cols])
- return df.to_pandas()
-
-
-def format_numeric_for_display(
- value,
- *,
- precision: int | None = 2,
-):
- """Format numbers using significant digits while preserving integers.
-
- `precision` means significant digits, not fixed decimal places.
- """
- if value is None:
- return None
- if isinstance(value, (bool, np.bool_)):
- return value
- if isinstance(value, (int, np.integer)):
- return str(int(value))
- if isinstance(value, (float, np.floating)):
- numeric = float(value)
- if not math.isfinite(numeric):
- return None
- if numeric.is_integer():
- return str(int(numeric))
- if precision is None:
- return str(numeric)
- if precision <= 0:
- return str(int(round(numeric)))
- magnitude = math.floor(math.log10(abs(numeric)))
- decimals = precision - 1 - magnitude
- rounded = round(numeric, decimals)
- if float(rounded).is_integer():
- return str(int(rounded))
- decimal_places = max(decimals, 0)
- return f"{rounded:.{decimal_places}f}".rstrip("0").rstrip(".")
- return value
-
-
-def format_numeric_frame_for_display(
- df: pl.DataFrame,
- *,
- numeric_precision: int | None = 2,
- numeric_precision_by_column: dict[str, int] | None = None,
-) -> pl.DataFrame:
- """Return a copy with numeric columns converted to display-ready strings."""
- if numeric_precision is None and not numeric_precision_by_column:
- return df
- exprs: list[pl.Expr] = []
- for column, dtype in df.schema.items():
- if not getattr(dtype, "is_numeric", lambda: False)():
- continue
- column_precision = (
- numeric_precision_by_column.get(column)
- if numeric_precision_by_column and column in numeric_precision_by_column
- else numeric_precision
- )
- exprs.append(
- pl.col(column)
- .map_elements(
- lambda value, precision=column_precision: format_numeric_for_display(
- value,
- precision=precision,
- ),
- return_dtype=pl.Utf8,
- )
- .alias(column)
- )
- return df.with_columns(exprs) if exprs else df
-
-
-def data_table(
- data_list: list[tuple[str, pl.DataFrame]],
- title: str = "",
- height: int = 300,
- numeric_precision: int | None = 2,
- numeric_precision_by_column: dict[str, int] | None = None,
-) -> pn.viewable.Viewable:
- """
- Display a data table. If multiple runs, show side by side.
- """
- tabs = pn.Tabs()
- for label, df in data_list:
- if df is not None and len(df) > 0:
- display_df = format_numeric_frame_for_display(
- df,
- numeric_precision=numeric_precision,
- numeric_precision_by_column=numeric_precision_by_column,
- )
- tabs.append(
- (
- label,
- pn.widgets.Tabulator(
- _to_pandas(display_df),
- height=height,
- sizing_mode="stretch_width",
- theme="simple",
- ),
- )
- )
- if title:
- return pn.Column(pn.pane.Markdown(f"### {title}"), tabs)
- return tabs
-
-
-def get_standard_df(
- data_list: list[tuple[str, pl.DataFrame]],
- key_col: str,
- val_col: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """
- Normalize DataFrames across runs to same key set.
- Fills missing keys with 0.
- """
- if not data_list:
- return []
- all_keys = set()
- for _, df in data_list:
- if df is not None:
- all_keys.update(df[key_col].to_list())
-
- result = []
- for label, df in data_list:
- if df is None:
- df = pl.DataFrame({key_col: list(all_keys), val_col: [0.0] * len(all_keys)})
- else:
- base = pl.DataFrame({key_col: list(all_keys)})
- df = base.join(
- df.select([key_col, val_col]), on=key_col, how="left"
- ).fill_null(0)
- result.append((label, df.sort(key_col)))
- return result
-
-
-def purpose_selector(
- purposes: list[str], name: str = "purpose_select"
-) -> pn.widgets.Select:
- return pn.widgets.Select(name=name, options=purposes, value=purposes[0])
-
-
-def district_selector(
- districts: list[str], name: str = "district_select"
-) -> pn.widgets.Select:
- all_opts = ["All"] + districts
- return pn.widgets.Select(name=name, options=all_opts, value="All")
-
-
-def data_unavailable_card(
- title: str,
- detail: str,
- missing_items: list[str] | tuple[str, ...] | None = None,
-) -> pn.Card:
- """Return a standard placeholder card for unavailable page data."""
- detail_lines = [detail]
- if missing_items:
- detail_lines.append("")
- detail_lines.append("Required inputs:")
- detail_lines.extend(f"- `{item}`" for item in missing_items)
- return pn.Card(
- pn.pane.Markdown("\n".join(detail_lines)),
- title=title,
- sizing_mode="stretch_width",
- styles={"border-radius": "10px"},
- )
-
-
-def control_row(*objects, height: int = 72) -> pn.Row:
- """Return a standard right-aligned control row for chart alignment."""
- return pn.Row(
- *objects,
- sizing_mode="stretch_width",
- min_height=height,
- margin=(0, 0, 8, 0),
- styles={
- "justify-content": "flex-end",
- "align-items": "flex-start",
- "flex-wrap": "wrap",
- "row-gap": "8px",
- "column-gap": "12px",
- },
- )
-
-
-def selector_row(*objects, height: int = 72) -> pn.Row:
- """Return a standard right-aligned row for page-local selector widgets."""
- return control_row(*objects, height=height)
-
-
-def control_row_spacer(height: int = 56) -> pn.pane.HTML:
- """Return a blank fixed-height row used to align sibling plots."""
- return pn.pane.HTML(
- f"",
- sizing_mode="stretch_width",
- margin=(0, 0, 8, 0),
- )
diff --git a/dashboard/data_access.py b/dashboard/data_access.py
index ea252b8..dbb37ee 100644
--- a/dashboard/data_access.py
+++ b/dashboard/data_access.py
@@ -3,12 +3,17 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, Literal
+from typing import Any, Callable, Iterable, Iterator, Literal, TYPE_CHECKING
import polars as pl
from processor.models import RunData
-from processor.summarize.cache import SummaryRun, strip_weights
+from processor.summarize.cache_types import SummaryRun
+
+if TYPE_CHECKING:
+ from dashboard.state import DashboardState
+ from runtime.config import Config
+ from runtime.weighting import WeightingModeDefinition
PreparedRunAvailability = Literal["loaded", "unavailable", "not_requested"]
VisualizationAvailability = Literal[
@@ -20,6 +25,160 @@
"failed",
]
VisualizationRenderState = Literal["rendered", "partial", "skipped"]
+RunTableData = Iterable[tuple[str, pl.DataFrame]]
+
+
+@dataclass(frozen=True)
+class RunTables:
+ """Canonical multi-run table value used from lookup through rendering.
+
+ Run labels, usable frames, and structured availability issues travel together.
+ The object is iterable for domain helpers, while fluent methods preserve the
+ metadata needed by the page rendering boundary.
+ """
+
+ runs: tuple[tuple[str, pl.DataFrame], ...]
+ issues: tuple["VisualizationRunAvailability", ...] = ()
+ source_ids: tuple[str, ...] = ()
+
+ @classmethod
+ def from_runs(
+ cls,
+ runs: RunTableData | None,
+ *,
+ issues: Iterable["VisualizationRunAvailability"] = (),
+ source_ids: Iterable[str] = (),
+ ) -> "RunTables":
+ return cls(
+ tuple((label, frame) for label, frame in (runs or []) if not frame.is_empty()),
+ tuple(issues),
+ tuple(source_ids),
+ )
+
+ def __iter__(self) -> Iterator[tuple[str, pl.DataFrame]]:
+ return iter(self.runs)
+
+ def __len__(self) -> int:
+ return len(self.runs)
+
+ def __bool__(self) -> bool:
+ return bool(self.runs)
+
+ def __getitem__(self, index):
+ return self.runs[index]
+
+ @property
+ def available(self) -> bool:
+ return bool(self.runs)
+
+ @property
+ def partial(self) -> bool:
+ return bool(self.runs) and bool(self.issues)
+
+ def to_list(self) -> list[tuple[str, pl.DataFrame]]:
+ """Materialize tuples only for external APIs that cannot accept RunTables."""
+ return list(self.runs)
+
+ def _replace(self, runs: Iterable[tuple[str, pl.DataFrame]]) -> "RunTables":
+ return RunTables(tuple(runs), self.issues, self.source_ids)
+
+ def requiring(self, *columns: str) -> "RunTables":
+ """Keep runs whose table contains every requested column."""
+ required = set(columns)
+ return self._replace(
+ (label, frame)
+ for label, frame in self.runs
+ if required.issubset(frame.columns)
+ )
+
+ def drop_empty(self) -> "RunTables":
+ """Remove runs made empty by an earlier query operation."""
+ return self._replace(
+ (label, frame) for label, frame in self.runs if not frame.is_empty()
+ )
+
+ def map(self, transform: Callable[[pl.DataFrame], pl.DataFrame]) -> "RunTables":
+ return self._replace(
+ (label, transform(frame)) for label, frame in self.runs
+ )
+
+ def where(self, **equals: object) -> "RunTables":
+ """Filter each run using column equality or membership constraints."""
+
+ def filter_frame(frame: pl.DataFrame) -> pl.DataFrame:
+ predicate: pl.Expr | None = None
+ for column, value in equals.items():
+ values = value if isinstance(value, (list, tuple, set, frozenset)) else None
+ condition = (
+ pl.col(column).is_in(list(values))
+ if values is not None
+ else pl.col(column) == value
+ )
+ predicate = condition if predicate is None else predicate & condition
+ return frame if predicate is None else frame.filter(predicate)
+
+ return self.map(filter_frame)
+
+ def with_columns(self, *expressions: pl.Expr) -> "RunTables":
+ return self.map(lambda frame: frame.with_columns(*expressions))
+
+ def select(self, *expressions: str | pl.Expr) -> "RunTables":
+ return self.map(lambda frame: frame.select(*expressions))
+
+ def sort(self, *by: str | pl.Expr) -> "RunTables":
+ return self.map(lambda frame: frame.sort(*by))
+
+ def group(self, by: str | Iterable[str], *aggregations: pl.Expr, **named_aggregations: pl.Expr) -> "RunTables":
+ aggregations = (*aggregations, *(expr.alias(name) for name, expr in named_aggregations.items()))
+ return self.map(lambda frame: frame.group_by(by).agg(*aggregations))
+
+ def join(
+ self,
+ other: "RunTables",
+ *,
+ on: str | list[str],
+ how: str = "left",
+ coalesce: bool | None = None,
+ ) -> "RunTables":
+ """Join tables with the corresponding run from another view."""
+ other_by_label = dict(other.runs)
+ combined_issues = tuple(dict.fromkeys((*self.issues, *other.issues)))
+ combined_sources = tuple(dict.fromkeys((*self.source_ids, *other.source_ids)))
+ return RunTables(
+ tuple(
+ (
+ label,
+ frame.join(
+ other_by_label[label],
+ on=on,
+ how=how,
+ coalesce=coalesce,
+ ),
+ )
+ for label, frame in self.runs
+ if label in other_by_label
+ ),
+ combined_issues,
+ combined_sources,
+ )
+
+ def values(self, column: str) -> list[object]:
+ """Return distinct non-null values in first-seen run order."""
+ values: list[object] = []
+ for _, frame in self.runs:
+ if column not in frame.columns:
+ continue
+ for value in frame.get_column(column).drop_nulls().unique(maintain_order=True):
+ if value not in values:
+ values.append(value)
+ return values
+
+ def scalar(self, column: str, *, default: object = None) -> list[tuple[str, object]]:
+ """Return the first value of a column for each usable run."""
+ return [
+ (label, frame[column][0] if column in frame.columns and len(frame) else default)
+ for label, frame in self.runs
+ ]
@dataclass(frozen=True)
@@ -50,19 +209,121 @@ def has_usable_runs(self) -> bool:
return bool(self.usable_runs)
-@dataclass(frozen=True)
-class VisualizationInputResult:
- """Combined run selection for one visualization across one or more inputs."""
+class PageData:
+ """The single dashboard-page gateway for summary and prepared data."""
- visualization_id: str
- input_kind: Literal["summary", "prepared", "mixed"]
- usable_by_input: dict[str, list[tuple[str, Any]]]
- excluded_runs: list[VisualizationRunAvailability]
- input_ids: tuple[str, ...]
+ def __init__(
+ self,
+ state: "DashboardState",
+ *,
+ weighting_key: Callable[[], str],
+ required_summary_ids: Callable[[], tuple[str, ...]],
+ record_selection: Callable[[str, DashboardDataSelection], None],
+ warn_missing: Callable[[str], None],
+ warn_missing_prepared: Callable[[], None],
+ ) -> None:
+ self._state = state
+ self._weighting_key = weighting_key
+ self._required_summary_ids = required_summary_ids
+ self._record_selection = record_selection
+ self._warn_missing = warn_missing
+ self._warn_missing_prepared = warn_missing_prepared
+
+ def summary(
+ self,
+ summary_id: str,
+ weighting: str | None = None,
+ *,
+ columns: Iterable[str] = (),
+ required: bool | None = None,
+ ) -> RunTables:
+ """Resolve one summary into a queryable multi-run table value."""
+ selection = self._state.inspect_summary_table(
+ summary_id,
+ weighting_key=weighting or self._weighting_key(),
+ required_columns=tuple(columns),
+ )
+ self._record_selection(summary_id, selection)
+ is_required = (
+ summary_id in self._required_summary_ids()
+ if required is None
+ else required
+ )
+ if is_required and not selection.has_usable_runs:
+ self._warn_missing(summary_id)
+ return RunTables.from_runs(
+ selection.usable_runs,
+ issues=selection.excluded_runs,
+ source_ids=(summary_id,),
+ )
- @property
- def has_usable_runs(self) -> bool:
- return bool(self.usable_by_input) and all(self.usable_by_input.values())
+ def summaries(
+ self,
+ *summary_ids: str,
+ columns: dict[str, Iterable[str]] | None = None,
+ required: bool | None = None,
+ ) -> dict[str, RunTables]:
+ """Resolve several summaries through the same availability contract."""
+ columns = columns or {}
+ return {
+ summary_id: self.summary(
+ summary_id,
+ columns=columns.get(summary_id, ()),
+ required=required,
+ )
+ for summary_id in summary_ids
+ }
+
+ def prepared(
+ self,
+ table_name: str,
+ *,
+ columns: Iterable[str] = (),
+ weighting_mode: str | None = None,
+ ) -> RunTables:
+ """Resolve one prepared table without exposing RunData to ordinary pages."""
+ selection = self._state.inspect_prepared_table(
+ table_name,
+ weighting_mode=weighting_mode or self._weighting_key(),
+ required_columns=tuple(columns),
+ )
+ self._record_selection(table_name, selection)
+ if not selection.has_usable_runs:
+ self._warn_missing_prepared()
+ frames = (
+ (label, getattr(run, table_name))
+ for label, run in selection.usable_runs
+ )
+ return RunTables.from_runs(
+ frames,
+ issues=selection.excluded_runs,
+ source_ids=(table_name,),
+ )
+
+ def prepared_runs(
+ self,
+ *,
+ weighting_mode: str | None = None,
+ ) -> list[tuple[str, RunData]] | None:
+ """Escape hatch for skim features that require matrices on RunData."""
+ runs = self._state.get_prepared_runs_if_loaded(
+ weighting_mode=weighting_mode or self._weighting_key()
+ )
+ if runs is None:
+ self._warn_missing_prepared()
+ return runs
+
+ def summary_series(
+ self,
+ summary_id: str,
+ *,
+ weighting: str | None = None,
+ ) -> list[tuple[str, "DashboardSummarySeries", pl.DataFrame]] | None:
+ """Specialized skim-page view retaining owning summary-series metadata."""
+ return self._state.get_summary_series_set(
+ summary_id,
+ weighting or self._weighting_key(),
+ )
@dataclass(frozen=True)
@@ -150,19 +411,23 @@ class DashboardPreparedRunProvider:
"""Prepared-run access for dashboard pages that need disaggregate data."""
availability: PreparedRunAvailability
- weighted_runs: list[tuple[str, RunData]] = field(default_factory=list)
- _unweighted_runs: list[tuple[str, RunData]] | None = field(
- default=None,
+ base_runs: list[tuple[str, RunData]] = field(default_factory=list)
+ _definitions: dict[str, "WeightingModeDefinition"] = field(
+ default_factory=dict,
init=False,
repr=False,
)
+ _config: "Config | None" = field(default=None, init=False, repr=False)
+ _runs_by_mode: dict[str, list[tuple[str, RunData]]] = field(
+ default_factory=dict, init=False, repr=False
+ )
@classmethod
def loaded(
cls,
runs: list[tuple[str, RunData]] | None,
) -> "DashboardPreparedRunProvider":
- return cls("loaded", weighted_runs=list(runs or []))
+ return cls("loaded", base_runs=list(runs or []))
@classmethod
def unavailable(cls) -> "DashboardPreparedRunProvider":
@@ -179,19 +444,38 @@ def is_loaded(self) -> bool:
def labels(self) -> list[str]:
if not self.is_loaded:
return []
- return [label for label, _ in self.weighted_runs]
+ return [label for label, _ in self.base_runs]
+
+ def configure_weighting_modes(
+ self,
+ definitions: Iterable["WeightingModeDefinition"],
+ *,
+ config: "Config | None" = None,
+ ) -> None:
+ """Bind the immutable mode definitions selected for this dashboard."""
+ self._definitions = {
+ definition.mode_id: definition for definition in definitions
+ }
+ self._config = config
+ self._runs_by_mode.clear()
def get_runs_if_loaded(
self,
*,
- weighted: bool = True,
+ weighting_mode: str,
) -> list[tuple[str, RunData]] | None:
if not self.is_loaded:
return None
- if weighted:
- return list(self.weighted_runs)
- if self._unweighted_runs is None:
- self._unweighted_runs = [
- (label, strip_weights(rd)) for label, rd in self.weighted_runs
+ mode_id = str(weighting_mode).strip().lower()
+ try:
+ definition = self._definitions[mode_id]
+ except KeyError as exc:
+ raise ValueError(
+ f"Prepared dashboard data does not have weighting mode {mode_id!r}."
+ ) from exc
+ if mode_id not in self._runs_by_mode:
+ self._runs_by_mode[mode_id] = [
+ (label, definition.apply(run, self._config))
+ for label, run in self.base_runs
]
- return list(self._unweighted_runs)
+ return list(self._runs_by_mode[mode_id])
diff --git a/dashboard/export.7z b/dashboard/export.7z
deleted file mode 100644
index 34b1d6c..0000000
Binary files a/dashboard/export.7z and /dev/null differ
diff --git a/dashboard/export/README.md b/dashboard/export/README.md
index f79815b..6cd6245 100644
--- a/dashboard/export/README.md
+++ b/dashboard/export/README.md
@@ -17,9 +17,19 @@ the result. This is not a frontend application with a separate deployment model.
## Important Files
- `payload.py`
- Builds page descriptors, dashboard states, selector metadata, and region variants.
+ Composes dashboard states, page descriptors, selector metadata, and the final
+ client payload.
+- `traversal.py`
+ Projects registered page sections onto the export contract and validates that
+ enabled region roots are present and non-overlapping.
+- `selector_states.py`
+ Resolves selector requests, enumerates canonical region states, and owns the
+ scoped widget-mutation boundary.
+- `page_serializer.py`
+ Serializes page shells and selector-driven region variants.
- `serializer.py`
- Converts supported Panel objects into export nodes and sanitizes JSON-unsafe values.
+ Converts individual supported Panel objects into export nodes and sanitizes
+ JSON-unsafe values.
- `types.py`
Defines the Python-side payload and node shapes, including `EXPORT_SCHEMA_VERSION`.
- `runtime_assets.py`
@@ -61,14 +71,15 @@ The contract is versioned by `EXPORT_SCHEMA_VERSION` in `types.py`.
When you change payload structure:
1. Update `types.py`.
-2. Update the Python payload builder in `payload.py`.
+2. Update the relevant Python contract layer (`payload.py`, `page_serializer.py`,
+ or `serializer.py`).
3. Update the runtime source in `js_runtime/`.
4. Rebuild `assets/export_runtime.js`.
5. Update fixture payloads in `tests/fixtures/`.
6. Update contract and smoke tests.
7. Bump `EXPORT_SCHEMA_VERSION` if the runtime can no longer safely read older payloads.
-The contract reference lives in [`docs/export_html_schema.md`](../../docs/export_html_schema.md).
+The export reference lives in [`wiki/34-html-export.md`](../../wiki/34-html-export.md).
## Trusted HTML Boundary
@@ -86,8 +97,8 @@ boundary explicit in both Python and JavaScript code.
Region export works by temporarily mutating selector widget values so each selector
combination can be rendered and serialized. That mutation is isolated in
-`payload.temporary_widget_values(...)` so widget state is restored on both success
-and failure paths.
+`selector_states.scoped_widget_values(...)` so widget state is restored on
+both success and failure paths.
If you touch selector-driven region export:
@@ -108,12 +119,12 @@ When an export looks wrong:
## Segmentation Filtering
When summary caches include segmented runs, HTML export uses one build-time
-segmentation view per file. By default export mirrors `segmentation.dashboard`,
-but you can override it under `visualizer.export_html.dashboard`:
+segmentation view per file. By default export mirrors `segment.dashboard`, but
+you can override it under `dashboard.export.dashboard`:
```yaml
-visualizer:
- export_html:
+dashboard:
+ export:
dashboard:
segmentation_type: signup_platform
segmentation_visibility: segments_only
@@ -134,6 +145,6 @@ Supported `segmentation_visibility` values are `full_only`,
## Related Docs
- `js_runtime_guide.md`
-- `docs/export_html_schema.md`
-- `docs/export_html_contributor_guide.md`
-- `docs/adding-dashboard-pages.md`
+- `wiki/34-html-export.md`
+- `wiki/32-figures-and-widgets.md`
+- `wiki/33-dashboard-page-recipes.md`
diff --git a/dashboard/export/assets/export.css b/dashboard/export/assets/export.css
index 29aa6fe..56b80e8 100644
--- a/dashboard/export/assets/export.css
+++ b/dashboard/export/assets/export.css
@@ -205,6 +205,24 @@ body {
justify-content: flex-end;
}
+.container-row--controls {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ min-width: 0;
+ gap: 14px;
+ padding-inline: 8px;
+}
+
+.container-row--controls > .container-item--controls {
+ display: flex;
+ justify-content: flex-start;
+ flex: 0 1 auto;
+ min-width: 0;
+}
+
.container-item--plot,
.container-item--table,
.container-item--card,
@@ -244,6 +262,15 @@ body {
margin-left: auto;
}
+.container-row--controls .widget-shell {
+ width: min(100%, 190px);
+ min-width: min(160px, 100%);
+ margin-left: 0;
+ padding: 12px 14px;
+ border-radius: 14px;
+ box-shadow: 0 4px 14px rgba(31, 41, 55, 0.05);
+}
+
.widget-label {
font-size: 15px;
color: #1f2937;
@@ -257,6 +284,19 @@ body {
background: var(--surface-soft);
}
+.widget-checkbox {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+ color: #334155;
+}
+
+.widget-checkbox input {
+ width: 16px;
+ height: 16px;
+}
+
.widget-radio-option {
padding: 8px 12px;
border-radius: 999px;
@@ -277,6 +317,95 @@ body {
opacity: 0.75;
}
+.export-dropdown-note {
+ margin: 0 0 10px 0;
+ color: #64748b;
+ font-size: 13px;
+}
+
+.calculation-note-block {
+ margin: 4px 0 12px;
+}
+
+.calculation-note-summary {
+ margin: 3px 0 0;
+ color: inherit;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.calculation-note {
+ margin: 0;
+ border: 1px solid #dbe3ec;
+ border-radius: 10px;
+ background: #f8fafc;
+ color: #334155;
+ overflow: hidden;
+}
+
+.calculation-note summary {
+ padding: 9px 12px;
+ cursor: pointer;
+ color: #334155;
+ font-size: 13px;
+ font-weight: 650;
+ user-select: none;
+}
+
+.calculation-note summary:hover {
+ background: #f1f5f9;
+}
+
+.calculation-note[open] summary {
+ border-bottom: 1px solid #dbe3ec;
+}
+
+.calculation-note-content {
+ padding: 10px 14px 12px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.calculation-note-content p {
+ margin: 0 0 8px;
+}
+
+.calculation-note-content p:last-child,
+.calculation-note-content ul:last-child {
+ margin-bottom: 0;
+}
+
+.calculation-note-content .calculation-note-formula {
+ margin-top: 8px;
+}
+
+.calculation-note-formula code {
+ white-space: normal;
+}
+
+.calculation-note-section {
+ margin-top: 8px;
+}
+
+.calculation-note-section ul {
+ margin: 3px 0 0;
+ padding-left: 20px;
+}
+
+.calculation-note-section p {
+ margin: 3px 0 0;
+}
+
+.calculation-note-view {
+ margin-top: 6px;
+}
+
+@media print {
+ .calculation-note:not([open]) > .calculation-note-content {
+ display: block;
+ }
+}
+
.table-wrap {
max-height: 520px;
overflow-x: auto;
@@ -307,6 +436,28 @@ table.export-table thead th {
z-index: 1;
}
+.export-table-sort {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ text-align: left;
+}
+
+.export-table-sort-indicator {
+ color: var(--muted);
+ font-size: 12px;
+ flex: 0 0 auto;
+}
+
.local-tab-panel {
margin-top: 12px;
}
diff --git a/dashboard/export/assets/export_runtime.js b/dashboard/export/assets/export_runtime.js
index dbe43c1..e6ed08a 100644
--- a/dashboard/export/assets/export_runtime.js
+++ b/dashboard/export/assets/export_runtime.js
@@ -413,6 +413,29 @@
"INVALID_SELECTOR_DEFAULT"
);
}
+ if (selector.parent_selector_id) {
+ const parentSelector = (pageDescriptor.selectors || []).find((candidate) => {
+ return candidate.id === selector.parent_selector_id;
+ });
+ if (!parentSelector) {
+ fail(
+ "Selector " + selector.id + " on page " + pageDescriptor.id + " references an unknown parent selector.",
+ null,
+ "INVALID_SELECTOR_PARENT"
+ );
+ }
+ for (const options of Object.values(selector.options_by_parent_value || {})) {
+ for (const option of options || []) {
+ if ((selector.options || []).indexOf(option) === -1) {
+ fail(
+ "Dependent selector " + selector.id + " contains an option outside its exported domain.",
+ null,
+ "INVALID_DEPENDENT_SELECTOR_OPTION"
+ );
+ }
+ }
+ }
+ }
}
}
}
@@ -542,6 +565,19 @@
return null;
}
+ function findPageDescriptorById(pageDescriptors, pageId) {
+ for (const page of pageDescriptors || []) {
+ if (page.id === pageId) {
+ return page;
+ }
+ const childMatch = findPageDescriptorById(page.children || [], pageId);
+ if (childMatch) {
+ return childMatch;
+ }
+ }
+ return null;
+ }
+
function hasChildren(pageDescriptor) {
return !!(pageDescriptor && pageDescriptor.children && pageDescriptor.children.length);
}
@@ -687,6 +723,33 @@
const nextState = cloneState(currentState);
const pageState = Object.assign({}, nextState.pageSelectors[pageId] || {});
pageState[selectorId] = value;
+ if (
+ pageId === "vmt"
+ && selectorId === "personal_auto_vmt_breakdown"
+ && value !== "Home Geography"
+ ) {
+ pageState.personal_auto_vmt_geography_type = "All Geography Types";
+ }
+ if (
+ pageId === "vmt"
+ && selectorId === "non_motorized_vmt_breakdown"
+ && value !== "Home Geography"
+ ) {
+ pageState.non_motorized_vmt_geography_type = "All Geography Types";
+ }
+ const pageDescriptor = findPageDescriptorById(currentPayload.pages || [], pageId);
+ for (const selector of (pageDescriptor && pageDescriptor.selectors) || []) {
+ if (selector.parent_selector_id !== selectorId) {
+ continue;
+ }
+ const dependentOptions = (
+ selector.options_by_parent_value
+ && selector.options_by_parent_value[value]
+ ) || [];
+ if (dependentOptions.length) {
+ pageState[selector.id] = dependentOptions[0];
+ }
+ }
nextState.pageSelectors[pageId] = pageState;
return normalizeState(currentPayload, nextState);
}
@@ -800,6 +863,121 @@
registeredPlots.add(element);
}
+ function isArrayLikeValue(value) {
+ return Array.isArray(value) || ArrayBuffer.isView(value);
+ }
+
+ function csvEscape(value) {
+ if (value === undefined || value === null) {
+ return "";
+ }
+ let stringValue = "";
+ if (typeof value === "object") {
+ try {
+ stringValue = JSON.stringify(value);
+ } catch (error) {
+ stringValue = String(value);
+ }
+ } else {
+ stringValue = String(value);
+ }
+ if (/[",\n]/.test(stringValue)) {
+ return '"' + stringValue.replace(/"/g, '""') + '"';
+ }
+ return stringValue;
+ }
+
+ function getTraceFieldLength(value) {
+ if (isArrayLikeValue(value)) {
+ return value.length;
+ }
+ if (value === undefined || value === null) {
+ return 0;
+ }
+ return 1;
+ }
+
+ function getTraceFieldValue(value, index) {
+ if (isArrayLikeValue(value)) {
+ return index < value.length ? value[index] : "";
+ }
+ if (index === 0 && value !== undefined && value !== null) {
+ return value;
+ }
+ return "";
+ }
+
+ function slugifyFilenameBase(value) {
+ const normalized = String(value || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return normalized || "plot-data";
+ }
+
+ function resolvePlotCsvFilename(figure) {
+ const layout = figure && figure.layout ? figure.layout : {};
+ const titleValue = layout && layout.title;
+ const titleText = typeof titleValue === "string"
+ ? titleValue
+ : (titleValue && titleValue.text) || "";
+ const yaxis = layout && layout.yaxis ? layout.yaxis : {};
+ const yaxisTitle = yaxis && yaxis.title;
+ const yaxisTitleText = typeof yaxisTitle === "string"
+ ? yaxisTitle
+ : (yaxisTitle && yaxisTitle.text) || "";
+ const valueMode = /percent|\(%\)/i.test(String(yaxisTitleText))
+ ? "percent"
+ : "count";
+ return slugifyFilenameBase(titleText) + "-" + valueMode + ".csv";
+ }
+
+ function buildTraceCsvRows(gd) {
+ const rows = [["run_name", "x", "y"]];
+ const traces = gd && Array.isArray(gd.data) ? gd.data : [];
+ traces.forEach((trace, traceIndex) => {
+ const pointCount = Math.max(
+ getTraceFieldLength(trace && trace.x),
+ getTraceFieldLength(trace && trace.y)
+ );
+ const traceName = trace && trace.name ? trace.name : "trace_" + String(traceIndex + 1);
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
+ rows.push([
+ traceName,
+ getTraceFieldValue(trace && trace.x, pointIndex),
+ getTraceFieldValue(trace && trace.y, pointIndex),
+ ]);
+ }
+ });
+ return rows;
+ }
+
+ function downloadCsvRows(rows, filename) {
+ const csv = rows
+ .map((row) => row.map(csvEscape).join(","))
+ .join("\n");
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 0);
+ }
+
+ function makePlotCsvDownloadButton(figure) {
+ return {
+ name: "Download CSV",
+ title: "Download plot data as CSV",
+ icon: plotly && plotly.Icons ? plotly.Icons.disk : undefined,
+ click: function (gd) {
+ downloadCsvRows(buildTraceCsvRows(gd), resolvePlotCsvFilename(figure));
+ },
+ };
+ }
+
/**
* Render any plot containers that were added during the latest DOM paint.
*/
@@ -819,7 +997,11 @@
Promise.resolve(
plotly.react(div, figure.data || [], figure.layout || {}, {
responsive: true,
- displayModeBar: false,
+ displayModeBar: "hover",
+ toImageButtonOptions: {
+ scale: 2,
+ },
+ modeBarButtonsToAdd: [makePlotCsvDownloadButton(figure)],
})
).catch((error) => {
onRuntimeError("Plot rendering failed while loading this export.", error);
@@ -867,31 +1049,123 @@
* Widget renderers for export payload nodes.
*/
- function renderWidget(node, context, actions) {
+ function resolveWidgetOptions(node, context, leafPageId) {
+ if (!(node.parent_selector_id && leafPageId)) {
+ return node.options || [];
+ }
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const parentValue = pageSelectorState[node.parent_selector_id];
+ const dependentOptions = (
+ node.options_by_parent_value
+ && node.options_by_parent_value[parentValue]
+ );
+ return dependentOptions || node.options || [];
+ }
+
+ function resolveWidgetValue(node, context, leafPageId, effectiveOptions) {
+ if (!(node.export_enabled && node.selector_id && leafPageId)) {
+ return node.value;
+ }
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const runtimeValue = pageSelectorState[node.selector_id];
+ if (
+ runtimeValue !== undefined
+ && runtimeValue !== null
+ && effectiveOptions.indexOf(runtimeValue) !== -1
+ ) {
+ return runtimeValue;
+ }
+ return node.value;
+ }
+
+ function isVmtGeographyTypeUnavailable(node, context, leafPageId) {
+ if (leafPageId !== "vmt") {
+ return false;
+ }
+ const selectorPrefixes = {
+ personal_auto_vmt_geography_type: "personal_auto_vmt",
+ non_motorized_vmt_geography_type: "non_motorized_vmt",
+ };
+ const prefix = selectorPrefixes[node.selector_id];
+ if (!prefix) return false;
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ return pageSelectorState[`${prefix}_breakdown`] !== "Home Geography";
+ }
+
+ function isWidgetDisabled(node, context, leafPageId) {
+ if (node.parent_selector_id && leafPageId) {
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const parentValue = pageSelectorState[node.parent_selector_id];
+ if ((node.disabled_parent_values || []).indexOf(parentValue) !== -1) {
+ return true;
+ }
+ }
+ return !!node.disabled || isVmtGeographyTypeUnavailable(node, context, leafPageId);
+ }
+
+ function selectorHasDependents(context, leafPageId, selectorId) {
+ const pageDescriptor = findPageDescriptorById(
+ context.payload.pages || [],
+ leafPageId
+ );
+ return !!(
+ pageDescriptor
+ && (pageDescriptor.selectors || []).some((selector) => {
+ return selector.parent_selector_id === selectorId;
+ })
+ );
+ }
+
+ function selectorChangeOptions(node, context, leafPageId) {
+ if (selectorHasDependents(context, leafPageId, node.selector_id)) {
+ return {};
+ }
+ if (
+ leafPageId === "vmt"
+ && (
+ node.selector_id === "personal_auto_vmt_breakdown"
+ || node.selector_id === "non_motorized_vmt_breakdown"
+ )
+ ) {
+ return {};
+ }
+ return { preferPartialRegionUpdate: true };
+ }
+
+ function renderWidget(node, context, actions, leafPageId) {
const wrapper = el("div", { className: "widget-shell" }, [
el("div", {
className: "widget-label",
text: node.name || "",
}),
]);
+ const effectiveOptions = resolveWidgetOptions(node, context, leafPageId);
+ const effectiveValue = resolveWidgetValue(
+ node,
+ context,
+ leafPageId,
+ effectiveOptions
+ );
if (node.widget_type === "select") {
const select = document.createElement("select");
- select.disabled = !!node.disabled;
- for (const option of node.options || []) {
+ select.disabled = isWidgetDisabled(node, context, leafPageId);
+ for (const option of effectiveOptions) {
const opt = document.createElement("option");
opt.value = option;
opt.textContent = option;
- if (option === node.value) {
+ if (option === effectiveValue) {
opt.selected = true;
}
select.appendChild(opt);
}
if (node.export_enabled && node.selector_id) {
select.addEventListener("change", () => {
- actions.setPageSelector(node.selector_id, select.value, {
- preferPartialRegionUpdate: true,
- });
+ actions.setPageSelector(
+ node.selector_id,
+ select.value,
+ selectorChangeOptions(node, context, leafPageId)
+ );
});
}
wrapper.appendChild(select);
@@ -903,7 +1177,7 @@
el("div", { className: "widget-radio-options" }, (node.options || []).map((option) => {
return makeButton({
label: option,
- active: option === node.value,
+ active: option === effectiveValue,
disabled: !!node.disabled,
onClick: () => {
if (node.export_enabled && node.selector_id) {
@@ -917,6 +1191,49 @@
return wrapper;
}
+ if (node.widget_type === "checkbox") {
+ const label = document.createElement("label");
+ label.className = "widget-checkbox";
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.disabled = !!node.disabled;
+ checkbox.checked = effectiveValue === true || effectiveValue === "True";
+ checkbox.setAttribute("aria-label", node.name || "Checkbox");
+ checkbox.addEventListener("change", () => {
+ if (node.export_enabled && node.selector_id) {
+ actions.setPageSelector(node.selector_id, checkbox.checked ? "True" : "False", {
+ preferPartialRegionUpdate: true,
+ });
+ }
+ });
+ label.appendChild(checkbox);
+ wrapper.appendChild(label);
+ return wrapper;
+ }
+
+ if (node.widget_type === "float_input") {
+ const input = document.createElement("input");
+ input.type = "number";
+ input.value = effectiveValue ?? "";
+ if (node.step !== undefined && node.step !== null) {
+ input.step = String(node.step);
+ }
+ input.disabled = isWidgetDisabled(node, context, leafPageId);
+ wrapper.appendChild(input);
+ return wrapper;
+ }
+
+ if (node.widget_type === "button") {
+ wrapper.appendChild(
+ makeButton({
+ label: node.name || node.value || "",
+ disabled: true,
+ className: "widget-button",
+ })
+ );
+ return wrapper;
+ }
+
fail(
"Unknown widget type encountered in export payload:",
node.widget_type,
@@ -931,28 +1248,131 @@
*/
function renderTable(node) {
const table = el("table", { className: "export-table" });
+ const columns = node.columns || [];
+ const rows = (node.rows || []).slice();
+ const sortState = {
+ column: null,
+ direction: "asc",
+ };
+
+ function parseSortableNumber(value) {
+ const text = value == null ? "" : String(value).trim();
+ if (!text) {
+ return null;
+ }
+ let candidate = text.replace(/,/g, "");
+ let sign = 1;
+ const parenthesized = candidate.match(/^\((.*)\)$/);
+ if (parenthesized) {
+ sign = -1;
+ candidate = parenthesized[1];
+ }
+ candidate = candidate.replace(/^\$/, "");
+ candidate = candidate.replace(/%$/, "");
+ if (!/^-?\d+(\.\d+)?$/.test(candidate)) {
+ return null;
+ }
+ return sign * Number(candidate);
+ }
+
+ function compareCellValues(leftValue, rightValue) {
+ const leftNumber = parseSortableNumber(leftValue);
+ const rightNumber = parseSortableNumber(rightValue);
+ if (leftNumber !== null && rightNumber !== null) {
+ return leftNumber - rightNumber;
+ }
+ return String(leftValue == null ? "" : leftValue).localeCompare(
+ String(rightValue == null ? "" : rightValue),
+ undefined,
+ { numeric: true, sensitivity: "base" }
+ );
+ }
+
+ function sortRows(column, direction) {
+ rows.sort((leftRow, rightRow) => {
+ const comparison = compareCellValues(leftRow[column], rightRow[column]);
+ if (comparison !== 0) {
+ return direction === "asc" ? comparison : -comparison;
+ }
+ return 0;
+ });
+ }
+
+ function updateHeaderState() {
+ for (const headerButton of table.querySelectorAll(".export-table-sort")) {
+ const isActive = headerButton.getAttribute("data-column") === sortState.column;
+ const direction = isActive ? sortState.direction : "none";
+ const indicator = headerButton.querySelector(".export-table-sort-indicator");
+ headerButton.setAttribute("aria-sort", direction);
+ if (indicator) {
+ indicator.textContent = (
+ direction === "asc"
+ ? "▲"
+ : direction === "desc"
+ ? "▼"
+ : "↕"
+ );
+ }
+ }
+ }
+
+ function renderBody() {
+ const tbody = document.createElement("tbody");
+ for (const row of rows) {
+ const tr = document.createElement("tr");
+ for (const column of columns) {
+ const value = row[column];
+ tr.appendChild(
+ el("td", {
+ text: value == null ? "" : String(value),
+ })
+ );
+ }
+ tbody.appendChild(tr);
+ }
+ const existing = table.querySelector("tbody");
+ if (existing) {
+ table.replaceChild(tbody, existing);
+ } else {
+ table.appendChild(tbody);
+ }
+ }
+
+ function toggleSort(column) {
+ if (sortState.column === column) {
+ sortState.direction = sortState.direction === "asc" ? "desc" : "asc";
+ } else {
+ sortState.column = column;
+ sortState.direction = "asc";
+ }
+ sortRows(column, sortState.direction);
+ renderBody();
+ updateHeaderState();
+ }
+
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
- for (const column of node.columns || []) {
- headRow.appendChild(el("th", { text: column }));
+ for (const column of columns) {
+ const button = el("button", {
+ className: "export-table-sort",
+ attrs: {
+ type: "button",
+ "data-column": column,
+ "aria-sort": "none",
+ },
+ }, [
+ el("span", { className: "export-table-sort-label", text: column }),
+ el("span", { className: "export-table-sort-indicator", text: "↕" }),
+ ]);
+ button.addEventListener("click", () => {
+ toggleSort(column);
+ });
+ headRow.appendChild(el("th", {}, [button]));
}
thead.appendChild(headRow);
table.appendChild(thead);
-
- const tbody = document.createElement("tbody");
- for (const row of node.rows || []) {
- const tr = document.createElement("tr");
- for (const column of node.columns || []) {
- const value = row[column];
- tr.appendChild(
- el("td", {
- text: value == null ? "" : String(value),
- })
- );
- }
- tbody.appendChild(tr);
- }
- table.appendChild(tbody);
+ renderBody();
+ updateHeaderState();
return el("div", { className: "table-wrap" }, [table]);
}
@@ -1080,6 +1500,14 @@
if (node.variants && Object.prototype.hasOwnProperty.call(node.variants, variantLookupKey)) {
return node.variants[variantLookupKey];
}
+ if (
+ node.variant_aliases
+ && Object.prototype.hasOwnProperty.call(node.variant_aliases, variantLookupKey)
+ && node.variants
+ && Object.prototype.hasOwnProperty.call(node.variants, node.variant_aliases[variantLookupKey])
+ ) {
+ return node.variants[node.variant_aliases[variantLookupKey]];
+ }
// Falling back to default_content is expected when Python intentionally
// emitted a default snapshot for unmatched selector combinations. If that
// was not intended, this usually indicates payload/schema drift.
@@ -1198,12 +1626,36 @@
function renderContainer(node, context, actions, leafPageId) {
const layoutClass = node.layout === "row" ? "container-row" : "container-column";
const childCount = Number(node.child_count || (node.children || []).length || 0);
+ const cssClasses = Array.isArray(node.css_classes) ? node.css_classes.join(" ") : "";
+ const isFlexControlsRow = (
+ node.layout === "row"
+ && node.styles
+ && typeof node.styles === "object"
+ && (
+ Object.prototype.hasOwnProperty.call(node.styles, "flex-wrap")
+ || Object.prototype.hasOwnProperty.call(node.styles, "justify-content")
+ )
+ );
const container = el("div", {
- className: layoutClass + " child-count-" + String(childCount),
+ className:
+ layoutClass
+ + " child-count-" + String(childCount)
+ + (isFlexControlsRow ? " container-row--controls" : "")
+ + (cssClasses ? " " + cssClasses : ""),
});
+ if (node.styles && typeof node.styles === "object") {
+ for (const [key, value] of Object.entries(node.styles)) {
+ if (value !== undefined && value !== null) {
+ container.style.setProperty(String(key), String(value));
+ }
+ }
+ }
for (const child of node.children || []) {
const wrapper = el("div", {
- className: "container-item container-item--" + nodeRole(child),
+ className:
+ "container-item container-item--"
+ + nodeRole(child)
+ + (isFlexControlsRow ? " container-item--controls" : ""),
}, [
renderNode(child, context, actions, leafPageId),
]);
diff --git a/dashboard/export/context.py b/dashboard/export/context.py
index b664387..d09a40a 100644
--- a/dashboard/export/context.py
+++ b/dashboard/export/context.py
@@ -7,7 +7,7 @@
from dashboard import DashboardState
from dashboard.data_access import DashboardPreparedRunProvider
from runtime.config import Config
-from processor.summarize.cache import SummaryRun
+from processor.summarize.cache_types import SummaryRun
@dataclass(slots=True)
@@ -24,6 +24,8 @@ def build_dashboard_state(self) -> DashboardState:
return DashboardState(
summary_runs=self.summary_runs,
weighting_modes=self.config.weighting_modes,
+ weighting_definitions=self.config.weighting_mode_definitions,
+ config=self.config,
prepared_run_provider=self.prepared_run_provider,
dashboard_segmentation_type=self.config.export_html.dashboard.segmentation_type,
default_segmentation_visibility=(
diff --git a/dashboard/export/html.py b/dashboard/export/html.py
index a3e47ca..5aadc6f 100644
--- a/dashboard/export/html.py
+++ b/dashboard/export/html.py
@@ -5,17 +5,24 @@
import html
import json
from pathlib import Path
+from typing import Iterator
from uuid import uuid4
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from plotly.offline import get_plotlyjs
from dashboard.export.payload import build_export_artifacts, emit_export_size_warnings
-from dashboard.export.runtime_assets import build_export_html_shell
-from dashboard.export.serializer import json_default, sanitize_export_payload
+from dashboard.export.runtime_assets import (
+ build_export_html_shell,
+ build_export_html_shell_parts,
+)
+from dashboard.export.serializer import (
+ json_default,
+ sanitize_export_payload_in_place,
+)
from runtime.config import Config
from processor.models import RunData
-from processor.summarize.cache import SummaryRun
+from processor.summarize.cache_types import SummaryRun
LOGGER = get_logger("dashboard.export")
@@ -80,7 +87,10 @@ def _build_export_payload_and_diagnostics(
runs, config, summary_runs=summary_runs
)
emit_export_size_warnings(diagnostics.get("size_analysis"))
- return sanitize_export_payload(payload), sanitize_export_payload(diagnostics)
+ return (
+ sanitize_export_payload_in_place(payload),
+ sanitize_export_payload_in_place(diagnostics),
+ )
def _serialize_export_payload_json(payload: dict) -> str:
@@ -108,6 +118,26 @@ def _build_export_html_shell_document(*, title: str, payload_json: str) -> str:
)
+def _build_export_html_shell_parts(*, title: str) -> tuple[str, str]:
+ return build_export_html_shell_parts(
+ title=html.escape(title),
+ plotly_js=get_plotlyjs(),
+ )
+
+
+def _validate_export_html_shell_parts(prefix: str, suffix: str) -> None:
+ if not prefix.endswith(PAYLOAD_SCRIPT_START_TOKEN):
+ raise ExportBuildError(
+ phase="validate assembled HTML",
+ hint="The generated HTML is missing the embedded export payload script tag.",
+ )
+ if not suffix.startswith(PAYLOAD_SCRIPT_END_TOKEN):
+ raise ExportBuildError(
+ phase="validate assembled HTML",
+ hint="The generated HTML is missing the closing for the embedded payload.",
+ )
+
+
def _validate_export_html_document(document: str) -> None:
if PAYLOAD_SCRIPT_START_TOKEN not in document:
raise ExportBuildError(
@@ -121,28 +151,97 @@ def _validate_export_html_document(document: str) -> None:
phase="validate assembled HTML",
hint="The generated HTML is missing the closing for the embedded payload.",
)
- payload_json = document[start:end]
- if not payload_json.strip():
+ payload_start = start
+ while payload_start < end and document[payload_start].isspace():
+ payload_start += 1
+ payload_end = end - 1
+ while payload_end >= payload_start and document[payload_end].isspace():
+ payload_end -= 1
+ if payload_start > payload_end:
raise ExportBuildError(
phase="validate assembled HTML",
hint="The embedded export payload was empty. Regenerate the export and inspect the diagnostics output.",
)
- try:
- json.loads(payload_json)
- except json.JSONDecodeError as exc:
+ if document[payload_start] != "{" or document[payload_end] != "}":
raise ExportBuildError(
phase="validate assembled HTML",
- hint="The generated HTML contains malformed embedded JSON. Regenerate the export and inspect the diagnostics output.",
+ hint="The generated HTML payload does not have the expected JSON object boundaries.",
+ )
+
+
+def _iter_script_safe_payload_json(payload: dict) -> Iterator[str]:
+ """Yield JSON chunks while escaping closing tags across chunk boundaries."""
+
+ encoder = json.JSONEncoder(
+ default=json_default,
+ allow_nan=False,
+ )
+ pending = ""
+ for chunk in encoder.iterencode(payload):
+ text = pending + chunk
+ if text.endswith("<"):
+ text = text[:-1]
+ pending = "<"
+ else:
+ pending = ""
+ if text:
+ yield text.replace("", "<\\/")
+ if pending:
+ yield pending
+
+
+def _write_streamed_export_html(
+ path: Path,
+ *,
+ prefix: str,
+ payload: dict,
+ suffix: str,
+) -> None:
+ """Write one export without materializing its JSON or final HTML string."""
+
+ payload_characters = 0
+ first_payload_character: str | None = None
+ last_payload_character: str | None = None
+ try:
+ with path.open("w", encoding="utf-8") as stream:
+ stream.write(prefix)
+ for chunk in _iter_script_safe_payload_json(payload):
+ if first_payload_character is None:
+ first_payload_character = chunk[0]
+ last_payload_character = chunk[-1]
+ payload_characters += len(chunk)
+ stream.write(chunk)
+ stream.write(suffix)
+ except (TypeError, ValueError) as exc:
+ raise ExportBuildError(
+ phase="serialize payload JSON",
+ output_path=path,
+ hint="The export payload contained data that could not be serialized to JSON.",
detail=str(exc),
) from exc
+ if (
+ payload_characters == 0
+ or first_payload_character != "{"
+ or last_payload_character != "}"
+ ):
+ raise ExportBuildError(
+ phase="validate assembled HTML",
+ output_path=path,
+ hint="The streamed export payload was empty or incomplete.",
+ )
+
def _write_text_file(path: Path, contents: str) -> None:
path.write_text(contents, encoding="utf-8")
+def _temporary_path(final_path: Path) -> Path:
+ return final_path.with_name(f".{final_path.name}.{uuid4().hex}.tmp")
+
+
def _write_temp_text(final_path: Path, contents: str) -> Path:
- temp_path = final_path.with_name(f".{final_path.name}.{uuid4().hex}.tmp")
+ temp_path = _temporary_path(final_path)
_write_text_file(temp_path, contents)
return temp_path
@@ -222,25 +321,17 @@ def write_export_html_document(
output_path=output_path,
hint="Check summary/prepared data compatibility and export page configuration.",
)
- LOGGER.info("Export phase: serialize payload JSON")
- payload_json = _run_export_phase(
- "serialize payload JSON",
- lambda: _serialize_export_payload_json(payload),
- output_path=output_path,
- hint="The export payload contained data that could not be serialized to JSON.",
- )
diagnostics_json = _run_export_phase(
"serialize diagnostics JSON",
lambda: _serialize_export_diagnostics_json(diagnostics),
output_path=diagnostics_path,
hint="The export diagnostics contained data that could not be serialized to JSON.",
)
- LOGGER.info("Export phase: assemble HTML shell")
- document = _run_export_phase(
+ LOGGER.info("Export phase: assemble streaming HTML shell")
+ prefix, suffix = _run_export_phase(
"assemble HTML shell",
- lambda: _build_export_html_shell_document(
+ lambda: _build_export_html_shell_parts(
title=config.dashboard_title,
- payload_json=payload_json,
),
output_path=output_path,
hint="The standalone HTML shell could not be assembled.",
@@ -248,17 +339,23 @@ def write_export_html_document(
LOGGER.info("Export phase: validate assembled HTML")
_run_export_phase(
"validate assembled HTML",
- lambda: _validate_export_html_document(document),
+ lambda: _validate_export_html_shell_parts(prefix, suffix),
output_path=output_path,
hint="The generated HTML looked incomplete or malformed before it was written.",
)
try:
- LOGGER.info("Export phase: write HTML atomically")
- html_temp_path = _run_export_phase(
+ LOGGER.info("Export phase: stream HTML atomically")
+ html_temp_path = _temporary_path(output_path)
+ _run_export_phase(
"write HTML atomically",
- lambda: _write_temp_text(output_path, document),
+ lambda: _write_streamed_export_html(
+ html_temp_path,
+ prefix=prefix,
+ payload=payload,
+ suffix=suffix,
+ ),
output_path=output_path,
- hint="The HTML export file could not be written to its temporary location.",
+ hint="The HTML export could not be serialized or written to its temporary location.",
)
LOGGER.info("Export phase: write diagnostics file")
diagnostics_temp_path = _run_export_phase(
diff --git a/dashboard/export/js_runtime/plotly_lifecycle.js b/dashboard/export/js_runtime/plotly_lifecycle.js
index 16b2c1c..b69d6f3 100644
--- a/dashboard/export/js_runtime/plotly_lifecycle.js
+++ b/dashboard/export/js_runtime/plotly_lifecycle.js
@@ -105,6 +105,121 @@
registeredPlots.add(element);
}
+ function isArrayLikeValue(value) {
+ return Array.isArray(value) || ArrayBuffer.isView(value);
+ }
+
+ function csvEscape(value) {
+ if (value === undefined || value === null) {
+ return "";
+ }
+ let stringValue = "";
+ if (typeof value === "object") {
+ try {
+ stringValue = JSON.stringify(value);
+ } catch (error) {
+ stringValue = String(value);
+ }
+ } else {
+ stringValue = String(value);
+ }
+ if (/[",\n]/.test(stringValue)) {
+ return '"' + stringValue.replace(/"/g, '""') + '"';
+ }
+ return stringValue;
+ }
+
+ function getTraceFieldLength(value) {
+ if (isArrayLikeValue(value)) {
+ return value.length;
+ }
+ if (value === undefined || value === null) {
+ return 0;
+ }
+ return 1;
+ }
+
+ function getTraceFieldValue(value, index) {
+ if (isArrayLikeValue(value)) {
+ return index < value.length ? value[index] : "";
+ }
+ if (index === 0 && value !== undefined && value !== null) {
+ return value;
+ }
+ return "";
+ }
+
+ function slugifyFilenameBase(value) {
+ const normalized = String(value || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return normalized || "plot-data";
+ }
+
+ function resolvePlotCsvFilename(figure) {
+ const layout = figure && figure.layout ? figure.layout : {};
+ const titleValue = layout && layout.title;
+ const titleText = typeof titleValue === "string"
+ ? titleValue
+ : (titleValue && titleValue.text) || "";
+ const yaxis = layout && layout.yaxis ? layout.yaxis : {};
+ const yaxisTitle = yaxis && yaxis.title;
+ const yaxisTitleText = typeof yaxisTitle === "string"
+ ? yaxisTitle
+ : (yaxisTitle && yaxisTitle.text) || "";
+ const valueMode = /percent|\(%\)/i.test(String(yaxisTitleText))
+ ? "percent"
+ : "count";
+ return slugifyFilenameBase(titleText) + "-" + valueMode + ".csv";
+ }
+
+ function buildTraceCsvRows(gd) {
+ const rows = [["run_name", "x", "y"]];
+ const traces = gd && Array.isArray(gd.data) ? gd.data : [];
+ traces.forEach((trace, traceIndex) => {
+ const pointCount = Math.max(
+ getTraceFieldLength(trace && trace.x),
+ getTraceFieldLength(trace && trace.y)
+ );
+ const traceName = trace && trace.name ? trace.name : "trace_" + String(traceIndex + 1);
+ for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) {
+ rows.push([
+ traceName,
+ getTraceFieldValue(trace && trace.x, pointIndex),
+ getTraceFieldValue(trace && trace.y, pointIndex),
+ ]);
+ }
+ });
+ return rows;
+ }
+
+ function downloadCsvRows(rows, filename) {
+ const csv = rows
+ .map((row) => row.map(csvEscape).join(","))
+ .join("\n");
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 0);
+ }
+
+ function makePlotCsvDownloadButton(figure) {
+ return {
+ name: "Download CSV",
+ title: "Download plot data as CSV",
+ icon: plotly && plotly.Icons ? plotly.Icons.disk : undefined,
+ click: function (gd) {
+ downloadCsvRows(buildTraceCsvRows(gd), resolvePlotCsvFilename(figure));
+ },
+ };
+ }
+
/**
* Render any plot containers that were added during the latest DOM paint.
*/
@@ -124,7 +239,11 @@
Promise.resolve(
plotly.react(div, figure.data || [], figure.layout || {}, {
responsive: true,
- displayModeBar: false,
+ displayModeBar: "hover",
+ toImageButtonOptions: {
+ scale: 2,
+ },
+ modeBarButtonsToAdd: [makePlotCsvDownloadButton(figure)],
})
).catch((error) => {
onRuntimeError("Plot rendering failed while loading this export.", error);
diff --git a/dashboard/export/js_runtime/renderers/nodes.js b/dashboard/export/js_runtime/renderers/nodes.js
index ae534d3..3663bb9 100644
--- a/dashboard/export/js_runtime/renderers/nodes.js
+++ b/dashboard/export/js_runtime/renderers/nodes.js
@@ -39,12 +39,36 @@
function renderContainer(node, context, actions, leafPageId) {
const layoutClass = node.layout === "row" ? "container-row" : "container-column";
const childCount = Number(node.child_count || (node.children || []).length || 0);
+ const cssClasses = Array.isArray(node.css_classes) ? node.css_classes.join(" ") : "";
+ const isFlexControlsRow = (
+ node.layout === "row"
+ && node.styles
+ && typeof node.styles === "object"
+ && (
+ Object.prototype.hasOwnProperty.call(node.styles, "flex-wrap")
+ || Object.prototype.hasOwnProperty.call(node.styles, "justify-content")
+ )
+ );
const container = el("div", {
- className: layoutClass + " child-count-" + String(childCount),
+ className:
+ layoutClass
+ + " child-count-" + String(childCount)
+ + (isFlexControlsRow ? " container-row--controls" : "")
+ + (cssClasses ? " " + cssClasses : ""),
});
+ if (node.styles && typeof node.styles === "object") {
+ for (const [key, value] of Object.entries(node.styles)) {
+ if (value !== undefined && value !== null) {
+ container.style.setProperty(String(key), String(value));
+ }
+ }
+ }
for (const child of node.children || []) {
const wrapper = el("div", {
- className: "container-item container-item--" + nodeRole(child),
+ className:
+ "container-item container-item--"
+ + nodeRole(child)
+ + (isFlexControlsRow ? " container-item--controls" : ""),
}, [
renderNode(child, context, actions, leafPageId),
]);
diff --git a/dashboard/export/js_runtime/renderers/regions.js b/dashboard/export/js_runtime/renderers/regions.js
index 0dfc8d1..2f37229 100644
--- a/dashboard/export/js_runtime/renderers/regions.js
+++ b/dashboard/export/js_runtime/renderers/regions.js
@@ -51,6 +51,14 @@
if (node.variants && Object.prototype.hasOwnProperty.call(node.variants, variantLookupKey)) {
return node.variants[variantLookupKey];
}
+ if (
+ node.variant_aliases
+ && Object.prototype.hasOwnProperty.call(node.variant_aliases, variantLookupKey)
+ && node.variants
+ && Object.prototype.hasOwnProperty.call(node.variants, node.variant_aliases[variantLookupKey])
+ ) {
+ return node.variants[node.variant_aliases[variantLookupKey]];
+ }
// Falling back to default_content is expected when Python intentionally
// emitted a default snapshot for unmatched selector combinations. If that
// was not intended, this usually indicates payload/schema drift.
diff --git a/dashboard/export/js_runtime/renderers/tables.js b/dashboard/export/js_runtime/renderers/tables.js
index 508088c..6143057 100644
--- a/dashboard/export/js_runtime/renderers/tables.js
+++ b/dashboard/export/js_runtime/renderers/tables.js
@@ -3,28 +3,131 @@
*/
function renderTable(node) {
const table = el("table", { className: "export-table" });
+ const columns = node.columns || [];
+ const rows = (node.rows || []).slice();
+ const sortState = {
+ column: null,
+ direction: "asc",
+ };
+
+ function parseSortableNumber(value) {
+ const text = value == null ? "" : String(value).trim();
+ if (!text) {
+ return null;
+ }
+ let candidate = text.replace(/,/g, "");
+ let sign = 1;
+ const parenthesized = candidate.match(/^\((.*)\)$/);
+ if (parenthesized) {
+ sign = -1;
+ candidate = parenthesized[1];
+ }
+ candidate = candidate.replace(/^\$/, "");
+ candidate = candidate.replace(/%$/, "");
+ if (!/^-?\d+(\.\d+)?$/.test(candidate)) {
+ return null;
+ }
+ return sign * Number(candidate);
+ }
+
+ function compareCellValues(leftValue, rightValue) {
+ const leftNumber = parseSortableNumber(leftValue);
+ const rightNumber = parseSortableNumber(rightValue);
+ if (leftNumber !== null && rightNumber !== null) {
+ return leftNumber - rightNumber;
+ }
+ return String(leftValue == null ? "" : leftValue).localeCompare(
+ String(rightValue == null ? "" : rightValue),
+ undefined,
+ { numeric: true, sensitivity: "base" }
+ );
+ }
+
+ function sortRows(column, direction) {
+ rows.sort((leftRow, rightRow) => {
+ const comparison = compareCellValues(leftRow[column], rightRow[column]);
+ if (comparison !== 0) {
+ return direction === "asc" ? comparison : -comparison;
+ }
+ return 0;
+ });
+ }
+
+ function updateHeaderState() {
+ for (const headerButton of table.querySelectorAll(".export-table-sort")) {
+ const isActive = headerButton.getAttribute("data-column") === sortState.column;
+ const direction = isActive ? sortState.direction : "none";
+ const indicator = headerButton.querySelector(".export-table-sort-indicator");
+ headerButton.setAttribute("aria-sort", direction);
+ if (indicator) {
+ indicator.textContent = (
+ direction === "asc"
+ ? "▲"
+ : direction === "desc"
+ ? "▼"
+ : "↕"
+ );
+ }
+ }
+ }
+
+ function renderBody() {
+ const tbody = document.createElement("tbody");
+ for (const row of rows) {
+ const tr = document.createElement("tr");
+ for (const column of columns) {
+ const value = row[column];
+ tr.appendChild(
+ el("td", {
+ text: value == null ? "" : String(value),
+ })
+ );
+ }
+ tbody.appendChild(tr);
+ }
+ const existing = table.querySelector("tbody");
+ if (existing) {
+ table.replaceChild(tbody, existing);
+ } else {
+ table.appendChild(tbody);
+ }
+ }
+
+ function toggleSort(column) {
+ if (sortState.column === column) {
+ sortState.direction = sortState.direction === "asc" ? "desc" : "asc";
+ } else {
+ sortState.column = column;
+ sortState.direction = "asc";
+ }
+ sortRows(column, sortState.direction);
+ renderBody();
+ updateHeaderState();
+ }
+
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
- for (const column of node.columns || []) {
- headRow.appendChild(el("th", { text: column }));
+ for (const column of columns) {
+ const button = el("button", {
+ className: "export-table-sort",
+ attrs: {
+ type: "button",
+ "data-column": column,
+ "aria-sort": "none",
+ },
+ }, [
+ el("span", { className: "export-table-sort-label", text: column }),
+ el("span", { className: "export-table-sort-indicator", text: "↕" }),
+ ]);
+ button.addEventListener("click", () => {
+ toggleSort(column);
+ });
+ headRow.appendChild(el("th", {}, [button]));
}
thead.appendChild(headRow);
table.appendChild(thead);
-
- const tbody = document.createElement("tbody");
- for (const row of node.rows || []) {
- const tr = document.createElement("tr");
- for (const column of node.columns || []) {
- const value = row[column];
- tr.appendChild(
- el("td", {
- text: value == null ? "" : String(value),
- })
- );
- }
- tbody.appendChild(tr);
- }
- table.appendChild(tbody);
+ renderBody();
+ updateHeaderState();
return el("div", { className: "table-wrap" }, [table]);
}
diff --git a/dashboard/export/js_runtime/renderers/widgets.js b/dashboard/export/js_runtime/renderers/widgets.js
index 563e6b0..c4ce55f 100644
--- a/dashboard/export/js_runtime/renderers/widgets.js
+++ b/dashboard/export/js_runtime/renderers/widgets.js
@@ -2,31 +2,123 @@
* Widget renderers for export payload nodes.
*/
- function renderWidget(node, context, actions) {
+ function resolveWidgetOptions(node, context, leafPageId) {
+ if (!(node.parent_selector_id && leafPageId)) {
+ return node.options || [];
+ }
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const parentValue = pageSelectorState[node.parent_selector_id];
+ const dependentOptions = (
+ node.options_by_parent_value
+ && node.options_by_parent_value[parentValue]
+ );
+ return dependentOptions || node.options || [];
+ }
+
+ function resolveWidgetValue(node, context, leafPageId, effectiveOptions) {
+ if (!(node.export_enabled && node.selector_id && leafPageId)) {
+ return node.value;
+ }
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const runtimeValue = pageSelectorState[node.selector_id];
+ if (
+ runtimeValue !== undefined
+ && runtimeValue !== null
+ && effectiveOptions.indexOf(runtimeValue) !== -1
+ ) {
+ return runtimeValue;
+ }
+ return node.value;
+ }
+
+ function isVmtGeographyTypeUnavailable(node, context, leafPageId) {
+ if (leafPageId !== "vmt") {
+ return false;
+ }
+ const selectorPrefixes = {
+ personal_auto_vmt_geography_type: "personal_auto_vmt",
+ non_motorized_vmt_geography_type: "non_motorized_vmt",
+ };
+ const prefix = selectorPrefixes[node.selector_id];
+ if (!prefix) return false;
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ return pageSelectorState[`${prefix}_breakdown`] !== "Home Geography";
+ }
+
+ function isWidgetDisabled(node, context, leafPageId) {
+ if (node.parent_selector_id && leafPageId) {
+ const pageSelectorState = getPageSelectorState(context.state, leafPageId);
+ const parentValue = pageSelectorState[node.parent_selector_id];
+ if ((node.disabled_parent_values || []).indexOf(parentValue) !== -1) {
+ return true;
+ }
+ }
+ return !!node.disabled || isVmtGeographyTypeUnavailable(node, context, leafPageId);
+ }
+
+ function selectorHasDependents(context, leafPageId, selectorId) {
+ const pageDescriptor = findPageDescriptorById(
+ context.payload.pages || [],
+ leafPageId
+ );
+ return !!(
+ pageDescriptor
+ && (pageDescriptor.selectors || []).some((selector) => {
+ return selector.parent_selector_id === selectorId;
+ })
+ );
+ }
+
+ function selectorChangeOptions(node, context, leafPageId) {
+ if (selectorHasDependents(context, leafPageId, node.selector_id)) {
+ return {};
+ }
+ if (
+ leafPageId === "vmt"
+ && (
+ node.selector_id === "personal_auto_vmt_breakdown"
+ || node.selector_id === "non_motorized_vmt_breakdown"
+ )
+ ) {
+ return {};
+ }
+ return { preferPartialRegionUpdate: true };
+ }
+
+ function renderWidget(node, context, actions, leafPageId) {
const wrapper = el("div", { className: "widget-shell" }, [
el("div", {
className: "widget-label",
text: node.name || "",
}),
]);
+ const effectiveOptions = resolveWidgetOptions(node, context, leafPageId);
+ const effectiveValue = resolveWidgetValue(
+ node,
+ context,
+ leafPageId,
+ effectiveOptions
+ );
if (node.widget_type === "select") {
const select = document.createElement("select");
- select.disabled = !!node.disabled;
- for (const option of node.options || []) {
+ select.disabled = isWidgetDisabled(node, context, leafPageId);
+ for (const option of effectiveOptions) {
const opt = document.createElement("option");
opt.value = option;
opt.textContent = option;
- if (option === node.value) {
+ if (option === effectiveValue) {
opt.selected = true;
}
select.appendChild(opt);
}
if (node.export_enabled && node.selector_id) {
select.addEventListener("change", () => {
- actions.setPageSelector(node.selector_id, select.value, {
- preferPartialRegionUpdate: true,
- });
+ actions.setPageSelector(
+ node.selector_id,
+ select.value,
+ selectorChangeOptions(node, context, leafPageId)
+ );
});
}
wrapper.appendChild(select);
@@ -38,7 +130,7 @@
el("div", { className: "widget-radio-options" }, (node.options || []).map((option) => {
return makeButton({
label: option,
- active: option === node.value,
+ active: option === effectiveValue,
disabled: !!node.disabled,
onClick: () => {
if (node.export_enabled && node.selector_id) {
@@ -52,6 +144,49 @@
return wrapper;
}
+ if (node.widget_type === "checkbox") {
+ const label = document.createElement("label");
+ label.className = "widget-checkbox";
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.disabled = !!node.disabled;
+ checkbox.checked = effectiveValue === true || effectiveValue === "True";
+ checkbox.setAttribute("aria-label", node.name || "Checkbox");
+ checkbox.addEventListener("change", () => {
+ if (node.export_enabled && node.selector_id) {
+ actions.setPageSelector(node.selector_id, checkbox.checked ? "True" : "False", {
+ preferPartialRegionUpdate: true,
+ });
+ }
+ });
+ label.appendChild(checkbox);
+ wrapper.appendChild(label);
+ return wrapper;
+ }
+
+ if (node.widget_type === "float_input") {
+ const input = document.createElement("input");
+ input.type = "number";
+ input.value = effectiveValue ?? "";
+ if (node.step !== undefined && node.step !== null) {
+ input.step = String(node.step);
+ }
+ input.disabled = isWidgetDisabled(node, context, leafPageId);
+ wrapper.appendChild(input);
+ return wrapper;
+ }
+
+ if (node.widget_type === "button") {
+ wrapper.appendChild(
+ makeButton({
+ label: node.name || node.value || "",
+ disabled: true,
+ className: "widget-button",
+ })
+ );
+ return wrapper;
+ }
+
fail(
"Unknown widget type encountered in export payload:",
node.widget_type,
diff --git a/dashboard/export/js_runtime/schema.js b/dashboard/export/js_runtime/schema.js
index d461a38..8e93497 100644
--- a/dashboard/export/js_runtime/schema.js
+++ b/dashboard/export/js_runtime/schema.js
@@ -128,6 +128,29 @@
"INVALID_SELECTOR_DEFAULT"
);
}
+ if (selector.parent_selector_id) {
+ const parentSelector = (pageDescriptor.selectors || []).find((candidate) => {
+ return candidate.id === selector.parent_selector_id;
+ });
+ if (!parentSelector) {
+ fail(
+ "Selector " + selector.id + " on page " + pageDescriptor.id + " references an unknown parent selector.",
+ null,
+ "INVALID_SELECTOR_PARENT"
+ );
+ }
+ for (const options of Object.values(selector.options_by_parent_value || {})) {
+ for (const option of options || []) {
+ if ((selector.options || []).indexOf(option) === -1) {
+ fail(
+ "Dependent selector " + selector.id + " contains an option outside its exported domain.",
+ null,
+ "INVALID_DEPENDENT_SELECTOR_OPTION"
+ );
+ }
+ }
+ }
+ }
}
}
}
diff --git a/dashboard/export/js_runtime/state.js b/dashboard/export/js_runtime/state.js
index 88b0762..6868325 100644
--- a/dashboard/export/js_runtime/state.js
+++ b/dashboard/export/js_runtime/state.js
@@ -19,6 +19,19 @@
return null;
}
+ function findPageDescriptorById(pageDescriptors, pageId) {
+ for (const page of pageDescriptors || []) {
+ if (page.id === pageId) {
+ return page;
+ }
+ const childMatch = findPageDescriptorById(page.children || [], pageId);
+ if (childMatch) {
+ return childMatch;
+ }
+ }
+ return null;
+ }
+
function hasChildren(pageDescriptor) {
return !!(pageDescriptor && pageDescriptor.children && pageDescriptor.children.length);
}
@@ -164,6 +177,33 @@
const nextState = cloneState(currentState);
const pageState = Object.assign({}, nextState.pageSelectors[pageId] || {});
pageState[selectorId] = value;
+ if (
+ pageId === "vmt"
+ && selectorId === "personal_auto_vmt_breakdown"
+ && value !== "Home Geography"
+ ) {
+ pageState.personal_auto_vmt_geography_type = "All Geography Types";
+ }
+ if (
+ pageId === "vmt"
+ && selectorId === "non_motorized_vmt_breakdown"
+ && value !== "Home Geography"
+ ) {
+ pageState.non_motorized_vmt_geography_type = "All Geography Types";
+ }
+ const pageDescriptor = findPageDescriptorById(currentPayload.pages || [], pageId);
+ for (const selector of (pageDescriptor && pageDescriptor.selectors) || []) {
+ if (selector.parent_selector_id !== selectorId) {
+ continue;
+ }
+ const dependentOptions = (
+ selector.options_by_parent_value
+ && selector.options_by_parent_value[value]
+ ) || [];
+ if (dependentOptions.length) {
+ pageState[selector.id] = dependentOptions[0];
+ }
+ }
nextState.pageSelectors[pageId] = pageState;
return normalizeState(currentPayload, nextState);
}
diff --git a/dashboard/export/page_serializer.py b/dashboard/export/page_serializer.py
new file mode 100644
index 0000000..49710b5
--- /dev/null
+++ b/dashboard/export/page_serializer.py
@@ -0,0 +1,374 @@
+"""Serialize one dashboard page and its selector-driven export regions."""
+
+from __future__ import annotations
+
+from time import perf_counter
+from typing import Any
+
+import panel as pn
+
+from runtime.logging import get_logger
+from dashboard.export import selector_states, traversal
+from dashboard.export.serializer import serialize_viewable, variant_key
+from dashboard.export.types import PageContentPayload, SelectorMetadataPayload
+from dashboard.page_definitions import DashboardPageDefinition
+
+LOGGER = get_logger("dashboard.export")
+
+EXPORT_SECTION_VARIANT_WARNING_COUNT = 500
+EXPORT_REGION_PROGRESS_INTERVAL_SECONDS = 30.0
+EXPORT_REGION_PROGRESS_VARIANT_INTERVAL = 25
+VMT_EXPORT_DROPDOWN_NOTE = (
+ "Greyed-out dropdowns are unavailable in this HTML export. "
+ "Use the live dashboard to access all dropdowns."
+)
+
+
+def serialize_page_content(
+ page: Any,
+ *,
+ page_def: DashboardPageDefinition,
+ widget_metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]],
+ selector_metadata_by_id: dict[str, SelectorMetadataPayload],
+ diagnostics_for_state: dict[str, Any] | None = None,
+) -> PageContentPayload:
+ """Serialize one page shell with explicit region nodes."""
+ page_diagnostics: dict[str, Any] = {"default": _serialize_page_diagnostics(page)}
+ enabled_part_defs = traversal.enabled_export_parts(
+ page,
+ page_def,
+ page.config.export_html,
+ )
+ enabled_part_ids = {part_def.part_id for part_def in enabled_part_defs}
+ enabled_selector_ids = {
+ selector_id
+ for part_def in enabled_part_defs
+ for selector_id in part_def.selector_ids
+ }
+ interactive_selectors = [
+ selector_meta
+ for selector_meta in selector_metadata_by_id.values()
+ if selector_meta["export_enabled"]
+ ]
+ if interactive_selectors and not traversal.page_export_parts(page):
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} declares exportable selectors but no export parts."
+ )
+
+ region_nodes_by_id = build_region_nodes(
+ page,
+ page_def=page_def,
+ widget_metadata=widget_metadata,
+ selector_metadata_by_id=selector_metadata_by_id,
+ page_diagnostics=page_diagnostics,
+ )
+ content = serialize_viewable(
+ page.view,
+ disable_widgets=False,
+ widget_metadata=widget_metadata,
+ region_nodes_by_id=region_nodes_by_id,
+ hidden_widget_ids={
+ id(widget)
+ for selector_def in traversal.page_selectors(page)
+ for widget in [traversal.selector_widget(selector_def)]
+ if traversal.selector_id(selector_def) not in enabled_selector_ids
+ and widget is not None
+ },
+ hidden_view_ids={
+ id(part_def.view_for(page))
+ for part_def in traversal.page_export_parts(page)
+ if part_def.part_id not in enabled_part_ids
+ and part_def.view_for(page) is not None
+ },
+ )
+ content = _with_export_page_notes(page_def, content)
+ if diagnostics_for_state is not None:
+ diagnostics_for_state[page_def.page_id] = page_diagnostics
+ return {"kind": "page", "content": content}
+
+
+def _with_export_page_notes(
+ page_def: DashboardPageDefinition,
+ content: dict[str, Any],
+) -> dict[str, Any]:
+ if page_def.page_id != "vmt":
+ return content
+ note_node = {
+ "kind": "html",
+ "html": (
+ ""
+ f"{VMT_EXPORT_DROPDOWN_NOTE}"
+ "
"
+ ),
+ }
+ if content.get("kind") == "container":
+ children = list(content.get("children", []))
+ insert_at = 1 if children else 0
+ return {
+ **content,
+ "children": [*children[:insert_at], note_node, *children[insert_at:]],
+ "child_count": len(children) + 1,
+ }
+ return {
+ "kind": "container",
+ "layout": "column",
+ "child_count": 2,
+ "children": [content, note_node],
+ "styles": {},
+ "css_classes": ["export-page-note-wrapper"],
+ }
+
+
+def build_widget_metadata(
+ page: Any,
+ *,
+ selector_metadata_by_id: dict[str, SelectorMetadataPayload],
+) -> dict[int, tuple[str | None, SelectorMetadataPayload | None]]:
+ """Build selector metadata keyed by widget identity for serialization."""
+ metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]] = {}
+ for selector_def in traversal.page_selectors(page):
+ selector_id = traversal.selector_id(selector_def)
+ selector_meta = selector_metadata_by_id[selector_id]
+ widget = traversal.selector_widget(selector_def)
+ if widget is not None:
+ metadata[id(widget)] = (selector_id, selector_meta)
+ return metadata
+
+
+def build_region_nodes(
+ page: Any,
+ *,
+ page_def: DashboardPageDefinition,
+ widget_metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]],
+ selector_metadata_by_id: dict[str, SelectorMetadataPayload],
+ page_diagnostics: dict[str, Any],
+) -> dict[int, dict[str, Any]]:
+ """Build serialized region nodes keyed by region-root object id."""
+ if page.view is None:
+ return {}
+
+ resolved_parts = traversal.resolve_page_parts(page, page_def=page_def)
+ if not resolved_parts:
+ return {}
+
+ interactive_selector_ids = {
+ selector_id
+ for selector_id, selector_meta in selector_metadata_by_id.items()
+ if selector_meta["export_enabled"]
+ }
+ referenced_selector_ids = {
+ selector_id
+ for part_def, _ in resolved_parts
+ for selector_id in part_def.selector_ids
+ }
+ unused_selector_ids = sorted(interactive_selector_ids - referenced_selector_ids)
+ if unused_selector_ids:
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} does not assign export regions to selector ids: "
+ + ", ".join(repr(selector_id) for selector_id in unused_selector_ids)
+ )
+
+ selector_widgets = {
+ traversal.selector_id(selector_def): traversal.selector_widget(selector_def)
+ for selector_def in traversal.page_selectors(page)
+ }
+ region_nodes: dict[int, dict[str, Any]] = {}
+ for part_def, part_view in resolved_parts:
+ region_start = perf_counter()
+ region_label = f"{page_def.page_id}.{part_def.part_id}"
+ active_selector_ids = [
+ selector_id
+ for selector_id in part_def.selector_ids
+ if selector_metadata_by_id.get(selector_id, {}).get("export_enabled")
+ ]
+ LOGGER.info(
+ "Export region %s: start (%s).",
+ region_label,
+ (
+ "selectors: " + ", ".join(active_selector_ids)
+ if active_selector_ids
+ else "static/no exported selectors"
+ ),
+ )
+ default_values = [
+ selector_metadata_by_id[selector_id]["default_value"]
+ for selector_id in active_selector_ids
+ ]
+ default_key = variant_key(default_values)
+ variants: dict[str, Any] = {}
+ variant_aliases: dict[str, str] = {}
+
+ if active_selector_ids:
+ state_specs, variant_aliases = selector_states.resolve_export_section_states(
+ page,
+ page_def=page_def,
+ part_def=part_def,
+ active_selector_ids=active_selector_ids,
+ selector_widgets=selector_widgets,
+ selector_metadata_by_id=selector_metadata_by_id,
+ )
+ selector_counts = {
+ selector_id: len(selector_metadata_by_id[selector_id]["resolved_values"])
+ for selector_id in active_selector_ids
+ }
+ raw_state_count = 1
+ for count in selector_counts.values():
+ raw_state_count *= count
+ diagnostics_key = f"export_region:{part_def.part_id}"
+ page_diagnostics[diagnostics_key] = {
+ "selector_ids": list(active_selector_ids),
+ "selector_counts": selector_counts,
+ "raw_state_count": raw_state_count,
+ "valid_state_count": len(state_specs),
+ "alias_count": len(variant_aliases),
+ "pruned_state_count": max(
+ raw_state_count - len(state_specs) - len(variant_aliases),
+ 0,
+ ),
+ }
+ diagnostic_message = (
+ f"Export region {region_label}: "
+ f"{len(state_specs)} valid states from {raw_state_count} raw selector combinations "
+ f"({len(variant_aliases)} aliases, "
+ f"{max(raw_state_count - len(state_specs) - len(variant_aliases), 0)} pruned)."
+ )
+ if len(state_specs) > EXPORT_SECTION_VARIANT_WARNING_COUNT:
+ LOGGER.warning("Warning: " + diagnostic_message)
+ else:
+ LOGGER.info(diagnostic_message)
+ last_progress_log = perf_counter()
+ total_states = len(state_specs)
+ for state_index, state_spec in enumerate(state_specs, start=1):
+ state_values = [
+ state_spec[selector_id] for selector_id in active_selector_ids
+ ]
+ state_key = variant_key(state_values)
+ with (
+ selector_states.suppress_page_selector_refresh(page),
+ selector_states.scoped_widget_values(
+ selector_widgets,
+ state_spec,
+ ),
+ ):
+ refreshed_part_view = _refresh_page_part_view(
+ page,
+ part_def,
+ page_id=page_def.page_id,
+ context_label="during variant serialization",
+ )
+ page_diagnostics[
+ f"region:{part_def.part_id}:{state_key}"
+ ] = _serialize_page_diagnostics(page)
+ variants[state_key] = serialize_viewable(
+ refreshed_part_view,
+ disable_widgets=False,
+ widget_metadata=widget_metadata,
+ )
+ now = perf_counter()
+ if (
+ state_index == total_states
+ or state_index % EXPORT_REGION_PROGRESS_VARIANT_INTERVAL == 0
+ or now - last_progress_log >= EXPORT_REGION_PROGRESS_INTERVAL_SECONDS
+ ):
+ LOGGER.info(
+ "Export region %s: serialized %s/%s variants in %.1fs.",
+ region_label,
+ state_index,
+ total_states,
+ now - region_start,
+ )
+ last_progress_log = now
+ else:
+ LOGGER.info(
+ "Export region %s: static region, serializing default content.",
+ region_label,
+ )
+
+ with (
+ selector_states.suppress_page_selector_refresh(page),
+ selector_states.scoped_widget_values(
+ selector_widgets,
+ dict(zip(active_selector_ids, default_values)),
+ ),
+ ):
+ refreshed_part_view = _refresh_page_part_view(
+ page,
+ part_def,
+ page_id=page_def.page_id,
+ context_label="after restoring defaults",
+ )
+ default_content = serialize_viewable(
+ refreshed_part_view,
+ disable_widgets=False,
+ widget_metadata=widget_metadata,
+ )
+ region_nodes[id(part_view)] = {
+ "kind": "region",
+ "region_id": part_def.part_id,
+ "selector_ids": active_selector_ids,
+ "content_mode": "snapshot",
+ "default_key": default_key,
+ "default_content": default_content,
+ "variants": variants,
+ "variant_aliases": variant_aliases,
+ }
+ LOGGER.info(
+ "Export region %s: finished in %.1fs (variants: %s, aliases: %s).",
+ region_label,
+ perf_counter() - region_start,
+ len(variants),
+ len(variant_aliases),
+ )
+
+ return region_nodes
+
+
+def _refresh_page_part_view(
+ page: Any,
+ part_def: Any,
+ *,
+ page_id: str,
+ context_label: str,
+) -> pn.viewable.Viewable:
+ """Refresh one export section and resolve its current subtree."""
+ if hasattr(page, "clear_query_cache"):
+ page.clear_query_cache()
+ if hasattr(page, "mark_section_stale"):
+ page.mark_section_stale(part_def.part_id)
+ page.refresh(force=False)
+ else:
+ page.refresh(force=True)
+ refreshed_part_view = part_def.view_for(page)
+ if refreshed_part_view is None:
+ raise ValueError(
+ f"Dashboard page {page_id!r} export region {part_def.part_id!r} "
+ f"resolved to no view {context_label}."
+ )
+ return refreshed_part_view
+
+
+def _serialize_page_diagnostics(page: Any) -> list[dict[str, Any]]:
+ diagnostics = getattr(page, "visualization_diagnostics", [])
+ serialized: list[dict[str, Any]] = []
+ for diagnostic in diagnostics:
+ serialized.append(
+ {
+ "visualization_id": diagnostic.visualization_id,
+ "render_state": diagnostic.render_state,
+ "input_kind": diagnostic.input_kind,
+ "input_ids": list(diagnostic.input_ids),
+ "usable_run_labels": list(diagnostic.usable_run_labels),
+ "excluded_runs": [
+ {
+ "label": issue.label,
+ "status": issue.status,
+ "detail": issue.detail,
+ "source_kind": issue.source_kind,
+ "source_id": issue.source_id,
+ "missing_columns": list(issue.missing_columns),
+ }
+ for issue in diagnostic.excluded_runs
+ ],
+ }
+ )
+ return serialized
diff --git a/dashboard/export/payload.py b/dashboard/export/payload.py
index 184ec95..3673810 100644
--- a/dashboard/export/payload.py
+++ b/dashboard/export/payload.py
@@ -2,30 +2,22 @@
from __future__ import annotations
-from contextlib import contextmanager
-from itertools import product
import json
from typing import Any
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import panel as pn
-from dashboard.components import (
- build_run_legend_entries,
- set_percent_mode,
- set_run_colors,
- set_run_label_order,
-)
+from dashboard.rendering import RenderContext, run_legend_entries
from dashboard import DashboardState
from dashboard.export.context import ExportBuildContext
from dashboard.export.protocols import validate_export_page
from dashboard.export.serializer import (
json_default,
page_definition_for_page,
- sanitize_export_payload,
- serialize_viewable,
- variant_key,
+ sanitize_export_payload_in_place,
)
+from dashboard.export import page_serializer, selector_states, traversal
from dashboard.export.types import (
EXPORT_CLIENT_RUNTIME,
EXPORT_PAGE_SELECTOR_RUNTIME,
@@ -42,14 +34,13 @@
from dashboard.page_registry import (
build_export_prepared_run_provider,
build_registered_export_pages,
- effective_export_parts,
group_definition_by_id,
page_definition_by_id,
resolve_export_navigation_entries,
)
from processor.models import RunData
-from processor.summarize.cache import SummaryRun
-from runtime.config import Config, ExportSelectorRequest
+from processor.summarize.cache_types import SummaryRun
+from runtime.config import Config
LOGGER = get_logger("dashboard.export")
@@ -58,100 +49,6 @@
PAGE_WARNING_BYTES = 10 * 1024 * 1024
STATIC_REGION_WARNING_BYTES = 5 * 1024 * 1024
SELECTOR_REGION_WARNING_BYTES = 1 * 1024 * 1024
-
-
-class _RuntimeExportPart:
- def __init__(
- self,
- *,
- part_id: str,
- selector_ids: tuple[str, ...],
- export_data_mode: str,
- view: pn.viewable.Viewable,
- ) -> None:
- self.part_id = part_id
- self.selector_ids = selector_ids
- self.export_data_mode = export_data_mode
- self._view = view
-
- def view_for(self, page: Any) -> pn.viewable.Viewable | None:
- return self._view
-
-
-def _page_selector_defs(
- page: Any, page_def: DashboardPageDefinition
-) -> tuple[Any, ...]:
- runtime_selectors = tuple(getattr(page, "registered_selectors", ()))
- if runtime_selectors:
- return runtime_selectors
- return tuple(page_def.selectors)
-
-
-def _selector_id(selector_def: Any) -> str:
- return str(selector_def.selector_id)
-
-
-def _selector_label(selector_def: Any) -> str:
- return str(selector_def.label)
-
-
-def _selector_exportable(selector_def: Any) -> bool:
- return bool(getattr(selector_def, "exportable", True))
-
-
-def _selector_widget(selector_def: Any, page: Any) -> pn.widgets.Widget | None:
- widget = getattr(selector_def, "widget", None)
- if isinstance(widget, pn.widgets.Widget):
- return widget
- if hasattr(selector_def, "widget_for"):
- return selector_def.widget_for(page)
- return None
-
-
-def _selector_available(selector_def: Any, page: Any, config: Config) -> bool:
- widget = _selector_widget(selector_def, page)
- if widget is None:
- return False
- if hasattr(selector_def, "available_for"):
- return bool(selector_def.available_for(page, config))
- return True
-
-
-def _selector_options(widget: pn.widgets.Widget) -> list[str]:
- raw_options = getattr(widget, "options", None)
- if raw_options is None:
- return []
- return [str(option) for option in raw_options]
-
-
-def _selector_supports_option_enumeration(widget: pn.widgets.Widget) -> bool:
- return hasattr(widget, "options")
-
-
-def _page_export_parts(page: Any, page_def: DashboardPageDefinition) -> tuple[Any, ...]:
- runtime_sections = tuple(getattr(page, "registered_sections", ()))
- if runtime_sections:
- return tuple(
- _RuntimeExportPart(
- part_id=section.section_id,
- selector_ids=section.selector_ids,
- export_data_mode=section.export_data_mode,
- view=section.container,
- )
- for section in runtime_sections
- if section.export
- )
- return effective_export_parts(page_def)
-
-
-def _part_uses_prepared_data(part_def: Any) -> bool:
- return str(getattr(part_def, "export_data_mode", "none")) != "none"
-
-
-def _include_part_in_export(part_def: Any) -> bool:
- return not _part_uses_prepared_data(part_def)
-
-
def _build_validation_page(
page_def: DashboardPageDefinition,
config: Config,
@@ -178,7 +75,6 @@ def build_export_artifacts(
summary_runs: list[SummaryRun] | None = None,
) -> tuple[ExportPayload, dict[str, Any]]:
"""Build export payload plus sidecar diagnostics."""
- set_run_colors(config.run_colors)
validate_page_export_config(config)
export_weight_values = config.export_html.panel_weighting_values()
export_value_values = config.export_html.panel_value_values()
@@ -188,7 +84,6 @@ def build_export_artifacts(
prepared_run_provider=build_export_prepared_run_provider(runs, config),
)
chrome_state = context.build_dashboard_state()
- set_run_label_order(chrome_state.run_labels)
state_payloads: dict[str, dict[str, Any]] = {}
diagnostics_by_state: dict[str, Any] = {}
@@ -208,7 +103,9 @@ def build_export_artifacts(
payload: ExportPayload = {
"schema_version": EXPORT_SCHEMA_VERSION,
"title": config.dashboard_title,
- "runs_loaded": build_run_legend_entries(chrome_state.run_labels),
+ "runs_loaded": run_legend_entries(
+ RenderContext.from_dashboard(config, chrome_state)
+ ),
"chrome": {
"layout": "left_rail",
"rail_sections": ["runs_loaded", "display_options"],
@@ -406,7 +303,7 @@ def emit_export_size_warnings(size_analysis: dict[str, Any] | None) -> None:
def _estimate_json_bytes(value: Any) -> int:
- sanitized = sanitize_export_payload(value)
+ sanitized = sanitize_export_payload_in_place(value)
return len(
json.dumps(
sanitized,
@@ -461,7 +358,7 @@ def _export_part_disable_hint(
part_id: str,
) -> str:
page_key = _page_config_key(page_def)
- return f"visualizer.export_html.pages.{page_key}.parts.{part_id}.enabled: false"
+ return f"dashboard.export.pages.{page_key}.parts.{part_id}.enabled: false"
def serialize_dashboard_state(
@@ -475,7 +372,6 @@ def serialize_dashboard_state(
state = context.build_dashboard_state()
state.weight_mode = weight_mode
state.value_mode = value_mode
- set_percent_mode(value_mode == "Percent")
pages = build_registered_export_pages(state, context.config)
leaf_page_defs: list[PageDescriptorPayload] = []
@@ -488,16 +384,16 @@ def serialize_dashboard_state(
if page.view is None:
continue
page_def = page_definition_for_page(page)
- enabled_part_defs = resolve_enabled_export_parts(
+ enabled_part_defs = traversal.enabled_export_parts(
page,
page_def,
page.config.export_html,
)
- if _page_export_parts(page, page_def) and not enabled_part_defs:
+ if traversal.page_export_parts(page) and not enabled_part_defs:
continue
- selector_defs = _page_selector_defs(page, page_def)
+ selector_defs = traversal.page_selectors(page)
selector_metadata_by_id = {
- _selector_id(selector_def): resolve_selector_metadata(
+ traversal.selector_id(selector_def): resolve_selector_metadata(
page_def,
selector_def,
page,
@@ -505,8 +401,8 @@ def serialize_dashboard_state(
)
for selector_def in selector_defs
}
- widget_metadata = build_widget_metadata(
- page_def,
+ selector_states.apply_selector_dependencies(page, selector_metadata_by_id)
+ widget_metadata = page_serializer.build_widget_metadata(
page,
selector_metadata_by_id=selector_metadata_by_id,
)
@@ -525,7 +421,7 @@ def serialize_dashboard_state(
}
leaf_page_defs.append(descriptor)
page_by_id[page_def.page_id] = descriptor
- content_by_page[page_def.page_id] = serialize_page_content(
+ content_by_page[page_def.page_id] = page_serializer.serialize_page_content(
page,
page_def=page_def,
widget_metadata=widget_metadata,
@@ -583,338 +479,6 @@ def _resolve_group_default_child_page_id(
return page_definitions[0].page_id
-def serialize_page_content(
- page: Any,
- *,
- page_def: DashboardPageDefinition,
- widget_metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]],
- selector_metadata_by_id: dict[str, SelectorMetadataPayload],
- diagnostics_for_state: dict[str, Any] | None = None,
-) -> PageContentPayload:
- """Serialize one page shell with explicit region nodes."""
- page_diagnostics: dict[str, Any] = {"default": _serialize_page_diagnostics(page)}
- enabled_part_defs = resolve_enabled_export_parts(
- page,
- page_def,
- page.config.export_html,
- )
- enabled_part_ids = {part_def.part_id for part_def in enabled_part_defs}
- enabled_selector_ids = {
- selector_id
- for part_def in enabled_part_defs
- for selector_id in part_def.selector_ids
- }
- interactive_selectors = [
- selector_meta
- for selector_meta in selector_metadata_by_id.values()
- if selector_meta["export_enabled"]
- ]
- if interactive_selectors and not _page_export_parts(page, page_def):
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} declares exportable selectors but no export parts."
- )
-
- region_nodes_by_id = build_region_nodes(
- page,
- page_def=page_def,
- widget_metadata=widget_metadata,
- selector_metadata_by_id=selector_metadata_by_id,
- page_diagnostics=page_diagnostics,
- )
- content = serialize_viewable(
- page.view,
- disable_widgets=False,
- widget_metadata=widget_metadata,
- region_nodes_by_id=region_nodes_by_id,
- hidden_widget_ids={
- id(widget)
- for selector_def in _page_selector_defs(page, page_def)
- for widget in [_selector_widget(selector_def, page)]
- if _selector_id(selector_def) not in enabled_selector_ids
- and widget is not None
- },
- hidden_view_ids={
- id(part_def.view_for(page))
- for part_def in _page_export_parts(page, page_def)
- if part_def.part_id not in enabled_part_ids
- and part_def.view_for(page) is not None
- },
- )
- if diagnostics_for_state is not None:
- diagnostics_for_state[page_def.page_id] = page_diagnostics
- return {"kind": "page", "content": content}
-
-
-def build_widget_metadata(
- page_def: DashboardPageDefinition,
- page: Any,
- *,
- selector_metadata_by_id: dict[str, SelectorMetadataPayload],
-) -> dict[int, tuple[str | None, SelectorMetadataPayload | None]]:
- """Build selector metadata keyed by widget identity for serialization."""
- metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]] = {}
- for selector_def in _page_selector_defs(page, page_def):
- selector_id = _selector_id(selector_def)
- selector_meta = selector_metadata_by_id[selector_id]
- widget = _selector_widget(selector_def, page)
- if widget is not None:
- metadata[id(widget)] = (selector_id, selector_meta)
- return metadata
-
-
-def build_region_nodes(
- page: Any,
- *,
- page_def: DashboardPageDefinition,
- widget_metadata: dict[int, tuple[str | None, SelectorMetadataPayload | None]],
- selector_metadata_by_id: dict[str, SelectorMetadataPayload],
- page_diagnostics: dict[str, Any],
-) -> dict[int, dict[str, Any]]:
- """Build serialized region nodes keyed by region-root object id."""
- if page.view is None:
- return {}
-
- resolved_parts = resolve_page_parts(page, page_def=page_def)
- if not resolved_parts:
- return {}
-
- interactive_selector_ids = {
- selector_id
- for selector_id, selector_meta in selector_metadata_by_id.items()
- if selector_meta["export_enabled"]
- }
- referenced_selector_ids = {
- selector_id
- for part_def, _ in resolved_parts
- for selector_id in part_def.selector_ids
- }
- unused_selector_ids = sorted(interactive_selector_ids - referenced_selector_ids)
- if unused_selector_ids:
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} does not assign export regions to selector ids: "
- + ", ".join(repr(selector_id) for selector_id in unused_selector_ids)
- )
-
- selector_widgets = {
- _selector_id(selector_def): _selector_widget(selector_def, page)
- for selector_def in _page_selector_defs(page, page_def)
- }
- region_nodes: dict[int, dict[str, Any]] = {}
- for part_def, part_view in resolved_parts:
- active_selector_ids = [
- selector_id
- for selector_id in part_def.selector_ids
- if selector_metadata_by_id.get(selector_id, {}).get("export_enabled")
- ]
- default_values = [
- selector_metadata_by_id[selector_id]["default_value"]
- for selector_id in active_selector_ids
- ]
- default_key = variant_key(default_values)
- variants: dict[str, Any] = {}
-
- if active_selector_ids:
- selector_values = [
- selector_metadata_by_id[selector_id]["resolved_values"]
- for selector_id in active_selector_ids
- ]
- for combination in product(*selector_values):
- with temporary_widget_values(
- selector_widgets,
- dict(zip(active_selector_ids, combination)),
- ):
- refreshed_part_view = _refresh_page_part_view(
- page,
- part_def,
- page_id=page_def.page_id,
- context_label="during variant serialization",
- )
- page_diagnostics[
- f"region:{part_def.part_id}:{variant_key(combination)}"
- ] = _serialize_page_diagnostics(page)
- variants[variant_key(combination)] = serialize_viewable(
- refreshed_part_view,
- disable_widgets=False,
- widget_metadata=widget_metadata,
- )
-
- with temporary_widget_values(
- selector_widgets,
- dict(zip(active_selector_ids, default_values)),
- ):
- refreshed_part_view = _refresh_page_part_view(
- page,
- part_def,
- page_id=page_def.page_id,
- context_label="after restoring defaults",
- )
- default_content = serialize_viewable(
- refreshed_part_view,
- disable_widgets=False,
- widget_metadata=widget_metadata,
- )
- region_nodes[id(part_view)] = {
- "kind": "region",
- "region_id": part_def.part_id,
- "selector_ids": active_selector_ids,
- "content_mode": "snapshot",
- "default_key": default_key,
- "default_content": default_content,
- "variants": variants,
- }
-
- return region_nodes
-
-
-def resolve_page_parts(
- page: Any,
- *,
- page_def: DashboardPageDefinition,
-) -> list[tuple[Any, pn.viewable.Viewable]]:
- """Resolve and validate explicit export parts for one page instance."""
- if page.view is None:
- return []
-
- root_paths = _view_paths_by_id(page.view)
- enabled_part_defs = resolve_enabled_export_parts(
- page, page_def, page.config.export_html
- )
- resolved: list[tuple[Any, pn.viewable.Viewable]] = []
- for part_def in enabled_part_defs:
- part_view = part_def.view_for(page)
- if part_view is None:
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} export region {part_def.part_id!r} "
- "could not resolve its registered view."
- )
- part_path = root_paths.get(id(part_view))
- if part_path is None:
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} export region {part_def.part_id!r} "
- "does not belong to the page view tree."
- )
- resolved.append((part_def, part_view))
-
- for index, (part_def, part_view) in enumerate(resolved):
- part_path = root_paths[id(part_view)]
- for other_def, other_view in resolved[index + 1 :]:
- other_path = root_paths[id(other_view)]
- if part_path == other_path:
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} export regions {part_def.part_id!r} "
- f"and {other_def.part_id!r} resolve to the same subtree."
- )
- if _is_prefix(part_path, other_path) or _is_prefix(other_path, part_path):
- raise ValueError(
- f"Dashboard page {page_def.page_id!r} export regions {part_def.part_id!r} "
- f"and {other_def.part_id!r} must not overlap or nest."
- )
-
- return resolved
-
-
-@contextmanager
-def temporary_widget_values(
- selector_widgets: dict[str, pn.widgets.Widget | None],
- values_by_selector_id: dict[str, Any],
-):
- """Temporarily set selector widget values and always restore the originals.
-
- Export region serialization needs to mutate live widget instances so each
- selector combination can be rendered offline. This helper keeps that
- mutation boundary explicit and guarantees restoration on both success and
- failure paths.
- """
- original_values = {
- selector_id: widget.value
- for selector_id, widget in selector_widgets.items()
- if widget is not None
- }
- try:
- for selector_id, value in values_by_selector_id.items():
- widget = selector_widgets.get(selector_id)
- if widget is not None:
- widget.value = value
- yield
- finally:
- for selector_id, original_value in original_values.items():
- widget = selector_widgets.get(selector_id)
- if widget is not None:
- widget.value = original_value
-
-
-def _refresh_page_part_view(
- page: Any,
- part_def: Any,
- *,
- page_id: str,
- context_label: str,
-) -> pn.viewable.Viewable:
- """Refresh one page and resolve the current export-part subtree."""
- if hasattr(page, "clear_filtered_view_cache"):
- page.clear_filtered_view_cache()
- page.refresh(force=True)
- refreshed_part_view = part_def.view_for(page)
- if refreshed_part_view is None:
- raise ValueError(
- f"Dashboard page {page_id!r} export region {part_def.part_id!r} "
- f"resolved to no view {context_label}."
- )
- return refreshed_part_view
-
-
-def resolve_enabled_export_parts(
- page: Any,
- page_def: DashboardPageDefinition,
- export_html: Any,
-) -> tuple[Any, ...]:
- """Return the enabled export parts for one page definition."""
- override = export_html.page_override(
- page_def.page_id,
- group_id=page_def.group_id,
- )
- enabled_parts = []
- for part_def in _page_export_parts(page, page_def):
- if not _include_part_in_export(part_def):
- continue
- part_override = override.parts.get(part_def.part_id)
- if part_override is not None and part_override.enabled is False:
- continue
- enabled_parts.append(part_def)
- return tuple(enabled_parts)
-
-
-def _view_paths_by_id(
- root: pn.viewable.Viewable,
- path: tuple[int, ...] = (),
- result: dict[int, tuple[int, ...]] | None = None,
-) -> dict[int, tuple[int, ...]]:
- """Return stable DFS paths for supported viewables in one page tree."""
- result = result or {}
- result[id(root)] = path
- for index, child in enumerate(_child_viewables(root)):
- _view_paths_by_id(child, path + (index,), result)
- return result
-
-
-def _child_viewables(view: pn.viewable.Viewable) -> list[pn.viewable.Viewable]:
- """Return supported child viewables for export-region path validation."""
- if isinstance(view, (pn.Column, pn.Row, pn.Card)):
- return [
- child for child in view.objects if isinstance(child, pn.viewable.Viewable)
- ]
- if isinstance(view, pn.Tabs):
- return [
- child for child in view.objects if isinstance(child, pn.viewable.Viewable)
- ]
- return []
-
-
-def _is_prefix(left: tuple[int, ...], right: tuple[int, ...]) -> bool:
- """Return whether one view path is an ancestor-prefix of another."""
- return len(left) < len(right) and right[: len(left)] == left
-
-
def resolve_selector_metadata(
page_def: DashboardPageDefinition,
selector_def: Any,
@@ -936,9 +500,9 @@ def resolve_selector_metadata(
group_id=page_def.group_id,
).selector_requests
)
- widget = _selector_widget(selector_def, page)
- available = _selector_available(selector_def, page, context.config)
- export_enabled = _selector_exportable(selector_def)
+ widget = traversal.selector_widget(selector_def)
+ available = traversal.selector_available(selector_def)
+ export_enabled = traversal.selector_exportable(selector_def)
if not available or widget is None:
if configured:
@@ -953,7 +517,7 @@ def resolve_selector_metadata(
context.warned_unavailable_selectors.add(warning_key)
return {
"id": selector_id,
- "label": _selector_label(selector_def),
+ "label": traversal.selector_label(selector_def),
"available": False,
"request_mode": request.mode,
"requested_values": list(request.values),
@@ -963,15 +527,15 @@ def resolve_selector_metadata(
"export_enabled": False,
}
- options = _selector_options(widget)
+ options = selector_states.selector_options(widget)
default_value = str(widget.value)
- enabled_part_defs = resolve_enabled_export_parts(
+ enabled_part_defs = traversal.enabled_export_parts(
page, page_def, context.config.export_html
)
selector_used_by_enabled_part = any(
selector_id in part_def.selector_ids for part_def in enabled_part_defs
)
- supports_option_enumeration = _selector_supports_option_enumeration(widget)
+ supports_option_enumeration = selector_states.supports_option_enumeration(widget)
if configured and not selector_used_by_enabled_part:
warning_key = (page_id, selector_id, "unused")
if warning_key not in context.warned_unavailable_selectors:
@@ -983,7 +547,7 @@ def resolve_selector_metadata(
context.warned_unavailable_selectors.add(warning_key)
return {
"id": selector_id,
- "label": _selector_label(selector_def),
+ "label": traversal.selector_label(selector_def),
"available": True,
"request_mode": request.mode,
"requested_values": list(request.values),
@@ -1006,7 +570,7 @@ def resolve_selector_metadata(
context.warned_unavailable_selectors.add(warning_key)
return {
"id": selector_id,
- "label": _selector_label(selector_def),
+ "label": traversal.selector_label(selector_def),
"available": True,
"request_mode": request.mode,
"requested_values": list(request.values),
@@ -1015,16 +579,16 @@ def resolve_selector_metadata(
"options": options,
"export_enabled": False,
}
- resolved_values = resolve_selector_values(
+ resolved_values = selector_states.resolve_selector_values(
request=request,
options=options,
default_value=default_value,
field_name=_selector_field_name(page_def, selector_id),
)
- export_enabled = _selector_exportable(selector_def) and len(resolved_values) > 1
+ export_enabled = traversal.selector_exportable(selector_def) and len(resolved_values) > 1
return {
"id": selector_id,
- "label": _selector_label(selector_def),
+ "label": traversal.selector_label(selector_def),
"available": True,
"request_mode": request.mode,
"requested_values": list(request.values),
@@ -1035,51 +599,17 @@ def resolve_selector_metadata(
}
-def resolve_selector_values(
- *,
- request: ExportSelectorRequest,
- options: list[str],
- default_value: str,
- field_name: str,
-) -> list[str]:
- """Resolve configured selector requests against actual widget options."""
- if request.mode == "default":
- return [default_value]
- if request.mode == "all":
- if not options:
- raise ValueError(f"{field_name} resolved to no values.")
- return list(options)
-
- option_lookup = {option.strip().lower(): option for option in options}
- resolved: list[str] = []
- invalid: list[str] = []
- for token in request.values:
- option = option_lookup.get(token)
- if option is None:
- invalid.append(token)
- continue
- if option not in resolved:
- resolved.append(option)
- if invalid:
- raise ValueError(
- f"Unsupported {field_name} values: "
- + ", ".join(repr(token) for token in invalid)
- )
- if not resolved:
- raise ValueError(f"{field_name} resolved to no values.")
- return resolved
-
-
def validate_page_export_config(config: Config) -> None:
"""Validate export page and selector ids against the live registry."""
unknown_pages = sorted(
page_id
for page_id in config.export_html.pages
if _page_definition_for_export_override(page_id) is None
+ and group_definition_by_id(page_id) is None
)
if unknown_pages:
raise ValueError(
- "Unsupported visualizer.export_html.pages entries: "
+ "Unsupported dashboard.export.pages entries: "
+ ", ".join(repr(page_id) for page_id in unknown_pages)
)
@@ -1090,7 +620,7 @@ def validate_page_export_config(config: Config) -> None:
)
if unknown_excluded_pages:
raise ValueError(
- "Unsupported visualizer.export_html.exclude_pages entries: "
+ "Unsupported dashboard.export.exclude_pages entries: "
+ ", ".join(repr(page_id) for page_id in unknown_excluded_pages)
)
unknown_excluded_groups = sorted(
@@ -1100,29 +630,31 @@ def validate_page_export_config(config: Config) -> None:
)
if unknown_excluded_groups:
raise ValueError(
- "Unsupported visualizer.export_html.exclude_groups entries: "
+ "Unsupported dashboard.export.exclude_groups entries: "
+ ", ".join(repr(group_id) for group_id in unknown_excluded_groups)
)
for page_id, override in config.export_html.pages.items():
page_def = _page_definition_for_export_override(page_id)
if page_def is None:
+ # A group-level empty mapping is a valid no-op override and has no
+ # leaf selectors or parts to validate.
continue
page = _build_validation_page(page_def, config)
- selector_defs = _page_selector_defs(page, page_def)
+ selector_defs = traversal.page_selectors(page)
unknown_selectors = sorted(
selector_id
for selector_id in override.selector_requests
if selector_defs
and selector_id
- not in {_selector_id(selector_def) for selector_def in selector_defs}
+ not in {traversal.selector_id(selector_def) for selector_def in selector_defs}
)
if unknown_selectors:
raise ValueError(
- f"Unsupported visualizer.export_html.pages.{page_id} entries: "
+ f"Unsupported dashboard.export.pages.{page_id} entries: "
+ ", ".join(repr(selector_id) for selector_id in unknown_selectors)
)
- export_parts = _page_export_parts(page, page_def)
+ export_parts = traversal.page_export_parts(page)
unknown_parts = sorted(
part_id
for part_id in override.parts
@@ -1130,7 +662,7 @@ def validate_page_export_config(config: Config) -> None:
)
if unknown_parts:
raise ValueError(
- f"Unsupported visualizer.export_html.pages.{_page_config_key(page_def)}.parts entries: "
+ f"Unsupported dashboard.export.pages.{_page_config_key(page_def)}.parts entries: "
+ ", ".join(repr(part_id) for part_id in unknown_parts)
)
@@ -1159,7 +691,7 @@ def _selector_field_name(
page_def: DashboardPageDefinition,
selector_id: str,
) -> str:
- return f"visualizer.export_html.pages.{_page_config_key(page_def)}.{selector_id}"
+ return f"dashboard.export.pages.{_page_config_key(page_def)}.{selector_id}"
def _page_config_key(page_def: DashboardPageDefinition | None) -> str:
@@ -1188,30 +720,3 @@ def _page_definition_for_export_override(
def state_key(weight_mode: str, value_mode: str) -> str:
"""Return the stable key for one dashboard-level state combination."""
return f"{weight_mode}||{value_mode}"
-
-
-def _serialize_page_diagnostics(page: Any) -> list[dict[str, Any]]:
- diagnostics = getattr(page, "visualization_diagnostics", [])
- serialized: list[dict[str, Any]] = []
- for diagnostic in diagnostics:
- serialized.append(
- {
- "visualization_id": diagnostic.visualization_id,
- "render_state": diagnostic.render_state,
- "input_kind": diagnostic.input_kind,
- "input_ids": list(diagnostic.input_ids),
- "usable_run_labels": list(diagnostic.usable_run_labels),
- "excluded_runs": [
- {
- "label": issue.label,
- "status": issue.status,
- "detail": issue.detail,
- "source_kind": issue.source_kind,
- "source_id": issue.source_id,
- "missing_columns": list(issue.missing_columns),
- }
- for issue in diagnostic.excluded_runs
- ],
- }
- )
- return serialized
diff --git a/dashboard/export/runtime_assets.py b/dashboard/export/runtime_assets.py
index 2578717..4ca179f 100644
--- a/dashboard/export/runtime_assets.py
+++ b/dashboard/export/runtime_assets.py
@@ -30,12 +30,12 @@ def load_export_runtime_js() -> str:
)
-def build_export_html_shell(*, title: str, payload_json: str, plotly_js: str) -> str:
- """Assemble the final self-contained HTML document."""
+def build_export_html_shell_parts(*, title: str, plotly_js: str) -> tuple[str, str]:
+ """Build the shell surrounding the export payload JSON."""
export_css = load_export_css()
runtime_js = load_export_runtime_js()
- return f"""
+ prefix = f"""
@@ -50,10 +50,19 @@ def build_export_html_shell(*, title: str, payload_json: str, plotly_js: str) ->
-
+
"""
+ return prefix, suffix
+
+
+def build_export_html_shell(*, title: str, payload_json: str, plotly_js: str) -> str:
+ """Assemble the final self-contained HTML document."""
+
+ prefix, suffix = build_export_html_shell_parts(title=title, plotly_js=plotly_js)
+ return f"{prefix}{payload_json}{suffix}"
diff --git a/dashboard/export/selector_states.py b/dashboard/export/selector_states.py
new file mode 100644
index 0000000..a61e9dc
--- /dev/null
+++ b/dashboard/export/selector_states.py
@@ -0,0 +1,350 @@
+"""Resolve and safely enumerate page-selector states for offline export."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Any, Iterator
+
+import panel as pn
+
+from dashboard.export.serializer import variant_key
+from dashboard.export.types import SelectorMetadataPayload
+from dashboard.page_definitions import DashboardPageDefinition
+from runtime.config import ExportSelectorRequest
+
+
+def selector_options(widget: pn.widgets.Widget) -> list[str]:
+ if isinstance(widget, pn.widgets.Checkbox):
+ return ["False", "True"]
+ options = getattr(widget, "options", None)
+ return [] if options is None else [str(option) for option in options]
+
+
+def supports_option_enumeration(widget: pn.widgets.Widget) -> bool:
+ return isinstance(widget, pn.widgets.Checkbox) or hasattr(widget, "options")
+
+
+def resolve_selector_values(
+ *,
+ request: ExportSelectorRequest,
+ options: list[str],
+ default_value: str,
+ field_name: str,
+) -> list[str]:
+ """Resolve a configured selector request against current widget options."""
+ if request.mode == "default":
+ return [default_value]
+ if request.mode == "all":
+ if not options:
+ raise ValueError(f"{field_name} resolved to no values.")
+ return list(options)
+
+ option_lookup = {option.strip().lower(): option for option in options}
+ resolved: list[str] = []
+ invalid: list[str] = []
+ for token in request.values:
+ option = option_lookup.get(token)
+ if option is None and token == "all":
+ option = next(
+ (
+ candidate
+ for candidate in options
+ if candidate.strip().lower() == "all"
+ or candidate.strip().lower().startswith("all ")
+ ),
+ None,
+ )
+ if option is None:
+ invalid.append(token)
+ elif option not in resolved:
+ resolved.append(option)
+ if invalid:
+ raise ValueError(
+ f"Unsupported {field_name} values: "
+ + ", ".join(repr(token) for token in invalid)
+ + ". Supported values: "
+ + ", ".join(repr(option) for option in options)
+ )
+ if not resolved:
+ raise ValueError(f"{field_name} resolved to no values.")
+ return resolved
+
+
+def apply_selector_dependencies(
+ page: Any,
+ selector_metadata_by_id: dict[str, SelectorMetadataPayload],
+) -> None:
+ """Attach page-declared dependent selector domains to export metadata."""
+ dependency_provider = getattr(page, "export_selector_dependencies", None)
+ if not callable(dependency_provider):
+ return
+
+ dependencies = dependency_provider() or {}
+ for selector_id, dependency in dependencies.items():
+ selector_meta = selector_metadata_by_id.get(str(selector_id))
+ parent_selector_id = str(dependency.get("parent_selector_id", ""))
+ parent_meta = selector_metadata_by_id.get(parent_selector_id)
+ if selector_meta is None or parent_meta is None:
+ continue
+
+ raw_options_by_parent = dependency.get("options_by_parent_value", {})
+ if not isinstance(raw_options_by_parent, dict):
+ continue
+ parent_values = [str(value) for value in parent_meta["resolved_values"]]
+ options_by_parent_value = {
+ parent_value: [
+ str(option)
+ for option in raw_options_by_parent.get(parent_value, [])
+ ]
+ for parent_value in parent_values
+ }
+ allowed_options = list(
+ dict.fromkeys(
+ option
+ for parent_value in parent_values
+ for option in options_by_parent_value[parent_value]
+ )
+ )
+ if not allowed_options:
+ continue
+
+ original_resolved = {
+ str(value) for value in selector_meta["resolved_values"]
+ }
+ resolved_values = (
+ allowed_options
+ if selector_meta["request_mode"] == "all"
+ else [
+ option for option in allowed_options if option in original_resolved
+ ]
+ )
+ if not resolved_values:
+ resolved_values = [allowed_options[0]]
+
+ selector_meta["options"] = allowed_options
+ selector_meta["resolved_values"] = resolved_values
+ if str(selector_meta["default_value"]) not in allowed_options:
+ selector_meta["default_value"] = allowed_options[0]
+ selector_meta["export_enabled"] = bool(
+ selector_meta["available"] and len(resolved_values) > 1
+ )
+ selector_meta["parent_selector_id"] = parent_selector_id
+ selector_meta["options_by_parent_value"] = options_by_parent_value
+ selector_meta["disabled_parent_values"] = [
+ str(value)
+ for value in dependency.get("disabled_parent_values", [])
+ if str(value) in parent_values
+ ]
+
+
+def resolve_export_section_states(
+ page: Any,
+ *,
+ page_def: DashboardPageDefinition,
+ part_def: Any,
+ active_selector_ids: list[str],
+ selector_widgets: dict[str, pn.widgets.Widget | None],
+ selector_metadata_by_id: dict[str, SelectorMetadataPayload],
+) -> tuple[list[dict[str, str]], dict[str, str]]:
+ """Return canonical section states and aliases for collapsed raw states."""
+ if not active_selector_ids:
+ return [], {}
+
+ states_by_key: dict[str, dict[str, str]] = {}
+ aliases: dict[str, str] = {}
+
+ def visit(
+ index: int,
+ canonical_values: dict[str, str],
+ raw_values: dict[str, str],
+ ) -> None:
+ with suppress_page_selector_refresh(page), scoped_widget_values(
+ selector_widgets, canonical_values
+ ):
+ sync_page_controls(page)
+ if index >= len(active_selector_ids):
+ effective_values = effective_selector_values(
+ active_selector_ids, selector_widgets
+ )
+ state_key = variant_key(
+ [effective_values[item] for item in active_selector_ids]
+ )
+ raw_key = variant_key(
+ [
+ raw_values.get(item, effective_values[item])
+ for item in active_selector_ids
+ ]
+ )
+ states_by_key.setdefault(state_key, effective_values)
+ if raw_key != state_key:
+ aliases[raw_key] = state_key
+ return
+
+ selector_id = active_selector_ids[index]
+ widget = selector_widgets.get(selector_id)
+ if widget is None:
+ return
+ ignored = export_ignored_selectors(page, part_def.part_id, canonical_values)
+ candidates = selector_values_for_current_state(
+ selector_id=selector_id,
+ widget=widget,
+ selector_metadata=selector_metadata_by_id[selector_id],
+ selected_values=canonical_values,
+ )
+ if not candidates:
+ return
+ collapsed = (
+ bool(getattr(widget, "disabled", False)) or selector_id in ignored
+ )
+ for raw_value in candidates:
+ canonical_value = export_canonical_selector_value(
+ page,
+ part_def.part_id,
+ selector_id,
+ raw_value,
+ canonical_values,
+ )
+ if collapsed and canonical_value == raw_value:
+ canonical_value = str(widget.value)
+ visit(
+ index + 1,
+ {**canonical_values, selector_id: canonical_value},
+ {**raw_values, selector_id: raw_value},
+ )
+
+ visit(0, {}, {})
+ aliases = {
+ raw_key: canonical_key
+ for raw_key, canonical_key in aliases.items()
+ if canonical_key in states_by_key and raw_key != canonical_key
+ }
+ if not states_by_key:
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} export region "
+ f"{part_def.part_id!r} resolved to no valid selector states."
+ )
+ return list(states_by_key.values()), aliases
+
+
+def sync_page_controls(page: Any) -> None:
+ sync_declared = getattr(page, "_sync_declared_selectors", None)
+ if callable(sync_declared):
+ sync_declared()
+ sync_controls = getattr(page, "sync_controls", None)
+ if callable(sync_controls):
+ sync_controls()
+
+
+@contextmanager
+def suppress_page_selector_refresh(page: Any) -> Iterator[None]:
+ if not hasattr(page, "_is_refreshing"):
+ yield
+ return
+ previous_refreshing = bool(getattr(page, "_is_refreshing"))
+ previous_queue = set(getattr(page, "_queued_selector_ids", set()))
+ page._is_refreshing = True
+ try:
+ yield
+ finally:
+ page._is_refreshing = previous_refreshing
+ if hasattr(page, "_queued_selector_ids"):
+ page._queued_selector_ids = previous_queue
+
+
+@contextmanager
+def scoped_widget_values(
+ selector_widgets: dict[str, pn.widgets.Widget | None],
+ values_by_selector_id: dict[str, Any],
+) -> Iterator[None]:
+ """Set selector widgets temporarily and restore them on every exit path."""
+ original_values = {
+ selector_id: widget.value
+ for selector_id, widget in selector_widgets.items()
+ if widget is not None
+ }
+ try:
+ for selector_id, value in values_by_selector_id.items():
+ widget = selector_widgets.get(selector_id)
+ if widget is not None:
+ widget.value = coerce_widget_value(widget, value)
+ yield
+ finally:
+ for selector_id, original_value in original_values.items():
+ widget = selector_widgets.get(selector_id)
+ if widget is not None:
+ widget.value = original_value
+
+
+def coerce_widget_value(widget: pn.widgets.Widget, value: Any) -> Any:
+ if isinstance(widget, pn.widgets.Checkbox) and isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized == "true":
+ return True
+ if normalized == "false":
+ return False
+ return value
+
+
+def effective_selector_values(
+ active_selector_ids: list[str],
+ selector_widgets: dict[str, pn.widgets.Widget | None],
+) -> dict[str, str]:
+ return {
+ selector_id: str(widget.value)
+ for selector_id in active_selector_ids
+ if (widget := selector_widgets.get(selector_id)) is not None
+ }
+
+
+def export_ignored_selectors(
+ page: Any,
+ section_id: str,
+ selected_values: dict[str, str],
+) -> set[str]:
+ ignored = getattr(page, "export_ignored_selectors", None)
+ if not callable(ignored):
+ return set()
+ return set(ignored(section_id, dict(selected_values)) or set())
+
+
+def export_canonical_selector_value(
+ page: Any,
+ section_id: str,
+ selector_id: str,
+ value: str,
+ selected_values: dict[str, str],
+) -> str:
+ canonical = getattr(page, "export_canonical_selector_value", None)
+ if not callable(canonical):
+ return value
+ return str(canonical(section_id, selector_id, value, dict(selected_values)))
+
+
+def selector_values_for_current_state(
+ *,
+ selector_id: str,
+ widget: pn.widgets.Widget,
+ selector_metadata: SelectorMetadataPayload,
+ selected_values: dict[str, str] | None = None,
+) -> list[str]:
+ options = selector_options(widget)
+ parent_selector_id = selector_metadata.get("parent_selector_id")
+ if parent_selector_id and selected_values is not None:
+ parent_value = selected_values.get(parent_selector_id)
+ options_by_parent = selector_metadata.get("options_by_parent_value", {})
+ if parent_value in options_by_parent:
+ options = list(options_by_parent[parent_value])
+ default_value = str(widget.value)
+ request_mode = selector_metadata["request_mode"]
+ if request_mode == "default":
+ return [default_value]
+ if request_mode == "all":
+ return options or [default_value]
+
+ option_lookup = {option.strip().lower(): option for option in options}
+ resolved: list[str] = []
+ for value in selector_metadata["resolved_values"]:
+ option = option_lookup.get(str(value).strip().lower())
+ if option is not None and option not in resolved:
+ resolved.append(option)
+ return resolved
diff --git a/dashboard/export/serializer.py b/dashboard/export/serializer.py
index 435294f..f952361 100644
--- a/dashboard/export/serializer.py
+++ b/dashboard/export/serializer.py
@@ -12,7 +12,7 @@
import pandas as pd
import panel as pn
-from dashboard.components import format_numeric_for_display
+from dashboard.rendering import format_numeric
from dashboard.export.types import ExportNode
from dashboard.page_definitions import DashboardPageDefinition
@@ -24,7 +24,7 @@ def _serialize_table_cell(value: Any) -> Any:
Tabulator, so we normalize numeric display here using the same
significant-digit rule as live dashboard tables.
"""
- return format_numeric_for_display(sanitize_export_payload(value), precision=2)
+ return format_numeric(sanitize_export_payload(value), precision=2)
def serialize_viewable(
@@ -41,8 +41,37 @@ def serialize_viewable(
region_nodes_by_id = region_nodes_by_id or {}
hidden_widget_ids = hidden_widget_ids or set()
hidden_view_ids = hidden_view_ids or set()
+
+ def _is_hidden_view(viewable: Any) -> bool:
+ if id(viewable) in hidden_view_ids:
+ return True
+ note_target_id = getattr(viewable, "_calculation_note_target_id", None)
+ if note_target_id in hidden_view_ids:
+ return True
+ css_classes = set(getattr(viewable, "css_classes", []) or [])
+ if not css_classes.intersection(
+ {"calculation-note-section", "calculation-note-view"}
+ ):
+ return False
+ return any(
+ id(child) in hidden_view_ids
+ for child in getattr(viewable, "objects", [])
+ )
+
if id(obj) in region_nodes_by_id:
return region_nodes_by_id[id(obj)]
+
+ def _container_styles(viewable: Any) -> dict[str, Any]:
+ styles = getattr(viewable, "styles", None)
+ if not isinstance(styles, dict):
+ return {}
+ return {str(key): value for key, value in styles.items() if value is not None}
+
+ def _container_css_classes(viewable: Any) -> list[str]:
+ css_classes = getattr(viewable, "css_classes", None)
+ if not css_classes:
+ return []
+ return [str(css_class) for css_class in css_classes if css_class]
if isinstance(obj, pn.Card):
children = [
serialize_viewable(
@@ -54,7 +83,7 @@ def serialize_viewable(
hidden_view_ids=hidden_view_ids,
)
for child in obj.objects
- if id(child) not in hidden_view_ids
+ if not _is_hidden_view(child)
and not (
isinstance(child, pn.widgets.Widget) and id(child) in hidden_widget_ids
)
@@ -75,7 +104,7 @@ def serialize_viewable(
hidden_view_ids=hidden_view_ids,
)
for child in obj.objects
- if id(child) not in hidden_view_ids
+ if not _is_hidden_view(child)
and not (
isinstance(child, pn.widgets.Widget) and id(child) in hidden_widget_ids
)
@@ -85,6 +114,8 @@ def serialize_viewable(
"layout": "column",
"child_count": len(children),
"children": children,
+ "styles": _container_styles(obj),
+ "css_classes": _container_css_classes(obj),
}
if isinstance(obj, pn.Row):
children = [
@@ -97,7 +128,7 @@ def serialize_viewable(
hidden_view_ids=hidden_view_ids,
)
for child in obj.objects
- if id(child) not in hidden_view_ids
+ if not _is_hidden_view(child)
and not (
isinstance(child, pn.widgets.Widget) and id(child) in hidden_widget_ids
)
@@ -107,6 +138,8 @@ def serialize_viewable(
"layout": "row",
"child_count": len(children),
"children": children,
+ "styles": _container_styles(obj),
+ "css_classes": _container_css_classes(obj),
}
if isinstance(obj, pn.Tabs):
return {
@@ -124,7 +157,7 @@ def serialize_viewable(
),
}
for title, child in iter_tabs(obj)
- if id(child) not in hidden_view_ids
+ if not _is_hidden_view(child)
],
}
if isinstance(obj, pn.pane.Plotly):
@@ -134,13 +167,20 @@ def serialize_viewable(
return {"kind": "plotly", "figure": figure, "height": height}
if isinstance(obj, pn.widgets.Tabulator):
frame = obj.value
+ title_map = {
+ str(column): str(title)
+ for column, title in (obj.titles or {}).items()
+ if title is not None
+ }
+ columns = [str(column) for column in frame.columns]
+ display_columns = [title_map.get(column, column) for column in columns]
return {
"kind": "table",
- "columns": [str(column) for column in frame.columns],
+ "columns": display_columns,
"rows": [
{
- str(column): _serialize_table_cell(value)
- for column, value in row.items()
+ display_column: _serialize_table_cell(row.get(column))
+ for column, display_column in zip(columns, display_columns)
}
for row in frame.to_dict(orient="records")
],
@@ -189,7 +229,7 @@ def serialize_viewable(
disabled = False
else:
disabled = True
- return {
+ payload = {
"kind": "widget",
"widget_type": "select",
"name": widget_name,
@@ -199,6 +239,46 @@ def serialize_viewable(
"selector_id": selector_id,
"export_enabled": bool(selector_meta and selector_meta["export_enabled"]),
}
+ if selector_meta and selector_meta.get("parent_selector_id"):
+ payload.update(
+ {
+ "parent_selector_id": selector_meta["parent_selector_id"],
+ "options_by_parent_value": selector_meta.get(
+ "options_by_parent_value", {}
+ ),
+ "disabled_parent_values": selector_meta.get(
+ "disabled_parent_values", []
+ ),
+ }
+ )
+ return payload
+ if isinstance(obj, pn.widgets.Checkbox):
+ if id(obj) in hidden_widget_ids:
+ return {"kind": "spacer", "height": 0, "width": 0}
+ selector_id, selector_meta = widget_metadata.get(id(obj), (None, None))
+ widget_name = (
+ str(selector_meta.get("label"))
+ if selector_meta and selector_meta.get("label")
+ else obj.name or ""
+ )
+ options = ["False", "True"]
+ disabled = True if disable_widgets else bool(obj.disabled)
+ if selector_meta:
+ if selector_meta["export_enabled"]:
+ options = list(selector_meta["resolved_values"])
+ disabled = False
+ else:
+ disabled = True
+ return {
+ "kind": "widget",
+ "widget_type": "checkbox",
+ "name": widget_name,
+ "value": "True" if obj.value else "False",
+ "options": options,
+ "disabled": disabled,
+ "selector_id": selector_id,
+ "export_enabled": bool(selector_meta and selector_meta["export_enabled"]),
+ }
if isinstance(obj, pn.widgets.FloatInput):
if id(obj) in hidden_widget_ids:
return {"kind": "spacer", "height": 0, "width": 0}
@@ -222,6 +302,18 @@ def serialize_viewable(
"selector_id": selector_id,
"export_enabled": bool(selector_meta and selector_meta["export_enabled"]),
}
+ if isinstance(obj, pn.widgets.Button):
+ return {
+ "kind": "widget",
+ "widget_type": "button",
+ "name": obj.name or "",
+ "value": obj.name or "",
+ "options": [],
+ "step": None,
+ "disabled": True if disable_widgets else bool(obj.disabled),
+ "selector_id": None,
+ "export_enabled": False,
+ }
if isinstance(obj, pn.pane.Markdown):
source = (
obj.object if isinstance(obj.object, str) else obj.object._repr_markdown_()
@@ -264,7 +356,7 @@ def page_definition_for_page(page: Any) -> DashboardPageDefinition:
if isinstance(page_def, DashboardPageDefinition):
return page_def
raise ValueError(
- f"Dashboard page {type(page).__name__} is missing its registered PAGE definition."
+ f"Dashboard page {type(page).__name__} is missing its @dashboard_page definition."
)
@@ -298,6 +390,41 @@ def sanitize_export_payload(value: Any) -> Any:
return value
+def sanitize_export_payload_in_place(value: Any) -> Any:
+ """Replace JSON-unsafe values while retaining existing containers.
+
+ Export payloads are transient and can be very large. Mutating their
+ dictionaries and lists avoids constructing a second complete object graph
+ immediately before JSON serialization.
+ """
+
+ if isinstance(value, dict):
+ for key, item in value.items():
+ value[key] = sanitize_export_payload_in_place(item)
+ return value
+ if isinstance(value, list):
+ for index, item in enumerate(value):
+ value[index] = sanitize_export_payload_in_place(item)
+ return value
+ if isinstance(value, tuple):
+ return [sanitize_export_payload_in_place(item) for item in value]
+ if isinstance(value, np.integer):
+ return int(value)
+ if isinstance(value, np.floating):
+ if np.isnan(value) or np.isinf(value):
+ return None
+ return float(value)
+ if isinstance(value, float):
+ if np.isnan(value) or np.isinf(value):
+ return None
+ return value
+ if isinstance(value, pd.Timestamp):
+ return value.isoformat()
+ if pd.isna(value):
+ return None
+ return value
+
+
def json_default(value: Any) -> Any:
"""Serialize pandas/numpy scalars that standard ``json`` cannot handle."""
if isinstance(value, np.integer):
diff --git a/dashboard/export/traversal.py b/dashboard/export/traversal.py
new file mode 100644
index 0000000..e2c4606
--- /dev/null
+++ b/dashboard/export/traversal.py
@@ -0,0 +1,154 @@
+"""Traverse dashboard pages and resolve their exportable section subtrees."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import panel as pn
+
+from dashboard.page_definitions import DashboardPageDefinition
+
+
+@dataclass(frozen=True)
+class RuntimeExportPart:
+ """Export-facing view of one section registered by a dashboard page."""
+
+ part_id: str
+ selector_ids: tuple[str, ...]
+ export_data_mode: str
+ view: pn.viewable.Viewable
+
+ def view_for(self, page: Any) -> pn.viewable.Viewable:
+ return self.view
+
+
+def page_selectors(page: Any) -> tuple[Any, ...]:
+ return tuple(getattr(page, "registered_selectors", ()))
+
+
+def selector_id(selector: Any) -> str:
+ return str(selector.selector_id)
+
+
+def selector_label(selector: Any) -> str:
+ return str(selector.label)
+
+
+def selector_exportable(selector: Any) -> bool:
+ return bool(getattr(selector, "exportable", True))
+
+
+def selector_widget(selector: Any) -> pn.widgets.Widget | None:
+ widget = getattr(selector, "widget", None)
+ return widget if isinstance(widget, pn.widgets.Widget) else None
+
+
+def selector_available(selector: Any) -> bool:
+ return selector_widget(selector) is not None
+
+
+def page_export_parts(page: Any) -> tuple[RuntimeExportPart, ...]:
+ """Project registered page sections onto the export traversal contract."""
+ exportable_selector_ids = {
+ selector_id(selector)
+ for selector in page_selectors(page)
+ if selector_exportable(selector)
+ }
+ return tuple(
+ RuntimeExportPart(
+ part_id=section.section_id,
+ selector_ids=tuple(
+ item
+ for item in section.selector_ids
+ if item in exportable_selector_ids
+ ),
+ export_data_mode=section.export_data_mode,
+ view=section.container,
+ )
+ for section in tuple(getattr(page, "registered_sections", ()))
+ if section.export
+ )
+
+
+def enabled_export_parts(
+ page: Any,
+ page_definition: DashboardPageDefinition,
+ export_config: Any,
+) -> tuple[RuntimeExportPart, ...]:
+ """Return sections enabled by both their page contract and export config."""
+ override = export_config.page_override(
+ page_definition.page_id,
+ group_id=page_definition.group_id,
+ )
+ enabled: list[RuntimeExportPart] = []
+ for part in page_export_parts(page):
+ if part.export_data_mode != "none":
+ continue
+ part_override = override.parts.get(part.part_id)
+ if part_override is not None and part_override.enabled is False:
+ continue
+ enabled.append(part)
+ return tuple(enabled)
+
+
+def resolve_page_parts(
+ page: Any,
+ *,
+ page_def: DashboardPageDefinition,
+) -> list[tuple[RuntimeExportPart, pn.viewable.Viewable]]:
+ """Resolve non-overlapping export section roots inside one page tree."""
+ if page.view is None:
+ return []
+
+ paths = view_paths_by_id(page.view)
+ resolved: list[tuple[RuntimeExportPart, pn.viewable.Viewable]] = []
+ for part in enabled_export_parts(page, page_def, page.config.export_html):
+ part_view = part.view_for(page)
+ part_path = paths.get(id(part_view))
+ if part_path is None:
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} export region "
+ f"{part.part_id!r} does not belong to the page view tree."
+ )
+ resolved.append((part, part_view))
+
+ for index, (part, part_view) in enumerate(resolved):
+ part_path = paths[id(part_view)]
+ for other, other_view in resolved[index + 1 :]:
+ other_path = paths[id(other_view)]
+ if part_path == other_path:
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} export regions "
+ f"{part.part_id!r} and {other.part_id!r} resolve to the same subtree."
+ )
+ if is_prefix(part_path, other_path) or is_prefix(other_path, part_path):
+ raise ValueError(
+ f"Dashboard page {page_def.page_id!r} export regions "
+ f"{part.part_id!r} and {other.part_id!r} must not overlap or nest."
+ )
+ return resolved
+
+
+def view_paths_by_id(
+ root: pn.viewable.Viewable,
+ path: tuple[int, ...] = (),
+ result: dict[int, tuple[int, ...]] | None = None,
+) -> dict[int, tuple[int, ...]]:
+ result = result or {}
+ result[id(root)] = path
+ for index, child in enumerate(child_viewables(root)):
+ view_paths_by_id(child, path + (index,), result)
+ return result
+
+
+def child_viewables(view: pn.viewable.Viewable) -> list[pn.viewable.Viewable]:
+ if isinstance(view, (pn.Column, pn.Row, pn.Card, pn.Tabs)):
+ return [
+ child for child in view.objects if isinstance(child, pn.viewable.Viewable)
+ ]
+ return []
+
+
+def is_prefix(left: tuple[int, ...], right: tuple[int, ...]) -> bool:
+ return len(left) < len(right) and right[: len(left)] == left
diff --git a/dashboard/export/types.py b/dashboard/export/types.py
index 03e1748..efa4f24 100644
--- a/dashboard/export/types.py
+++ b/dashboard/export/types.py
@@ -20,11 +20,17 @@
"region",
]
ContainerLayout = Literal["row", "column"]
-WidgetType = Literal["radio_button_group", "select", "float_input"]
+WidgetType = Literal["radio_button_group", "select", "float_input", "checkbox", "button"]
RegionContentMode = Literal["snapshot"]
-class SelectorMetadataPayload(TypedDict):
+class DependentSelectorPayload(TypedDict, total=False):
+ parent_selector_id: str
+ options_by_parent_value: dict[str, list[str]]
+ disabled_parent_values: list[str]
+
+
+class SelectorMetadataPayload(DependentSelectorPayload):
id: str
label: str
available: bool
@@ -74,6 +80,9 @@ class ContainerNode(TypedDict):
kind: Literal["container"]
layout: ContainerLayout
children: list["ExportNode"]
+ child_count: int
+ styles: dict[str, Any]
+ css_classes: list[str]
class CardNode(TypedDict):
@@ -103,7 +112,7 @@ class TableNode(TypedDict):
rows: list[dict[str, Any]]
-class WidgetNode(TypedDict):
+class WidgetNode(DependentSelectorPayload):
kind: Literal["widget"]
widget_type: WidgetType
name: str
@@ -132,6 +141,7 @@ class RegionNode(TypedDict):
default_key: str
default_content: "ExportNode"
variants: dict[str, "ExportNode"]
+ variant_aliases: dict[str, str]
ExportNode = (
diff --git a/dashboard/helpers/category_helpers.py b/dashboard/helpers/category_helpers.py
index 75b2037..667d73a 100644
--- a/dashboard/helpers/category_helpers.py
+++ b/dashboard/helpers/category_helpers.py
@@ -135,6 +135,34 @@ def ordered_category_values(
return config.ordered_values(category_id, values)
+def category_label_matches(
+ config: Config,
+ category_id: str,
+ raw_value: object,
+ label: str,
+) -> bool:
+ """Return whether one raw category value resolves to the target display label."""
+ return (
+ config.label_value(category_id, raw_value).strip().casefold()
+ == str(label).strip().casefold()
+ )
+
+
+def exclude_category_values_by_label(
+ raw_values: Iterable[str],
+ *,
+ category_id: str,
+ config: Config,
+ label: str,
+) -> list[str]:
+ """Return raw category values whose display label does not match ``label``."""
+ return [
+ str(raw_value)
+ for raw_value in raw_values
+ if not category_label_matches(config, category_id, raw_value, label)
+ ]
+
+
def selector_options_from_values(
raw_values: Iterable[str],
*,
@@ -320,10 +348,117 @@ def numeric_like_sort_expr(column: str) -> pl.Expr:
return pl.when(base_value.is_null()).then(pl.lit(float("inf"))).otherwise(base_value)
+def capped_numeric_category_expr(
+ column: str,
+ cap_value: int,
+ *,
+ target_col: str | None = None,
+) -> pl.Expr:
+ """Return a string category expression with values at or above ``cap_value`` capped."""
+ numeric_value = pl.col(column).cast(pl.Int64, strict=False)
+ return (
+ pl.when(numeric_value >= cap_value)
+ .then(pl.lit(f"{cap_value}+"))
+ .otherwise(pl.col(column).cast(pl.Int64, strict=False).cast(pl.Utf8))
+ .alias(target_col or column)
+ )
+
+
+def cap_numeric_category_frame(
+ df: pl.DataFrame,
+ *,
+ category: str,
+ cap_value: int,
+ value_cols: tuple[str, ...],
+ target_col: str | None = None,
+) -> pl.DataFrame:
+ """Aggregate numeric categories at or above ``cap_value`` into one ``N+`` bucket."""
+ output_col = target_col or category
+ if category not in df.columns:
+ return df
+ available_value_cols = [column for column in value_cols if column in df.columns]
+ if not available_value_cols:
+ return df.with_columns(
+ capped_numeric_category_expr(
+ category,
+ cap_value,
+ target_col=output_col,
+ )
+ )
+ return (
+ df.with_columns(
+ capped_numeric_category_expr(
+ category,
+ cap_value,
+ target_col=output_col,
+ )
+ )
+ .group_by(output_col)
+ .agg([pl.col(column).sum().alias(column) for column in available_value_cols])
+ .sort(numeric_like_sort_expr(output_col))
+ )
+
+
+def cap_numeric_category_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ category: str,
+ cap_value: int,
+ value_cols: tuple[str, ...],
+ target_col: str | None = None,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Apply numeric category capping across a run-indexed data list."""
+ return [
+ (
+ label,
+ cap_numeric_category_frame(
+ df,
+ category=category,
+ cap_value=cap_value,
+ value_cols=value_cols,
+ target_col=target_col,
+ ),
+ )
+ for label, df in nonempty(data_list)
+ ]
+
+
+def capped_numeric_category_values(
+ data_list: list[tuple[str, pl.DataFrame]],
+ column: str,
+ *,
+ cap_value: int,
+ minimum: int | None = None,
+) -> list[str]:
+ """Return observed numeric category labels after applying an ``N+`` cap."""
+ values: set[int] = set()
+ saw_capped = False
+ for _, df in nonempty(data_list):
+ if column not in df.columns:
+ continue
+ for value in (
+ df.select(pl.col(column).cast(pl.Int64, strict=False))
+ .to_series()
+ .to_list()
+ ):
+ if value is None:
+ continue
+ if minimum is not None and value < minimum:
+ continue
+ if value >= cap_value:
+ saw_capped = True
+ else:
+ values.add(int(value))
+ labels = [str(value) for value in sorted(values)]
+ if saw_capped:
+ labels.append(f"{cap_value}+")
+ return labels
+
+
def complete_category_counts(
data_list: list[tuple[str, pl.DataFrame]],
*,
- category_col: str,
+ category: str,
category_values: list[str],
value_cols: tuple[str, ...],
extra_fill_values: dict[str, Any] | None = None,
@@ -331,19 +466,19 @@ def complete_category_counts(
"""Ensure each run has one row per category value with zero-filled metric columns."""
if not category_values:
return data_list
- base = pl.DataFrame({category_col: category_values}, schema={category_col: pl.Utf8})
+ base = pl.DataFrame({category: category_values}, schema={category: pl.Utf8})
fill_values = dict(extra_fill_values or {})
out: list[tuple[str, pl.DataFrame]] = []
for label, df in data_list:
if df is None:
completed = base
else:
- available_cols = [column for column in df.columns if column != category_col]
+ available_cols = [column for column in df.columns if column != category]
completed = base.join(
- df.with_columns(pl.col(category_col).cast(pl.Utf8)).select(
- category_col, *available_cols
+ df.with_columns(pl.col(category).cast(pl.Utf8)).select(
+ category, *available_cols
),
- on=category_col,
+ on=category,
how="left",
)
fill_exprs = []
@@ -359,3 +494,25 @@ def complete_category_counts(
fill_exprs.append(pl.lit(fill_value).alias(column))
out.append((label, completed.with_columns(fill_exprs)))
return out
+
+
+def add_percent_of_total(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ value_col: str,
+ percent_col: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Add a percent column using each run table's full value-column total."""
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in data_list:
+ if df is None or value_col not in df.columns:
+ out.append((label, df))
+ continue
+ denominator = float(df[value_col].sum() or 0.0)
+ percent_expr = (
+ (pl.col(value_col).cast(pl.Float64) / denominator * 100.0)
+ if denominator > 0
+ else pl.lit(0.0)
+ )
+ out.append((label, df.with_columns(percent_expr.alias(percent_col))))
+ return out
diff --git a/dashboard/helpers/comparison_helpers.py b/dashboard/helpers/comparison_helpers.py
index 31683ef..7db242d 100644
--- a/dashboard/helpers/comparison_helpers.py
+++ b/dashboard/helpers/comparison_helpers.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import math
-from typing import Any
+from typing import Any, Mapping
import polars as pl
@@ -50,6 +50,95 @@ def percent_difference_string(
return f"{pct_diff:.{precision}f}%"
+def _finite_float(value: float | int | None) -> float | None:
+ if value is None:
+ return None
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return None
+ return numeric if math.isfinite(numeric) else None
+
+
+def ab_percent_difference_string(
+ quantity_a: float | int | None,
+ quantity_b: float | int | None,
+ *,
+ precision: int = 2,
+) -> str:
+ """Return formatted percent difference using the second value as denominator."""
+ a_value = _finite_float(quantity_a)
+ b_value = _finite_float(quantity_b)
+ if a_value is None or b_value is None or b_value == 0.0:
+ return ""
+ pct_diff = ((a_value - b_value) / b_value) * 100.0
+ return f"{pct_diff:.{precision}f}%"
+
+
+def ab_difference_value(
+ quantity_a: float | int | None,
+ quantity_b: float | int | None,
+) -> float | None:
+ """Return absolute difference for a single A/B comparison."""
+ a_value = _finite_float(quantity_a)
+ b_value = _finite_float(quantity_b)
+ if a_value is None or b_value is None:
+ return None
+ return math.sqrt((a_value - b_value) ** 2)
+
+
+def build_ab_comparison_row(
+ *,
+ keys: Mapping[str, Any],
+ quantity_a: float | int | None,
+ quantity_b: float | int | None,
+ quantity_a_column: str,
+ quantity_b_column: str,
+ precision: int = 2,
+) -> dict[str, Any]:
+ """Build one long-form A/B comparison row with caller-supplied value labels."""
+ a_value = _finite_float(quantity_a)
+ b_value = _finite_float(quantity_b)
+ return {
+ **dict(keys),
+ quantity_a_column: a_value,
+ quantity_b_column: b_value,
+ "Difference": ab_difference_value(a_value, b_value),
+ "% Difference": ab_percent_difference_string(
+ a_value,
+ b_value,
+ precision=precision,
+ ),
+ }
+
+
+def build_ab_comparison_table(
+ rows: list[Mapping[str, Any]],
+ *,
+ key_columns: list[str],
+ quantity_a_column: str,
+ quantity_b_column: str,
+) -> pl.DataFrame:
+ """Return rows with stable key/A/B/difference/% difference column ordering."""
+ columns = [
+ *key_columns,
+ quantity_a_column,
+ quantity_b_column,
+ "Difference",
+ "% Difference",
+ ]
+ if not rows:
+ schema = {
+ **{column: pl.Utf8 for column in key_columns},
+ quantity_a_column: pl.Float64,
+ quantity_b_column: pl.Float64,
+ "Difference": pl.Float64,
+ "% Difference": pl.Utf8,
+ }
+ return pl.DataFrame(schema=schema).select(columns)
+ return pl.DataFrame(rows).select(columns)
+
+
def build_base_run_percent_difference_table(
*,
run_labels: list[str],
@@ -91,7 +180,7 @@ def build_base_run_percent_difference_table(
def weighted_average_lookup(
df: pl.DataFrame,
*,
- category_col: str,
+ category: str,
average_col: str,
weight_col: str,
) -> dict[str, float]:
@@ -103,21 +192,21 @@ def weighted_average_lookup(
"""
if df.is_empty():
return {}
- if category_col not in df.columns or average_col not in df.columns:
+ if category not in df.columns or average_col not in df.columns:
return {}
if weight_col not in df.columns:
aggregated = (
- df.group_by(category_col)
+ df.group_by(category)
.agg(pl.col(average_col).mean().alias(average_col))
- .select(category_col, average_col)
+ .select(category, average_col)
)
return {
- str(row[category_col]): float(row[average_col])
+ str(row[category]): float(row[average_col])
for row in aggregated.to_dicts()
- if row.get(category_col) is not None and row.get(average_col) is not None
+ if row.get(category) is not None and row.get(average_col) is not None
}
aggregated = (
- df.group_by(category_col)
+ df.group_by(category)
.agg(
pl.col(weight_col).sum().alias(weight_col),
(pl.col(average_col) * pl.col(weight_col)).sum().alias("_weighted_value"),
@@ -128,10 +217,10 @@ def weighted_average_lookup(
.otherwise(None)
.alias(average_col)
)
- .select(category_col, average_col)
+ .select(category, average_col)
)
return {
- str(row[category_col]): float(row[average_col])
+ str(row[category]): float(row[average_col])
for row in aggregated.to_dicts()
- if row.get(category_col) is not None and row.get(average_col) is not None
+ if row.get(category) is not None and row.get(average_col) is not None
}
diff --git a/dashboard/helpers/distance_range.py b/dashboard/helpers/distance_range.py
new file mode 100644
index 0000000..3d4c09a
--- /dev/null
+++ b/dashboard/helpers/distance_range.py
@@ -0,0 +1,267 @@
+"""Live-only distance range controls for distance distribution charts."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from math import isfinite
+import re
+from typing import Any
+
+import panel as pn
+import polars as pl
+
+from dashboard.rendering import control_row
+from dashboard.helpers.category_helpers import nonempty
+
+
+_DISTANCE_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?")
+
+
+def distance_bin_value(value: object) -> float | None:
+ """Return the numeric position for a distance-bin label such as ``40+``."""
+ if value is None:
+ return None
+ if isinstance(value, int | float):
+ numeric = float(value)
+ return numeric if isfinite(numeric) else None
+ match = _DISTANCE_NUMBER_RE.search(str(value).strip())
+ if match is None:
+ return None
+ numeric = float(match.group(0))
+ return numeric if isfinite(numeric) else None
+
+
+def with_distance_axis(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ source_col: str = "distance_bin",
+ axis_col: str = "_distance_axis",
+) -> list[tuple[str, pl.DataFrame]]:
+ """Add a numeric distance-axis column while preserving distance-bin labels."""
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ if source_col not in df.columns:
+ out.append((label, df))
+ continue
+ axis_values = [distance_bin_value(value) for value in df[source_col].to_list()]
+ out.append((label, df.with_columns(pl.Series(axis_col, axis_values))))
+ return out
+
+
+def distance_axis_bounds(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ source_col: str = "distance_bin",
+) -> tuple[float, float] | None:
+ """Return finite min/max distance-axis bounds across chart-ready data."""
+ values: list[float] = []
+ for _, df in nonempty(data_list):
+ if source_col not in df.columns:
+ continue
+ values.extend(
+ numeric
+ for numeric in (distance_bin_value(value) for value in df[source_col].to_list())
+ if numeric is not None
+ )
+ if not values:
+ return None
+ lower = min(values)
+ upper = max(values)
+ if lower == upper:
+ upper = lower + 1.0
+ return (float(lower), float(upper))
+
+
+def distance_axis_ticks(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ source_col: str = "distance_bin",
+) -> tuple[list[float], list[str]]:
+ """Return stable numeric tick positions and original distance-bin labels."""
+ tick_labels: dict[float, str] = {}
+ for _, df in nonempty(data_list):
+ if source_col not in df.columns:
+ continue
+ for value in df[source_col].to_list():
+ numeric = distance_bin_value(value)
+ if numeric is not None and numeric not in tick_labels:
+ tick_labels[numeric] = str(value)
+ tickvals = sorted(tick_labels)
+ return (tickvals, [tick_labels[value] for value in tickvals])
+
+
+def fixed_distance_axis_ticks(
+ *,
+ max_value: int = 40,
+ step: int = 2,
+ plus_label: bool = True,
+) -> tuple[list[float], list[str]]:
+ """Return fixed whole-mile distance ticks, labeling the max as ``40+``."""
+ tickvals = [float(value) for value in range(0, max_value + 1, step)]
+ ticktext = [str(int(value)) for value in tickvals]
+ if plus_label and ticktext:
+ ticktext[-1] = f"{int(tickvals[-1])}+"
+ return tickvals, ticktext
+
+
+def capped_distance_max_options(max_value: int = 40) -> list[str]:
+ """Return max-range dropdown options ending with a capped ``40+`` label."""
+ return [str(value) for value in range(1, max_value)] + [f"{max_value}+"]
+
+
+def resolve_distance_range(
+ min_value: object,
+ max_value: object,
+) -> tuple[float, float] | None:
+ """Return a valid finite distance range, or None."""
+ lower = distance_bin_value(min_value)
+ upper = distance_bin_value(max_value)
+ if lower is None or upper is None:
+ return None
+ if not isfinite(lower) or not isfinite(upper) or lower >= upper:
+ return None
+ return (lower, upper)
+
+
+def _range_widget_value(value: float, widget: pn.widgets.Widget) -> object:
+ """Return a range-bound value compatible with the target widget."""
+ if isinstance(widget, pn.widgets.Select):
+ if abs(value - 40.0) < 1e-9:
+ return "40+"
+ if abs(value - round(value)) < 1e-9:
+ return str(int(round(value)))
+ return str(value)
+ return float(value)
+
+
+def _ranges_equal(
+ left: tuple[float, float] | None,
+ right: tuple[float, float] | None,
+) -> bool:
+ if left is None or right is None:
+ return False
+ return abs(left[0] - right[0]) < 1e-9 and abs(left[1] - right[1]) < 1e-9
+
+
+@dataclass
+class DistanceRangeControls:
+ """A pair of min/max widgets plus reset behavior for one distance chart group."""
+
+ page: Any
+ prefix: str
+ min_widget: pn.widgets.FloatInput
+ max_widget: pn.widgets.Widget
+ reset_button: pn.widgets.Button
+ selector_ids: tuple[str, ...]
+
+ @classmethod
+ def create(
+ cls,
+ page: Any,
+ prefix: str,
+ *,
+ min_label: str = "Distance Min",
+ max_label: str = "Distance Max",
+ reset_label: str = "Reset distance range",
+ step: float = 1.0,
+ max_options: list[object] | None = None,
+ ) -> "DistanceRangeControls":
+ """Create registered live controls, or disabled export-only clones."""
+ min_widget = pn.widgets.FloatInput(name=min_label, step=step, value=0.0)
+ max_widget: pn.widgets.Widget
+ if max_options:
+ max_widget = pn.widgets.Select(
+ name=max_label,
+ options=list(max_options),
+ value=list(max_options)[-1],
+ )
+ else:
+ max_widget = pn.widgets.FloatInput(name=max_label, step=step, value=1.0)
+ selector_ids: tuple[str, ...] = ()
+ if page.state.export_mode:
+ min_widget.disabled = True
+ max_widget.disabled = True
+ else:
+ min_widget = page.selector(
+ f"{prefix}_min",
+ widget=min_widget,
+ label=min_label,
+ exportable=False,
+ )
+ max_widget = page.selector(
+ f"{prefix}_max",
+ widget=max_widget,
+ label=max_label,
+ exportable=False,
+ )
+ selector_ids = (f"{prefix}_min", f"{prefix}_max")
+ reset_button = pn.widgets.Button(
+ name=reset_label,
+ button_type="default",
+ width=175,
+ disabled=page.state.export_mode,
+ )
+ controls = cls(
+ page=page,
+ prefix=prefix,
+ min_widget=min_widget,
+ max_widget=max_widget,
+ reset_button=reset_button,
+ selector_ids=selector_ids,
+ )
+ if not page.state.export_mode:
+ reset_button.on_click(lambda event: controls.reset())
+ return controls
+
+ def row(self) -> pn.Row:
+ """Return a standard controls row for this range group."""
+ return control_row(self.min_widget, self.max_widget, self.reset_button)
+
+ def current_range(self) -> tuple[float, float] | None:
+ """Return the currently selected valid range, or None."""
+ return resolve_distance_range(self.min_widget.value, self.max_widget.value)
+
+ def sync(
+ self,
+ context_key: object,
+ bounds: tuple[float, float] | None,
+ ) -> None:
+ """Initialize or update controls for the current chart context."""
+ if bounds is None:
+ self.min_widget.disabled = True
+ self.max_widget.disabled = True
+ self.reset_button.disabled = True
+ return
+
+ if self.page.state.export_mode:
+ self.min_widget.value = float(bounds[0])
+ self.max_widget.value = _range_widget_value(float(bounds[1]), self.max_widget)
+ self.min_widget.disabled = True
+ self.max_widget.disabled = True
+ self.reset_button.disabled = True
+ return
+
+ self.min_widget.disabled = False
+ self.max_widget.disabled = False
+ self.reset_button.disabled = False
+ state_key = f"{self.prefix}_range_context"
+ auto_key = f"{self.prefix}_auto_range"
+ last_context = self.page._page_state.get(state_key)
+ last_auto_range = self.page._page_state.get(auto_key)
+ current_range = self.current_range()
+ should_reset = last_auto_range is None or _ranges_equal(
+ current_range, last_auto_range
+ )
+ if should_reset:
+ self.min_widget.value = float(bounds[0])
+ self.max_widget.value = _range_widget_value(float(bounds[1]), self.max_widget)
+ self.page._page_state[state_key] = context_key
+ self.page._page_state[auto_key] = tuple(bounds)
+
+ def reset(self) -> None:
+ """Restore the controls to the last observed auto range."""
+ auto_range = self.page._page_state.get(f"{self.prefix}_auto_range")
+ if not auto_range:
+ return
+ self.min_widget.value = float(auto_range[0])
+ self.max_widget.value = _range_widget_value(float(auto_range[1]), self.max_widget)
diff --git a/dashboard/helpers/geography_helpers.py b/dashboard/helpers/geography_helpers.py
index 829461a..829b916 100644
--- a/dashboard/helpers/geography_helpers.py
+++ b/dashboard/helpers/geography_helpers.py
@@ -6,7 +6,11 @@
import polars as pl
-from dashboard.helpers.category_helpers import first_nonempty_frame, nonempty
+from dashboard.helpers.category_helpers import (
+ first_nonempty_frame,
+ nonempty,
+ raw_display_options,
+)
if TYPE_CHECKING:
from runtime.config import Config
@@ -20,7 +24,11 @@
]
AGGREGATE_GEOGRAPHY_LEVEL = "all_geographies"
ALL_WITHIN_LEVEL_VALUE = "All"
+ALL_GEOGRAPHY_TYPES_VALUE = AGGREGATE_GEOGRAPHY_LEVEL
+ALL_GEOGRAPHY_TYPES_LABEL = "All Geography Types"
ALL_GEOGRAPHIES_LABEL = "All Geographies"
+GEOGRAPHY_TYPE_SELECTOR_LABEL = "Geography Type"
+GEOGRAPHY_NAME_SELECTOR_LABEL = "Geography Name"
DEFAULT_GEO_LEVEL_COL = "geography_level"
DEFAULT_GEO_COL = "geography"
DEFAULT_GEO_TYPE_COL = "geography_type"
@@ -29,7 +37,10 @@
def is_all_geographies(value: str | None) -> bool:
"""Return whether a selector value targets the cross-level aggregate geography."""
- return str(value) in {AGGREGATE_GEOGRAPHY_LEVEL, ALL_GEOGRAPHIES_LABEL}
+ return str(value) in {
+ AGGREGATE_GEOGRAPHY_LEVEL,
+ ALL_GEOGRAPHIES_LABEL,
+ }
def is_all_within_level(value: str | None) -> bool:
@@ -37,6 +48,140 @@ def is_all_within_level(value: str | None) -> bool:
return str(value) in {ALL_WITHIN_LEVEL_VALUE, "All", "Total"}
+def geography_type_label(value: str | None, *, config: Config) -> str:
+ """Return the display label for one raw geography type/level value."""
+ value_str = str(value)
+ if value_str in {ALL_GEOGRAPHY_TYPES_VALUE, "Total"}:
+ return ALL_GEOGRAPHY_TYPES_LABEL
+ configured = config.label_value("geography", value_str)
+ if configured != value_str:
+ return configured
+ uppercase_labels = {"taz": "TAZ", "maz": "MAZ", "mpo": "MPO"}
+ if value_str.lower() in uppercase_labels:
+ return uppercase_labels[value_str.lower()]
+ return value_str.replace("_", " ").title()
+
+
+def geography_name_label(value: str | None, *, config: Config) -> str:
+ """Return a display label for one geography id/name value."""
+ value_str = str(value)
+ if is_all_geographies(value_str) or value_str in aggregate_geography_level_values():
+ configured = config.label_value("geography", AGGREGATE_GEOGRAPHY_LEVEL)
+ return configured if configured != AGGREGATE_GEOGRAPHY_LEVEL else ALL_GEOGRAPHIES_LABEL
+ configured = config.label_value("geography", value_str)
+ if configured != value_str:
+ return configured
+ return value_str.replace("_", " ").title()
+
+
+def with_display_geography_columns(
+ df: pl.DataFrame,
+ *,
+ config: Config,
+ geography_level_col: str = DEFAULT_GEO_LEVEL_COL,
+ geography_col: str = DEFAULT_GEO_COL,
+ geography_type_col: str = DEFAULT_GEO_TYPE_COL,
+ geography_id_col: str = DEFAULT_GEO_ID_COL,
+ type_display_col: str = GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ name_display_col: str = GEOGRAPHY_NAME_SELECTOR_LABEL,
+) -> pl.DataFrame:
+ """Add friendly geography type/name columns for dashboard tables."""
+ level_col = (
+ geography_level_col
+ if geography_level_col in df.columns
+ else geography_type_col
+ if geography_type_col in df.columns
+ else None
+ )
+ name_col = (
+ geography_col
+ if geography_col in df.columns
+ else geography_id_col
+ if geography_id_col in df.columns
+ else None
+ )
+ exprs: list[pl.Expr] = []
+ if level_col is not None:
+ exprs.append(
+ pl.col(level_col)
+ .cast(pl.Utf8)
+ .map_elements(
+ lambda value: (
+ geography_name_label(value, config=config)
+ if is_all_geographies(str(value))
+ else geography_type_label(value, config=config)
+ ),
+ return_dtype=pl.Utf8,
+ )
+ .alias(type_display_col)
+ )
+ if name_col is not None:
+ exprs.append(
+ pl.col(name_col)
+ .cast(pl.Utf8)
+ .map_elements(
+ lambda value: geography_name_label(value, config=config),
+ return_dtype=pl.Utf8,
+ )
+ .alias(name_display_col)
+ )
+ return df.with_columns(exprs) if exprs else df
+
+
+def normalize_geography_level_value(value: str | None) -> str:
+ """Normalize accepted aggregate geography tokens to the canonical raw value."""
+ value_str = str(value)
+ if value_str in {ALL_GEOGRAPHIES_LABEL, "All", "Total"}:
+ return AGGREGATE_GEOGRAPHY_LEVEL
+ return value_str
+
+
+def pluralize_geography_label(label: str) -> str:
+ """Return a compact plural display label for a geography type label."""
+ label = str(label)
+ lower = label.lower()
+ explicit = {
+ "county": "Counties",
+ "geography": "Geographies",
+ "all geography": "All Geographies",
+ }
+ if lower in explicit:
+ return explicit[lower]
+ if label.isupper():
+ return f"{label}s"
+ if lower.endswith("y") and (len(lower) < 2 or lower[-2] not in "aeiou"):
+ return f"{label[:-1]}ies"
+ if lower.endswith(("s", "x", "z")) or lower.endswith(("ch", "sh")):
+ return f"{label}es"
+ return f"{label}s"
+
+
+def all_within_geography_type_label(
+ geography_type: str | None,
+ *,
+ config: Config,
+) -> str:
+ """Return the display label for all geography names within a selected type."""
+ if geography_type in {None, ALL_GEOGRAPHY_TYPES_VALUE, "Total"}:
+ return config.label_value("geography", AGGREGATE_GEOGRAPHY_LEVEL)
+ if is_all_geographies(geography_type):
+ return config.label_value("geography", AGGREGATE_GEOGRAPHY_LEVEL)
+ return f"All {pluralize_geography_label(geography_type_label(geography_type, config=config))}"
+
+
+def geography_name_selector_label(
+ geography_type: str | None,
+ *,
+ config: Config,
+) -> str:
+ """Return the selector label for geography names under one geography type."""
+ if geography_type in {None, ALL_GEOGRAPHY_TYPES_VALUE, "Total"}:
+ return GEOGRAPHY_NAME_SELECTOR_LABEL
+ if is_all_geographies(geography_type):
+ return GEOGRAPHY_NAME_SELECTOR_LABEL
+ return f"{geography_type_label(geography_type, config=config)} Name"
+
+
def visible_geography_levels(
values: list[str] | set[str] | tuple[str, ...],
*,
@@ -71,9 +216,16 @@ def detail_geography_levels(
values: list[str] | set[str] | tuple[str, ...],
*,
config: Config,
+ include_disabled_maz: bool = False,
) -> list[str]:
"""Return selector geography levels, hiding only MAZ when disabled."""
- return ordered_visible_geography_levels(values, config=config)
+ if include_disabled_maz:
+ visible = [str(value).strip() for value in values if str(value).strip()]
+ ordered = [value for value in PREFERRED_TYPED_GEO_ORDER if value in visible]
+ ordered.extend(sorted(value for value in visible if value not in ordered))
+ else:
+ ordered = ordered_visible_geography_levels(values, config=config)
+ return config.ordered_values("geography", ordered)
def rename_present(df: pl.DataFrame, mapping: dict[str, str]) -> pl.DataFrame:
@@ -152,7 +304,7 @@ def geography_level_option_set(
.cast(pl.Utf8)
.to_list()
)
- available.update(values)
+ available.update(normalize_geography_level_value(value) for value in values)
elif geography_type_col in df.columns:
values = (
df.select(geography_type_col)
@@ -162,13 +314,19 @@ def geography_level_option_set(
.cast(pl.Utf8)
.to_list()
)
- available.update(values)
+ available.update(normalize_geography_level_value(value) for value in values)
elif {origin_level_col, destination_level_col}.issubset(df.columns):
origin_values = set(
- df[origin_level_col].cast(pl.Utf8).drop_nulls().unique().to_list()
+ normalize_geography_level_value(value)
+ for value in df[origin_level_col].cast(pl.Utf8).drop_nulls().unique().to_list()
)
destination_values = set(
- df[destination_level_col].cast(pl.Utf8).drop_nulls().unique().to_list()
+ normalize_geography_level_value(value)
+ for value in df[destination_level_col]
+ .cast(pl.Utf8)
+ .drop_nulls()
+ .unique()
+ .to_list()
)
available.update(origin_values & destination_values)
return available
@@ -189,10 +347,45 @@ def geography_level_options(
if not available_sets:
return [total_label]
ordered = detail_geography_levels(set().union(*available_sets), config=config)
- return [
- config.label_value("geography", value) if is_all_geographies(value) else value
- for value in (ordered or [total_label])
+ return [geography_type_label(value, config=config) for value in ordered] if ordered else [total_label]
+
+
+def geography_type_options(
+ *summary_lists: list[tuple[str, pl.DataFrame]] | None,
+ config: Config,
+ include_all_types: bool = False,
+ include_disabled_maz: bool = False,
+ all_types_label: str = ALL_GEOGRAPHY_TYPES_LABEL,
+ fallback_raw: str = AGGREGATE_GEOGRAPHY_LEVEL,
+) -> tuple[list[str], dict[str, str | None]]:
+ """Return geography type display options plus display-to-raw lookup."""
+ available_sets = [
+ geography_level_option_set(summary)
+ for summary in summary_lists
+ if summary is not None
]
+ available_sets = [available for available in available_sets if available]
+ if not available_sets:
+ raw_values = [fallback_raw]
+ else:
+ raw_values = detail_geography_levels(
+ set().union(*available_sets),
+ config=config,
+ include_disabled_maz=include_disabled_maz,
+ )
+ raw_by_label: dict[str, str | None] = {}
+ if include_all_types:
+ raw_by_label[all_types_label] = ALL_GEOGRAPHY_TYPES_VALUE
+ for raw_value in raw_values:
+ if include_all_types and raw_value == ALL_GEOGRAPHY_TYPES_VALUE:
+ continue
+ raw_by_label[geography_type_label(raw_value, config=config)] = raw_value
+ return list(raw_by_label), raw_by_label
+
+
+def aggregate_geography_level_values() -> set[str]:
+ """Return accepted raw values for aggregate geography levels."""
+ return {AGGREGATE_GEOGRAPHY_LEVEL, "All", "Total", ALL_GEOGRAPHIES_LABEL}
def geography_id_option_set(
@@ -282,6 +475,39 @@ def geography_options_for_level(
return [all_within_level_label] + ordered if ordered else [all_within_level_label]
+def geography_name_options_for_type(
+ geography_type: str,
+ *summary_lists: list[tuple[str, pl.DataFrame]] | None,
+ config: Config,
+) -> tuple[list[str], dict[str, str | None]]:
+ """Return geography name display options plus display-to-raw lookup."""
+ if geography_type in {ALL_GEOGRAPHY_TYPES_VALUE, "Total"}:
+ display = config.label_value("geography", AGGREGATE_GEOGRAPHY_LEVEL)
+ return [display], {display: ALL_WITHIN_LEVEL_VALUE}
+ if is_all_geographies(geography_type):
+ display = config.label_value("geography", AGGREGATE_GEOGRAPHY_LEVEL)
+ return [display], {display: AGGREGATE_GEOGRAPHY_LEVEL}
+
+ available_sets = [
+ geography_id_option_set(summary, geography_type)
+ for summary in summary_lists
+ if summary is not None
+ ]
+ available_sets = [available for available in available_sets if available]
+ all_label = all_within_geography_type_label(geography_type, config=config)
+ if not available_sets:
+ return [all_label], {all_label: ALL_WITHIN_LEVEL_VALUE}
+
+ raw_values = config.ordered_values("geography", sorted(set().union(*available_sets)))
+ return raw_display_options(
+ raw_values,
+ category_id="geography",
+ config=config,
+ total_raw=ALL_WITHIN_LEVEL_VALUE,
+ total_label=all_label,
+ )
+
+
def export_geography_options(
geography_opts_by_level: dict[str, list[str]],
*,
@@ -300,6 +526,33 @@ def export_geography_options(
return [all_within_level_label] + ordered if ordered else [all_within_level_label]
+def export_geography_name_options(
+ geography_opts_by_level: dict[str, tuple[list[str], dict[str, str | None]]],
+ *,
+ config: Config,
+ all_within_level_label: str = ALL_WITHIN_LEVEL_VALUE,
+) -> tuple[list[str], dict[str, str | None]]:
+ """Flatten per-level geography display options for export mode."""
+ raw_values: set[str] = set()
+ for options, raw_by_label in geography_opts_by_level.values():
+ for option in options:
+ raw_value = raw_by_label.get(str(option), str(option))
+ if raw_value is None:
+ continue
+ raw_value_str = str(raw_value)
+ if raw_value_str == all_within_level_label or is_all_geographies(raw_value_str):
+ continue
+ raw_values.add(raw_value_str)
+ ordered = config.ordered_values("geography", sorted(raw_values))
+ return raw_display_options(
+ ordered,
+ category_id="geography",
+ config=config,
+ total_raw=all_within_level_label,
+ total_label=all_within_level_label,
+ )
+
+
def filter_geography_level(
data_list: list[tuple[str, pl.DataFrame]] | None,
geo_level: str,
@@ -312,8 +565,8 @@ def filter_geography_level(
"""Filter a run-indexed summary list to one geography level."""
if not data_list:
return []
- if is_all_geographies(geo_level):
- geo_level = AGGREGATE_GEOGRAPHY_LEVEL
+ aggregate_values = aggregate_geography_level_values()
+ match_aggregate = is_all_geographies(geo_level)
out: list[tuple[str, pl.DataFrame]] = []
for label, df in nonempty(data_list):
filtered = df
@@ -321,19 +574,34 @@ def filter_geography_level(
if geography_level_col in filtered.columns:
filtered = filtered.with_columns(
pl.col(geography_level_col).cast(pl.Utf8)
- ).filter(pl.col(geography_level_col) == geo_level)
+ ).filter(
+ pl.col(geography_level_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_level_col) == geo_level
+ )
elif geography_type_col in filtered.columns:
filtered = filtered.with_columns(
pl.col(geography_type_col).cast(pl.Utf8)
- ).filter(pl.col(geography_type_col) == geo_level)
+ ).filter(
+ pl.col(geography_type_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_type_col) == geo_level
+ )
elif {origin_level_col, destination_level_col}.issubset(filtered.columns):
filtered = filtered.with_columns(
pl.col(origin_level_col).cast(pl.Utf8),
pl.col(destination_level_col).cast(pl.Utf8),
- ).filter(
- (pl.col(origin_level_col) == geo_level)
- & (pl.col(destination_level_col) == geo_level)
)
+ if match_aggregate:
+ filtered = filtered.filter(
+ pl.col(origin_level_col).is_in(aggregate_values)
+ & pl.col(destination_level_col).is_in(aggregate_values)
+ )
+ else:
+ filtered = filtered.filter(
+ (pl.col(origin_level_col) == geo_level)
+ & (pl.col(destination_level_col) == geo_level)
+ )
out.append((label, filtered))
return out
@@ -350,20 +618,26 @@ def filter_geography(
return []
if is_all_within_level(geography):
return nonempty(data_list)
- if is_all_geographies(geography):
- geography = AGGREGATE_GEOGRAPHY_LEVEL
+ aggregate_values = aggregate_geography_level_values()
+ match_aggregate = is_all_geographies(geography)
out: list[tuple[str, pl.DataFrame]] = []
for label, df in nonempty(data_list):
filtered = df
if geography_col in filtered.columns:
filtered = filtered.with_columns(pl.col(geography_col).cast(pl.Utf8)).filter(
- pl.col(geography_col) == geography
+ pl.col(geography_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_col) == geography
)
elif geography_id_col in filtered.columns:
filtered = filtered.with_columns(
pl.col(geography_id_col).cast(pl.Utf8)
- ).filter(pl.col(geography_id_col) == geography)
+ ).filter(
+ pl.col(geography_id_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_id_col) == geography
+ )
out.append((label, filtered))
return out
@@ -381,24 +655,32 @@ def filter_origin_geography(
return []
if is_all_within_level(geography):
return nonempty(data_list)
- if is_all_geographies(geography):
- geography = AGGREGATE_GEOGRAPHY_LEVEL
+ aggregate_values = aggregate_geography_level_values()
+ match_aggregate = is_all_geographies(geography)
out: list[tuple[str, pl.DataFrame]] = []
for label, df in nonempty(data_list):
filtered = df
if origin_id_col in filtered.columns:
filtered = filtered.with_columns(pl.col(origin_id_col).cast(pl.Utf8)).filter(
- pl.col(origin_id_col) == geography
+ pl.col(origin_id_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(origin_id_col) == geography
)
elif geography_col in filtered.columns:
filtered = filtered.with_columns(pl.col(geography_col).cast(pl.Utf8)).filter(
- pl.col(geography_col) == geography
+ pl.col(geography_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_col) == geography
)
elif geography_id_col in filtered.columns:
filtered = filtered.with_columns(
pl.col(geography_id_col).cast(pl.Utf8)
- ).filter(pl.col(geography_id_col) == geography)
+ ).filter(
+ pl.col(geography_id_col).is_in(aggregate_values)
+ if match_aggregate
+ else pl.col(geography_id_col) == geography
+ )
out.append((label, filtered))
return out
diff --git a/dashboard/page_base.py b/dashboard/page_base.py
index 976f5c2..116dcd7 100644
--- a/dashboard/page_base.py
+++ b/dashboard/page_base.py
@@ -1,825 +1,21 @@
-"""Base classes for persistent Panel dashboard pages."""
+"""Small public facade for dashboard page authors."""
-from __future__ import annotations
-
-from collections import defaultdict
-from dataclasses import dataclass
-from typing import Any, Callable, TYPE_CHECKING
-
-import panel as pn
-
-from activitysim_viz_logging import get_logger
-from dashboard import DashboardState
-from dashboard.components import data_unavailable_card
-from dashboard.data_access import (
- DashboardDataSelection,
- VisualizationDiagnostic,
- VisualizationInputResult,
- VisualizationRunAvailability,
+from dashboard.page_declarations import (
+ PAGE_SELECTOR_STYLESHEET,
+ RegisteredPageSection,
+ RegisteredPageSelector,
+ SectionContent,
)
-from runtime.config import Config
-
-if TYPE_CHECKING:
- from dashboard.page_definitions import DashboardPageDefinition, PreparedDataMode
-
-LOGGER = get_logger("dashboard.page")
-
-SectionContent = (
- pn.viewable.Viewable | list[pn.viewable.Viewable] | tuple[pn.viewable.Viewable, ...]
-)
-
-PAGE_SELECTOR_STYLESHEET = """
-:host(.page-selector-widget) {
- max-width: 300px;
-}
-
-:host(.page-selector-widget) .bk-input-group {
- width: auto;
-}
-
-:host(.page-selector-widget) .bk-input-group-label,
-:host(.page-selector-widget) label {
- font-size: 15px;
- font-weight: 600;
- color: #1f2937;
- margin-bottom: 6px;
-}
-
-:host(.page-selector-widget) select,
-:host(.page-selector-widget) input {
- font-size: 13px;
- font-weight: 500;
-}
-"""
-
-
-@dataclass(frozen=True)
-class RegisteredPageSelector:
- selector_id: str
- widget: pn.widgets.Widget
- label: str
- exportable: bool = True
-
-
-@dataclass
-class RegisteredPageSection:
- section_id: str
- container: pn.Column
- selector_ids: tuple[str, ...]
- export: bool
- export_data_mode: "PreparedDataMode"
- render: Callable[[], SectionContent]
- dirty: bool = True
-
-
-class DashboardPage:
- """Persistent controller for one dashboard page.
-
- Pages own widget instances, page-local cached views, and the summary/prepared-run
- lookups needed to refresh their visible Panel layout.
- """
-
- definition: DashboardPageDefinition | None = None
-
- def __init__(self, *args) -> None:
- if len(args) == 2:
- state, config = args
- definition = self.definition
- derived_name = (
- definition.title if definition is not None else type(self).__name__
- )
- elif len(args) == 3:
- derived_name, state, config = args
- else:
- raise TypeError(
- "DashboardPage expects (state, config) or legacy (name, state, config)."
- )
- if not isinstance(state, DashboardState):
- raise TypeError("DashboardPage requires a DashboardState instance.")
- if not isinstance(config, Config):
- raise TypeError("DashboardPage requires a Config instance.")
-
- name = str(derived_name)
- self.name = name
- self.state = state
- self.config = config
- self._page_state = state.get_page_state(name)
- self.view: pn.viewable.Viewable | None = None
- self._registered_selectors: dict[str, RegisteredPageSelector] = {}
- self._registered_sections: dict[str, RegisteredPageSection] = {}
- self._selector_ids_by_widget_id: dict[int, str] = {}
- self._is_refreshing = False
- self._queued_selector_ids: set[str] = set()
- self._active_section_id: str | None = None
-
- if type(self).build_page is not DashboardPage.build_page:
- self.view = self.build_page()
- self._validate_registered_components()
-
- def refresh_if_needed(self) -> None:
- """Refresh the page when its rendered global state is stale."""
- if self._page_state.get("last_rendered_state") != self.state.global_state_key():
- self.refresh(force=True)
-
- def refresh(self, force: bool = False) -> None:
- """Refresh the page content."""
- current_state_key = self.state.global_state_key()
- last_state_key = self._page_state.get("last_rendered_state")
- global_state_changed = force or last_state_key != current_state_key
- if not force and not global_state_changed and not self._dirty_sections():
- return
- self._page_state["visualization_diagnostics"] = []
- if self._registered_sections:
- if global_state_changed:
- self.on_global_state_changed()
- for section in self._registered_sections.values():
- section.dirty = True
- self._refresh_registered_sections()
- else:
- self._active_section_id = None
- self._refresh()
- self._page_state["last_rendered_state"] = current_state_key
-
- def mark_stale(self) -> None:
- """Mark the page stale so the next activation refreshes it."""
- self._page_state["last_rendered_state"] = None
- for section in self._registered_sections.values():
- section.dirty = True
-
- def mark_section_stale(self, *section_ids: str) -> None:
- """Mark one or more registered sections stale."""
- for section_id in section_ids:
- section = self._registered_sections.get(section_id)
- if section is None:
- raise KeyError(
- f"Unknown section id {section_id!r} on page {self.name!r}."
- )
- section.dirty = True
-
- def build_page(self) -> pn.viewable.Viewable:
- raise NotImplementedError
-
- def sync_controls(self) -> None:
- """Update selector options/values before rendering dirty sections."""
-
- def on_global_state_changed(self) -> None:
- """Hook for page-local cache invalidation on global dashboard state changes."""
-
- def selector(
- self,
- selector_id: str,
- *,
- widget: pn.widgets.Widget,
- label: str,
- exportable: bool = True,
- ) -> pn.widgets.Widget:
- """Register one page-local selector widget."""
- if selector_id in self._registered_selectors:
- raise ValueError(
- f"Dashboard page {self.name!r} declares duplicate selector id {selector_id!r}."
- )
- if hasattr(widget, "name"):
- widget.name = label
- css_classes = list(getattr(widget, "css_classes", []) or [])
- if "page-selector-widget" not in css_classes:
- css_classes.append("page-selector-widget")
- widget.css_classes = css_classes
- stylesheets = list(getattr(widget, "stylesheets", []) or [])
- if PAGE_SELECTOR_STYLESHEET not in stylesheets:
- stylesheets.append(PAGE_SELECTOR_STYLESHEET)
- widget.stylesheets = stylesheets
- selector = RegisteredPageSelector(
- selector_id=selector_id,
- widget=widget,
- label=label,
- exportable=exportable,
- )
- self._registered_selectors[selector_id] = selector
- self._selector_ids_by_widget_id[id(widget)] = selector_id
- widget.param.watch(
- lambda event, sid=selector_id: self._handle_selector_change(sid),
- "value",
- )
- return widget
-
- def section(
- self,
- section_id: str,
- *,
- selectors: tuple[str, ...] = (),
- export: bool = True,
- export_data_mode: "PreparedDataMode" = "none",
- render: Callable[[], SectionContent],
- ) -> pn.Column:
- """Register one stable page section."""
- if section_id in self._registered_sections:
- raise ValueError(
- f"Dashboard page {self.name!r} declares duplicate section id {section_id!r}."
- )
- unknown_selectors = [
- selector_id
- for selector_id in selectors
- if selector_id not in self._registered_selectors
- ]
- if unknown_selectors:
- raise ValueError(
- f"Dashboard page {self.name!r} section {section_id!r} references unknown selectors: "
- + ", ".join(repr(selector_id) for selector_id in unknown_selectors)
- )
- container = self.new_section()
- self._registered_sections[section_id] = RegisteredPageSection(
- section_id=section_id,
- container=container,
- selector_ids=tuple(selectors),
- export=export,
- export_data_mode=export_data_mode,
- render=render,
- )
- return container
-
- def section_view(self, section_id: str) -> pn.Column:
- section = self._registered_sections.get(section_id)
- if section is None:
- raise KeyError(f"Unknown section id {section_id!r} on page {self.name!r}.")
- return section.container
-
- @property
- def registered_selectors(self) -> tuple[RegisteredPageSelector, ...]:
- return tuple(self._registered_selectors.values())
-
- @property
- def registered_sections(self) -> tuple[RegisteredPageSection, ...]:
- return tuple(self._registered_sections.values())
-
- def _dirty_sections(self) -> tuple[RegisteredPageSection, ...]:
- return tuple(
- section for section in self._registered_sections.values() if section.dirty
- )
-
- def _handle_selector_change(self, selector_id: str) -> None:
- if self._is_refreshing:
- self._queued_selector_ids.add(selector_id)
- return
- self._mark_sections_for_selectors({selector_id})
- self.refresh(force=False)
-
- def _mark_sections_for_selectors(self, selector_ids: set[str]) -> None:
- for section in self._registered_sections.values():
- if selector_ids.intersection(section.selector_ids):
- section.dirty = True
-
- def _refresh_registered_sections(self) -> None:
- self._is_refreshing = True
- try:
- rerun_requested = False
- for _ in range(2):
- self._queued_selector_ids.clear()
- self.sync_controls()
- dirty_sections = list(self._dirty_sections())
- if not dirty_sections and not self._queued_selector_ids:
- break
- for section in dirty_sections:
- self._render_section(section)
- section.dirty = False
- if not self._queued_selector_ids:
- break
- self._mark_sections_for_selectors(set(self._queued_selector_ids))
- rerun_requested = True
- if rerun_requested:
- self._queued_selector_ids.clear()
- finally:
- self._is_refreshing = False
-
- def _render_section(self, section: RegisteredPageSection) -> None:
- previous_section_id = self._active_section_id
- self._active_section_id = section.section_id
- try:
- rendered = section.render()
- finally:
- self._active_section_id = previous_section_id
- if isinstance(rendered, pn.viewable.Viewable):
- objects = [rendered]
- else:
- objects = list(rendered)
- section.container.objects = objects
-
- def _validate_registered_components(self) -> None:
- if self.view is None:
- raise ValueError(
- f"Dashboard page {self.name!r} build_page() returned no view."
- )
- if not isinstance(self.view, pn.viewable.Viewable):
- raise TypeError(
- f"Dashboard page {self.name!r} build_page() must return a Panel viewable."
- )
-
- def _watch_widget(self, widget: pn.widgets.Widget) -> None:
- """Legacy helper for pages that have not migrated to selector()."""
- widget.param.watch(lambda event: self.refresh(force=True), "value")
-
- def new_section(self, *objects, **kwargs) -> pn.Column:
- """Create a stable page section container that can be refreshed in place."""
- kwargs.setdefault("sizing_mode", "stretch_width")
- return pn.Column(*objects, **kwargs)
-
- @property
- def as_percent(self) -> bool:
- """Return whether the current display mode should show percentages."""
- return self.state.value_mode == "Percent"
-
- @property
- def weighting_key(self) -> str:
- """Return the current weighting key used for summary-table lookup."""
- return self.state.weighting_key()
-
- @classmethod
- def page_id(cls) -> str | None:
- """Return the registered page id when one has been assigned."""
- return cls.definition.page_id if cls.definition is not None else None
-
- @classmethod
- def page_title(cls) -> str | None:
- """Return the registered page title when one has been assigned."""
- return cls.definition.title if cls.definition is not None else None
-
- @property
- def required_summary_ids(self) -> tuple[str, ...]:
- if self.definition is None:
- return ()
- return self.definition.required_summary_ids
-
- @property
- def prepared_data_mode(self) -> str:
- if self.definition is None:
- return "none"
- return self.definition.prepared_data_mode
-
- def _warn_once(self, key: str, message: str) -> None:
- warnings = self._page_state.setdefault("warnings_emitted", set())
- if key in warnings:
- return
- LOGGER.warning(message)
- warnings.add(key)
-
- def data_not_available_card(
- self,
- *,
- detail: str,
- missing_items: list[str] | tuple[str, ...] | None = None,
- title: str = "Data Not Available",
- ) -> pn.Card:
- effective_title = title
- effective_detail = detail
- if missing_items:
- effective_detail = self._augment_missing_data_detail(detail, missing_items)
- if title == "Data Not Available":
- statuses = self._missing_item_statuses(missing_items)
- if statuses and statuses == {"empty"}:
- effective_title = "Data Empty"
- elif statuses and statuses == {"schema_mismatch"}:
- effective_title = "Schema Mismatch"
- elif statuses and statuses == {"failed"}:
- effective_title = "Data Failed"
- return data_unavailable_card(
- effective_title,
- effective_detail,
- missing_items=missing_items,
- )
-
- def no_runs_message(self) -> pn.pane.Markdown:
- """Return the standard no-runs-loaded placeholder used across pages."""
- return pn.pane.Markdown("No runs loaded.")
-
- def summary_only_unavailable_card(
- self,
- *,
- summary_ids: list[str] | tuple[str, ...] | None = None,
- detail: str = "This page only renders from precomputed summary tables.",
- title: str = "Data Not Available",
- ) -> pn.Card:
- """Return the standard summary-only unavailable card for this page."""
- missing_items = list(summary_ids or self.required_summary_ids)
- return self.data_not_available_card(
- detail=detail,
- missing_items=missing_items,
- title=title,
- )
-
- @property
- def missing_data_display(self) -> str:
- return self.config.missing_data_display
-
- @property
- def visualization_diagnostics(self) -> list[VisualizationDiagnostic]:
- return list(self._page_state.get("visualization_diagnostics", []))
-
- def _record_visualization_diagnostic(
- self,
- result: VisualizationInputResult,
- ) -> None:
- render_state = "rendered"
- if not result.has_usable_runs:
- render_state = "skipped"
- elif result.excluded_runs:
- render_state = "partial"
- diagnostics = self._page_state.setdefault("visualization_diagnostics", [])
- diagnostics.append(
- VisualizationDiagnostic(
- visualization_id=result.visualization_id,
- render_state=render_state,
- input_kind=result.input_kind,
- input_ids=result.input_ids,
- usable_run_labels=tuple(
- label
- for label, _ in next(iter(result.usable_by_input.values()), [])
- ),
- excluded_runs=tuple(result.excluded_runs),
- )
- )
-
- def _combine_selections(
- self,
- visualization_id: str,
- selections: dict[str, DashboardDataSelection],
- ) -> VisualizationInputResult:
- usable_labels_by_input = {
- input_id: {label for label, _ in selection.usable_runs}
- for input_id, selection in selections.items()
- }
- common_labels = (
- set.intersection(*usable_labels_by_input.values())
- if usable_labels_by_input
- else set()
- )
- usable_by_input = {
- input_id: [
- (label, value)
- for label, value in selection.usable_runs
- if label in common_labels
- ]
- for input_id, selection in selections.items()
- }
- excluded_by_key: dict[tuple[str, str, str], VisualizationRunAvailability] = {}
- for selection in selections.values():
- for issue in selection.excluded_runs:
- excluded_by_key[(issue.label, issue.source_kind, issue.source_id)] = (
- issue
- )
- for label, _ in selection.usable_runs:
- if label in common_labels:
- continue
- excluded_by_key[(label, selection.source_kind, selection.source_id)] = (
- VisualizationRunAvailability(
- label=label,
- status="missing",
- detail="required alongside another unavailable input for this visualization",
- source_kind=selection.source_kind,
- source_id=selection.source_id,
- )
- )
- input_kinds = {selection.source_kind for selection in selections.values()}
- input_kind = next(iter(input_kinds)) if len(input_kinds) == 1 else "mixed"
- result = VisualizationInputResult(
- visualization_id=visualization_id,
- input_kind=input_kind,
- usable_by_input=usable_by_input,
- excluded_runs=list(excluded_by_key.values()),
- input_ids=tuple(selections),
- )
- self._record_visualization_diagnostic(result)
- return result
-
- def resolve_summary_visualization(
- self,
- visualization_id: str,
- *,
- summary_requirements: dict[str, tuple[str, ...]],
- weighting_key: str | None = None,
- ) -> VisualizationInputResult:
- selections = {
- summary_name: self.state.inspect_summary_table(
- summary_name,
- weighting_key=weighting_key or self.weighting_key,
- required_columns=required_columns,
- )
- for summary_name, required_columns in summary_requirements.items()
- }
- return self._combine_selections(visualization_id, selections)
-
- def resolve_prepared_visualization(
- self,
- visualization_id: str,
- *,
- table_requirements: dict[str, tuple[str, ...]],
- weighted: bool | None = None,
- ) -> VisualizationInputResult:
- selections = {
- table_name: self.state.inspect_prepared_table(
- table_name,
- weighted=weighted,
- required_columns=required_columns,
- )
- for table_name, required_columns in table_requirements.items()
- }
- return self._combine_selections(visualization_id, selections)
-
- def unavailable_visualization(
- self,
- result: VisualizationInputResult,
- *,
- detail: str,
- title: str = "Data Not Available",
- ) -> pn.viewable.Viewable:
- if self.missing_data_display == "blank":
- return pn.Spacer(height=0)
- missing_items = list(result.input_ids)
- if result.excluded_runs:
- detail = self._format_issue_detail(detail, result.excluded_runs)
- return self.data_not_available_card(
- detail=detail,
- missing_items=missing_items,
- title=title,
- )
-
- def get_summary(self, summary_name: str):
- """Return one summary table per run for the current weighting mode."""
- return self.state.get_summary_table_set(summary_name, self.weighting_key)
-
- def has_summary(self, summary_name: str) -> bool:
- return self.state.has_summary_table_set(summary_name, self.weighting_key)
-
- def require_summary(self, summary_name: str):
- """Return one summary table per run, warning once when unavailable."""
- selection = self.state.inspect_summary_table(
- summary_name,
- weighting_key=self.weighting_key,
- )
- self._page_state.setdefault("required_summary_selections", {})[summary_name] = (
- selection
- )
- if not selection.has_usable_runs:
- self._warn_once(
- f"missing-summary:{summary_name}",
- (
- f"Warning: dashboard page '{self.name}' requires summary "
- f"'{summary_name}' for weighting mode '{self.weighting_key}', "
- "but no usable data was available."
- ),
- )
- return None
- return [(label, table) for label, table in selection.usable_runs]
-
- def inspect_summary(
- self,
- summary_name: str,
- *,
- required_columns: tuple[str, ...] = (),
- ):
- """Inspect one summary table and store its availability for page diagnostics."""
- selection = self.state.inspect_summary_table(
- summary_name,
- weighting_key=self.weighting_key,
- required_columns=required_columns,
- )
- self._page_state.setdefault("required_summary_selections", {})[summary_name] = (
- selection
- )
- return selection
-
- def optional_summary(
- self,
- summary_name: str,
- *,
- required_columns: tuple[str, ...] = (),
- ):
- """Return usable rows for one summary or ``None`` when unavailable."""
- selection = self.inspect_summary(
- summary_name,
- required_columns=required_columns,
- )
- if not selection.has_usable_runs:
- return None
- return [(label, table) for label, table in selection.usable_runs]
-
- def optional_summaries_dict(
- self,
- *summary_names: str,
- required_columns_by_summary: dict[str, tuple[str, ...]] | None = None,
- ) -> dict[str, Any]:
- """Return optional summaries keyed by summary id without failing the whole page."""
- required_columns_by_summary = required_columns_by_summary or {}
- return {
- summary_name: self.optional_summary(
- summary_name,
- required_columns=required_columns_by_summary.get(summary_name, ()),
- )
- for summary_name in summary_names
- }
-
- def require_summaries(self, *summary_names: str) -> dict[str, Any] | None:
- """Return multiple summary tables or ``None`` when any are missing."""
- selections = {
- summary_name: self.state.inspect_summary_table(
- summary_name,
- weighting_key=self.weighting_key,
- )
- for summary_name in summary_names
- }
- self._page_state["required_summary_selections"] = selections
- missing = [
- summary_name
- for summary_name in summary_names
- if not selections[summary_name].has_usable_runs
- ]
- if missing:
- for summary_name in missing:
- self._warn_once(
- f"missing-summary:{summary_name}",
- (
- f"Warning: dashboard page '{self.name}' requires summary "
- f"'{summary_name}' for weighting mode '{self.weighting_key}', "
- "but no usable data was available."
- ),
- )
- return None
- return {
- summary_name: [
- (label, table) for label, table in selections[summary_name].usable_runs
- ]
- for summary_name in summary_names
- }
-
- def _missing_item_statuses(
- self,
- missing_items: list[str] | tuple[str, ...],
- ) -> set[str]:
- selections = self._page_state.get("required_summary_selections", {})
- statuses: set[str] = set()
- for item in missing_items:
- selection = selections.get(item)
- if selection is None:
- continue
- statuses.update(issue.status for issue in selection.excluded_runs)
- return statuses
-
- def _augment_missing_data_detail(
- self,
- detail: str,
- missing_items: list[str] | tuple[str, ...],
- ) -> str:
- selections = self._page_state.get("required_summary_selections", {})
- excluded_runs: list[VisualizationRunAvailability] = []
- for item in missing_items:
- selection = selections.get(item)
- if selection is None:
- continue
- excluded_runs.extend(selection.excluded_runs)
- if not excluded_runs:
- return detail
- return self._format_issue_detail(detail, excluded_runs)
-
- def _format_issue_detail(
- self,
- detail: str,
- issues: (
- list[VisualizationRunAvailability]
- | tuple[VisualizationRunAvailability, ...]
- ),
- ) -> str:
- lines = [detail, "", "Availability details:"]
- by_source: dict[str, list[VisualizationRunAvailability]] = defaultdict(list)
- for issue in issues:
- by_source[issue.source_id].append(issue)
-
- status_order = {
- "empty": 0,
- "schema_mismatch": 1,
- "unavailable": 2,
- "failed": 3,
- "missing": 4,
- }
- for source_id in sorted(by_source):
- source_issues = by_source[source_id]
- grouped: dict[tuple[str, str], list[str]] = defaultdict(list)
- for issue in source_issues:
- grouped[(issue.status, issue.detail)].append(issue.label)
- for (status, issue_detail), labels in sorted(
- grouped.items(),
- key=lambda item: (
- status_order.get(item[0][0], 99),
- item[0][0],
- item[0][1],
- ),
- ):
- label_list = ", ".join(f"`{label}`" for label in sorted(labels))
- lines.append(
- f"- `{source_id}` is {status.replace('_', ' ')} for {label_list}: {issue_detail}"
- )
- return "\n".join(lines)
-
- def get_prepared_runs(self, *, weighted: bool | None = None):
- """Return prepared runs when this dashboard session has loaded them explicitly."""
- return self.state.get_prepared_runs_if_loaded(weighted=weighted)
-
- def require_prepared_runs(self, *, weighted: bool | None = None):
- """Return prepared runs or warn once when this session does not have them."""
- prepared_runs = self.get_prepared_runs(weighted=weighted)
- if prepared_runs is not None:
- return prepared_runs
-
- availability = self.state.prepared_run_availability
- reason = (
- "prepared run data was not requested for this dashboard session"
- if availability == "not_requested"
- else "prepared run data is unavailable"
- )
- self._warn_once(
- f"missing-prepared-runs:{availability}",
- (
- f"Warning: dashboard page '{self.name}' requires prepared run data, "
- f"but {reason}."
- ),
- )
- return None
-
- def get_filtered_view(self, view_name: str, *filters: Any, factory):
- """Return a cached chart-ready filtered view for the current page state."""
- page_cache_id = self.page_id() or self.name
- return self.state.get_or_create_cached(
- "filtered_view",
- page_cache_id,
- self._active_section_id or "*",
- self.weighting_key,
- view_name,
- *filters,
- factory=factory,
- )
-
- def clear_filtered_view_cache(self) -> None:
- """Clear cached filtered views for this page and weighting mode."""
- page_cache_id = self.page_id() or self.name
- cache = self.state.get_cache("filtered_view")
- prefix = (page_cache_id, self.weighting_key)
- stale_keys = [
- key
- for key in cache
- if key[0] == page_cache_id and key[2] == self.weighting_key
- ]
- for key in stale_keys:
- cache.pop(key, None)
-
- def _refresh(self) -> None:
- raise NotImplementedError
-
-
-class GroupedDashboardPage:
- """Top-level navigation wrapper that renders child dashboard pages as tabs."""
-
- def __init__(
- self,
- group_id: str,
- title: str,
- pages: list[DashboardPage],
- default_child_page_id: str | None = None,
- ) -> None:
- if not pages:
- raise ValueError("GroupedDashboardPage requires at least one child page.")
- self._group_id = group_id
- self.name = title
- self.pages = pages
- self.view = pn.Tabs(
- *[(page.name, page.view) for page in pages],
- dynamic=False,
- )
- self._active_child = self._default_child_index(default_child_page_id)
- self.view.active = self._active_child
- self.view.param.watch(self._on_child_tab_change, "active")
-
- def _default_child_index(self, default_child_page_id: str | None) -> int:
- if default_child_page_id is None:
- return 0
- for index, page in enumerate(self.pages):
- if page.page_id() == default_child_page_id:
- return index
- return 0
-
- def _on_child_tab_change(self, event) -> None:
- self._active_child = int(event.new)
- self.refresh_if_needed()
-
- def page_id(self) -> str:
- return self._group_id
-
- @property
- def active_child(self) -> DashboardPage:
- return self.pages[self._active_child]
-
- def refresh_if_needed(self) -> None:
- self.active_child.refresh_if_needed()
-
- def refresh(self, force: bool = False) -> None:
- self.active_child.refresh(force=force)
-
- def mark_stale(self) -> None:
- for page in self.pages:
- page.mark_stale()
+from dashboard.page_features import PageFeature
+from dashboard.page_lifecycle import DashboardPage
+from dashboard.page_navigation import GroupedDashboardPage
+
+__all__ = [
+ "DashboardPage",
+ "GroupedDashboardPage",
+ "PageFeature",
+ "PAGE_SELECTOR_STYLESHEET",
+ "RegisteredPageSection",
+ "RegisteredPageSelector",
+ "SectionContent",
+]
diff --git a/dashboard/page_declarations.py b/dashboard/page_declarations.py
new file mode 100644
index 0000000..9f3ea84
--- /dev/null
+++ b/dashboard/page_declarations.py
@@ -0,0 +1,81 @@
+"""Author-facing selector and section declarations for dashboard pages."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Callable, Literal, TypeAlias
+
+import panel as pn
+
+from dashboard.page_definitions import PreparedDataMode
+
+SectionContent: TypeAlias = (
+ pn.viewable.Viewable | list[pn.viewable.Viewable] | tuple[pn.viewable.Viewable, ...]
+)
+SelectorOptions: TypeAlias = list | tuple | dict
+OptionProvider: TypeAlias = Callable[[], SelectorOptions]
+DefaultPolicy: TypeAlias = (
+ Literal["first", "last"] | Callable[[SelectorOptions], object]
+)
+
+PAGE_SELECTOR_STYLESHEET = """
+:host(.page-selector-widget) { max-width: 300px; }
+:host(.page-selector-widget) .bk-input-group { width: auto; }
+:host(.page-selector-widget) .bk-input-group-label,
+:host(.page-selector-widget) label {
+ font-size: 15px;
+ font-weight: 600;
+ color: #1f2937;
+ margin-bottom: 6px;
+}
+:host(.page-selector-widget) select,
+:host(.page-selector-widget) input { font-size: 13px; font-weight: 500; }
+"""
+
+UNSET = object()
+
+
+@dataclass(frozen=True)
+class RegisteredPageSelector:
+ selector_id: str
+ widget: pn.widgets.Widget
+ label: str
+ exportable: bool = True
+ options: OptionProvider | None = None
+ default: DefaultPolicy = "first"
+
+
+@dataclass
+class RegisteredPageSection:
+ section_id: str
+ container: pn.Column
+ selector_ids: tuple[str, ...]
+ export: bool
+ export_data_mode: PreparedDataMode
+ render: Callable[[], SectionContent]
+ dirty: bool = True
+
+
+def option_values(options: SelectorOptions) -> list[object]:
+ """Return the selectable values represented by Panel options."""
+ return list(options.values()) if isinstance(options, dict) else list(options)
+
+
+def default_value(options: SelectorOptions, policy: DefaultPolicy) -> object:
+ """Resolve a selector's value after its previous value becomes stale."""
+ values = option_values(options)
+ if callable(policy):
+ return policy(options)
+ if not values:
+ return None
+ return values[-1] if policy == "last" else values[0]
+
+
+__all__ = [
+ "DefaultPolicy",
+ "OptionProvider",
+ "RegisteredPageSection",
+ "RegisteredPageSelector",
+ "SectionContent",
+ "SelectorOptions",
+]
diff --git a/dashboard/page_definitions.py b/dashboard/page_definitions.py
index 00300d4..c0a7897 100644
--- a/dashboard/page_definitions.py
+++ b/dashboard/page_definitions.py
@@ -3,81 +3,16 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any, Callable, Literal
-
-import panel as pn
+from typing import TYPE_CHECKING, Literal, TypeVar
from processor.models import PreparedTableName
if TYPE_CHECKING:
from dashboard.page_base import DashboardPage
- from runtime.config import Config
-
-PreparedDataMode = Literal["none", "optional", "required"]
-DashboardPageSelectionMode = Literal["default", "all", "explicit"]
-
-
-@dataclass(frozen=True)
-class PageSelectorDefinition:
- """Describe one page-local widget that may participate in HTML export.
-
- Attributes:
- selector_id: Stable config-facing selector name used under
- ``visualizer.export_html.pages..``.
- widget_attr: Attribute name on the page instance that resolves to the
- backing Panel widget.
- label: Human-readable label used in serialized export metadata.
- enabled_when: Optional predicate that can disable export support when
- page state or config makes the widget unavailable.
- exportable: Whether the export path should attempt to treat this widget
- as an interactive selector.
- """
-
- selector_id: str
- widget_attr: str
- label: str
- enabled_when: Callable[[Any, Config], bool] | None = None
- exportable: bool = True
-
- def widget_for(self, page: Any) -> pn.widgets.Widget | None:
- """Return the backing widget from a page instance when present."""
- widget = getattr(page, self.widget_attr, None)
- return widget if isinstance(widget, pn.widgets.Widget) else None
-
- def available_for(self, page: Any, config: Config) -> bool:
- """Return whether this selector is available for the current page state."""
- if self.enabled_when is not None and not self.enabled_when(page, config):
- return False
- return self.widget_for(page) is not None
-
-
-@dataclass(frozen=True)
-class PageExportRegionDefinition:
- """Describe one explicit page-owned export region."""
-
- region_id: str
- view_attr: str
- selector_ids: tuple[str, ...] = field(default_factory=tuple)
-
- def view_for(self, page: Any) -> pn.viewable.Viewable | None:
- """Return the stable region root viewable from a page instance when present."""
- view = getattr(page, self.view_attr, None)
- return view if isinstance(view, pn.viewable.Viewable) else None
-
-@dataclass(frozen=True)
-class PageExportPartDefinition:
- """Describe one explicit page-owned export part."""
-
- part_id: str
- view_attr: str
- selector_ids: tuple[str, ...] = field(default_factory=tuple)
- export_data_mode: PreparedDataMode = "none"
+PageT = TypeVar("PageT", bound="DashboardPage")
- def view_for(self, page: Any) -> pn.viewable.Viewable | None:
- """Return the stable part root viewable from a page instance when present."""
- view = getattr(page, self.view_attr, None)
- return view if isinstance(view, pn.viewable.Viewable) else None
+PreparedDataMode = Literal["none", "optional", "required"]
@dataclass(frozen=True)
@@ -89,21 +24,51 @@ class DashboardPageDefinition:
page_cls: type["DashboardPage"] | None = None
order: int = 0
group_id: str | None = None
- child_order: int = 0
default_enabled: bool = True
prepared_data_mode: PreparedDataMode = "none"
- # Legacy metadata remains temporarily supported for pages that have not yet
- # migrated to runtime selector/section registration.
- selectors: tuple[PageSelectorDefinition, ...] = field(default_factory=tuple)
- export_regions: tuple[PageExportRegionDefinition, ...] = field(
- default_factory=tuple
- )
- export_parts: tuple[PageExportPartDefinition, ...] = field(default_factory=tuple)
required_summary_ids: tuple[str, ...] = field(default_factory=tuple)
+ optional_summary_ids: tuple[str, ...] = field(default_factory=tuple)
required_prepared_tables: tuple[PreparedTableName, ...] = field(
default_factory=tuple
)
+ def __post_init__(self) -> None:
+ """Attach metadata to the page class at its single declaration site."""
+ if self.page_cls is not None:
+ self.page_cls.definition = self
+
+
+def dashboard_page(
+ *,
+ page_id: str,
+ title: str,
+ order: int = 0,
+ group_id: str | None = None,
+ default_enabled: bool = True,
+ prepared_data_mode: PreparedDataMode = "none",
+ required_summary_ids: tuple[str, ...] = (),
+ optional_summary_ids: tuple[str, ...] = (),
+ required_prepared_tables: tuple[PreparedTableName, ...] = (),
+):
+ """Declare a dashboard page and attach its discovery metadata to the class."""
+
+ def decorate(page_cls: type[PageT]) -> type[PageT]:
+ DashboardPageDefinition(
+ page_id=page_id,
+ title=title,
+ page_cls=page_cls,
+ order=order,
+ group_id=group_id,
+ default_enabled=default_enabled,
+ prepared_data_mode=prepared_data_mode,
+ required_summary_ids=required_summary_ids,
+ optional_summary_ids=optional_summary_ids,
+ required_prepared_tables=required_prepared_tables,
+ )
+ return page_cls
+
+ return decorate
+
@dataclass(frozen=True)
class DashboardDataRequirements:
@@ -111,10 +76,23 @@ class DashboardDataRequirements:
prepared_data_mode: PreparedDataMode = "none"
required_summary_ids: tuple[str, ...] = field(default_factory=tuple)
+ optional_summary_ids: tuple[str, ...] = field(default_factory=tuple)
required_prepared_tables: tuple[PreparedTableName, ...] = field(
default_factory=tuple
)
+ @property
+ def summary_ids_for_pruning(self) -> tuple[str, ...]:
+ """Return all summary IDs that pages may render."""
+ merged: list[str] = []
+ seen: set[str] = set()
+ for summary_id in (*self.required_summary_ids, *self.optional_summary_ids):
+ if summary_id in seen:
+ continue
+ merged.append(summary_id)
+ seen.add(summary_id)
+ return tuple(merged)
+
@dataclass(frozen=True)
class DashboardGroupDefinition:
@@ -125,12 +103,3 @@ class DashboardGroupDefinition:
order: int = 0
default_enabled: bool = True
default_page_id: str | None = None
-
-
-@dataclass(frozen=True)
-class DashboardPageConfigEntry:
- """Normalized dashboard page-selection entry from config."""
-
- page_id: str
- mode: DashboardPageSelectionMode = "explicit"
- page_ids: tuple[str, ...] = field(default_factory=tuple)
diff --git a/dashboard/page_diagnostics.py b/dashboard/page_diagnostics.py
new file mode 100644
index 0000000..0e900d6
--- /dev/null
+++ b/dashboard/page_diagnostics.py
@@ -0,0 +1,178 @@
+"""Availability diagnostics and standard missing-data presentation for pages."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+
+import panel as pn
+
+from dashboard.data_access import (
+ DashboardDataSelection,
+ VisualizationDiagnostic,
+ VisualizationRunAvailability,
+)
+from dashboard.rendering import data_unavailable_card
+from runtime.logging import get_logger
+
+LOGGER = get_logger("dashboard.page")
+
+
+class PageDiagnostics:
+ """Internal diagnostics behavior mixed into ``DashboardPage``."""
+
+ def _warn_once(self, key: str, message: str) -> None:
+ warnings = self._page_state.setdefault("warnings_emitted", set())
+ if key in warnings:
+ return
+ LOGGER.warning(message)
+ warnings.add(key)
+
+ def _record_data_selection(
+ self, source_id: str, selection: DashboardDataSelection
+ ) -> None:
+ self._page_state.setdefault("required_summary_selections", {})[source_id] = (
+ selection
+ )
+ render_state = (
+ "skipped"
+ if not selection.has_usable_runs
+ else "partial"
+ if selection.excluded_runs
+ else "rendered"
+ )
+ self._page_state.setdefault("visualization_diagnostics", []).append(
+ VisualizationDiagnostic(
+ visualization_id=source_id,
+ render_state=render_state,
+ input_kind=selection.source_kind,
+ input_ids=(source_id,),
+ usable_run_labels=tuple(label for label, _ in selection.usable_runs),
+ excluded_runs=tuple(selection.excluded_runs),
+ )
+ )
+
+ def _warn_missing_summary(self, summary_id: str) -> None:
+ self._warn_once(
+ f"missing-summary:{summary_id}",
+ f"Warning: dashboard page '{self.name}' requires summary '{summary_id}' "
+ f"for weighting mode '{self.weighting_key}', but no usable data was available.",
+ )
+
+ def _warn_missing_prepared(self) -> None:
+ availability = self.state.prepared_run_availability
+ reason = (
+ "prepared run data was not requested for this dashboard session"
+ if availability == "not_requested"
+ else "prepared run data is unavailable"
+ )
+ self._warn_once(
+ f"missing-prepared-runs:{availability}",
+ f"Warning: dashboard page '{self.name}' requires prepared run data, but {reason}.",
+ )
+
+ def data_not_available_card(
+ self,
+ *,
+ detail: str,
+ missing_items: list[str] | tuple[str, ...] | None = None,
+ title: str = "Data Not Available",
+ ) -> pn.Card:
+ effective_title = title
+ effective_detail = detail
+ if missing_items:
+ effective_detail = self._augment_missing_data_detail(detail, missing_items)
+ if title == "Data Not Available":
+ statuses = self._missing_item_statuses(missing_items)
+ if statuses == {"empty"}:
+ effective_title = "Data Empty"
+ elif statuses == {"schema_mismatch"}:
+ effective_title = "Schema Mismatch"
+ elif statuses == {"failed"}:
+ effective_title = "Data Failed"
+ return data_unavailable_card(
+ effective_title, effective_detail, missing_items=missing_items
+ )
+
+ def no_runs_message(self) -> pn.pane.Markdown:
+ return pn.pane.Markdown("No runs loaded.")
+
+ def summary_only_unavailable_card(
+ self,
+ *,
+ summary_ids: list[str] | tuple[str, ...] | None = None,
+ detail: str = "This page only renders from precomputed summary tables.",
+ title: str = "Data Not Available",
+ ) -> pn.Card:
+ return self.data_not_available_card(
+ detail=detail,
+ missing_items=list(summary_ids or self.required_summary_ids),
+ title=title,
+ )
+
+ @property
+ def missing_data_display(self) -> str:
+ return self.config.missing_data_display
+
+ @property
+ def visualization_diagnostics(self) -> list[VisualizationDiagnostic]:
+ return list(self._page_state.get("visualization_diagnostics", []))
+
+ def _missing_item_statuses(
+ self, missing_items: list[str] | tuple[str, ...]
+ ) -> set[str]:
+ selections = self._page_state.get("required_summary_selections", {})
+ return {
+ issue.status
+ for item in missing_items
+ for issue in getattr(selections.get(item), "excluded_runs", ())
+ }
+
+ def _augment_missing_data_detail(
+ self, detail: str, missing_items: list[str] | tuple[str, ...]
+ ) -> str:
+ selections = self._page_state.get("required_summary_selections", {})
+ issues = [
+ issue
+ for item in missing_items
+ for issue in getattr(selections.get(item), "excluded_runs", ())
+ ]
+ return self._format_issue_detail(detail, issues) if issues else detail
+
+ def _format_issue_detail(
+ self,
+ detail: str,
+ issues: list[VisualizationRunAvailability]
+ | tuple[VisualizationRunAvailability, ...],
+ ) -> str:
+ lines = [detail, "", "Availability details:"]
+ by_source: dict[str, list[VisualizationRunAvailability]] = defaultdict(list)
+ for issue in issues:
+ by_source[issue.source_id].append(issue)
+ status_order = {
+ "empty": 0,
+ "schema_mismatch": 1,
+ "unavailable": 2,
+ "failed": 3,
+ "missing": 4,
+ }
+ for source_id in sorted(by_source):
+ grouped: dict[tuple[str, str], list[str]] = defaultdict(list)
+ for issue in by_source[source_id]:
+ grouped[(issue.status, issue.detail)].append(issue.label)
+ for (status, issue_detail), labels in sorted(
+ grouped.items(),
+ key=lambda item: (
+ status_order.get(item[0][0], 99),
+ item[0][0],
+ item[0][1],
+ ),
+ ):
+ label_list = ", ".join(f"`{label}`" for label in sorted(labels))
+ lines.append(
+ f"- `{source_id}` is {status.replace('_', ' ')} for "
+ f"{label_list}: {issue_detail}"
+ )
+ return "\n".join(lines)
+
+
+__all__ = ["PageDiagnostics"]
diff --git a/dashboard/page_features.py b/dashboard/page_features.py
new file mode 100644
index 0000000..74f46e3
--- /dev/null
+++ b/dashboard/page_features.py
@@ -0,0 +1,72 @@
+"""Composable, page-local feature blocks."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Callable
+
+if TYPE_CHECKING:
+ import panel as pn
+ from dashboard.page_declarations import (
+ DefaultPolicy,
+ SectionContent,
+ SelectorOptions,
+ )
+ from dashboard.page_lifecycle import DashboardPage
+
+
+class PageFeature:
+ """A coherent block of selectors, queries, and sections within one page.
+
+ Feature ids prefix component ids, so a large page can be assembled from small
+ domain objects without creating another page type or managing cache keys.
+ """
+
+ def __init__(self, page: DashboardPage, feature_id: str) -> None:
+ self.page = page
+ self.feature_id = feature_id
+
+ def component_id(self, local_id: str) -> str:
+ return f"{self.feature_id}.{local_id}"
+
+ def select(
+ self,
+ local_id: str,
+ label: str,
+ *,
+ options: SelectorOptions | Callable[[], SelectorOptions],
+ default: DefaultPolicy = "first",
+ **widget_options,
+ ) -> pn.widgets.Select:
+ return self.page.select(
+ self.component_id(local_id),
+ label,
+ options=options,
+ default=default,
+ **widget_options,
+ )
+
+ def section(
+ self,
+ local_id: str,
+ *,
+ selectors: tuple[str, ...] = (),
+ render: Callable[[], SectionContent],
+ **options,
+ ) -> pn.Column:
+ return self.page.section(
+ self.component_id(local_id),
+ selectors=tuple(
+ selector
+ if selector in self.page._registered_selectors
+ else self.component_id(selector)
+ for selector in selectors
+ ),
+ render=render,
+ **options,
+ )
+
+ def query(self, factory: Callable):
+ return self.page.query(factory)
+
+
+__all__ = ["PageFeature"]
diff --git a/dashboard/page_lifecycle.py b/dashboard/page_lifecycle.py
new file mode 100644
index 0000000..4418b3a
--- /dev/null
+++ b/dashboard/page_lifecycle.py
@@ -0,0 +1,531 @@
+"""Lifecycle and authoring implementation behind the public page facade."""
+
+from __future__ import annotations
+
+from typing import Callable, TYPE_CHECKING
+
+import panel as pn
+
+from dashboard.state import DashboardState
+from dashboard.data_access import (
+ PageData,
+)
+from dashboard.page_diagnostics import PageDiagnostics
+from dashboard.rendering import Plotter, RenderContext
+from runtime.config import Config
+from dashboard.page_declarations import (
+ DefaultPolicy,
+ OptionProvider,
+ PAGE_SELECTOR_STYLESHEET,
+ RegisteredPageSection,
+ RegisteredPageSelector,
+ SectionContent,
+ SelectorOptions,
+ UNSET,
+ default_value,
+ option_values,
+)
+
+if TYPE_CHECKING:
+ from dashboard.page_definitions import DashboardPageDefinition, PreparedDataMode
+
+
+def _query_capture(value):
+ """Return a stable, hashable representation of a query closure value."""
+ if value is None or isinstance(value, (str, int, float, bool, bytes)):
+ return value
+ if isinstance(value, tuple):
+ return tuple(_query_capture(item) for item in value)
+ if isinstance(value, frozenset):
+ return tuple(sorted(_query_capture(item) for item in value))
+ return (type(value).__module__, type(value).__qualname__)
+
+
+class DashboardPage(PageDiagnostics):
+ """Persistent controller for one dashboard page.
+
+ Pages own widget instances, page-local cached views, and the summary/prepared-run
+ lookups needed to refresh their visible Panel layout.
+ """
+
+ definition: DashboardPageDefinition | None = None
+
+ def __init__(self, state: DashboardState, config: Config) -> None:
+ if not isinstance(state, DashboardState):
+ raise TypeError("DashboardPage requires a DashboardState instance.")
+ if not isinstance(config, Config):
+ raise TypeError("DashboardPage requires a Config instance.")
+
+ definition = self.definition
+ name = definition.title if definition is not None else type(self).__name__
+ page_state_id = (
+ definition.page_id if definition is not None else type(self).__name__
+ )
+ self.name = name
+ self.state = state
+ self.config = config
+ self._page_state = state.get_page_state(page_state_id)
+ self.data = PageData(
+ state,
+ weighting_key=lambda: self.weighting_key,
+ required_summary_ids=lambda: self.required_summary_ids,
+ record_selection=self._record_data_selection,
+ warn_missing=self._warn_missing_summary,
+ warn_missing_prepared=self._warn_missing_prepared,
+ )
+ self.view: pn.viewable.Viewable | None = None
+ self._registered_selectors: dict[str, RegisteredPageSelector] = {}
+ self._registered_sections: dict[str, RegisteredPageSection] = {}
+ self._features = []
+ self._selector_ids_by_widget_id: dict[int, str] = {}
+ self._is_refreshing = False
+ self._queued_selector_ids: set[str] = set()
+ self._active_section_id: str | None = None
+
+ if type(self).build_page is not DashboardPage.build_page:
+ self.view = self.build_page()
+ self._validate_registered_components()
+
+ def refresh_if_needed(self) -> None:
+ """Refresh the page when its rendered global state is stale."""
+ if self._page_state.get("last_rendered_state") != self.state.global_state_key():
+ self.refresh(force=True)
+
+ def refresh(self, force: bool = False) -> None:
+ """Refresh the page content."""
+ current_state_key = self.state.global_state_key()
+ last_state_key = self._page_state.get("last_rendered_state")
+ global_state_changed = force or last_state_key != current_state_key
+ if not force and not global_state_changed and not self._dirty_sections():
+ return
+ self._page_state["visualization_diagnostics"] = []
+ if self._registered_sections:
+ if global_state_changed:
+ self.on_global_state_changed()
+ for section in self._registered_sections.values():
+ section.dirty = True
+ self._refresh_registered_sections()
+ else:
+ self._active_section_id = None
+ self._refresh()
+ self._page_state["last_rendered_state"] = current_state_key
+
+ def mark_stale(self) -> None:
+ """Mark the page stale so the next activation refreshes it."""
+ self._page_state["last_rendered_state"] = None
+ for section in self._registered_sections.values():
+ section.dirty = True
+
+ def mark_section_stale(self, *section_ids: str) -> None:
+ """Mark one or more registered sections stale."""
+ for section_id in section_ids:
+ section = self._registered_sections.get(section_id)
+ if section is None:
+ raise KeyError(
+ f"Unknown section id {section_id!r} on page {self.name!r}."
+ )
+ section.dirty = True
+
+ def build_page(self) -> pn.viewable.Viewable:
+ raise NotImplementedError
+
+ def sync_controls(self) -> None:
+ """Update selector options/values before rendering dirty sections."""
+
+ def export_ignored_selectors(
+ self,
+ section_id: str,
+ selected_values: dict[str, str],
+ ) -> set[str]:
+ """Return selectors ignored by one export section state."""
+ return set()
+
+ def export_canonical_selector_value(
+ self,
+ section_id: str,
+ selector_id: str,
+ value: str,
+ selected_values: dict[str, str],
+ ) -> str:
+ """Return the canonical value for one selector during export enumeration."""
+ return value
+
+ def on_global_state_changed(self) -> None:
+ """Hook for page-local cache invalidation on global dashboard state changes."""
+
+ def selector(
+ self,
+ selector_id: str,
+ *,
+ widget: pn.widgets.Widget,
+ label: str,
+ exportable: bool = True,
+ options: OptionProvider | None = None,
+ default: DefaultPolicy = "first",
+ ) -> pn.widgets.Widget:
+ """Register one page-local selector widget."""
+ if selector_id in self._registered_selectors:
+ raise ValueError(
+ f"Dashboard page {self.name!r} declares duplicate selector id {selector_id!r}."
+ )
+ if hasattr(widget, "name"):
+ widget.name = label
+ css_classes = list(getattr(widget, "css_classes", []) or [])
+ if "page-selector-widget" not in css_classes:
+ css_classes.append("page-selector-widget")
+ widget.css_classes = css_classes
+ stylesheets = list(getattr(widget, "stylesheets", []) or [])
+ if PAGE_SELECTOR_STYLESHEET not in stylesheets:
+ stylesheets.append(PAGE_SELECTOR_STYLESHEET)
+ widget.stylesheets = stylesheets
+ selector = RegisteredPageSelector(
+ selector_id=selector_id,
+ widget=widget,
+ label=label,
+ exportable=exportable,
+ options=options,
+ default=default,
+ )
+ self._registered_selectors[selector_id] = selector
+ self._selector_ids_by_widget_id[id(widget)] = selector_id
+ widget.param.watch(
+ lambda event, sid=selector_id: self._handle_selector_change(sid),
+ "value",
+ )
+ return widget
+
+ def select(
+ self,
+ selector_id: str,
+ label: str,
+ *,
+ options: SelectorOptions | OptionProvider,
+ value: object = UNSET,
+ default: DefaultPolicy = "first",
+ exportable: bool = True,
+ **widget_options,
+ ) -> pn.widgets.Select:
+ """Create and register the common single-value dropdown selector."""
+ provider = options if callable(options) else None
+ initial_options = provider() if provider is not None else options
+ if value is UNSET:
+ value = default_value(initial_options, default)
+ widget = pn.widgets.Select(
+ name=label,
+ options=initial_options,
+ value=value,
+ **widget_options,
+ )
+ return self.selector(
+ selector_id,
+ widget=widget,
+ label=label,
+ exportable=exportable,
+ options=provider,
+ default=default,
+ )
+
+ def section(
+ self,
+ section_id: str,
+ *,
+ selectors: tuple[str, ...] = (),
+ export: bool = True,
+ export_data_mode: "PreparedDataMode" = "none",
+ render: Callable[[], SectionContent],
+ ) -> pn.Column:
+ """Register one stable page section."""
+ if section_id in self._registered_sections:
+ raise ValueError(
+ f"Dashboard page {self.name!r} declares duplicate section id {section_id!r}."
+ )
+ unknown_selectors = [
+ selector_id
+ for selector_id in selectors
+ if selector_id not in self._registered_selectors
+ ]
+ if unknown_selectors:
+ raise ValueError(
+ f"Dashboard page {self.name!r} section {section_id!r} references unknown selectors: "
+ + ", ".join(repr(selector_id) for selector_id in unknown_selectors)
+ )
+ container = self.new_section()
+ self._registered_sections[section_id] = RegisteredPageSection(
+ section_id=section_id,
+ container=container,
+ selector_ids=tuple(selectors),
+ export=export,
+ export_data_mode=export_data_mode,
+ render=render,
+ )
+ return container
+
+ def section_view(self, section_id: str) -> pn.Column:
+ section = self._registered_sections.get(section_id)
+ if section is None:
+ raise KeyError(f"Unknown section id {section_id!r} on page {self.name!r}.")
+ return section.container
+
+ def feature(self, feature_id: str):
+ """Create a composable page-local feature with namespaced components."""
+ from dashboard.page_features import PageFeature
+
+ if any(feature.feature_id == feature_id for feature in self._features):
+ raise ValueError(
+ f"Dashboard page {self.name!r} declares duplicate feature id {feature_id!r}."
+ )
+ feature = PageFeature(self, feature_id)
+ self._features.append(feature)
+ return feature
+
+ @property
+ def features(self) -> tuple:
+ return tuple(self._features)
+
+ @property
+ def registered_selectors(self) -> tuple[RegisteredPageSelector, ...]:
+ return tuple(self._registered_selectors.values())
+
+ @property
+ def registered_sections(self) -> tuple[RegisteredPageSection, ...]:
+ return tuple(self._registered_sections.values())
+
+ def _dirty_sections(self) -> tuple[RegisteredPageSection, ...]:
+ return tuple(
+ section for section in self._registered_sections.values() if section.dirty
+ )
+
+ def _handle_selector_change(self, selector_id: str) -> None:
+ if self._is_refreshing:
+ self._queued_selector_ids.add(selector_id)
+ return
+ self._mark_sections_for_selectors({selector_id})
+ self.refresh(force=False)
+
+ def _mark_sections_for_selectors(self, selector_ids: set[str]) -> None:
+ for section in self._registered_sections.values():
+ if selector_ids.intersection(section.selector_ids):
+ section.dirty = True
+
+ def _refresh_registered_sections(self) -> None:
+ self._is_refreshing = True
+ try:
+ rerun_requested = False
+ for _ in range(2):
+ self._queued_selector_ids.clear()
+ self._sync_declared_selectors()
+ self.sync_controls()
+ dirty_sections = list(self._dirty_sections())
+ if not dirty_sections and not self._queued_selector_ids:
+ break
+ for section in dirty_sections:
+ self._render_section(section)
+ section.dirty = False
+ if not self._queued_selector_ids:
+ break
+ self._mark_sections_for_selectors(set(self._queued_selector_ids))
+ rerun_requested = True
+ if rerun_requested:
+ self._queued_selector_ids.clear()
+ finally:
+ self._is_refreshing = False
+
+ def _render_section(self, section: RegisteredPageSection) -> None:
+ previous_section_id = self._active_section_id
+ self._active_section_id = section.section_id
+ try:
+ rendered = section.render()
+ finally:
+ self._active_section_id = previous_section_id
+ if isinstance(rendered, pn.viewable.Viewable):
+ objects = [rendered]
+ else:
+ objects = list(rendered)
+ section.container.objects = objects
+
+ def _sync_declared_selectors(self) -> None:
+ """Refresh provider-backed options and repair stale selector values."""
+ for selector in self._registered_selectors.values():
+ if selector.options is None:
+ continue
+ options = selector.options()
+ values = option_values(options)
+ selector.widget.options = options
+ if selector.widget.value not in values:
+ selector.widget.value = default_value(options, selector.default)
+
+ def _validate_registered_components(self) -> None:
+ if self.view is None:
+ raise ValueError(
+ f"Dashboard page {self.name!r} build_page() returned no view."
+ )
+ if not isinstance(self.view, pn.viewable.Viewable):
+ raise TypeError(
+ f"Dashboard page {self.name!r} build_page() must return a Panel viewable."
+ )
+
+ def new_section(self, *objects, **kwargs) -> pn.Column:
+ """Create a stable page section container that can be refreshed in place."""
+ kwargs.setdefault("sizing_mode", "stretch_width")
+ return pn.Column(*objects, **kwargs)
+
+ @property
+ def notes_enabled(self) -> bool:
+ """Return whether explanatory calculation notes should be displayed."""
+ return bool(getattr(getattr(self, "config", None), "include_notes", True))
+
+ def section_note(self, note_id: str, section: pn.Column) -> pn.pane.HTML:
+ """Build a static note associated with one registered page section."""
+ registered = next(
+ (
+ item
+ for item in self._registered_sections.values()
+ if item.container is section
+ ),
+ None,
+ )
+ if registered is None:
+ raise ValueError(
+ f"Dashboard page {self.name!r} cannot annotate an unregistered section."
+ )
+ if not self.notes_enabled:
+ return pn.pane.HTML("", sizing_mode="stretch_width", margin=0)
+ from dashboard.calculation_notes import calculation_note
+
+ note = calculation_note(note_id)
+ note._calculation_note_target_id = id(section)
+ note._calculation_note_section_id = registered.section_id
+ section._calculation_note_id = note_id
+ return note
+
+ def noted_section(self, note_id: str, section: pn.Column) -> pn.Column:
+ """Pair a static calculation note with a selector-driven section."""
+ if not self.notes_enabled:
+ return section
+ note = self.section_note(note_id, section)
+ wrapper = self.new_section(
+ section,
+ note,
+ css_classes=["calculation-note-section"],
+ )
+ wrapper._calculation_note_id = note_id
+ wrapper._calculation_note_section_id = note._calculation_note_section_id
+ return wrapper
+
+ def noted_view(
+ self,
+ note_id: str,
+ view: pn.viewable.Viewable,
+ ) -> pn.viewable.Viewable:
+ """Place one calculation note immediately below one plot or table."""
+ if not self.notes_enabled:
+ return view
+ from dashboard.calculation_notes import calculation_note
+
+ note = calculation_note(note_id)
+ note._calculation_note_target_id = id(view)
+ wrapper = self.new_section(
+ view,
+ note,
+ css_classes=["calculation-note-view"],
+ )
+ wrapper._calculation_note_id = note_id
+ return wrapper
+
+ @property
+ def as_percent(self) -> bool:
+ """Return whether the current display mode should show percentages."""
+ return self.state.value_mode == "Percent"
+
+ @property
+ def plot(self) -> Plotter:
+ """Return a plotter bound to the current immutable render state."""
+ return Plotter(RenderContext.from_dashboard(self.config, self.state))
+
+ @property
+ def weighting_key(self) -> str:
+ """Return the current weighting key used for summary-table lookup."""
+ return self.state.weighting_key()
+
+ @classmethod
+ def page_id(cls) -> str | None:
+ """Return the registered page id when one has been assigned."""
+ return cls.definition.page_id if cls.definition is not None else None
+
+ @classmethod
+ def page_title(cls) -> str | None:
+ """Return the registered page title when one has been assigned."""
+ return cls.definition.title if cls.definition is not None else None
+
+ @property
+ def required_summary_ids(self) -> tuple[str, ...]:
+ if self.definition is None:
+ return ()
+ return self.definition.required_summary_ids
+
+ @property
+ def optional_summary_ids(self) -> tuple[str, ...]:
+ if self.definition is None:
+ return ()
+ return self.definition.optional_summary_ids
+
+ @property
+ def prepared_data_mode(self) -> str:
+ if self.definition is None:
+ return "none"
+ return self.definition.prepared_data_mode
+
+ def query(self, factory: Callable):
+ """Memoize one section query from declared state and captured arguments.
+
+ Authors provide only the transformation. The framework derives identity
+ from the page, global state, active section, that section's selectors,
+ the callable location, and values captured by the callable.
+ """
+ page_cache_id = self.page_id() or self.name
+ section_id = self._active_section_id or "*"
+ section = self._registered_sections.get(section_id)
+ selector_ids = section.selector_ids if section is not None else ()
+ selector_values = tuple(
+ (
+ selector_id,
+ _query_capture(self._registered_selectors[selector_id].widget.value),
+ )
+ for selector_id in selector_ids
+ )
+ code = getattr(factory, "__code__", None)
+ callable_id = (
+ getattr(factory, "__module__", type(factory).__module__),
+ getattr(factory, "__qualname__", type(factory).__qualname__),
+ getattr(code, "co_filename", None),
+ getattr(code, "co_firstlineno", None),
+ )
+ closure = tuple(
+ _query_capture(cell.cell_contents)
+ for cell in (getattr(factory, "__closure__", None) or ())
+ )
+ defaults = _query_capture(getattr(factory, "__defaults__", None))
+ keyword_defaults = _query_capture(getattr(factory, "__kwdefaults__", None))
+ return self.state.get_or_create_cached(
+ "page_query",
+ page_cache_id,
+ self.state.global_state_key(),
+ section_id,
+ selector_values,
+ callable_id,
+ closure,
+ defaults,
+ keyword_defaults,
+ factory=factory,
+ )
+
+ def clear_query_cache(self) -> None:
+ """Clear memoized queries for this page."""
+ page_cache_id = self.page_id() or self.name
+ cache = self.state.get_cache("page_query")
+ stale_keys = [key for key in cache if key[0] == page_cache_id]
+ for key in stale_keys:
+ cache.pop(key, None)
+
+ def _refresh(self) -> None:
+ raise NotImplementedError
diff --git a/dashboard/page_navigation.py b/dashboard/page_navigation.py
new file mode 100644
index 0000000..7a542ca
--- /dev/null
+++ b/dashboard/page_navigation.py
@@ -0,0 +1,59 @@
+"""Grouped dashboard navigation behavior."""
+
+from __future__ import annotations
+
+import panel as pn
+
+from dashboard.page_lifecycle import DashboardPage
+
+
+class GroupedDashboardPage:
+ """Top-level navigation item that renders child dashboard pages as tabs."""
+
+ def __init__(
+ self,
+ group_id: str,
+ title: str,
+ pages: list[DashboardPage],
+ default_child_page_id: str | None = None,
+ ) -> None:
+ if not pages:
+ raise ValueError("GroupedDashboardPage requires at least one child page.")
+ self._group_id = group_id
+ self.name = title
+ self.pages = pages
+ self.view = pn.Tabs(*[(page.name, page.view) for page in pages], dynamic=False)
+ self._active_child = self._default_child_index(default_child_page_id)
+ self.view.active = self._active_child
+ self.view.param.watch(self._on_child_tab_change, "active")
+
+ def _default_child_index(self, default_child_page_id: str | None) -> int:
+ if default_child_page_id is not None:
+ for index, page in enumerate(self.pages):
+ if page.page_id() == default_child_page_id:
+ return index
+ return 0
+
+ def _on_child_tab_change(self, event) -> None:
+ self._active_child = int(event.new)
+ self.refresh_if_needed()
+
+ def page_id(self) -> str:
+ return self._group_id
+
+ @property
+ def active_child(self) -> DashboardPage:
+ return self.pages[self._active_child]
+
+ def refresh_if_needed(self) -> None:
+ self.active_child.refresh_if_needed()
+
+ def refresh(self, force: bool = False) -> None:
+ self.active_child.refresh(force=force)
+
+ def mark_stale(self) -> None:
+ for page in self.pages:
+ page.mark_stale()
+
+
+__all__ = ["GroupedDashboardPage"]
diff --git a/dashboard/page_registry.py b/dashboard/page_registry.py
index 7b7199c..b848d58 100644
--- a/dashboard/page_registry.py
+++ b/dashboard/page_registry.py
@@ -7,24 +7,21 @@
import importlib
import pkgutil
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import dashboard.pages as dashboard_pages_package
from dashboard import DashboardState
from dashboard.data_access import DashboardPreparedRunProvider
from dashboard.page_base import DashboardPage
from dashboard.page_definitions import (
DashboardDataRequirements,
- PageExportPartDefinition,
- PageExportRegionDefinition,
DashboardGroupDefinition,
- DashboardPageConfigEntry,
DashboardPageDefinition,
- PageSelectorDefinition,
PreparedDataMode,
)
from processor.models import PREPARED_TABLE_NAMES, PreparedTableName, RunData
-from processor.summarize.cache import SUMMARY_SPEC_BY_ID
+from processor.summarize.catalog import SUMMARY_BY_ID
from runtime.config import Config
+from runtime.config.models import DashboardPageConfigEntry
LOGGER = get_logger("dashboard.page_registry")
VALID_PREPARED_DATA_MODES: tuple[PreparedDataMode, ...] = (
@@ -178,21 +175,33 @@ def all_page_definitions() -> tuple[DashboardPageDefinition, ...]:
def _page_definition_from_module(module: object) -> DashboardPageDefinition:
- page_definition = getattr(module, "PAGE", None)
- if page_definition is None:
+ page_classes = [
+ value
+ for value in vars(module).values()
+ if isinstance(value, type)
+ and issubclass(value, DashboardPage)
+ and value is not DashboardPage
+ and value.__module__ == module.__name__
+ ]
+ if not page_classes:
+ raise ValueError(
+ f"{module.__name__} must declare one @dashboard_page class."
+ )
+ if len(page_classes) > 1:
raise ValueError(
- f"{module.__name__} must declare a module-level PAGE definition."
+ f"{module.__name__} declares multiple DashboardPage classes; "
+ "each page module must contain exactly one."
)
+ page_definition = page_classes[0].definition
if not isinstance(page_definition, DashboardPageDefinition):
raise TypeError(
- f"{module.__name__}.PAGE must be a DashboardPageDefinition instance."
+ f"{module.__name__}.{page_classes[0].__name__} must use @dashboard_page."
)
page_cls = page_definition.page_cls
if page_cls is None:
raise ValueError(
f"Dashboard page {page_definition.page_id!r} does not declare a page class."
)
- page_cls.definition = page_definition
return page_definition
@@ -214,10 +223,21 @@ def _validate_page_definition(page_definition: DashboardPageDefinition) -> None:
)
required_summary_ids = page_definition.required_summary_ids
+ optional_summary_ids = page_definition.optional_summary_ids
if len(set(required_summary_ids)) != len(required_summary_ids):
raise ValueError(
f"Dashboard page {page_definition.page_id!r} declares duplicate required_summary_ids."
)
+ if len(set(optional_summary_ids)) != len(optional_summary_ids):
+ raise ValueError(
+ f"Dashboard page {page_definition.page_id!r} declares duplicate optional_summary_ids."
+ )
+ summary_id_overlap = sorted(set(required_summary_ids).intersection(optional_summary_ids))
+ if summary_id_overlap:
+ raise ValueError(
+ f"Dashboard page {page_definition.page_id!r} declares summary ids as both required and optional: "
+ + ", ".join(repr(summary_id) for summary_id in summary_id_overlap)
+ )
required_prepared_tables = page_definition.required_prepared_tables
if len(set(required_prepared_tables)) != len(required_prepared_tables):
raise ValueError(
@@ -240,8 +260,8 @@ def _validate_page_definition(page_definition: DashboardPageDefinition) -> None:
)
unknown_summary_ids = [
summary_id
- for summary_id in required_summary_ids
- if summary_id not in SUMMARY_SPEC_BY_ID
+ for summary_id in (*required_summary_ids, *optional_summary_ids)
+ if summary_id not in SUMMARY_BY_ID
]
if unknown_summary_ids:
raise ValueError(
@@ -249,57 +269,6 @@ def _validate_page_definition(page_definition: DashboardPageDefinition) -> None:
+ ", ".join(repr(summary_id) for summary_id in unknown_summary_ids)
)
- selector_ids = [selector.selector_id for selector in page_definition.selectors]
- if len(selector_ids) != len(set(selector_ids)):
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} declares duplicate selector ids."
- )
-
- region_ids = [region.region_id for region in page_definition.export_regions]
- if len(region_ids) != len(set(region_ids)):
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} declares duplicate export region ids."
- )
- part_ids = [part.part_id for part in page_definition.export_parts]
- if len(part_ids) != len(set(part_ids)):
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} declares duplicate export part ids."
- )
- if page_definition.export_regions and page_definition.export_parts:
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} must not declare both export_regions and export_parts."
- )
-
- selector_id_set = set(selector_ids)
- exportable_selector_ids = {
- selector.selector_id
- for selector in page_definition.selectors
- if selector.exportable
- }
- referenced_selector_ids: set[str] = set()
- export_parts = effective_export_parts(page_definition)
- for part in export_parts:
- unknown_part_selectors = [
- selector_id
- for selector_id in part.selector_ids
- if selector_id not in selector_id_set
- ]
- if unknown_part_selectors:
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} export part {part.part_id!r} "
- "references unknown selector ids: "
- + ", ".join(repr(selector_id) for selector_id in unknown_part_selectors)
- )
- referenced_selector_ids.update(part.selector_ids)
- missing_region_selectors = sorted(exportable_selector_ids - referenced_selector_ids)
- if exportable_selector_ids and missing_region_selectors:
- raise ValueError(
- f"Dashboard page {page_definition.page_id!r} does not assign export regions to "
- "selector ids: "
- + ", ".join(repr(selector_id) for selector_id in missing_region_selectors)
- )
-
-
def _validate_selected_page_definitions(
page_definitions: (
list[DashboardPageDefinition] | tuple[DashboardPageDefinition, ...]
@@ -334,61 +303,6 @@ def page_definitions_for_group(group_id: str) -> tuple[DashboardPageDefinition,
)
-def selector_definition_by_id(
- page_id: str, selector_id: str
-) -> PageSelectorDefinition | None:
- """Look up one registered selector definition by page id and selector id."""
- page_definition = page_definition_by_id(page_id)
- if page_definition is None:
- return None
- for selector in page_definition.selectors:
- if selector.selector_id == selector_id:
- return selector
- return None
-
-
-def export_part_definition_by_id(
- page_id: str,
- part_id: str,
-) -> PageExportPartDefinition | None:
- """Look up one registered export part definition by page id and part id."""
- page_definition = page_definition_by_id(page_id)
- if page_definition is None:
- return None
- for part in effective_export_parts(page_definition):
- if part.part_id == part_id:
- return part
- return None
-
-
-def effective_export_parts(
- page_definition: DashboardPageDefinition,
-) -> tuple[PageExportPartDefinition, ...]:
- """Return the effective export parts for one page definition."""
- if page_definition.export_parts:
- return page_definition.export_parts
- return tuple(
- PageExportPartDefinition(
- part_id=region.region_id,
- view_attr=region.view_attr,
- selector_ids=region.selector_ids,
- )
- for region in page_definition.export_regions
- )
-
-
-def exportable_page_selectors() -> (
- list[tuple[DashboardPageDefinition, PageSelectorDefinition]]
-):
- """Return all exportable page selectors in stable page/selector order."""
- return [
- (page_definition, selector)
- for page_definition in all_page_definitions()
- for selector in page_definition.selectors
- if selector.exportable
- ]
-
-
def default_page_definitions() -> tuple[DashboardPageDefinition, ...]:
"""Return the default dashboard leaf page set used when config omits `dashboard_pages`."""
default_pages: list[DashboardPageDefinition] = []
@@ -563,7 +477,7 @@ def resolve_live_page_definitions(config: Config) -> list[DashboardPageDefinitio
return page_definitions
return _resolve_page_definitions_from_entries(
config.dashboard_pages,
- error_field_name="visualizer.dashboard_pages entries",
+ error_field_name="dashboard.live.pages entries",
)
@@ -572,11 +486,6 @@ def resolve_live_navigation_entries(config: Config) -> list[DashboardNavigationE
return list(navigation_entries_for_pages(resolve_live_page_definitions(config)))
-def resolve_page_definitions(config: Config) -> list[DashboardPageDefinition]:
- """Compatibility alias for the live dashboard page resolver."""
- return resolve_live_page_definitions(config)
-
-
def resolve_export_page_definitions(config: Config) -> list[DashboardPageDefinition]:
"""Resolve the export HTML leaf pages in display order."""
resolved_pages = list(resolve_live_page_definitions(config))
@@ -631,8 +540,10 @@ def data_requirements_for_pages(
) -> DashboardDataRequirements:
"""Return the summary/prepared-table requirements for a page definition set."""
required_summary_ids: list[str] = []
+ optional_summary_ids: list[str] = []
required_prepared_tables: list[PreparedTableName] = []
seen_summary_ids: set[str] = set()
+ seen_optional_summary_ids: set[str] = set()
seen_prepared_tables: set[PreparedTableName] = set()
for page_definition in page_definitions:
@@ -640,6 +551,11 @@ def data_requirements_for_pages(
if summary_id not in seen_summary_ids:
required_summary_ids.append(summary_id)
seen_summary_ids.add(summary_id)
+ for summary_id in page_definition.optional_summary_ids:
+ if summary_id in seen_summary_ids or summary_id in seen_optional_summary_ids:
+ continue
+ optional_summary_ids.append(summary_id)
+ seen_optional_summary_ids.add(summary_id)
for table_name in page_definition.required_prepared_tables:
if table_name not in seen_prepared_tables:
required_prepared_tables.append(table_name)
@@ -648,6 +564,7 @@ def data_requirements_for_pages(
return DashboardDataRequirements(
prepared_data_mode=enabled_prepared_data_mode_for_pages(page_definitions),
required_summary_ids=tuple(required_summary_ids),
+ optional_summary_ids=tuple(optional_summary_ids),
required_prepared_tables=tuple(required_prepared_tables),
)
@@ -669,6 +586,7 @@ def export_data_requirements(config: Config) -> DashboardDataRequirements:
return DashboardDataRequirements(
prepared_data_mode="none",
required_summary_ids=requirements.required_summary_ids,
+ optional_summary_ids=requirements.optional_summary_ids,
required_prepared_tables=(),
)
@@ -678,12 +596,20 @@ def build_prepared_run_provider_for_page_definitions(
page_definitions: (
list[DashboardPageDefinition] | tuple[DashboardPageDefinition, ...]
),
+ *,
+ config: Config | None = None,
) -> DashboardPreparedRunProvider:
prepared_mode = data_requirements_for_pages(page_definitions).prepared_data_mode
if prepared_mode == "none":
return DashboardPreparedRunProvider.not_requested()
if runs:
- return DashboardPreparedRunProvider.loaded(runs)
+ provider = DashboardPreparedRunProvider.loaded(runs)
+ if config is not None:
+ provider.configure_weighting_modes(
+ config.weighting_mode_definitions,
+ config=config,
+ )
+ return provider
return DashboardPreparedRunProvider.unavailable()
@@ -694,6 +620,7 @@ def build_dashboard_prepared_run_provider(
return build_prepared_run_provider_for_page_definitions(
runs,
resolve_live_page_definitions(config),
+ config=config,
)
diff --git a/dashboard/pages.zip b/dashboard/pages.zip
deleted file mode 100644
index 3e63b57..0000000
Binary files a/dashboard/pages.zip and /dev/null differ
diff --git a/dashboard/pages/_joint_travel_data.py b/dashboard/pages/_joint_travel_data.py
new file mode 100644
index 0000000..84f974f
--- /dev/null
+++ b/dashboard/pages/_joint_travel_data.py
@@ -0,0 +1,183 @@
+"""Chart-ready queries for the joint-travel dashboard page."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ add_percent_of_total,
+ cap_numeric_category_data,
+ capped_numeric_category_expr,
+ complete_category_counts,
+ nonempty,
+ numeric_like_sort_expr,
+)
+
+PARTY_SIZE_ALL_LABEL = "All Party Sizes"
+HOUSEHOLD_SIZE_ALL_LABEL = "All"
+JOINT_SIZE_VALUES = ["2", "3", "4", "5+"]
+
+
+def party_size_options(data_list) -> list[str]:
+ return (
+ [PARTY_SIZE_ALL_LABEL, *JOINT_SIZE_VALUES]
+ if nonempty(data_list)
+ else [PARTY_SIZE_ALL_LABEL]
+ )
+
+
+def household_size_options(data_list) -> list[str]:
+ return (
+ [HOUSEHOLD_SIZE_ALL_LABEL, *JOINT_SIZE_VALUES]
+ if nonempty(data_list)
+ else [HOUSEHOLD_SIZE_ALL_LABEL]
+ )
+
+
+def joint_household_size_values(*data_lists) -> list[str]:
+ return (
+ JOINT_SIZE_VALUES.copy() if any(nonempty(data) for data in data_lists) else []
+ )
+
+
+def complete_joint_household_size_data(
+ data_list,
+ *,
+ value_col: str,
+ household_size_values: list[str],
+):
+ normalized = [
+ (
+ label,
+ cap_numeric_category_data(
+ [
+ (
+ label,
+ frame.filter(
+ pl.col("household_size").cast(pl.Int64, strict=False) >= 2
+ ),
+ )
+ ],
+ category="household_size",
+ cap_value=5,
+ value_cols=(value_col,),
+ )[0][1].select("household_size", value_col),
+ )
+ for label, frame in nonempty(data_list)
+ ]
+ return complete_category_counts(
+ normalized,
+ category="household_size",
+ category_values=household_size_values,
+ value_cols=(value_col,),
+ )
+
+
+def joint_party_size_data(data_list):
+ """Return the standard capped party-size distribution."""
+ return cap_numeric_category_data(
+ data_list,
+ category="party_size",
+ cap_value=5,
+ value_cols=("joint_tour_count",),
+ )
+
+
+def _ordered_composition(frame: pl.DataFrame) -> pl.DataFrame:
+ if frame.is_empty() or "tour_composition" not in frame.columns:
+ return frame
+ return (
+ frame.with_columns(
+ pl.col("tour_composition")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .alias("tour_composition")
+ )
+ .with_columns(
+ pl.when(pl.col("tour_composition") == "adults")
+ .then(0)
+ .when(pl.col("tour_composition") == "mixed")
+ .then(1)
+ .when(pl.col("tour_composition") == "children")
+ .then(2)
+ .otherwise(99)
+ .alias("_ord")
+ )
+ .sort("_ord")
+ .drop("_ord")
+ )
+
+
+def composition_by_party_size_data(data_list, party_size: str):
+ view = RunTables.from_runs(data_list).with_columns(
+ capped_numeric_category_expr("party_size", 5)
+ )
+ if party_size != PARTY_SIZE_ALL_LABEL:
+ view = view.where(party_size=party_size)
+ return (
+ view.group(
+ "tour_composition",
+ pl.col("joint_tour_count").sum().alias("joint_tour_count"),
+ )
+ .with_columns(pl.col("tour_composition").cast(pl.Utf8))
+ .map(_ordered_composition)
+ )
+
+
+def household_participation_data(data_list, household_size: str):
+ view = RunTables.from_runs(data_list).with_columns(
+ capped_numeric_category_expr("household_size", 5),
+ pl.col("jtf").cast(pl.Utf8),
+ )
+ if household_size != HOUSEHOLD_SIZE_ALL_LABEL:
+ view = view.where(household_size=household_size)
+ return view.group(
+ "jtf",
+ pl.col("household_percent").mean().alias("household_percent"),
+ ).sort("jtf")
+
+
+def person_participation_data(data_list, *, as_percent: bool):
+ view = (
+ RunTables.from_runs(data_list)
+ .with_columns(capped_numeric_category_expr("household_size", 5))
+ .group(
+ "household_size",
+ pl.col("joint_tour_person_count").sum(),
+ pl.col("total_person_count").sum(),
+ )
+ .sort(numeric_like_sort_expr("household_size"))
+ )
+ if as_percent:
+ view = view.with_columns(
+ pl.when(pl.col("total_person_count") > 0)
+ .then(
+ pl.col("joint_tour_person_count") / pl.col("total_person_count") * 100.0
+ )
+ .otherwise(0.0)
+ .alias("person_value")
+ )
+ else:
+ view = view.with_columns(
+ pl.col("joint_tour_person_count").alias("person_value")
+ )
+ return view
+
+
+def joint_tour_frequency_data(data_list, *, hide_no_joint_tours: bool):
+ def transform(frame: pl.DataFrame) -> pl.DataFrame:
+ result = frame.with_columns(pl.col("jtf_label").cast(pl.Utf8))
+ result = add_percent_of_total(
+ [("run", result)],
+ value_col="household_count",
+ percent_col="household_count_percent",
+ )[0][1]
+ if hide_no_joint_tours:
+ result = result.filter(
+ pl.col("jtf_label").str.strip_chars().str.to_lowercase()
+ != "no joint tours"
+ )
+ return result
+
+ return RunTables.from_runs(data_list).map(transform)
diff --git a/dashboard/pages/daily_travel/_escorted_tours/__init__.py b/dashboard/pages/daily_travel/_escorted_tours/__init__.py
new file mode 100644
index 0000000..0aaba3a
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours/__init__.py
@@ -0,0 +1,5 @@
+"""Private implementation package for Escorted Tours."""
+
+from .contracts import *
+
+__all__ = [name for name in globals() if name.isupper() or not name.startswith("__")]
diff --git a/dashboard/pages/daily_travel/_escorted_tours/composition.py b/dashboard/pages/daily_travel/_escorted_tours/composition.py
new file mode 100644
index 0000000..3e37576
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours/composition.py
@@ -0,0 +1,73 @@
+"""Page composition for Escorted Tours."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.category_helpers import (
+ complete_category_counts,
+ label_category_data,
+ ordered_category_values,
+)
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.rendering import selector_row
+from dashboard.pages.daily_travel._escorted_tours_data import *
+
+from .contracts import *
+
+
+class EscortedToursCompositionMixin:
+ def build_page(self) -> pn.viewable.Viewable:
+ """Build the page shell with one direction selector and two stable sections."""
+ school_escort = self.feature("school_escort")
+ adult_escort = self.feature("adult_escort")
+ direction = self.feature("direction")
+ distance = self.feature("distance")
+ self.direction_sel = direction.select(
+ "value",
+ "Direction",
+ options=self._direction_options,
+ default=lambda options: default_direction_option(list(options)),
+ )
+ self.escort_distance_range = DistanceRangeControls.create(
+ self,
+ "escort_distance",
+ max_options=capped_distance_max_options(),
+ reset_label="Reset distance range",
+ )
+ self._school_escort_body = school_escort.section(
+ "body",
+ render=self.render_school_escort_feature,
+ )
+ self._adult_escort_body = adult_escort.section(
+ "body",
+ render=self.render_adult_escort_feature,
+ )
+ self._direction_body = direction.section(
+ "body",
+ selectors=("value",),
+ render=self.render_direction_feature,
+ )
+ self._distance_body = distance.section(
+ "body",
+ selectors=(
+ "direction.value",
+ *self.escort_distance_range.selector_ids,
+ ),
+ render=self.render_distance_feature,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## Escorted Tours"),
+ self._school_escort_body,
+ self._adult_escort_body,
+ self._direction_body,
+ self._distance_body,
+ sizing_mode="stretch_width",
+ )
diff --git a/dashboard/pages/daily_travel/_escorted_tours/contracts.py b/dashboard/pages/daily_travel/_escorted_tours/contracts.py
new file mode 100644
index 0000000..5754b9a
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours/contracts.py
@@ -0,0 +1,54 @@
+"""Stable contracts and copy for the Escorted Tours page."""
+
+STUDENT_ESCORT_TYPE_ORDER = [
+ "not_escorted",
+ "pure_escort",
+ "ride_share",
+]
+CORE_SUMMARY_IDS = (
+ "escorted_tour_totals",
+ "school_escorted_tours_by_escort_type_and_direction",
+ "adult_escort_event_stop_distribution",
+ "adult_escorted_tours_by_person_type_and_direction",
+ "adult_escorted_tour_distance_distribution_by_direction",
+ "adult_escorted_trip_distance_distribution_by_direction",
+)
+OPTIONAL_SUMMARY_IDS = (
+ "student_school_escort_status_by_direction",
+ "student_households_by_student_count",
+ "households_with_school_escorting_by_student_count_and_direction",
+ "schoolkids_per_escorted_tour_by_student_count_and_direction",
+)
+PAGE_SUMMARY_IDS = (*CORE_SUMMARY_IDS, *OPTIONAL_SUMMARY_IDS)
+STOP_SEGMENT_LABELS = {
+ "outbound_before_dropoff": "Adult Escort Stops Before Dropoff - Outbound",
+ "outbound_after_dropoff": "Adult Escort Stops After Dropoff - Outbound",
+ "inbound_before_pickup": "Adult Escort Stops Before Pickup - Inbound",
+ "inbound_after_pickup": "Adult Escort Stops After Pickup - Inbound",
+}
+STUDENT_ESCORT_DESCRIPTION = (
+ "Student school tours by escort type. `Both Directions` means the same child "
+ "school tour is escorted in both outbound and inbound directions."
+)
+HOUSEHOLD_ESCORT_DESCRIPTION = (
+ "Households with school escorting by number of students per household. "
+ "A household counts if it has at least one escorted school tour in the "
+ "selected direction."
+)
+SCHOOLKIDS_DESCRIPTION = (
+ "Average number of escortees on adult chauffer tours, grouped by number of students "
+ "in the household. `Both Directions` only counts chauffer tours where "
+ "escorting occurred in both directions."
+)
+STOP_DISTRIBUTION_DESCRIPTION = (
+ "Number of stops before and after the dropoff/pickup on each adult chauffeur trip. "
+)
+PERSON_TYPE_DESCRIPTION = (
+ "Adult chauffeur tours by person type. `Both Directions` means the "
+ "chauffer escorted in both outbound and inbound directions."
+)
+DISTANCE_DESCRIPTION = (
+ "Distance distributions for adult chauffeur tours and trips. "
+ "`Both Directions` means the chauffer escorted in both outbound and inbound "
+ "directions."
+)
diff --git a/dashboard/pages/daily_travel/_escorted_tours/domains.py b/dashboard/pages/daily_travel/_escorted_tours/domains.py
new file mode 100644
index 0000000..5d784a8
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours/domains.py
@@ -0,0 +1,53 @@
+"""Selector and summary domains for Escorted Tours."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.category_helpers import (
+ complete_category_counts,
+ label_category_data,
+ ordered_category_values,
+)
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.rendering import selector_row
+from dashboard.pages.daily_travel._escorted_tours_data import *
+
+from .contracts import *
+
+
+class EscortedToursDomainMixin:
+ def _direction_options(self) -> list[str]:
+ """Discover available direction values from the core school escort summary."""
+ data = self.data.summary(
+ "school_escorted_tours_by_escort_type_and_direction",
+ "weighted",
+ )
+ if data is None:
+ return ["Both Directions"]
+ return direction_options(data)
+
+ def _load_page_summaries(self):
+ """Load core summaries plus optional add-on summaries used by static sections."""
+ summaries = self.data.summaries(*CORE_SUMMARY_IDS)
+ if not all(summaries.values()):
+ return None
+ optional_summaries = self.data.summaries(*OPTIONAL_SUMMARY_IDS, required=False)
+ return {**summaries, **optional_summaries}
+
+ def _feature_summaries(self):
+ if not self.state.run_labels:
+ return None, [self.no_runs_message()]
+ summaries = self._load_page_summaries()
+ if summaries is None:
+ return None, [
+ self.summary_only_unavailable_card(summary_ids=CORE_SUMMARY_IDS)
+ ]
+ return summaries, None
diff --git a/dashboard/pages/daily_travel/_escorted_tours/features.py b/dashboard/pages/daily_travel/_escorted_tours/features.py
new file mode 100644
index 0000000..dec42e4
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours/features.py
@@ -0,0 +1,524 @@
+"""Feature rendering for Escorted Tours."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.category_helpers import (
+ complete_category_counts,
+ label_category_data,
+ ordered_category_values,
+)
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.rendering import selector_row
+from dashboard.pages.daily_travel._escorted_tours_data import *
+
+from .contracts import *
+
+
+class EscortedToursFeatureMixin:
+ def render_school_escort_feature(self):
+ """Render school status, household, and schoolkids views as one feature."""
+ summaries, unavailable = self._feature_summaries()
+ if unavailable is not None:
+ return unavailable
+ student_count_values = student_count_category_values(
+ summaries["student_households_by_student_count"] or []
+ )
+ body: list[pn.viewable.Viewable] = []
+ body.extend(
+ self.render_student_school_escort_section(
+ summaries["student_school_escort_status_by_direction"]
+ )
+ )
+ body.extend(
+ self.render_household_school_escort_section(
+ summaries["student_households_by_student_count"],
+ summaries[
+ "households_with_school_escorting_by_student_count_and_direction"
+ ],
+ student_count_values,
+ )
+ )
+ body.extend(
+ self.render_schoolkids_per_escorted_tour_section(
+ summaries[
+ "schoolkids_per_escorted_tour_by_student_count_and_direction"
+ ],
+ student_count_values,
+ )
+ )
+ return body
+
+ def render_adult_escort_feature(self):
+ """Render the adult chauffeur stop-distribution feature."""
+ summaries, unavailable = self._feature_summaries()
+ if unavailable is not None:
+ return unavailable
+ return self.render_chauffeur_stop_distribution_section(
+ summaries["adult_escort_event_stop_distribution"],
+ stop_count_category_values(
+ summaries["adult_escort_event_stop_distribution"]
+ ),
+ )
+
+ def render_direction_feature(self):
+ """Render the direction-dependent adult chauffeur profile."""
+ summaries, unavailable = self._feature_summaries()
+ if unavailable is not None:
+ return unavailable
+ direction_label = str(self.direction_sel.value)
+ raw_direction = adult_raw_direction(direction_label)
+ return [
+ pn.pane.Markdown("## Adult Chauffeur Tours and Trips"),
+ selector_row(self.direction_sel),
+ pn.pane.Markdown("### Chauffeur Person Type Distribution"),
+ pn.pane.Markdown(PERSON_TYPE_DESCRIPTION),
+ self.noted_view(
+ "escorted_tours.chauffeur_person_type",
+ self.render_person_type_chart(
+ summaries["adult_escorted_tours_by_person_type_and_direction"],
+ raw_direction,
+ direction_label,
+ ),
+ ),
+ ]
+
+ def render_distance_feature(self):
+ """Render adult chauffeur tour/trip distance controls and charts."""
+ summaries, unavailable = self._feature_summaries()
+ if unavailable is not None:
+ return unavailable
+ direction_label = str(self.direction_sel.value)
+ raw_direction = adult_raw_direction(direction_label)
+ tour_distance_data = self.escort_distance_data(
+ summaries["adult_escorted_tour_distance_distribution_by_direction"],
+ raw_direction,
+ y="tour_count",
+ )
+ trip_distance_data = self.escort_distance_data(
+ summaries["adult_escorted_trip_distance_distribution_by_direction"],
+ raw_direction,
+ y="trip_count",
+ )
+ observed_bounds = distance_axis_bounds(
+ [*tour_distance_data, *trip_distance_data]
+ )
+ bounds = (0.0, 40.0) if observed_bounds is not None else None
+ self.escort_distance_range.sync((raw_direction, self.weighting_key), bounds)
+ x_range = self.escort_distance_range.current_range()
+ if bounds is not None and x_range is None:
+ charts = self.data_not_available_card(
+ detail="Chauffeur distance controls require finite values with min less than max.",
+ title="Chauffeur Distance Data Not Available",
+ )
+ else:
+ charts = pn.Row(
+ self.noted_view(
+ "escorted_tours.tour_distance",
+ self.render_distance_chart(
+ tour_distance_data,
+ direction_label,
+ title_prefix="Chauffeur Tour Distance Distribution",
+ yaxis_title="Chauffeur Tours",
+ x_range=x_range,
+ ),
+ ),
+ self.noted_view(
+ "escorted_tours.trip_distance",
+ self.render_distance_chart(
+ trip_distance_data,
+ direction_label,
+ title_prefix="Chauffeur Trip Distance Distribution",
+ yaxis_title="Chauffeur Trips",
+ x_range=x_range,
+ ),
+ ),
+ sizing_mode="stretch_width",
+ )
+ return [
+ pn.pane.Markdown("### Chauffeur Tour and Trip Distance Distributions"),
+ pn.pane.Markdown(DISTANCE_DESCRIPTION),
+ self.escort_distance_range.row(),
+ charts,
+ ]
+
+ def render_student_school_escort_section(self, summary_data):
+ """Render outbound, inbound, and both-direction student escort status charts."""
+ charts = self.render_student_school_escort_charts(summary_data)
+ return self.render_static_triptych_section(
+ title="Student School Tour Escort Status",
+ description=STUDENT_ESCORT_DESCRIPTION,
+ charts=charts,
+ unavailable_detail=(
+ "This section only renders when the student school escort summary "
+ "is available."
+ ),
+ missing_items=["student_school_escort_status_by_direction"],
+ )
+
+ def render_student_school_escort_charts(self, summary_data):
+ """Build the three student escort status charts when the summary is available."""
+ if summary_data is None:
+ return None
+
+ escort_order = self.config.ordered_values("escort", STUDENT_ESCORT_TYPE_ORDER)
+ escort_labels = self.config.ordered_labels("escort", STUDENT_ESCORT_TYPE_ORDER)
+ charts: list[pn.viewable.Viewable] = []
+ for direction, label in (
+ ("outbound", "Outbound"),
+ ("inbound", "Inbound"),
+ ("both", "Both Directions"),
+ ):
+ chart_data = self.query(
+ lambda direction=direction: complete_category_counts(
+ student_school_escort_chart_data(summary_data, direction),
+ category="escort_type",
+ category_values=escort_order,
+ value_cols=("tour_count", "pct"),
+ )
+ )
+ charts.append(
+ self.noted_view(
+ "escorted_tours.student_status",
+ self.plot.bar(
+ label_category_data(
+ chart_data,
+ source_col="escort_type",
+ category_id="escort",
+ config=self.config,
+ target_col="escort_type_label",
+ ),
+ x="escort_type_label",
+ y="tour_count",
+ title=f"Student School Escort Status - {label}",
+ x_title="Escort Type",
+ y_title="Student School Tours",
+ category_order=escort_labels,
+ ),
+ )
+ )
+ return charts
+
+ def render_household_school_escort_section(
+ self,
+ denominator_summary,
+ numerator_summary,
+ student_count_values: list[str],
+ ):
+ """Render household escorting charts or an unavailable placeholder."""
+ charts = self.render_household_school_escort_charts(
+ denominator_summary,
+ numerator_summary,
+ student_count_values,
+ )
+ return self.render_static_triptych_section(
+ title="Households With School Escorting",
+ description=HOUSEHOLD_ESCORT_DESCRIPTION,
+ charts=charts,
+ unavailable_detail=(
+ "This section only renders when the household school escort summaries "
+ "are available."
+ ),
+ missing_items=[
+ "student_households_by_student_count",
+ "households_with_school_escorting_by_student_count_and_direction",
+ ],
+ )
+
+ def render_household_school_escort_charts(
+ self,
+ denominator_summary,
+ numerator_summary,
+ student_count_values: list[str],
+ ):
+ """Build household escort count/rate charts for each direction."""
+ if denominator_summary is None or numerator_summary is None:
+ return None
+
+ charts: list[pn.viewable.Viewable] = []
+ for direction, label in (
+ ("outbound", "Outbound"),
+ ("inbound", "Inbound"),
+ ("both", "Both Directions"),
+ ):
+ chart_data = self.query(
+ lambda direction=direction: complete_category_counts(
+ [
+ (
+ run_label,
+ df.with_columns(
+ pl.col("student_count")
+ .cast(pl.Utf8)
+ .alias("student_count")
+ ),
+ )
+ for run_label, df in household_school_escort_chart_data(
+ numerator_summary,
+ denominator_summary,
+ direction,
+ )
+ ],
+ category="student_count",
+ category_values=student_count_values,
+ value_cols=("household_count", "pct"),
+ )
+ )
+ charts.append(
+ self.noted_view(
+ "escorted_tours.households",
+ self.plot.bar(
+ chart_data,
+ x="student_count",
+ y="pct" if self.as_percent else "household_count",
+ title=f"Households With School Escorting - {label}",
+ x_title="Students in Household",
+ y_title=(
+ "Percent of Households with Students (%)"
+ if self.as_percent
+ else "Number of Households with Students"
+ ),
+ value_mode="count",
+ category_order=student_count_values,
+ ),
+ )
+ )
+ return charts
+
+ def render_schoolkids_per_escorted_tour_section(
+ self,
+ summary_data,
+ student_count_values: list[str],
+ ):
+ """Render schoolkids-per-tour charts or an unavailable placeholder."""
+ charts = self.render_schoolkids_per_escorted_tour_charts(
+ summary_data,
+ student_count_values,
+ )
+ return self.render_static_triptych_section(
+ title="Schoolkids Per Escorted Tour",
+ description=SCHOOLKIDS_DESCRIPTION,
+ charts=charts,
+ unavailable_detail=(
+ "This section only renders when the schoolkids-per-chauffer-tour "
+ "summary is available."
+ ),
+ missing_items=[
+ "schoolkids_per_escorted_tour_by_student_count_and_direction"
+ ],
+ )
+
+ def render_schoolkids_per_escorted_tour_charts(
+ self,
+ summary_data,
+ student_count_values: list[str],
+ ):
+ """Build average schoolkids-per-tour charts for each direction."""
+ if summary_data is None:
+ return None
+
+ charts: list[pn.viewable.Viewable] = []
+ for direction, label in (
+ ("outbound", "Outbound"),
+ ("inbound", "Inbound"),
+ ("both", "Both Directions"),
+ ):
+ chart_data = self.query(
+ lambda direction=direction: complete_category_counts(
+ [
+ (
+ run_label,
+ df.with_columns(
+ pl.col("student_count")
+ .cast(pl.Utf8)
+ .alias("student_count")
+ ),
+ )
+ for run_label, df in schoolkids_per_escorted_tour_chart_data(
+ summary_data,
+ direction,
+ )
+ ],
+ category="student_count",
+ category_values=student_count_values,
+ value_cols=("avg_schoolkids_per_tour", "tour_count"),
+ )
+ )
+ charts.append(
+ self.noted_view(
+ "escorted_tours.students_per_tour",
+ self.plot.bar(
+ chart_data,
+ x="student_count",
+ y="avg_schoolkids_per_tour",
+ title=f"Schoolkids Per Adult Chauffeur Tour - {label}",
+ x_title="Students in Household",
+ y_title="Average Schoolkids per Adult Chauffeur Tour",
+ value_mode="count",
+ category_order=student_count_values,
+ ),
+ )
+ )
+ return charts
+
+ def render_chauffeur_stop_distribution_section(
+ self,
+ summary_data,
+ stop_values: list[str],
+ ):
+ """Render the four chauffeur stop-distribution charts."""
+ charts = [
+ self.noted_view(
+ "escorted_tours.chauffeur_stops",
+ self.render_chauffeur_stop_distribution_chart(
+ summary_data,
+ segment,
+ title,
+ stop_values,
+ ),
+ )
+ for segment, title in STOP_SEGMENT_LABELS.items()
+ ]
+ return [
+ pn.pane.Markdown("### Chauffer Stop Distribution"),
+ pn.pane.Markdown(STOP_DISTRIBUTION_DESCRIPTION),
+ pn.Row(*charts[:2], sizing_mode="stretch_width"),
+ pn.Row(*charts[2:], sizing_mode="stretch_width"),
+ ]
+
+ def render_chauffeur_stop_distribution_chart(
+ self,
+ summary_data,
+ segment: str,
+ title: str,
+ stop_values: list[str],
+ ) -> pn.viewable.Viewable:
+ """Render one chauffeur stop-distribution chart."""
+ chart_data = self.query(
+ lambda: complete_category_counts(
+ adult_escort_event_stop_chart_data(summary_data, segment),
+ category="stop_count",
+ category_values=stop_values,
+ value_cols=("tour_count",),
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="stop_count",
+ y="tour_count",
+ title=title,
+ x_title="Stop Count",
+ y_title="Chauffer Escorting Tour-Legs",
+ category_order=stop_values,
+ )
+
+ def render_static_triptych_section(
+ self,
+ *,
+ title: str,
+ description: str,
+ charts: list[pn.viewable.Viewable] | None,
+ unavailable_detail: str,
+ missing_items: list[str],
+ ) -> list[pn.viewable.Viewable]:
+ """Render a three-chart static section or a targeted unavailable card."""
+ if charts is None:
+ return [
+ pn.pane.Markdown(f"### {title}"),
+ pn.pane.Markdown(description),
+ self.data_not_available_card(
+ detail=unavailable_detail,
+ missing_items=missing_items,
+ ),
+ ]
+ return [
+ pn.pane.Markdown(f"### {title}"),
+ pn.pane.Markdown(description),
+ pn.Row(*charts, sizing_mode="stretch_width"),
+ ]
+
+ def render_person_type_chart(
+ self,
+ summary_data,
+ raw_direction: str,
+ direction_label: str,
+ ) -> pn.viewable.Viewable:
+ """Render adult escorting tours by person type."""
+ person_type_values = ordered_category_values(
+ summary_data,
+ "person_type",
+ category_id="person_type",
+ config=self.config,
+ )
+ chart_data = self.query(
+ lambda: complete_category_counts(
+ escort_person_type_chart_data(summary_data, raw_direction),
+ category="person_type",
+ category_values=person_type_values,
+ value_cols=("tour_count",),
+ )
+ )
+ return self.plot.bar(
+ label_category_data(
+ chart_data,
+ source_col="person_type",
+ category_id="person_type",
+ config=self.config,
+ target_col="person_type_label",
+ ),
+ x="person_type_label",
+ y="tour_count",
+ title=f"Chauffeur Tours by Person Type - {direction_label}",
+ x_title="Person Type",
+ y_title="Chauffeur Tours",
+ category_order=self.config.ordered_labels(
+ "person_type", person_type_values
+ ),
+ )
+
+ def escort_distance_data(
+ self,
+ summary_data,
+ raw_direction: str,
+ *,
+ y: str,
+ ) -> list[tuple[str, pl.DataFrame]]:
+ """Return one chart-ready escort distance distribution."""
+ return self.query(
+ lambda: escort_distance_chart_data(
+ summary_data,
+ raw_direction,
+ y_col=y,
+ )
+ )
+
+ def render_distance_chart(
+ self,
+ chart_data: list[tuple[str, pl.DataFrame]],
+ direction_label: str,
+ *,
+ title_prefix: str,
+ yaxis_title: str,
+ x_range: tuple[float, float] | None,
+ ) -> pn.viewable.Viewable:
+ """Render one escort distance distribution."""
+ axis_data = with_distance_axis(chart_data)
+ tickvals, ticktext = fixed_distance_axis_ticks()
+ return self.plot.density(
+ axis_data,
+ x="_distance_axis",
+ y="freq",
+ title=f"{title_prefix} - {direction_label}",
+ x_title="Distance (miles)",
+ y_title=yaxis_title,
+ x_range=x_range,
+ tick_values=tickvals,
+ tick_text=ticktext,
+ )
diff --git a/dashboard/pages/daily_travel/_escorted_tours_data.py b/dashboard/pages/daily_travel/_escorted_tours_data.py
new file mode 100644
index 0000000..a4976d5
--- /dev/null
+++ b/dashboard/pages/daily_travel/_escorted_tours_data.py
@@ -0,0 +1,173 @@
+"""Chart-ready queries for the escorted-tours dashboard page."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ capped_numeric_category_expr,
+ capped_numeric_category_values,
+ numeric_like_sort_expr,
+ ordered_category_values,
+)
+
+DIRECTION_COL = "direction"
+DISTANCE_BINS = [str(i) for i in range(40)] + ["40+"]
+
+
+def direction_options(data_list: list[tuple[str, pl.DataFrame]]) -> list[str]:
+ directions = ordered_category_values(data_list, DIRECTION_COL)
+ if not directions:
+ return ["Both Directions"]
+ return [
+ "Both Directions",
+ *(["Outbound"] if "outbound" in directions else []),
+ *(["Inbound"] if "inbound" in directions else []),
+ ]
+
+
+def adult_raw_direction(value: str) -> str:
+ return {
+ "Both Directions": "both",
+ "Outbound": "outbound",
+ "Inbound": "inbound",
+ }.get(value, "both")
+
+
+def default_direction_option(options: list[str]) -> str:
+ return (
+ "Outbound"
+ if "Outbound" in options
+ else (options[0] if options else "Both Directions")
+ )
+
+
+def adult_escort_event_stop_chart_data(data_list, segment: str):
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col("segment").cast(pl.Utf8))
+ .where(segment=segment)
+ .with_columns(capped_numeric_category_expr("stop_count", 3))
+ .group("stop_count", pl.col("tour_count").sum().alias("tour_count"))
+ .select("stop_count", "tour_count")
+ .sort(numeric_like_sort_expr("stop_count"))
+ )
+
+
+def escort_person_type_chart_data(data_list, direction: str):
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(
+ pl.col(DIRECTION_COL).cast(pl.Utf8),
+ pl.col("person_type").cast(pl.Utf8),
+ )
+ .where(direction=direction)
+ .select("person_type", "tour_count")
+ )
+
+
+def escort_distance_chart_data(data_list, direction: str, *, y_col: str):
+ bins = pl.DataFrame(
+ {"distance_bin": DISTANCE_BINS},
+ schema={"distance_bin": pl.Utf8},
+ )
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
+ .where(direction=direction)
+ .select(
+ pl.col("distance_bin").cast(pl.Utf8),
+ pl.col(y_col).cast(pl.Float64).alias("freq"),
+ )
+ .map(
+ lambda filtered: (
+ bins.join(filtered, on="distance_bin", how="left")
+ .with_columns(pl.col("freq").fill_null(0.0))
+ .select("distance_bin", "freq")
+ )
+ )
+ )
+
+
+def student_school_escort_chart_data(data_list, direction: str):
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
+ .where(direction=direction)
+ .select("escort_type", "tour_count")
+ )
+
+
+def household_school_escort_chart_data(numerator, denominator, direction: str):
+ totals = (
+ RunTables.from_runs(denominator)
+ .with_columns(capped_numeric_category_expr("student_count", 6))
+ .group(
+ "student_count",
+ pl.col("household_count")
+ .cast(pl.Float64)
+ .sum()
+ .alias("total_household_count"),
+ )
+ )
+ escorted = (
+ RunTables.from_runs(numerator)
+ .with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
+ .where(direction=direction)
+ .with_columns(capped_numeric_category_expr("student_count", 6))
+ .group(
+ "student_count",
+ pl.col("household_count").cast(pl.Float64).sum().alias("household_count"),
+ )
+ )
+ return (
+ totals.join(escorted, on="student_count")
+ .with_columns(
+ pl.col("household_count").fill_null(0.0),
+ pl.when(pl.col("total_household_count") > 0)
+ .then(pl.col("household_count") / pl.col("total_household_count") * 100.0)
+ .otherwise(0.0)
+ .alias("pct"),
+ )
+ .select("student_count", "household_count", "pct")
+ )
+
+
+def schoolkids_per_escorted_tour_chart_data(data_list, direction: str):
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
+ .where(direction=direction)
+ .with_columns(
+ capped_numeric_category_expr("student_count", 6),
+ pl.col("avg_schoolkids_per_tour").cast(pl.Float64),
+ pl.col("tour_count").cast(pl.Float64),
+ )
+ .with_columns(
+ (pl.col("avg_schoolkids_per_tour") * pl.col("tour_count")).alias(
+ "_weighted_schoolkids"
+ )
+ )
+ .group(
+ "student_count",
+ pl.col("_weighted_schoolkids").sum(),
+ pl.col("tour_count").sum(),
+ )
+ .with_columns(
+ pl.when(pl.col("tour_count") > 0)
+ .then(pl.col("_weighted_schoolkids") / pl.col("tour_count"))
+ .otherwise(0.0)
+ .alias("avg_schoolkids_per_tour")
+ )
+ .select("student_count", "avg_schoolkids_per_tour", "tour_count")
+ .sort(numeric_like_sort_expr("student_count"))
+ )
+
+
+def student_count_category_values(data_list) -> list[str]:
+ return capped_numeric_category_values(data_list, "student_count", cap_value=6)
+
+
+def stop_count_category_values(data_list) -> list[str]:
+ return capped_numeric_category_values(data_list, "stop_count", cap_value=3)
diff --git a/dashboard/pages/daily_travel/daily_activity_pattern.py b/dashboard/pages/daily_travel/daily_activity_pattern.py
index 066dce8..305da91 100644
--- a/dashboard/pages/daily_travel/daily_activity_pattern.py
+++ b/dashboard/pages/daily_travel/daily_activity_pattern.py
@@ -4,7 +4,7 @@
import panel as pn
-from dashboard.components import bar_chart, selector_row
+from dashboard.rendering import selector_row
from dashboard.helpers.category_helpers import (
complete_category_counts,
label_category_data,
@@ -12,14 +12,12 @@
)
from dashboard.helpers.person_type_helpers import (
ALL_PERSON_TYPES,
- PERSON_TYPE_COL,
filter_person_type_counts,
filter_person_type_rates,
person_type_selector_options,
person_type_weights_by_run,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
TOUR_PURPOSE_LABEL_COL = "tour_purpose_label"
PERSON_TYPE_SUMMARY_IDS = (
@@ -31,19 +29,27 @@
)
+@dashboard_page(
+ page_id="daily_activity_pattern",
+ title="Daily Activity Pattern",
+ group_id="daily_travel",
+ order=28,
+ required_summary_ids=(
+ "daily_activity_pattern_by_person_type",
+ "mandatory_tour_frequency_by_person_type",
+ "nonmandatory_tour_frequency_by_person_type",
+ "tour_rates_by_person_type_and_tour_purpose",
+ "trip_rates_by_person_type_and_trip_purpose",
+ ),
+)
class DailyActivityPatternPage(DashboardPage):
"""Reference page for person-type filtering and weighted total-rate rollups."""
def build_page(self) -> pn.viewable.Viewable:
- person_type_opts = self._person_type_options("weighted")
- self.person_type_sel = self.selector(
+ self.person_type_sel = self.select(
"person_type",
- widget=pn.widgets.Select(
- name="Person Type",
- options=person_type_opts,
- value=person_type_opts[0],
- ),
- label="Person Type",
+ "Person Type",
+ options=lambda: self._person_type_options(self.weighting_key),
)
self._body = self.section(
"activity_pattern_body",
@@ -60,7 +66,7 @@ def build_page(self) -> pn.viewable.Viewable:
def _person_type_source_data(self, weighting_key: str):
"""Use the first available person-type summary to seed the selector domain."""
for summary_id in PERSON_TYPE_SUMMARY_IDS:
- data = self.state.get_summary_table_set(summary_id, weighting_key)
+ data = self.data.summary(summary_id, weighting_key)
if data is not None:
return data
return None
@@ -74,24 +80,16 @@ def _person_type_options(self, weighting_key: str) -> list[str]:
options, self._person_type_to_raw = person_type_selector_options(
data,
config=self.config,
- state=self.state,
- cache_key=("daily_activity_pattern", PERSON_TYPE_COL, weighting_key),
)
return options or ["All Person Types"]
- def sync_controls(self) -> None:
- options = self._person_type_options(self.weighting_key)
- self.person_type_sel.options = options
- if self.person_type_sel.value not in options:
- self.person_type_sel.value = options[0]
-
def _selected_person_type(self) -> tuple[str, str | None]:
display_value = str(self.person_type_sel.value)
return display_value, self._person_type_to_raw.get(display_value)
def _optional_summaries(self):
"""Load each chart's summary independently so partial pages still render."""
- return self.optional_summaries_dict(*self.required_summary_ids)
+ return self.data.summaries(*self.required_summary_ids)
def _missing_chart_card(self, summary_id: str) -> pn.Card:
return self.data_not_available_card(
@@ -103,9 +101,8 @@ def _count_chart(
self,
summary_data,
*,
- cache_key: str,
raw_person_type: str | None,
- category_col: str,
+ category: str,
category_id: str | None,
source_col_for_labels: str | None = None,
target_col_for_labels: str | None = None,
@@ -113,19 +110,17 @@ def _count_chart(
"""Build one count-style chart dataset after person-type filtering and completion."""
category_values = ordered_category_values(
summary_data,
- category_col,
+ category,
category_id=category_id,
config=self.config,
)
- chart_data = self.get_filtered_view(
- cache_key,
- raw_person_type,
- factory=lambda: complete_category_counts(
+ chart_data = self.query(
+ lambda: complete_category_counts(
filter_person_type_counts(summary_data, raw_person_type),
- category_col=category_col,
+ category=category,
category_values=category_values,
value_cols=("person_count", "pct"),
- ),
+ )
)
if source_col_for_labels is None or category_id is None:
return chart_data, category_values, category_values
@@ -144,9 +139,8 @@ def _rate_chart(
self,
summary_data,
*,
- cache_key: str,
raw_person_type: str | None,
- category_col: str,
+ category: str,
category_id: str,
rate_col: str,
target_col: str,
@@ -155,31 +149,29 @@ def _rate_chart(
"""Build one rate chart dataset, including weighted total-person-type rollups."""
category_values = ordered_category_values(
summary_data,
- category_col,
+ category,
category_id=category_id,
config=self.config,
)
- chart_data = self.get_filtered_view(
- cache_key,
- raw_person_type,
- factory=lambda: label_category_data(
+ chart_data = self.query(
+ lambda: label_category_data(
complete_category_counts(
filter_person_type_rates(
summary_data,
raw_person_type,
- purpose_col=category_col,
+ purpose_col=category,
rate_col=rate_col,
person_weights=person_weights,
),
- category_col=category_col,
+ category=category,
category_values=category_values,
value_cols=(rate_col,),
),
category_id=category_id,
config=self.config,
- source_col=category_col,
+ source_col=category,
target_col=target_col,
- ),
+ )
)
return chart_data, self.config.ordered_labels(category_id, category_values)
@@ -191,27 +183,24 @@ def render_daily_activity_pattern_chart(
raw_person_type,
):
summary_data = summaries["daily_activity_pattern_by_person_type"]
- if summary_data is None:
+ if not summary_data:
return self._missing_chart_card("daily_activity_pattern_by_person_type")
chart_data, _, label_values = self._count_chart(
summary_data,
- cache_key="daily_activity_pattern",
raw_person_type=raw_person_type,
- category_col="daily_activity_pattern",
+ category="daily_activity_pattern",
category_id="daily_activity_pattern",
source_col_for_labels="daily_activity_pattern",
target_col_for_labels="daily_activity_pattern_label",
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="daily_activity_pattern_label",
- y_col="person_count",
+ x="daily_activity_pattern_label",
+ y="person_count",
title=f"Daily Activity Pattern - {display_person_type}",
- xaxis_title="Daily Activity Pattern",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x_title="Daily Activity Pattern",
+ y_title="Persons",
+ category_order=label_values,
)
def render_mandatory_tour_frequency_chart(
@@ -222,27 +211,24 @@ def render_mandatory_tour_frequency_chart(
raw_person_type,
):
summary_data = summaries["mandatory_tour_frequency_by_person_type"]
- if summary_data is None:
+ if not summary_data:
return self._missing_chart_card("mandatory_tour_frequency_by_person_type")
chart_data, _, label_values = self._count_chart(
summary_data,
- cache_key="mandatory_tour_frequency",
raw_person_type=raw_person_type,
- category_col="mandatory_tour_frequency",
+ category="mandatory_tour_frequency",
category_id="mandatory_tour_frequency",
source_col_for_labels="mandatory_tour_frequency",
target_col_for_labels="mandatory_tour_frequency_label",
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="mandatory_tour_frequency_label",
- y_col="person_count",
+ x="mandatory_tour_frequency_label",
+ y="person_count",
title=f"Mandatory Tour Frequency - {display_person_type}",
- xaxis_title="Mandatory Tour Frequency",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x_title="Mandatory Tour Frequency",
+ y_title="Persons",
+ category_order=label_values,
)
def render_nonmandatory_tour_frequency_chart(
@@ -253,25 +239,24 @@ def render_nonmandatory_tour_frequency_chart(
raw_person_type,
):
summary_data = summaries["nonmandatory_tour_frequency_by_person_type"]
- if summary_data is None:
- return self._missing_chart_card("nonmandatory_tour_frequency_by_person_type")
+ if not summary_data:
+ return self._missing_chart_card(
+ "nonmandatory_tour_frequency_by_person_type"
+ )
chart_data, x_values, _ = self._count_chart(
summary_data,
- cache_key="nonmandatory_tour_frequency",
raw_person_type=raw_person_type,
- category_col="nonmandatory_tour_frequency",
+ category="nonmandatory_tour_frequency",
category_id=None,
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="nonmandatory_tour_frequency",
- y_col="person_count",
+ x="nonmandatory_tour_frequency",
+ y="person_count",
title=f"Non-Mandatory Tour Frequency - {display_person_type}",
- xaxis_title="Non-Mandatory Tour Frequency",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=x_values,
+ x_title="Non-Mandatory Tour Frequency",
+ y_title="Persons",
+ category_order=x_values,
)
def render_tour_rate_chart(
@@ -283,27 +268,28 @@ def render_tour_rate_chart(
person_weights,
):
summary_data = summaries["tour_rates_by_person_type_and_tour_purpose"]
- if summary_data is None:
- return self._missing_chart_card("tour_rates_by_person_type_and_tour_purpose")
+ if not summary_data:
+ return self._missing_chart_card(
+ "tour_rates_by_person_type_and_tour_purpose"
+ )
chart_data, label_values = self._rate_chart(
summary_data,
- cache_key="tour_rate_per_person",
raw_person_type=raw_person_type,
- category_col="tour_purpose",
+ category="tour_purpose",
category_id="tour_purpose",
rate_col="tour_rate",
target_col=TOUR_PURPOSE_LABEL_COL,
person_weights=person_weights,
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col=TOUR_PURPOSE_LABEL_COL,
- y_col="tour_rate",
+ x=TOUR_PURPOSE_LABEL_COL,
+ y="tour_rate",
title=f"Daily Tour Rate per Person by Tour Purpose - {display_person_type}",
- xaxis_title="Tour Purpose",
- yaxis_title="Tours per Person-Day",
- as_percent=False,
- xaxis_categoryarray=label_values,
+ x_title="Tour Purpose",
+ y_title="Tours per Person-Day",
+ value_mode="count",
+ category_order=label_values,
)
def render_trip_rate_chart(
@@ -315,27 +301,28 @@ def render_trip_rate_chart(
person_weights,
):
summary_data = summaries["trip_rates_by_person_type_and_trip_purpose"]
- if summary_data is None:
- return self._missing_chart_card("trip_rates_by_person_type_and_trip_purpose")
+ if not summary_data:
+ return self._missing_chart_card(
+ "trip_rates_by_person_type_and_trip_purpose"
+ )
chart_data, label_values = self._rate_chart(
summary_data,
- cache_key="trip_rate_per_person",
raw_person_type=raw_person_type,
- category_col="trip_purpose",
+ category="trip_purpose",
category_id="trip_purpose",
rate_col="trip_rate",
target_col="trip_purpose",
person_weights=person_weights,
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="trip_purpose",
- y_col="trip_rate",
+ x="trip_purpose",
+ y="trip_rate",
title=f"Daily Trip Rate per Person by Trip Purpose - {display_person_type}",
- xaxis_title="Trip Purpose",
- yaxis_title="Trips per Person-Day",
- as_percent=False,
- xaxis_categoryarray=label_values,
+ x_title="Trip Purpose",
+ y_title="Trips per Person-Day",
+ value_mode="count",
+ category_order=label_values,
)
def render_body(self):
@@ -353,53 +340,50 @@ def render_body(self):
summaries["daily_activity_pattern_by_person_type"] or []
)
return [
+ self.noted_view(
+ "daily_activity.activity_pattern",
self.render_daily_activity_pattern_chart(
summaries,
display_person_type=display_person_type,
raw_person_type=raw_person_type,
+ ),
),
pn.Row(
- self.render_mandatory_tour_frequency_chart(
- summaries,
- display_person_type=display_person_type,
- raw_person_type=raw_person_type,
+ self.noted_view(
+ "daily_activity.mandatory_tour_frequency",
+ self.render_mandatory_tour_frequency_chart(
+ summaries,
+ display_person_type=display_person_type,
+ raw_person_type=raw_person_type,
+ ),
),
- self.render_nonmandatory_tour_frequency_chart(
- summaries,
- display_person_type=display_person_type,
- raw_person_type=raw_person_type,
+ self.noted_view(
+ "daily_activity.nonmandatory_tour_frequency",
+ self.render_nonmandatory_tour_frequency_chart(
+ summaries,
+ display_person_type=display_person_type,
+ raw_person_type=raw_person_type,
+ ),
),
),
pn.Row(
- self.render_tour_rate_chart(
- summaries,
- display_person_type=display_person_type,
- raw_person_type=raw_person_type,
- person_weights=person_weights,
+ self.noted_view(
+ "daily_activity.tour_rate",
+ self.render_tour_rate_chart(
+ summaries,
+ display_person_type=display_person_type,
+ raw_person_type=raw_person_type,
+ person_weights=person_weights,
+ ),
),
- self.render_trip_rate_chart(
- summaries,
- display_person_type=display_person_type,
- raw_person_type=raw_person_type,
- person_weights=person_weights,
+ self.noted_view(
+ "daily_activity.trip_rate",
+ self.render_trip_rate_chart(
+ summaries,
+ display_person_type=display_person_type,
+ raw_person_type=raw_person_type,
+ person_weights=person_weights,
+ ),
),
),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="daily_activity_pattern",
- title="Daily Activity Pattern",
- group_id="daily_travel",
- order=28,
- page_cls=DailyActivityPatternPage,
- required_summary_ids=(
- "daily_activity_pattern_by_person_type",
- "mandatory_tour_frequency_by_person_type",
- "nonmandatory_tour_frequency_by_person_type",
- "tour_rates_by_person_type_and_tour_purpose",
- "trip_rates_by_person_type_and_trip_purpose",
- ),
-)
-
-DailyActivityPatternPage.definition = PAGE
diff --git a/dashboard/pages/daily_travel/escorted_tours.py b/dashboard/pages/daily_travel/escorted_tours.py
index ec8eab7..15afb63 100644
--- a/dashboard/pages/daily_travel/escorted_tours.py
+++ b/dashboard/pages/daily_travel/escorted_tours.py
@@ -2,834 +2,25 @@
from __future__ import annotations
-import panel as pn
-import polars as pl
-
-from dashboard.components import bar_chart, density_chart, selector_row
-from dashboard.helpers.category_helpers import (
- complete_category_counts,
- label_category_data,
- nonempty,
- ordered_category_values,
-)
-from dashboard.helpers.time_distance_helpers import distance_sort_expr
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
-
-DIRECTION_COL = "direction"
-DISTANCE_BINS = [str(i) for i in range(40)] + ["40+"]
-STUDENT_ESCORT_TYPE_ORDER = [
- "not_escorted",
- "pure_escort",
- "ride_share",
-]
-CORE_SUMMARY_IDS = (
- "escorted_tour_totals",
- "school_escorted_tours_by_escort_type_and_direction",
- "adult_escort_event_stop_distribution",
- "adult_escorted_tours_by_person_type_and_direction",
- "adult_escorted_tour_distance_distribution_by_direction",
- "adult_escorted_trip_distance_distribution_by_direction",
-)
-OPTIONAL_SUMMARY_IDS = (
- "student_school_escort_status_by_direction",
- "student_households_by_student_count",
- "households_with_school_escorting_by_student_count_and_direction",
- "schoolkids_per_escorted_tour_by_student_count_and_direction",
-)
-PAGE_SUMMARY_IDS = (*CORE_SUMMARY_IDS, *OPTIONAL_SUMMARY_IDS)
-STOP_SEGMENT_LABELS = {
- "outbound_before_dropoff": "Adult Escort Stops Before Dropoff - Outbound",
- "outbound_after_dropoff": "Adult Escort Stops After Dropoff - Outbound",
- "inbound_before_pickup": "Adult Escort Stops Before Pickup - Inbound",
- "inbound_after_pickup": "Adult Escort Stops After Pickup - Inbound",
-}
-STUDENT_ESCORT_DESCRIPTION = (
- "Student school tours by escort type. `Both Directions` means the same child "
- "school tour is escorted in both outbound and inbound directions."
-)
-HOUSEHOLD_ESCORT_DESCRIPTION = (
- "Households with school escorting by number of students per household. "
- "A household counts if it has at least one escorted school tour in the "
- "selected direction."
-)
-SCHOOLKIDS_DESCRIPTION = (
- "Average number of escortees on adult chauffer tours, grouped by number of students "
- "in the household. `Both Directions` only counts chauffer tours where "
- "escorting occurred in both directions."
-)
-STOP_DISTRIBUTION_DESCRIPTION = (
- "Number of stops before and after the dropoff/pickup on each adult chauffeur trip. "
-)
-PERSON_TYPE_DESCRIPTION = (
- "Adult chauffeur tours by person type. `Both Directions` means the "
- "chauffer escorted in both outbound and inbound directions."
-)
-DISTANCE_DESCRIPTION = (
- "Distance distributions for adult chauffeur tours and trips. "
- "`Both Directions` means the chauffer escorted in both outbound and inbound "
- "directions."
-)
-
-
-def direction_options(data_list: list[tuple[str, pl.DataFrame]]) -> list[str]:
- """Return the dashboard's display directions in stable order."""
- directions = ordered_category_values(data_list, DIRECTION_COL)
- if not directions:
- return ["Both Directions"]
- options = ["Both Directions"]
- if "outbound" in directions:
- options.append("Outbound")
- if "inbound" in directions:
- options.append("Inbound")
- return options
-
-
-def adult_raw_direction(value: str) -> str:
- """Translate the selector label into the raw summary value."""
- return {
- "Both Directions": "both",
- "Outbound": "outbound",
- "Inbound": "inbound",
- }.get(value, "both")
-
-
-def default_direction_option(options: list[str]) -> str:
- """Prefer outbound when available because it is usually the first interesting view."""
- if "Outbound" in options:
- return "Outbound"
- return options[0] if options else "Both Directions"
-
-
-def adult_escort_event_stop_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- segment: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build one stop-count distribution for a specific chauffeur segment."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- filtered = (
- df.with_columns(pl.col("segment").cast(pl.Utf8))
- .filter(pl.col("segment") == segment)
- .group_by("stop_count")
- .agg(tour_count=pl.col("tour_count").sum())
- .with_columns(pl.col("stop_count").cast(pl.Utf8))
- .select("stop_count", "tour_count")
- .sort(pl.col("stop_count").cast(pl.Int64, strict=False))
- )
- out.append((label, filtered))
- return out
-
-
-def escort_person_type_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build the person-type distribution for one adult escort direction."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- out.append(
- (
- label,
- df.with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
- .filter(pl.col(DIRECTION_COL) == direction)
- .with_columns(pl.col("person_type").cast(pl.Utf8))
- .select("person_type", "tour_count"),
- )
- )
- return out
-
-
-def escort_distance_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
- *,
- y_col: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build one complete distance distribution for escort tours or trips."""
- out: list[tuple[str, pl.DataFrame]] = []
- bins_df = pl.DataFrame(
- {"distance_bin": DISTANCE_BINS}, schema={"distance_bin": pl.Utf8}
- )
- for label, df in nonempty(data_list):
- filtered = (
- df.with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
- .filter(pl.col(DIRECTION_COL) == direction)
- .select(
- pl.col("distance_bin").cast(pl.Utf8),
- pl.col(y_col).cast(pl.Float64).alias("freq"),
- )
- .with_columns(distance_sort_expr("distance_bin").alias("_sort_distance"))
- .sort("_sort_distance")
- .drop("_sort_distance")
- )
- completed = (
- bins_df.join(filtered, on="distance_bin", how="left")
- .with_columns(pl.col("freq").fill_null(0.0))
- .select("distance_bin", "freq")
- )
- out.append((label, completed))
- return out
-
-
-def student_school_escort_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build one student escort-type distribution for the selected direction."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- out.append(
- (
- label,
- df.with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
- .filter(pl.col(DIRECTION_COL) == direction)
- .select("escort_type", "tour_count"),
- )
- )
- return out
-
-
-def household_school_escort_chart_data(
- numerator_data_list: list[tuple[str, pl.DataFrame]],
- denominator_data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Compute household counts and rates for the selected escort direction."""
- denominator_by_label = {
- label: df.select(
- pl.col("student_count").cast(pl.Int64),
- pl.col("household_count").cast(pl.Float64).alias("total_household_count"),
- )
- for label, df in nonempty(denominator_data_list)
- }
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(numerator_data_list):
- denominator = denominator_by_label.get(label)
- if denominator is None or denominator.is_empty():
- continue
- filtered = (
- df.with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
- .filter(pl.col(DIRECTION_COL) == direction)
- .select(
- pl.col("student_count").cast(pl.Int64),
- pl.col("household_count").cast(pl.Float64),
- )
- )
- out.append(
- (
- label,
- denominator.join(filtered, on="student_count", how="left")
- .with_columns(
- pl.col("household_count").fill_null(0.0),
- pl.when(pl.col("total_household_count") > 0)
- .then(
- pl.col("household_count")
- / pl.col("total_household_count")
- * 100.0
- )
- .otherwise(0.0)
- .alias("pct"),
- )
- .select("student_count", "household_count", "pct"),
- )
- )
- return out
-
-
-def schoolkids_per_escorted_tour_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build one schoolkids-per-tour distribution for the selected direction."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- out.append(
- (
- label,
- df.with_columns(pl.col(DIRECTION_COL).cast(pl.Utf8))
- .filter(pl.col(DIRECTION_COL) == direction)
- .select(
- pl.col("student_count").cast(pl.Int64),
- pl.col("avg_schoolkids_per_tour").cast(pl.Float64),
- pl.col("tour_count").cast(pl.Float64),
- ),
- )
- )
- return out
-
-
-def student_count_category_values(
- data_list: list[tuple[str, pl.DataFrame]],
-) -> list[str]:
- """Return observed student-count bins in numeric order."""
- values: set[int] = set()
- for _, df in nonempty(data_list):
- if "student_count" not in df.columns:
- continue
- values.update(
- value
- for value in df.select(pl.col("student_count").cast(pl.Int64))
- .to_series()
- .to_list()
- if value is not None
- )
- return [str(value) for value in sorted(values)]
+from dashboard import DashboardPage, dashboard_page
+from ._escorted_tours import *
+from ._escorted_tours.composition import EscortedToursCompositionMixin
+from ._escorted_tours.domains import EscortedToursDomainMixin
+from ._escorted_tours.features import EscortedToursFeatureMixin
-def stop_count_category_values(
- data_list: list[tuple[str, pl.DataFrame]],
-) -> list[str]:
- """Return observed stop-count bins in numeric order."""
- values: set[int] = set()
- for _, df in nonempty(data_list):
- if "stop_count" not in df.columns:
- continue
- values.update(
- value
- for value in df.select(pl.col("stop_count").cast(pl.Int64))
- .to_series()
- .to_list()
- if value is not None
- )
- return [str(value) for value in sorted(values)]
-
-class EscortedToursPage(DashboardPage):
- """Render school escorting and adult chauffeur escorting summaries."""
-
- def build_page(self) -> pn.viewable.Viewable:
- """Build the page shell with one direction selector and two stable sections."""
- direction_opts = self._direction_options()
- self.direction_sel = self.selector(
- "direction",
- widget=pn.widgets.Select(
- name="Direction",
- options=direction_opts,
- value=default_direction_option(direction_opts),
- ),
- label="Direction",
- )
- self._static_body = self.section(
- "escorted_tours_static_body",
- render=self.render_static_body_section,
- )
- self._directional_body = self.section(
- "escorted_tours_directional_body",
- selectors=("direction",),
- render=self.render_directional_body_section,
- )
- return self.new_section(
- pn.pane.Markdown("## Escorted Tours"),
- self.new_section(self._static_body, self._directional_body),
- sizing_mode="stretch_width",
- )
-
- def _direction_options(self) -> list[str]:
- """Discover available direction values from the core school escort summary."""
- data = self.state.get_summary_table_set(
- "school_escorted_tours_by_escort_type_and_direction",
- "weighted",
- )
- if data is None:
- return ["Both Directions"]
- return direction_options(data)
-
- def sync_controls(self) -> None:
- """Keep the direction selector aligned with currently available summaries."""
- summaries = self.require_summaries(*CORE_SUMMARY_IDS)
- if summaries is None:
- return
- options = direction_options(
- summaries["school_escorted_tours_by_escort_type_and_direction"]
- )
- self.direction_sel.options = options
- if self.direction_sel.value not in options:
- self.direction_sel.value = default_direction_option(options)
-
- def _load_page_summaries(self):
- """Load core summaries plus optional add-on summaries used by static sections."""
- summaries = self.require_summaries(*CORE_SUMMARY_IDS)
- if summaries is None:
- return None
- optional_summaries = self.optional_summaries_dict(*OPTIONAL_SUMMARY_IDS)
- return {**summaries, **optional_summaries}
-
- def render_static_body_section(self):
- """Render the sections that do not depend on the live direction selector."""
- if not self.state.run_labels:
- return [self.no_runs_message()]
-
- summaries = self._load_page_summaries()
- if summaries is None:
- return [self.summary_only_unavailable_card(summary_ids=CORE_SUMMARY_IDS)]
-
- stop_values = stop_count_category_values(
- summaries["adult_escort_event_stop_distribution"]
- )
- student_count_values = student_count_category_values(
- summaries["student_households_by_student_count"] or []
- )
-
- body_objects: list[pn.viewable.Viewable] = []
- body_objects.extend(
- self.render_student_school_escort_section(
- summaries["student_school_escort_status_by_direction"]
- )
- )
- body_objects.extend(
- self.render_household_school_escort_section(
- summaries["student_households_by_student_count"],
- summaries[
- "households_with_school_escorting_by_student_count_and_direction"
- ],
- student_count_values,
- )
- )
- body_objects.extend(
- self.render_schoolkids_per_escorted_tour_section(
- summaries[
- "schoolkids_per_escorted_tour_by_student_count_and_direction"
- ],
- student_count_values,
- )
- )
- body_objects.extend(
- self.render_chauffeur_stop_distribution_section(
- summaries["adult_escort_event_stop_distribution"],
- stop_values,
- )
- )
- return [pn.Column(*body_objects)]
-
- def render_student_school_escort_section(self, summary_data):
- """Render outbound, inbound, and both-direction student escort status charts."""
- charts = self.render_student_school_escort_charts(summary_data)
- return self.render_static_triptych_section(
- title="Student School Tour Escort Status",
- description=STUDENT_ESCORT_DESCRIPTION,
- charts=charts,
- unavailable_detail=(
- "This section only renders when the student school escort summary "
- "is available."
- ),
- missing_items=["student_school_escort_status_by_direction"],
- )
-
- def render_student_school_escort_charts(self, summary_data):
- """Build the three student escort status charts when the summary is available."""
- if summary_data is None:
- return None
-
- escort_order = self.config.ordered_values("escort", STUDENT_ESCORT_TYPE_ORDER)
- escort_labels = self.config.ordered_labels("escort", STUDENT_ESCORT_TYPE_ORDER)
- charts: list[pn.viewable.Viewable] = []
- for direction, label in (
- ("outbound", "Outbound"),
- ("inbound", "Inbound"),
- ("both", "Both Directions"),
- ):
- chart_data = self.get_filtered_view(
- "student_school_escort_status",
- direction,
- factory=lambda direction=direction: complete_category_counts(
- student_school_escort_chart_data(summary_data, direction),
- category_col="escort_type",
- category_values=escort_order,
- value_cols=("tour_count", "pct"),
- ),
- )
- charts.append(
- bar_chart(
- label_category_data(
- chart_data,
- source_col="escort_type",
- category_id="escort",
- config=self.config,
- target_col="escort_type_label",
- ),
- x_col="escort_type_label",
- y_col="tour_count",
- title=f"Student School Escort Status - {label}",
- xaxis_title="Escort Type",
- yaxis_title="Student School Tours",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=escort_labels,
- )
- )
- return charts
-
- def render_household_school_escort_section(
- self,
- denominator_summary,
- numerator_summary,
- student_count_values: list[str],
- ):
- """Render household escorting charts or an unavailable placeholder."""
- charts = self.render_household_school_escort_charts(
- denominator_summary,
- numerator_summary,
- student_count_values,
- )
- return self.render_static_triptych_section(
- title="Households With School Escorting",
- description=HOUSEHOLD_ESCORT_DESCRIPTION,
- charts=charts,
- unavailable_detail=(
- "This section only renders when the household school escort summaries "
- "are available."
- ),
- missing_items=[
- "student_households_by_student_count",
- "households_with_school_escorting_by_student_count_and_direction",
- ],
- )
-
- def render_household_school_escort_charts(
- self,
- denominator_summary,
- numerator_summary,
- student_count_values: list[str],
- ):
- """Build household escort count/rate charts for each direction."""
- if denominator_summary is None or numerator_summary is None:
- return None
-
- charts: list[pn.viewable.Viewable] = []
- for direction, label in (
- ("outbound", "Outbound"),
- ("inbound", "Inbound"),
- ("both", "Both Directions"),
- ):
- chart_data = self.get_filtered_view(
- "household_school_escort_status",
- direction,
- factory=lambda direction=direction: complete_category_counts(
- [
- (
- run_label,
- df.with_columns(
- pl.col("student_count")
- .cast(pl.Utf8)
- .alias("student_count")
- ),
- )
- for run_label, df in household_school_escort_chart_data(
- numerator_summary,
- denominator_summary,
- direction,
- )
- ],
- category_col="student_count",
- category_values=student_count_values,
- value_cols=("household_count", "pct"),
- ),
- )
- charts.append(
- bar_chart(
- chart_data,
- x_col="student_count",
- y_col="pct" if self.as_percent else "household_count",
- title=f"Households With School Escorting - {label}",
- xaxis_title="Students in Household",
- yaxis_title=(
- "Percent of Households with Students (%)"
- if self.as_percent
- else "Number of Households with Students"
- ),
- as_percent=False,
- xaxis_categoryarray=student_count_values,
- )
- )
- return charts
-
- def render_schoolkids_per_escorted_tour_section(
- self,
- summary_data,
- student_count_values: list[str],
- ):
- """Render schoolkids-per-tour charts or an unavailable placeholder."""
- charts = self.render_schoolkids_per_escorted_tour_charts(
- summary_data,
- student_count_values,
- )
- return self.render_static_triptych_section(
- title="Schoolkids Per Escorted Tour",
- description=SCHOOLKIDS_DESCRIPTION,
- charts=charts,
- unavailable_detail=(
- "This section only renders when the schoolkids-per-chauffer-tour "
- "summary is available."
- ),
- missing_items=[
- "schoolkids_per_escorted_tour_by_student_count_and_direction"
- ],
- )
-
- def render_schoolkids_per_escorted_tour_charts(
- self,
- summary_data,
- student_count_values: list[str],
- ):
- """Build average schoolkids-per-tour charts for each direction."""
- if summary_data is None:
- return None
-
- charts: list[pn.viewable.Viewable] = []
- for direction, label in (
- ("outbound", "Outbound"),
- ("inbound", "Inbound"),
- ("both", "Both Directions"),
- ):
- chart_data = self.get_filtered_view(
- "schoolkids_per_escorted_tour",
- direction,
- factory=lambda direction=direction: complete_category_counts(
- [
- (
- run_label,
- df.with_columns(
- pl.col("student_count")
- .cast(pl.Utf8)
- .alias("student_count")
- ),
- )
- for run_label, df in schoolkids_per_escorted_tour_chart_data(
- summary_data,
- direction,
- )
- ],
- category_col="student_count",
- category_values=student_count_values,
- value_cols=("avg_schoolkids_per_tour", "tour_count"),
- ),
- )
- charts.append(
- bar_chart(
- chart_data,
- x_col="student_count",
- y_col="avg_schoolkids_per_tour",
- title=f"Schoolkids Per Adult Chauffer Tour - {label}",
- xaxis_title="Students in Household",
- yaxis_title="Average Schoolkids per Adult Chauffer Tour",
- as_percent=False,
- xaxis_categoryarray=student_count_values,
- )
- )
- return charts
-
- def render_chauffeur_stop_distribution_section(
- self,
- summary_data,
- stop_values: list[str],
- ):
- """Render the four chauffeur stop-distribution charts."""
- charts = [
- self.render_chauffeur_stop_distribution_chart(
- summary_data,
- segment,
- title,
- stop_values,
- )
- for segment, title in STOP_SEGMENT_LABELS.items()
- ]
- return [
- pn.pane.Markdown("### Chauffer Stop Distribution"),
- pn.pane.Markdown(STOP_DISTRIBUTION_DESCRIPTION),
- pn.Row(*charts[:2], sizing_mode="stretch_width"),
- pn.Row(*charts[2:], sizing_mode="stretch_width"),
- ]
-
- def render_chauffeur_stop_distribution_chart(
- self,
- summary_data,
- segment: str,
- title: str,
- stop_values: list[str],
- ) -> pn.viewable.Viewable:
- """Render one chauffeur stop-distribution chart."""
- chart_data = self.get_filtered_view(
- "adult_escort_event_stop_distribution",
- segment,
- factory=lambda: complete_category_counts(
- adult_escort_event_stop_chart_data(summary_data, segment),
- category_col="stop_count",
- category_values=stop_values,
- value_cols=("tour_count",),
- ),
- )
- return bar_chart(
- chart_data,
- x_col="stop_count",
- y_col="tour_count",
- title=title,
- xaxis_title="Stop Count",
- yaxis_title="Chauffer Escorting Tour-Legs",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=stop_values,
- )
-
- def render_static_triptych_section(
- self,
- *,
- title: str,
- description: str,
- charts: list[pn.viewable.Viewable] | None,
- unavailable_detail: str,
- missing_items: list[str],
- ) -> list[pn.viewable.Viewable]:
- """Render a three-chart static section or a targeted unavailable card."""
- if charts is None:
- return [
- pn.pane.Markdown(f"### {title}"),
- pn.pane.Markdown(description),
- self.data_not_available_card(
- detail=unavailable_detail,
- missing_items=missing_items,
- ),
- ]
- return [
- pn.pane.Markdown(f"### {title}"),
- pn.pane.Markdown(description),
- pn.Row(*charts, sizing_mode="stretch_width"),
- ]
-
- def render_directional_body_section(self):
- """Render the charts that depend on the selected escort direction."""
- if not self.state.run_labels:
- return []
-
- summaries = self._load_page_summaries()
- if summaries is None:
- return []
-
- direction_label = str(self.direction_sel.value)
- raw_direction = adult_raw_direction(direction_label)
- return [
- pn.Column(
- pn.pane.Markdown("## Adult Chauffer Tours and Trips"),
- selector_row(self.direction_sel),
- pn.pane.Markdown("### Chauffer Person Type Distribution"),
- pn.pane.Markdown(PERSON_TYPE_DESCRIPTION),
- pn.Row(
- self.render_person_type_chart(
- summaries["adult_escorted_tours_by_person_type_and_direction"],
- raw_direction,
- direction_label,
- ),
- sizing_mode="stretch_width",
- ),
- pn.pane.Markdown("### Chauffer Tour and Trip Distance Distributions"),
- pn.pane.Markdown(DISTANCE_DESCRIPTION),
- self.render_distance_chart(
- summaries["adult_escorted_tour_distance_distribution_by_direction"],
- raw_direction,
- direction_label,
- cache_key="adult_escorted_tour_distance_distribution_by_direction",
- y_col="tour_count",
- title_prefix="Chauffer Tour Distance Distribution",
- yaxis_title="Chauffer Tours",
- ),
- self.render_distance_chart(
- summaries["adult_escorted_trip_distance_distribution_by_direction"],
- raw_direction,
- direction_label,
- cache_key="adult_escorted_trip_distance_distribution_by_direction",
- y_col="trip_count",
- title_prefix="Chauffer Trip Distance Distribution",
- yaxis_title="Chauffer Trips",
- ),
- )
- ]
-
- def render_person_type_chart(
- self,
- summary_data,
- raw_direction: str,
- direction_label: str,
- ) -> pn.viewable.Viewable:
- """Render adult escorting tours by person type."""
- person_type_values = ordered_category_values(
- summary_data,
- "person_type",
- category_id="person_type",
- config=self.config,
- )
- chart_data = self.get_filtered_view(
- "adult_escorted_tours_by_person_type_and_direction",
- raw_direction,
- factory=lambda: complete_category_counts(
- escort_person_type_chart_data(summary_data, raw_direction),
- category_col="person_type",
- category_values=person_type_values,
- value_cols=("tour_count",),
- ),
- )
- return bar_chart(
- label_category_data(
- chart_data,
- source_col="person_type",
- category_id="person_type",
- config=self.config,
- target_col="person_type_label",
- ),
- x_col="person_type_label",
- y_col="tour_count",
- title=f"Chauffer Tours by Person Type - {direction_label}",
- xaxis_title="Person Type",
- yaxis_title="Chauffer Tours",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=self.config.ordered_labels(
- "person_type", person_type_values
- ),
- )
-
- def render_distance_chart(
- self,
- summary_data,
- raw_direction: str,
- direction_label: str,
- *,
- cache_key: str,
- y_col: str,
- title_prefix: str,
- yaxis_title: str,
- ) -> pn.viewable.Viewable:
- """Render one escort distance distribution."""
- chart_data = self.get_filtered_view(
- cache_key,
- raw_direction,
- factory=lambda: escort_distance_chart_data(
- summary_data,
- raw_direction,
- y_col=y_col,
- ),
- )
- return density_chart(
- chart_data,
- x_col="distance_bin",
- y_col="freq",
- title=f"{title_prefix} - {direction_label}",
- xaxis_title="Distance (miles)",
- yaxis_title=yaxis_title,
- normalize=False,
- as_percent=self.as_percent,
- xaxis_categoryarray=DISTANCE_BINS,
- xaxis_tickvals=DISTANCE_BINS,
- xaxis_ticktext=DISTANCE_BINS,
- )
-
-
-PAGE = DashboardPageDefinition(
+@dashboard_page(
page_id="escorted_tours",
title="Escorted Tours",
group_id="daily_travel",
order=29,
- page_cls=EscortedToursPage,
required_summary_ids=(*PAGE_SUMMARY_IDS,),
)
-
-EscortedToursPage.definition = PAGE
+class EscortedToursPage(
+ EscortedToursCompositionMixin,
+ EscortedToursDomainMixin,
+ EscortedToursFeatureMixin,
+ DashboardPage,
+):
+ """Render school escorting and adult chauffeur escorting summaries."""
diff --git a/dashboard/pages/joint_travel.py b/dashboard/pages/joint_travel.py
index 491b170..33d00b8 100644
--- a/dashboard/pages/joint_travel.py
+++ b/dashboard/pages/joint_travel.py
@@ -5,196 +5,59 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, control_row, control_row_spacer, selector_row
+from dashboard.rendering import control_row, control_row_spacer, selector_row
from dashboard.helpers.category_helpers import (
- column_options,
- complete_category_counts,
label_category_data,
nonempty,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
-
-
-def party_size_options(data_list: list[tuple[str, pl.DataFrame]]) -> list[str]:
- opts, _ = column_options(
- data_list,
- "party_size",
- total_raw="All",
- total_label="All",
- )
- return opts or ["All"]
-
-
-def household_size_options(data_list: list[tuple[str, pl.DataFrame]]) -> list[str]:
- opts, _ = column_options(
- data_list,
- "household_size",
- total_raw="All",
- total_label="All",
- )
- return opts or ["All"]
-
-
-def joint_household_size_values(
- *data_lists: list[tuple[str, pl.DataFrame]],
-) -> list[str]:
- """Return every household size from 2 through the observed maximum, inclusive."""
- values: set[int] = set()
- for data_list in data_lists:
- for _, df in nonempty(data_list):
- if "household_size" not in df.columns:
- continue
- values.update(
- value
- for value in df.select(
- pl.col("household_size").cast(pl.Int64, strict=False)
- ).to_series().to_list()
- if value is not None and value >= 2
- )
- if not values:
- return []
- return [str(value) for value in range(2, max(values) + 1)]
-
-
-def complete_joint_household_size_data(
- data_list: list[tuple[str, pl.DataFrame]],
- *,
- value_col: str,
- household_size_values: list[str],
-) -> list[tuple[str, pl.DataFrame]]:
- """Complete household-size categories so joint-travel charts show every bin."""
- normalized = [
- (
- label,
- df.with_columns(pl.col("household_size").cast(pl.Utf8))
- .filter(pl.col("household_size") != "1")
- .select("household_size", value_col),
- )
- for label, df in nonempty(data_list)
- ]
- return complete_category_counts(
- normalized,
- category_col="household_size",
- category_values=household_size_values,
- value_cols=(value_col,),
- )
-
-
-def ordered_composition(df: pl.DataFrame) -> pl.DataFrame:
- """Order composition categories as adults, mixed, then children."""
- if len(df) == 0 or "tour_composition" not in df.columns:
- return df
- return (
- df.with_columns(
- pl.col("tour_composition").cast(pl.Utf8).str.to_lowercase().alias("tour_composition")
- )
- .with_columns(
- pl.when(pl.col("tour_composition") == "adults")
- .then(0)
- .when(pl.col("tour_composition") == "mixed")
- .then(1)
- .when(pl.col("tour_composition") == "children")
- .then(2)
- .otherwise(99)
- .alias("_ord")
- )
- .sort("_ord")
- .drop("_ord")
- )
-
-
-def composition_by_party_size_data(
- data_list: list[tuple[str, pl.DataFrame]],
- party_size: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Filter or aggregate joint-tour composition data by party size."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("party_size").cast(pl.Utf8))
- if party_size == "All":
- filtered = (
- filtered.group_by("tour_composition")
- .agg(joint_tour_count=pl.col("joint_tour_count").sum())
- .with_columns(pl.col("tour_composition").cast(pl.Utf8))
- .sort("tour_composition")
- )
- else:
- filtered = filtered.filter(pl.col("party_size") == party_size)
- out.append((label, ordered_composition(filtered)))
- return out
-
-
-def household_participation_data(
- data_list: list[tuple[str, pl.DataFrame]],
- household_size: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Filter household participation rows to one household size or aggregate all sizes."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(
- pl.col("household_size").cast(pl.Utf8),
- pl.col("jtf").cast(pl.Utf8),
- )
- if household_size == "All":
- filtered = (
- filtered.group_by("jtf")
- .agg(household_percent=pl.col("household_percent").mean())
- .with_columns(pl.col("jtf").cast(pl.Utf8))
- .sort("jtf")
- )
- else:
- filtered = filtered.filter(pl.col("household_size") == household_size)
- out.append((label, filtered))
- return out
-
-
-def person_participation_data(
- data_list: list[tuple[str, pl.DataFrame]],
- *,
- as_percent: bool,
-) -> list[tuple[str, pl.DataFrame]]:
- """Return joint-tour person participation counts or rates by household size."""
- out = []
- for label, df in nonempty(data_list):
- base = df.with_columns(pl.col("household_size").cast(pl.Utf8))
- if as_percent:
- base = base.with_columns(
- pl.when(pl.col("total_person_count") > 0)
- .then(pl.col("joint_tour_person_count") / pl.col("total_person_count") * 100.0)
- .otherwise(0.0)
- .alias("person_value")
- )
- else:
- base = base.with_columns(pl.col("joint_tour_person_count").alias("person_value"))
- out.append((label, base))
- return out
+from dashboard import DashboardPage, dashboard_page
+from dashboard.pages._joint_travel_data import (
+ JOINT_SIZE_VALUES,
+ complete_joint_household_size_data,
+ composition_by_party_size_data,
+ household_participation_data,
+ household_size_options,
+ joint_household_size_values,
+ joint_party_size_data,
+ joint_tour_frequency_data,
+ party_size_options,
+ person_participation_data,
+)
+@dashboard_page(
+ page_id="joint_travel",
+ title="Joint Travel",
+ order=40,
+ required_summary_ids=(
+ "jtf_distribution",
+ "joint_tours_by_household_size",
+ "joint_tour_party_size_distribution",
+ "joint_tour_composition_by_party_size",
+ "person_jtp_by_household_size",
+ "household_jtp_by_household_size_and_jtf",
+ ),
+)
class JointTravelPage(DashboardPage):
def build_page(self) -> pn.viewable.Viewable:
- party_opts = self._party_size_options()
- hh_opts = self._household_size_options()
- self.party_size_sel = self.selector(
+ self.hide_no_joint_tours = self.selector(
+ "hide_no_joint_tours",
+ widget=pn.widgets.Checkbox(name='Hide "No Joint Tours"', value=False),
+ label='Hide "No Joint Tours"',
+ )
+ self.party_size_sel = self.select(
"party_size",
- widget=pn.widgets.Select(
- name="Party Size",
- options=party_opts,
- value=party_opts[0],
- ),
- label="Party Size",
+ "Party Size",
+ options=self._party_size_options,
)
- self.hhsize_sel = self.selector(
+ self.hhsize_sel = self.select(
"household_size",
- widget=pn.widgets.Select(
- name="Household Size",
- options=hh_opts,
- value=hh_opts[0],
- ),
- label="Household Size",
+ "Household Size",
+ options=self._household_size_options,
)
self._frequency_section = self.section(
"joint_travel_frequency",
+ selectors=("hide_no_joint_tours",),
render=self.render_frequency,
)
self._joint_tour_detail_section = self.section(
@@ -209,40 +72,30 @@ def build_page(self) -> pn.viewable.Viewable:
)
return self.new_section(
pn.pane.Markdown("## Joint Travel"),
+ pn.pane.Markdown("### Joint Tour Frequency"),
self._frequency_section,
+ pn.pane.Markdown("### Joint Tour Characteristics"),
self._joint_tour_detail_section,
+ pn.pane.Markdown("### Joint Tour Participation"),
self._participation_section,
)
def _party_size_options(self) -> list[str]:
- data = self.state.get_summary_table_set(
+ data = self.data.summary(
"joint_tour_composition_by_party_size",
- "weighted",
+ self.weighting_key,
)
return party_size_options(data) if data is not None else ["All"]
def _household_size_options(self) -> list[str]:
- data = self.state.get_summary_table_set(
+ data = self.data.summary(
"household_jtp_by_household_size_and_jtf",
- "weighted",
+ self.weighting_key,
)
return household_size_options(data) if data is not None else ["All"]
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
- party_opts = party_size_options(summaries["joint_tour_composition_by_party_size"])
- hh_opts = household_size_options(summaries["household_jtp_by_household_size_and_jtf"])
- self.party_size_sel.options = party_opts
- if self.party_size_sel.value not in party_opts:
- self.party_size_sel.value = party_opts[0]
- self.hhsize_sel.options = hh_opts
- if self.hhsize_sel.value not in hh_opts:
- self.hhsize_sel.value = hh_opts[0]
-
def _summaries(self):
- return self.require_summaries(*self.required_summary_ids)
+ return self.data.summaries(*self.required_summary_ids)
def _values_for_column(
self,
@@ -253,7 +106,9 @@ def _values_for_column(
{
str(value)
for _, df in data_list
- for value in (df[column].cast(pl.Utf8).to_list() if column in df.columns else [])
+ for value in (
+ df[column].cast(pl.Utf8).to_list() if column in df.columns else []
+ )
}
)
@@ -264,8 +119,11 @@ def render_frequency(self):
if summaries is None:
return [self.summary_only_unavailable_card()]
return [
- pn.pane.Markdown("### Joint Tour Frequency"),
- self.render_joint_tour_frequency_chart(summaries["jtf_distribution"]),
+ selector_row(self.hide_no_joint_tours, height=48),
+ self.noted_view(
+ "joint_travel.frequency",
+ self.render_joint_tour_frequency_chart(summaries["jtf_distribution"]),
+ ),
]
def render_joint_tour_detail(self):
@@ -277,51 +135,60 @@ def render_joint_tour_detail(self):
(label, df.with_columns(pl.col("household_size").cast(pl.Utf8)))
for label, df in nonempty(summaries["joint_tours_by_household_size"])
]
- party_size_data = [
- (label, df.with_columns(pl.col("party_size").cast(pl.Utf8)))
- for label, df in nonempty(summaries["joint_tour_party_size_distribution"])
- ]
+ party_size_data = joint_party_size_data(
+ summaries["joint_tour_party_size_distribution"]
+ )
household_size_values = joint_household_size_values(
joint_tours_hhsize_data,
summaries["person_jtp_by_household_size"],
)
- party_size_values = self._values_for_column(party_size_data, "party_size")
+ party_size_values = JOINT_SIZE_VALUES.copy()
composition_label_values = self.config.ordered_labels(
"tour_composition",
[
str(value)
for _, df in nonempty(summaries["joint_tour_composition_by_party_size"])
- for value in (df["tour_composition"].cast(pl.Utf8).to_list() if "tour_composition" in df.columns else [])
+ for value in (
+ df["tour_composition"].cast(pl.Utf8).to_list()
+ if "tour_composition" in df.columns
+ else []
+ )
],
)
- comp_party_data = self.get_filtered_view(
- "joint_tour_composition_by_party_size",
- party_size,
- factory=lambda: composition_by_party_size_data(
+ comp_party_data = self.query(
+ lambda: composition_by_party_size_data(
summaries["joint_tour_composition_by_party_size"], party_size
- ),
+ )
)
return [
- pn.pane.Markdown("### Joint Tour Characteristics"),
pn.Column(
selector_row(self.party_size_sel),
pn.Row(
- self.render_household_size_chart(
- complete_joint_household_size_data(
- joint_tours_hhsize_data,
- value_col="joint_tour_hh_count",
- household_size_values=household_size_values,
+ self.noted_view(
+ "joint_travel.household_size",
+ self.render_household_size_chart(
+ complete_joint_household_size_data(
+ joint_tours_hhsize_data,
+ value_col="joint_tour_hh_count",
+ household_size_values=household_size_values,
+ ),
+ household_size_values,
),
- household_size_values,
),
- self.render_party_size_chart(
- party_size_data,
- party_size_values,
+ self.noted_view(
+ "joint_travel.party_size",
+ self.render_party_size_chart(
+ party_size_data,
+ party_size_values,
+ ),
),
- self.render_composition_chart(
- comp_party_data,
- composition_label_values,
- party_size,
+ self.noted_view(
+ "joint_travel.composition",
+ self.render_composition_chart(
+ comp_party_data,
+ composition_label_values,
+ party_size,
+ ),
),
sizing_mode="stretch_width",
),
@@ -334,20 +201,16 @@ def render_participation(self):
if summaries is None:
return []
hhsize = self.hhsize_sel.value
- person_participation = self.get_filtered_view(
- "person_jtp_by_household_size",
- self.as_percent,
- factory=lambda: person_participation_data(
+ person_participation = self.query(
+ lambda: person_participation_data(
summaries["person_jtp_by_household_size"],
as_percent=self.as_percent,
- ),
+ )
)
- household_participation = self.get_filtered_view(
- "household_jtp_by_household_size_and_jtf",
- hhsize,
- factory=lambda: household_participation_data(
+ household_participation = self.query(
+ lambda: household_participation_data(
summaries["household_jtp_by_household_size_and_jtf"], hhsize
- ),
+ )
)
household_size_values = joint_household_size_values(
person_participation,
@@ -357,12 +220,15 @@ def render_participation(self):
"jtf",
[
str(value)
- for _, df in nonempty(summaries["household_jtp_by_household_size_and_jtf"])
- for value in (df["jtf"].cast(pl.Utf8).to_list() if "jtf" in df.columns else [])
+ for _, df in nonempty(
+ summaries["household_jtp_by_household_size_and_jtf"]
+ )
+ for value in (
+ df["jtf"].cast(pl.Utf8).to_list() if "jtf" in df.columns else []
+ )
],
)
return [
- pn.pane.Markdown("### Joint Tour Participation"),
pn.Column(
pn.Row(
pn.Column(control_row_spacer()),
@@ -370,18 +236,24 @@ def render_participation(self):
sizing_mode="stretch_width",
),
pn.Row(
- self.render_person_participation_chart(
- complete_joint_household_size_data(
- person_participation,
- value_col="person_value",
- household_size_values=household_size_values,
+ self.noted_view(
+ "joint_travel.person_participation",
+ self.render_person_participation_chart(
+ complete_joint_household_size_data(
+ person_participation,
+ value_col="person_value",
+ household_size_values=household_size_values,
+ ),
+ household_size_values,
),
- household_size_values,
),
- self.render_household_participation_chart(
- household_participation,
- jtf_values,
- hhsize,
+ self.noted_view(
+ "joint_travel.household_participation",
+ self.render_household_participation_chart(
+ household_participation,
+ jtf_values,
+ hhsize,
+ ),
),
sizing_mode="stretch_width",
),
@@ -391,41 +263,47 @@ def render_participation(self):
def render_joint_tour_frequency_chart(self, summary_data):
"""Render joint tour frequency by joint tour pattern."""
- return bar_chart(
- nonempty(summary_data),
- x_col="jtf_label",
- y_col="household_count",
+ frequency_data = self.query(
+ lambda: joint_tour_frequency_data(
+ summary_data,
+ hide_no_joint_tours=bool(self.hide_no_joint_tours.value),
+ )
+ )
+ return self.plot.bar(
+ frequency_data,
+ x="jtf_label",
+ y="household_count",
title="Joint Tour Frequency by Joint Tour Pattern",
- xaxis_title="Joint Tour Pattern",
- yaxis_title="Households",
+ x_title="Joint Tour Pattern",
+ y_title="Households",
height=450,
- as_percent=self.as_percent,
+ share_y="household_count_percent",
)
- def render_household_size_chart(self, summary_data, household_size_values: list[str]):
+ def render_household_size_chart(
+ self, summary_data, household_size_values: list[str]
+ ):
"""Render joint tours by household size."""
- return bar_chart(
+ return self.plot.bar(
summary_data,
- "household_size",
- "joint_tour_hh_count",
- "Joint Tours by Household Size",
- "Household Size",
- yaxis_title="Households with a Joint Tour",
- as_percent=self.as_percent,
- xaxis_categoryarray=household_size_values,
+ x="household_size",
+ y="joint_tour_hh_count",
+ title="Joint Tours by Household Size",
+ x_title="Household Size",
+ y_title="Households with a Joint Tour",
+ category_order=household_size_values,
)
def render_party_size_chart(self, summary_data, party_size_values: list[str]):
"""Render joint tours by party size."""
- return bar_chart(
+ return self.plot.bar(
summary_data,
- "party_size",
- "joint_tour_count",
- "Joint Tours by Party Size",
- "Party Size",
- yaxis_title="Joint Tours",
- as_percent=self.as_percent,
- xaxis_categoryarray=party_size_values,
+ x="party_size",
+ y="joint_tour_count",
+ title="Joint Tours by Party Size",
+ x_title="Party Size",
+ y_title="Joint Tours",
+ category_order=party_size_values,
)
def render_composition_chart(
@@ -435,7 +313,7 @@ def render_composition_chart(
party_size: str,
):
"""Render joint tour composition for one selected party size."""
- return bar_chart(
+ return self.plot.bar(
label_category_data(
summary_data,
source_col="tour_composition",
@@ -443,13 +321,12 @@ def render_composition_chart(
config=self.config,
target_col="tour_composition_label",
),
- "tour_composition_label",
- "joint_tour_count",
- f"Joint Tour Composition by Party Size - {party_size}",
- "Tour Composition",
- yaxis_title="Joint Tours",
- as_percent=self.as_percent,
- xaxis_categoryarray=composition_label_values,
+ x="tour_composition_label",
+ y="joint_tour_count",
+ title=f"Joint Tour Composition by Party Size - {party_size}",
+ x_title="Tour Composition",
+ y_title="Joint Tours",
+ category_order=composition_label_values,
)
def render_person_participation_chart(
@@ -458,17 +335,19 @@ def render_person_participation_chart(
household_size_values: list[str],
):
"""Render people participating in joint travel by household size."""
- return bar_chart(
+ return self.plot.bar(
summary_data,
- "household_size",
- "person_value",
- "People Taking Part in a Joint Tour by Household Size",
- "Household Size",
- yaxis_title=(
- "Percent of People (%)" if self.as_percent else "People Taking Joint Tours"
+ x="household_size",
+ y="person_value",
+ title="People Taking Part in a Joint Tour by Household Size",
+ x_title="Household Size",
+ y_title=(
+ "Percent of People (%)"
+ if self.as_percent
+ else "People Taking Joint Tours"
),
- as_percent=False,
- xaxis_categoryarray=household_size_values,
+ value_mode="count",
+ category_order=household_size_values,
)
def render_household_participation_chart(
@@ -478,7 +357,7 @@ def render_household_participation_chart(
household_size: str,
):
"""Render household participation in joint tours for one household size."""
- return bar_chart(
+ return self.plot.bar(
label_category_data(
summary_data,
source_col="jtf",
@@ -486,29 +365,11 @@ def render_household_participation_chart(
config=self.config,
target_col="jtf_label",
),
- "jtf_label",
- "household_percent",
- f"Households Taking Part in a Joint Tour - {household_size}",
- "Joint Tour Count",
- yaxis_title="Percent of Households (%)",
- as_percent=False,
- xaxis_categoryarray=jtf_values,
+ x="jtf_label",
+ y="household_percent",
+ title=f"Households Taking Part in a Joint Tour - {household_size}",
+ x_title="Joint Tour Count",
+ y_title="Percent of Households (%)",
+ value_mode="count",
+ category_order=jtf_values,
)
-
-
-PAGE = DashboardPageDefinition(
- page_id="joint_travel",
- title="Joint Travel",
- order=40,
- page_cls=JointTravelPage,
- required_summary_ids=(
- "jtf_distribution",
- "joint_tours_by_household_size",
- "joint_tour_party_size_distribution",
- "joint_tour_composition_by_party_size",
- "person_jtp_by_household_size",
- "household_jtp_by_household_size_and_jtf",
- ),
-)
-
-JointTravelPage.definition = PAGE
diff --git a/dashboard/pages/long_term_choices/_mandatory_location_choice/__init__.py b/dashboard/pages/long_term_choices/_mandatory_location_choice/__init__.py
new file mode 100644
index 0000000..5e3d5be
--- /dev/null
+++ b/dashboard/pages/long_term_choices/_mandatory_location_choice/__init__.py
@@ -0,0 +1,5 @@
+"""Private implementation package for Mandatory Location Choice."""
+
+from dashboard.pages.long_term_choices._mandatory_location_choice_support import *
+
+__all__ = [name for name in globals() if not name.startswith("__")]
diff --git a/dashboard/pages/long_term_choices/_mandatory_location_choice/composition.py b/dashboard/pages/long_term_choices/_mandatory_location_choice/composition.py
new file mode 100644
index 0000000..762e241
--- /dev/null
+++ b/dashboard/pages/long_term_choices/_mandatory_location_choice/composition.py
@@ -0,0 +1,83 @@
+"""Page composition for Mandatory Location Choice."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.helpers.geography_helpers import *
+from dashboard.page_base import SectionContent
+from dashboard.rendering import data_table, selector_row
+from dashboard.pages.long_term_choices._mandatory_location_choice_support import *
+
+
+class MandatoryLocationCompositionMixin:
+ def build_page(self) -> pn.viewable.Viewable:
+ """Build the persistent selectors and stable section containers."""
+ self._current_data: dict[str, object] = {}
+ self._geo_level_raw_by_label: dict[str, str | None] = {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ }
+ self._geography_raw_by_label: dict[str, str | None] = {
+ ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE
+ }
+ self.geo_level_sel = self.select(
+ "geography_level",
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._geography_level_options,
+ )
+ self.geography_sel = self.select(
+ "geography",
+ GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=self._geography_options,
+ )
+ self.mandatory_distance_range = DistanceRangeControls.create(
+ self,
+ "mandatory_distance",
+ max_options=capped_distance_max_options(),
+ reset_label="Reset distance range",
+ )
+ geography = self.feature("geography_comparison")
+ flows = self.feature("flows")
+ distance = self.feature("distance")
+ remote_work = self.feature("remote_work")
+ self._remote_work_section = remote_work.section(
+ "body",
+ selectors=("geography_level", "geography"),
+ render=self.render_remote_work_section,
+ )
+ self._distance_section = distance.section(
+ "distribution",
+ selectors=(
+ "geography_level",
+ "geography",
+ *self.mandatory_distance_range.selector_ids,
+ ),
+ render=self.render_distance_distribution_section,
+ )
+ self._worker_section = flows.section(
+ "body",
+ selectors=("geography_level", "geography"),
+ render=self.render_worker_geography_section,
+ )
+ self._mandatory_distance_table_section = geography.section(
+ "body",
+ selectors=("geography_level", "geography"),
+ render=self.render_mandatory_distance_table_section,
+ )
+
+ return self.new_section(
+ pn.pane.Markdown("## Mandatory Location Choice"),
+ selector_row(self.geo_level_sel, self.geography_sel),
+ self._remote_work_section,
+ self._distance_section,
+ self._worker_section,
+ self._mandatory_distance_table_section,
+ )
diff --git a/dashboard/pages/long_term_choices/_mandatory_location_choice/domains.py b/dashboard/pages/long_term_choices/_mandatory_location_choice/domains.py
new file mode 100644
index 0000000..888f310
--- /dev/null
+++ b/dashboard/pages/long_term_choices/_mandatory_location_choice/domains.py
@@ -0,0 +1,282 @@
+"""Geography, export, and data ownership for Mandatory Location Choice."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.helpers.geography_helpers import *
+from dashboard.page_base import SectionContent
+from dashboard.rendering import data_table, selector_row
+from dashboard.pages.long_term_choices._mandatory_location_choice_support import *
+
+
+class MandatoryLocationDomainMixin:
+ def on_global_state_changed(self) -> None:
+ """Invalidate page-local caches when the dashboard's global state changes."""
+ self.clear_query_cache()
+ self._current_data = self._collect_data()
+
+ def _geography_level_options(self) -> list[str]:
+ """Return available geography levels and refresh their raw mapping."""
+ if not self._current_data:
+ self._current_data = self._collect_data()
+ geo_opts = self._current_data["geo_opts"]
+ self._geo_level_raw_by_label = self._current_data["geo_raw_by_label"]
+ return geo_opts
+
+ def _geography_options(self) -> list[str]:
+ """Return names valid for the selected geography level."""
+ if not self._current_data:
+ self._current_data = self._collect_data()
+ geography_opts_by_level = self._current_data["geography_opts_by_level"]
+ selected_geo_level = self.selected_geography_level_raw()
+ if self.state.export_mode:
+ geography_opts, self._geography_raw_by_label = (
+ export_geography_name_options(
+ geography_opts_by_level,
+ config=self.config,
+ )
+ )
+ else:
+ geography_opts, self._geography_raw_by_label = geography_opts_by_level.get(
+ selected_geo_level,
+ (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ ),
+ )
+ if getattr(self, "geography_sel", None) is not None:
+ self.geography_sel.name = geography_name_selector_label(
+ selected_geo_level,
+ config=self.config,
+ )
+ return geography_opts
+
+ def selected_geography_level_raw(self) -> str:
+ """Return the raw geography type value selected in the display selector."""
+ selected = str(self.geo_level_sel.value)
+ raw_value = self._geo_level_raw_by_label.get(selected, selected)
+ return ALL_GEOGRAPHY_TYPES_VALUE if raw_value is None else str(raw_value)
+
+ def selected_geography_raw(self) -> str:
+ """Return the raw geography name/id value selected in the display selector."""
+ selected = str(self.geography_sel.value)
+ raw_value = self._geography_raw_by_label.get(selected, selected)
+ return ALL_WITHIN_LEVEL_VALUE if raw_value is None else str(raw_value)
+
+ def export_canonical_selector_value(
+ self,
+ section_id: str,
+ selector_id: str,
+ value: str,
+ selected_values: dict[str, str],
+ ) -> str:
+ if selector_id != "geography":
+ return value
+
+ selected_geo_level = selected_values.get("geography_level")
+ raw_geo_level = self._geo_level_raw_by_label.get(
+ str(selected_geo_level),
+ selected_geo_level,
+ )
+ raw_geo_level = (
+ ALL_GEOGRAPHY_TYPES_VALUE if raw_geo_level is None else str(raw_geo_level)
+ )
+ geography_opts_by_level = self._current_data.get("geography_opts_by_level", {})
+ _, raw_by_label = geography_opts_by_level.get(
+ raw_geo_level,
+ (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ ),
+ )
+ raw_geography = self._geography_raw_by_label.get(value, value)
+ raw_geography = (
+ ALL_WITHIN_LEVEL_VALUE if raw_geography is None else str(raw_geography)
+ )
+ valid_values = {str(raw) for raw in raw_by_label.values() if raw is not None}
+ if raw_geography in valid_values:
+ return value
+ return ALL_WITHIN_LEVEL_VALUE
+
+ def export_selector_dependencies(self) -> dict[str, dict[str, object]]:
+ """Describe the Geography Name selector's export-time dependency."""
+ geography_opts_by_level = self._current_data.get("geography_opts_by_level", {})
+ options_by_geography_type: dict[str, list[str]] = {}
+ disabled_geography_types: list[str] = []
+
+ for display_level, raw_level_value in self._geo_level_raw_by_label.items():
+ raw_level = (
+ ALL_GEOGRAPHY_TYPES_VALUE
+ if raw_level_value is None
+ else str(raw_level_value)
+ )
+ level_options, raw_by_label = geography_opts_by_level.get(
+ raw_level,
+ (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ ),
+ )
+ export_options = [ALL_WITHIN_LEVEL_VALUE]
+ for option in level_options:
+ raw_value = raw_by_label.get(str(option), str(option))
+ if raw_value is None:
+ continue
+ raw_value_str = str(raw_value)
+ if raw_value_str == ALL_WITHIN_LEVEL_VALUE or is_all_geographies(
+ raw_value_str
+ ):
+ continue
+ if str(option) not in export_options:
+ export_options.append(str(option))
+ options_by_geography_type[str(display_level)] = export_options
+ if raw_level == ALL_GEOGRAPHY_TYPES_VALUE:
+ disabled_geography_types.append(str(display_level))
+
+ return {
+ "geography": {
+ "parent_selector_id": "geography_level",
+ "options_by_parent_value": options_by_geography_type,
+ "disabled_parent_values": disabled_geography_types,
+ }
+ }
+
+ def _selected_geography(self) -> tuple[str, str]:
+ """Return the effective geography selection, honoring export-mode flattening."""
+ geo_level = self.selected_geography_level_raw()
+ geography = self.selected_geography_raw()
+ if not self.state.export_mode:
+ return geo_level, geography
+
+ geography_opts_by_level = self._current_data.get("geography_opts_by_level", {})
+ _, raw_by_label = geography_opts_by_level.get(
+ geo_level,
+ (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ ),
+ )
+ valid_options = {
+ str(value) for value in raw_by_label.values() if value is not None
+ }
+ if geography in valid_options:
+ return geo_level, geography
+ return geo_level, ALL_WITHIN_LEVEL_VALUE
+
+ def _collect_data(self) -> dict[str, object]:
+ """Collect and normalize every summary used by the page."""
+ if not self.state.run_labels:
+ return {
+ "mode": "no_runs",
+ "geo_opts": [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ },
+ "geography_opts_by_level": {
+ ALL_GEOGRAPHY_TYPES_VALUE: (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ )
+ },
+ }
+
+ summaries = self.data.summaries(
+ "internal_external_worker_by_geography",
+ "external_worker_workplace_locations",
+ "work_from_home_rate_by_geography",
+ "telecommute_frequency_distribution",
+ "work_location_distance_distribution_by_geography",
+ "school_location_distance_distribution_by_geography",
+ "university_location_distance_distribution_by_geography",
+ "average_mandatory_tour_distance_by_purpose_and_geography",
+ )
+
+ if not any(summary is not None for summary in summaries.values()):
+ return {
+ "mode": "unavailable",
+ "geo_opts": [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ },
+ "geography_opts_by_level": {
+ ALL_GEOGRAPHY_TYPES_VALUE: (
+ [ALL_WITHIN_LEVEL_VALUE],
+ {ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE},
+ )
+ },
+ }
+
+ internal_external = normalize_geography_data(
+ summaries["internal_external_worker_by_geography"]
+ )
+ external_workplace = adapt_external_workplace(
+ summaries["external_worker_workplace_locations"]
+ )
+ work_from_home = normalize_geography_data(
+ summaries["work_from_home_rate_by_geography"]
+ )
+ telecommute = normalize_geography_data(
+ summaries["telecommute_frequency_distribution"]
+ )
+ work_distance = normalize_geography_data(
+ summaries["work_location_distance_distribution_by_geography"]
+ )
+ school_distance = normalize_geography_data(
+ summaries["school_location_distance_distribution_by_geography"]
+ )
+ university_distance = normalize_geography_data(
+ summaries["university_location_distance_distribution_by_geography"]
+ )
+ average_distance = normalize_geography_data(
+ summaries["average_mandatory_tour_distance_by_purpose_and_geography"]
+ )
+
+ geo_opts, geo_raw_by_label = geography_type_options(
+ internal_external or None,
+ work_from_home or None,
+ work_distance or None,
+ school_distance or None,
+ university_distance or None,
+ average_distance or None,
+ config=self.config,
+ include_all_types=True,
+ )
+ geography_option_sources = (
+ internal_external or None,
+ work_distance or None,
+ school_distance or None,
+ university_distance or None,
+ average_distance or None,
+ )
+ geography_opts_by_level = {
+ str(raw_geo_level): geography_name_options_for_type(
+ str(raw_geo_level),
+ *geography_option_sources,
+ config=self.config,
+ )
+ for raw_geo_level in geo_raw_by_label.values()
+ if raw_geo_level is not None
+ }
+ return {
+ "mode": "ready",
+ "geo_opts": geo_opts,
+ "geo_raw_by_label": geo_raw_by_label,
+ "geography_opts_by_level": geography_opts_by_level,
+ "internal_external": internal_external or None,
+ "external_workplace": external_workplace or None,
+ "work_from_home": work_from_home or None,
+ "telecommute": telecommute or None,
+ "work_distance": work_distance or None,
+ "school_distance": school_distance or None,
+ "university_distance": university_distance or None,
+ "average_distance": average_distance or None,
+ }
diff --git a/dashboard/pages/long_term_choices/_mandatory_location_choice/features.py b/dashboard/pages/long_term_choices/_mandatory_location_choice/features.py
new file mode 100644
index 0000000..45ace21
--- /dev/null
+++ b/dashboard/pages/long_term_choices/_mandatory_location_choice/features.py
@@ -0,0 +1,473 @@
+"""Feature rendering for Mandatory Location Choice."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
+from dashboard.helpers.geography_helpers import *
+from dashboard.page_base import SectionContent
+from dashboard.rendering import data_table, selector_row
+from dashboard.pages.long_term_choices._mandatory_location_choice_support import *
+
+
+class MandatoryLocationFeatureMixin:
+ def _render_ready_state(self) -> SectionContent | None:
+ """Return a shared top-level placeholder for no-runs or no-summary states."""
+ mode = self._current_data["mode"]
+ if mode == "no_runs":
+ return [self.no_runs_message()]
+ if mode == "unavailable":
+ return [self.summary_only_unavailable_card()]
+ return None
+
+ def render_worker_geography_section(self) -> SectionContent:
+ """Render internal/external worker comparisons and external workplace charts."""
+ placeholder = self._render_ready_state()
+ if placeholder is not None:
+ return placeholder
+
+ geo_level, geography = self._selected_geography()
+ worker_views: list[pn.viewable.Viewable] = []
+ internal_external = self._current_data["internal_external"]
+ if internal_external is not None:
+ internal_external_table = self.query(
+ lambda: filter_selected_geography(
+ internal_external,
+ geo_level,
+ geography,
+ )
+ )
+ worker_views.append(
+ self.noted_view(
+ "mandatory_location.worker_status_table",
+ data_table(
+ [
+ (label, self.render_internal_external_worker_table(df))
+ for label, df in internal_external_table
+ ],
+ "Internal vs. External Workers",
+ ),
+ )
+ )
+ else:
+ worker_views.append(
+ self.data_not_available_card(
+ detail="The internal/external worker summary is unavailable.",
+ missing_items=["internal_external_worker_by_geography"],
+ )
+ )
+
+ worker_views.append(
+ self.noted_view(
+ "mandatory_location.external_workplace",
+ self.render_external_workplace_chart(geo_level, geography),
+ )
+ )
+ return worker_views
+
+ def render_internal_external_worker_table(self, df: pl.DataFrame) -> pl.DataFrame:
+ """Return a display-ready internal/external worker geography table."""
+ display_df = with_display_geography_columns(df, config=self.config)
+ columns = [
+ column
+ for column in (
+ "Geography Type",
+ "Geography Name",
+ "internal_worker_count",
+ "external_worker_count",
+ )
+ if column in display_df.columns
+ ]
+ return display_df.select(columns) if columns else display_df
+
+ def render_external_workplace_chart(
+ self,
+ geo_level: str,
+ geography: str,
+ ) -> pn.viewable.Viewable:
+ """Render workplace locations for workers with external jobs."""
+ external_workplace = self._current_data["external_workplace"]
+ if external_workplace is None:
+ return self.data_not_available_card(
+ detail="The external workplace summary is unavailable.",
+ missing_items=["external_worker_workplace_locations"],
+ )
+
+ external_workplace_level_data = self.query(
+ lambda: filter_geography_level(external_workplace, geo_level)
+ )
+ filtered_external_workplace = self.query(
+ lambda: filter_geography(external_workplace_level_data, geography)
+ )
+ if not any(not df.is_empty() for _, df in filtered_external_workplace):
+ return self.data_not_available_card(
+ detail=(
+ "No external workplace location data is available for the selected "
+ "geography. This summary can render MPO, County, or other configured "
+ "workplace geographies only when the prepared person data includes the "
+ "corresponding work geography columns."
+ ),
+ missing_items=["external_worker_workplace_locations"],
+ )
+ chart_data = filtered_external_workplace
+ if self.as_percent:
+ chart_data = self.query(
+ lambda: external_workplace_percent_data(
+ filtered_external_workplace,
+ geo_level,
+ )
+ )
+ chart_data = [
+ (
+ label,
+ df.with_columns(
+ pl.col("workplace_location")
+ .cast(pl.Utf8)
+ .map_elements(
+ lambda value: geography_name_label(value, config=self.config),
+ return_dtype=pl.Utf8,
+ )
+ .alias("workplace_location_label")
+ ),
+ )
+ for label, df in chart_data
+ if df is not None and "workplace_location" in df.columns
+ ]
+ workplace_location_values = sorted(
+ {
+ str(value)
+ for _, df in chart_data
+ for value in (
+ df["workplace_location_label"].cast(pl.Utf8).to_list()
+ if "workplace_location_label" in df.columns
+ else []
+ )
+ }
+ )
+
+ return self.plot.bar(
+ chart_data,
+ x="workplace_location_label",
+ y=(
+ "external_worker_percent"
+ if self.as_percent and is_all_geographies(geo_level)
+ else "person_count"
+ ),
+ title="External Worker Workplace Location",
+ x_title="Workplace Location",
+ y_title=(
+ "Workers with External Workplaces (%)"
+ if self.as_percent and is_all_geographies(geo_level)
+ else "External Workers"
+ ),
+ value_mode="count" if is_all_geographies(geo_level) else "dashboard",
+ category_order=workplace_location_values,
+ )
+
+ def render_distance_distribution_section(self) -> SectionContent:
+ """Render the three mandatory distance distributions side by side."""
+ if self._current_data["mode"] != "ready":
+ return []
+
+ geo_level, geography = self._selected_geography()
+ chart_specs = [
+ {
+ "summary_data": self._current_data["work_distance"],
+ "title": "Workplace Location Distance Distribution",
+ "yaxis_title": "Workplace Locations",
+ "summary_id": "work_location_distance_distribution_by_geography",
+ "note_id": "mandatory_location.work_distance",
+ },
+ {
+ "summary_data": self._current_data["school_distance"],
+ "title": "School Location Distance Distribution",
+ "yaxis_title": "School Locations",
+ "summary_id": "school_location_distance_distribution_by_geography",
+ "note_id": "mandatory_location.school_distance",
+ },
+ {
+ "summary_data": self._current_data["university_distance"],
+ "title": "University Location Distance Distribution",
+ "yaxis_title": "University Locations",
+ "summary_id": "university_location_distance_distribution_by_geography",
+ "note_id": "mandatory_location.university_distance",
+ },
+ ]
+ prepared_charts = [
+ (
+ spec,
+ self.distance_distribution_chart_data(
+ geo_level,
+ geography,
+ summary_data=spec["summary_data"],
+ ),
+ )
+ for spec in chart_specs
+ ]
+ observed_bounds = distance_axis_bounds(
+ [
+ item
+ for _, distance_data in prepared_charts
+ if distance_data is not None
+ for item in distance_data
+ ]
+ )
+ bounds = (0.0, 40.0) if observed_bounds is not None else None
+ self.mandatory_distance_range.sync(
+ (geo_level, geography, self.weighting_key),
+ bounds,
+ )
+ x_range = self.mandatory_distance_range.current_range()
+ if bounds is not None and x_range is None:
+ return [
+ pn.pane.Markdown("### Mandatory Location Distance"),
+ self.mandatory_distance_range.row(),
+ self.data_not_available_card(
+ detail="Mandatory location distance controls require finite values with min less than max.",
+ title="Mandatory Location Distance Data Not Available",
+ ),
+ ]
+ return [
+ pn.pane.Markdown("### Mandatory Location Distance"),
+ self.mandatory_distance_range.row(),
+ pn.Row(
+ *[
+ self.noted_view(
+ spec["note_id"],
+ self.render_distance_distribution_chart(
+ geo_level,
+ geography,
+ summary_data=spec["summary_data"],
+ title=spec["title"],
+ yaxis_title=spec["yaxis_title"],
+ summary_id=spec["summary_id"],
+ distance_data=distance_data,
+ x_range=x_range,
+ ),
+ )
+ for spec, distance_data in prepared_charts
+ ],
+ sizing_mode="stretch_width",
+ ),
+ ]
+
+ def distance_distribution_chart_data(
+ self,
+ geo_level: str,
+ geography: str,
+ *,
+ summary_data: list[tuple[str, pl.DataFrame]] | None,
+ ) -> list[tuple[str, pl.DataFrame]] | None:
+ """Return chart-ready mandatory distance data for one summary."""
+ if summary_data is None:
+ return None
+ filtered_summary = self.query(
+ lambda: filter_selected_geography(
+ summary_data,
+ geo_level,
+ geography,
+ )
+ )
+ return self.query(lambda: distance_distribution_chart_data(filtered_summary))
+
+ def render_distance_distribution_chart(
+ self,
+ geo_level: str,
+ geography: str,
+ *,
+ summary_data: list[tuple[str, pl.DataFrame]] | None,
+ title: str,
+ yaxis_title: str,
+ summary_id: str,
+ distance_data: list[tuple[str, pl.DataFrame]] | None = None,
+ x_range: tuple[float, float] | None = None,
+ ) -> pn.viewable.Viewable:
+ """Render one distance-distribution chart or a targeted unavailable card."""
+ if summary_data is None or distance_data is None:
+ return self.data_not_available_card(
+ detail="The selected distance distribution summary is unavailable.",
+ missing_items=[summary_id],
+ )
+ if not any(not df.is_empty() for _, df in distance_data):
+ return self.data_not_available_card(
+ detail=(
+ f"No distance distribution data is available for geography "
+ f"`{geography}` at level `{geo_level}`."
+ ),
+ missing_items=[summary_id],
+ )
+
+ axis_data = with_distance_axis(distance_data)
+ tickvals, ticktext = fixed_distance_axis_ticks()
+ return self.plot.density(
+ axis_data,
+ x="_distance_axis",
+ y="person_count",
+ title=title,
+ x_title="Distance (miles)",
+ y_title=yaxis_title,
+ x_range=x_range,
+ tick_values=tickvals,
+ tick_text=ticktext,
+ )
+
+ def render_remote_work_section(self) -> SectionContent:
+ """Render work-from-home and telecommute summaries."""
+ if self._current_data["mode"] != "ready":
+ return []
+
+ geo_level, geography = self._selected_geography()
+ return [
+ pn.pane.Markdown("### Remote Work"),
+ pn.Row(
+ self.noted_view(
+ "mandatory_location.work_from_home",
+ self.render_work_from_home_chart(geo_level, geography),
+ ),
+ self.noted_view(
+ "mandatory_location.telecommute",
+ self.render_telecommute_chart(geo_level, geography),
+ ),
+ ),
+ ]
+
+ def render_work_from_home_chart(
+ self,
+ geo_level: str,
+ geography: str,
+ ) -> pn.viewable.Viewable:
+ """Render work-from-home counts or rates by geography."""
+ work_from_home = self._current_data["work_from_home"]
+ if work_from_home is None:
+ return self.data_not_available_card(
+ detail="The work-from-home summary is unavailable.",
+ missing_items=["work_from_home_rate_by_geography"],
+ )
+
+ wfh_data = self.query(
+ lambda: work_from_home_chart_data(
+ work_from_home,
+ geo_level,
+ geography,
+ )
+ )
+ return self.plot.bar(
+ wfh_data,
+ x="geography_label",
+ y=(
+ "work_from_home_percent"
+ if self.as_percent
+ else "work_from_home_worker_count"
+ ),
+ title=(
+ "Work From Home Rate by Geography"
+ if self.as_percent
+ else "Workers Working From Home by Geography"
+ ),
+ x_title="Geography",
+ y_title=(
+ "Workers Working From Home (%)"
+ if self.as_percent
+ else "Workers Working From Home"
+ ),
+ value_mode="count",
+ )
+
+ def render_telecommute_chart(
+ self,
+ geo_level: str,
+ geography: str,
+ ) -> pn.viewable.Viewable:
+ """Render telecommute frequency for workers who do not work from home."""
+ telecommute = self._current_data["telecommute"]
+ if telecommute is None:
+ return self.data_not_available_card(
+ detail="The telecommute summary is unavailable.",
+ missing_items=["telecommute_frequency_distribution"],
+ )
+
+ telecommute_level_data = self.query(
+ lambda: filter_geography_level(telecommute, geo_level)
+ )
+ telecommute_values = selected_telecommute_values(
+ telecommute_level_data,
+ config=self.config,
+ )
+ filtered_telecommute = self.query(
+ lambda: filter_geography(telecommute_level_data, geography)
+ )
+ chart_data = self.query(
+ lambda: telecommute_chart_data(
+ filtered_telecommute,
+ telecommute_values,
+ config=self.config,
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="telecommute_frequency_label",
+ y="person_count",
+ title="Telecommute Rate",
+ x_title="Telecommute Frequency",
+ y_title="Workers Who Do Not Work From Home",
+ category_order=self.config.ordered_labels(
+ "telecommute_frequency",
+ telecommute_values,
+ ),
+ )
+
+ def render_mandatory_distance_table_section(self) -> SectionContent:
+ """Render the percent-difference table for average mandatory tour distance."""
+ if self._current_data["mode"] != "ready":
+ return []
+
+ geo_level, geography = self._selected_geography()
+ average_distance = self._current_data["average_distance"]
+ if average_distance is None:
+ return [
+ self.data_not_available_card(
+ detail="The average mandatory tour distance summary is unavailable.",
+ missing_items=[
+ "average_mandatory_tour_distance_by_purpose_and_geography"
+ ],
+ )
+ ]
+
+ comparison_tables = self.query(
+ lambda: mandatory_distance_comparison_table(
+ average_distance,
+ geo_level,
+ geography,
+ config=self.config,
+ )
+ )
+ if not comparison_tables:
+ return [
+ self.data_not_available_card(
+ detail=(
+ f"No average mandatory tour distance data is available for "
+ f"geography `{geography}` at level `{geo_level}`."
+ ),
+ missing_items=[
+ "average_mandatory_tour_distance_by_purpose_and_geography"
+ ],
+ )
+ ]
+
+ return [
+ self.noted_view(
+ "mandatory_location.average_distance",
+ data_table(
+ comparison_tables,
+ "Average Mandatory Tour Distance vs Base Run",
+ ),
+ )
+ ]
diff --git a/dashboard/pages/long_term_choices/_mandatory_location_choice_support.py b/dashboard/pages/long_term_choices/_mandatory_location_choice_support.py
index 211c233..8f219bb 100644
--- a/dashboard/pages/long_term_choices/_mandatory_location_choice_support.py
+++ b/dashboard/pages/long_term_choices/_mandatory_location_choice_support.py
@@ -9,13 +9,16 @@
import polars as pl
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
+ cap_numeric_category_frame,
label_category_data,
nonempty,
ordered_category_values,
)
from dashboard.helpers.comparison_helpers import (
- build_base_run_percent_difference_table,
+ build_ab_comparison_row,
+ build_ab_comparison_table,
weighted_average_lookup,
)
from dashboard.helpers.geography_helpers import (
@@ -36,42 +39,37 @@ def adapt_external_workplace(
data_list: list[tuple[str, pl.DataFrame]] | None,
) -> list[tuple[str, pl.DataFrame]]:
"""Normalize workplace summaries onto the page's chart-ready schema."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in normalize_geography_data(data_list):
- normalized = df
- if DEFAULT_GEO_COL in normalized.columns and "workplace_location" not in normalized.columns:
+
+ def normalize(frame: pl.DataFrame) -> pl.DataFrame:
+ normalized = frame
+ if (
+ DEFAULT_GEO_COL in normalized.columns
+ and "workplace_location" not in normalized.columns
+ ):
normalized = normalized.with_columns(
pl.col(DEFAULT_GEO_COL).alias("workplace_location")
)
- normalized = rename_present(
+ return rename_present(
normalized,
{"external_worker_count": "person_count"},
)
- out.append((label, normalized))
- return out
+
+ return RunTables.from_runs(normalize_geography_data(data_list)).map(normalize)
def adapt_commuting_flows(
data_list: list[tuple[str, pl.DataFrame]] | None,
) -> list[tuple[str, pl.DataFrame]]:
"""Normalize commuting-flow geography column names used by this page."""
- if not data_list:
- return []
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- out.append(
- (
- label,
- rename_present(
- df,
- {
- "origin_geography_type": "origin_geography_level",
- "destination_geography_type": "destination_geography_level",
- },
- ),
- )
+ return RunTables.from_runs(data_list).map(
+ lambda frame: rename_present(
+ frame,
+ {
+ "origin_geography_type": "origin_geography_level",
+ "destination_geography_type": "destination_geography_level",
+ },
)
- return out
+ )
def external_workplace_percent_data(
@@ -116,18 +114,20 @@ def work_from_home_chart_data(
geography_level = AGGREGATE_GEOGRAPHY_LEVEL
if is_all_geographies(geography):
geography = AGGREGATE_GEOGRAPHY_LEVEL
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(wfh_list):
- chart_df = (
- df.with_columns(
+
+ def prepare(frame: pl.DataFrame) -> pl.DataFrame:
+ return (
+ frame.with_columns(
pl.col(DEFAULT_GEO_LEVEL_COL).cast(pl.Utf8),
pl.col(DEFAULT_GEO_COL).cast(pl.Utf8),
)
.filter(pl.col(DEFAULT_GEO_LEVEL_COL) == geography_level)
.pipe(
- lambda frame: frame
- if geography in {ALL_WITHIN_LEVEL_VALUE, "Total", "All"}
- else frame.filter(pl.col(DEFAULT_GEO_COL) == geography)
+ lambda frame: (
+ frame
+ if geography in {ALL_WITHIN_LEVEL_VALUE, "Total", "All"}
+ else frame.filter(pl.col(DEFAULT_GEO_COL) == geography)
+ )
)
.with_columns(
pl.when(pl.col("worker_count") > 0)
@@ -145,27 +145,28 @@ def work_from_home_chart_data(
)
.sort("geography_label")
)
- out.append((label, chart_df))
- return out
+
+ return RunTables.from_runs(wfh_list).map(prepare)
def distance_distribution_chart_data(
data_list: list[tuple[str, pl.DataFrame]],
) -> list[tuple[str, pl.DataFrame]]:
"""Aggregate geography-sliced distance summaries into distance-bin distributions."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- if "person_count" not in df.columns or "distance_bin" not in df.columns:
- continue
- out.append(
- (
- label,
- df.group_by("distance_bin")
- .agg(person_count=pl.col("person_count").sum())
- .sort("distance_bin"),
+ return (
+ RunTables.from_runs(data_list)
+ .requiring("person_count", "distance_bin")
+ .map(
+ lambda frame: cap_numeric_category_frame(
+ frame.group_by("distance_bin").agg(
+ person_count=pl.col("person_count").sum()
+ ),
+ category="distance_bin",
+ cap_value=40,
+ value_cols=("person_count",),
)
)
- return out
+ )
def telecommute_chart_data(
@@ -182,23 +183,25 @@ def telecommute_chart_data(
{"telecommute_frequency": telecommute_values},
schema={"telecommute_frequency": pl.Utf8},
)
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- if "telecommute_frequency" not in df.columns or "person_count" not in df.columns:
- continue
+
+ def complete(frame: pl.DataFrame) -> pl.DataFrame:
aggregated = (
- df.with_columns(pl.col("telecommute_frequency").cast(pl.Utf8))
+ frame.with_columns(pl.col("telecommute_frequency").cast(pl.Utf8))
.group_by("telecommute_frequency")
.agg(person_count=pl.col("person_count").sum())
)
- completed = (
- scaffold.join(aggregated, on="telecommute_frequency", how="left")
- .with_columns(pl.col("person_count").fill_null(0.0).cast(pl.Float64))
- )
- out.append((label, completed))
+ return scaffold.join(
+ aggregated, on="telecommute_frequency", how="left"
+ ).with_columns(pl.col("person_count").fill_null(0.0).cast(pl.Float64))
+
+ completed = (
+ RunTables.from_runs(data_list)
+ .requiring("telecommute_frequency", "person_count")
+ .map(complete)
+ )
return label_category_data(
- out,
+ completed,
source_col="telecommute_frequency",
category_id="telecommute_frequency",
config=config,
@@ -212,7 +215,7 @@ def mandatory_distance_comparison_table(
geography: str,
*,
config: Config,
-) -> pl.DataFrame:
+) -> list[tuple[str, pl.DataFrame]]:
"""Compare average mandatory tour distances against the base run."""
filtered = filter_geography(
filter_geography_level(data_list, geography_level),
@@ -220,7 +223,7 @@ def mandatory_distance_comparison_table(
)
runs = nonempty(filtered)
if not runs:
- return pl.DataFrame()
+ return []
purpose_values = ordered_category_values(
runs,
@@ -229,29 +232,47 @@ def mandatory_distance_comparison_table(
config=config,
)
if not purpose_values:
- return pl.DataFrame()
-
- run_labels = [label for label, _ in runs]
- base_run_label = run_labels[0]
- row_values: dict[str, dict[str, float | None]] = {}
- for raw_purpose in purpose_values:
- display_purpose = config.label_value("tour_purpose", raw_purpose)
- row_values[display_purpose] = {}
- for run_label, run_df in runs:
- lookup = weighted_average_lookup(
- run_df,
- category_col="mandatory_tour_purpose",
- average_col="average_tour_distance",
- weight_col="person_count",
- )
- row_values[display_purpose][run_label] = lookup.get(str(raw_purpose))
+ return []
- return build_base_run_percent_difference_table(
- run_labels=run_labels,
- base_run_label=base_run_label,
- row_header="Mandatory Tour Purpose",
- row_values=row_values,
+ _, base_run_df = runs[0]
+ base_lookup = weighted_average_lookup(
+ base_run_df,
+ category="mandatory_tour_purpose",
+ average_col="average_tour_distance",
+ weight_col="person_count",
)
+ quantity_a_column = "Average Mandatory Tour Distance"
+ quantity_b_column = "Base Run Average Mandatory Tour Distance"
+ out: list[tuple[str, pl.DataFrame]] = []
+ for run_label, run_df in runs:
+ lookup = weighted_average_lookup(
+ run_df,
+ category="mandatory_tour_purpose",
+ average_col="average_tour_distance",
+ weight_col="person_count",
+ )
+ rows = []
+ for raw_purpose in purpose_values:
+ display_purpose = config.label_value("tour_purpose", raw_purpose)
+ rows.append(
+ build_ab_comparison_row(
+ keys={"Mandatory Tour Purpose": display_purpose},
+ quantity_a=lookup.get(str(raw_purpose)),
+ quantity_b=base_lookup.get(str(raw_purpose)),
+ quantity_a_column=quantity_a_column,
+ quantity_b_column=quantity_b_column,
+ )
+ )
+
+ table = build_ab_comparison_table(
+ rows,
+ key_columns=["Mandatory Tour Purpose"],
+ quantity_a_column=quantity_a_column,
+ quantity_b_column=quantity_b_column,
+ )
+ if not table.is_empty():
+ out.append((run_label, table))
+ return out
def selected_telecommute_values(
diff --git a/dashboard/pages/long_term_choices/individual_choices.py b/dashboard/pages/long_term_choices/individual_choices.py
index 32bede2..1e4b48a 100644
--- a/dashboard/pages/long_term_choices/individual_choices.py
+++ b/dashboard/pages/long_term_choices/individual_choices.py
@@ -5,7 +5,8 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, selector_row
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
complete_category_counts,
label_category_data,
@@ -14,12 +15,10 @@
)
from dashboard.helpers.person_type_helpers import (
ALL_PERSON_TYPES,
- PERSON_TYPE_COL,
filter_person_type_counts,
person_type_selector_options,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
_BICYCLE_COMFORT_DISPLAY = {
"1": "Strong and Fearless",
@@ -37,38 +36,39 @@
def normalize_bicycle_comfort_levels(
data_list: list[tuple[str, pl.DataFrame]],
) -> list[tuple[str, pl.DataFrame]]:
- """Map legacy bicycle comfort codes to the dashboard's readable category labels."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in normalize_category_strings(data_list, "bicycle_comfort_level"):
- out.append(
- (
- label,
- df.with_columns(
- pl.col("bicycle_comfort_level")
- .replace(
- _BICYCLE_COMFORT_DISPLAY,
- default=pl.col("bicycle_comfort_level"),
- )
- .alias("bicycle_comfort_level")
- ),
- )
+ """Map configured bicycle comfort codes to readable dashboard labels."""
+ return RunTables.from_runs(
+ normalize_category_strings(data_list, "bicycle_comfort_level")
+ ).with_columns(
+ pl.col("bicycle_comfort_level")
+ .replace_strict(
+ _BICYCLE_COMFORT_DISPLAY,
+ default=pl.col("bicycle_comfort_level"),
)
- return out
+ .alias("bicycle_comfort_level")
+ )
+@dashboard_page(
+ page_id="individual_choices",
+ title="Individual Choices",
+ group_id="long_term_choices",
+ order=25,
+ required_summary_ids=(
+ "license_holding_status_distribution",
+ "bicycle_comfort_level_distribution",
+ "transit_pass_ownership_by_person_type",
+ "transit_subsidy_by_person_type",
+ ),
+)
class IndividualChoicesPage(DashboardPage):
"""Person-type page for four long-term choice distributions."""
def build_page(self) -> pn.viewable.Viewable:
- person_type_opts = self._person_type_options()
- self.person_type_sel = self.selector(
+ self.person_type_sel = self.select(
"person_type",
- widget=pn.widgets.Select(
- name="Person Type",
- options=person_type_opts,
- value=person_type_opts[0],
- ),
- label="Person Type",
+ "Person Type",
+ options=self._person_type_options,
)
self._body = self.section(
"individual_choices_body",
@@ -93,23 +93,15 @@ def _summary_names(self) -> tuple[str, ...]:
def _person_type_options(self) -> list[str]:
"""Build one person-type selector across every summary used on the page."""
summary_lists = [
- self.state.get_summary_table_set(summary_name, self.weighting_key)
+ self.data.summary(summary_name, self.weighting_key)
for summary_name in self._summary_names()
]
options, self._person_type_to_raw = person_type_selector_options(
*summary_lists,
config=self.config,
- state=self.state,
- cache_key=("individual_choices", PERSON_TYPE_COL, self.weighting_key),
)
return options or ["Total"]
- def sync_controls(self) -> None:
- options = self._person_type_options()
- self.person_type_sel.options = options
- if self.person_type_sel.value not in options:
- self.person_type_sel.value = options[0]
-
def _selected_person_type(self) -> tuple[str, str | None]:
display_value = str(self.person_type_sel.value)
raw_value = self._person_type_to_raw.get(display_value, ALL_PERSON_TYPES)
@@ -121,8 +113,8 @@ def _summary_or_placeholder(
*,
detail: str,
) -> list[tuple[str, pl.DataFrame]] | pn.Card:
- summary = self.optional_summary(summary_name)
- if summary is not None:
+ summary = self.data.summary(summary_name, required=False)
+ if summary:
return summary
return self.data_not_available_card(detail=detail, missing_items=[summary_name])
@@ -130,9 +122,8 @@ def _count_chart_data(
self,
summary_data: list[tuple[str, pl.DataFrame]],
*,
- cache_key: str,
raw_person_type: str | None,
- category_col: str,
+ category: str,
category_id: str | None = None,
source_col_for_labels: str | None = None,
target_col_for_labels: str | None = None,
@@ -140,19 +131,17 @@ def _count_chart_data(
"""Filter one summary to the selected person type and complete missing categories."""
category_values = ordered_category_values(
summary_data,
- category_col,
+ category,
category_id=category_id,
config=self.config,
)
- chart_data = self.get_filtered_view(
- cache_key,
- raw_person_type,
- factory=lambda: complete_category_counts(
+ chart_data = self.query(
+ lambda: complete_category_counts(
filter_person_type_counts(summary_data, raw_person_type),
- category_col=category_col,
+ category=category,
category_values=category_values,
value_cols=("person_count", "pct"),
- ),
+ )
)
if source_col_for_labels is None or category_id is None:
return chart_data, category_values, category_values
@@ -167,7 +156,9 @@ def _count_chart_data(
label_values = self.config.ordered_labels(category_id, category_values)
return labeled_data, category_values, label_values
- def render_license_chart(self, display_person_type: str, raw_person_type: str | None):
+ def render_license_chart(
+ self, display_person_type: str, raw_person_type: str | None
+ ):
"""Render license holding status for the selected person type."""
summary = self._summary_or_placeholder(
"license_holding_status_distribution",
@@ -176,26 +167,25 @@ def render_license_chart(self, display_person_type: str, raw_person_type: str |
if isinstance(summary, pn.Card):
return summary
- normalized_summary = normalize_category_strings(summary, "license_holding_status")
+ normalized_summary = normalize_category_strings(
+ summary, "license_holding_status"
+ )
chart_data, _, label_values = self._count_chart_data(
normalized_summary,
- cache_key="license_holding_status_distribution",
raw_person_type=raw_person_type,
- category_col="license_holding_status",
+ category="license_holding_status",
category_id="license_holding_status",
source_col_for_labels="license_holding_status",
target_col_for_labels="license_holding_status_label",
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="license_holding_status_label",
- y_col="person_count",
+ x="license_holding_status_label",
+ y="person_count",
title=f"License Holding Status Among Persons Aged 16+ - {display_person_type}",
- xaxis_title="License Status",
- yaxis_title="Persons Age 16+",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x_title="License Status",
+ y_title="Persons Age 16+",
+ category_order=label_values,
)
def render_bike_chart(self, display_person_type: str, raw_person_type: str | None):
@@ -210,20 +200,17 @@ def render_bike_chart(self, display_person_type: str, raw_person_type: str | Non
normalized_summary = normalize_bicycle_comfort_levels(summary)
chart_data, x_values, _ = self._count_chart_data(
normalized_summary,
- cache_key="bicycle_comfort_level_distribution",
raw_person_type=raw_person_type,
- category_col="bicycle_comfort_level",
+ category="bicycle_comfort_level",
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="bicycle_comfort_level",
- y_col="person_count",
+ x="bicycle_comfort_level",
+ y="person_count",
title=f"Bicycle Comfort Level - {display_person_type}",
- xaxis_title="Bicycle Comfort Level",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=x_values,
+ x_title="Bicycle Comfort Level",
+ y_title="Persons",
+ category_order=x_values,
)
def render_pass_chart(self, display_person_type: str, raw_person_type: str | None):
@@ -240,26 +227,25 @@ def render_pass_chart(self, display_person_type: str, raw_person_type: str | Non
)
chart_data, _, label_values = self._count_chart_data(
normalized_summary,
- cache_key="transit_pass_ownership_by_person_type",
raw_person_type=raw_person_type,
- category_col="transit_pass_ownership_status",
+ category="transit_pass_ownership_status",
category_id="transit_pass_ownership_status",
source_col_for_labels="transit_pass_ownership_status",
target_col_for_labels="transit_pass_ownership_status_label",
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="transit_pass_ownership_status_label",
- y_col="person_count",
+ x="transit_pass_ownership_status_label",
+ y="person_count",
title=f"Transit Pass Ownership - {display_person_type}",
- xaxis_title="Transit Pass Ownership Status",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x_title="Transit Pass Ownership Status",
+ y_title="Persons",
+ category_order=label_values,
)
- def render_subsidy_chart(self, display_person_type: str, raw_person_type: str | None):
+ def render_subsidy_chart(
+ self, display_person_type: str, raw_person_type: str | None
+ ):
"""Render transit subsidy categories, handling both raw and pre-labeled summaries."""
summary = self._summary_or_placeholder(
"transit_subsidy_by_person_type",
@@ -285,21 +271,22 @@ def render_subsidy_chart(self, display_person_type: str, raw_person_type: str |
config=self.config,
)
display_values = (
- [self.config.label_value("transit_subsidy", value) for value in raw_subsidy_values]
+ [
+ self.config.label_value("transit_subsidy", value)
+ for value in raw_subsidy_values
+ ]
if subsidy_category_col == "transit_subsidy_label"
else raw_subsidy_values
)
# Some summary variants already contain dashboard-ready labels, while others still need
# config-driven relabeling from a raw status code.
- chart_data = self.get_filtered_view(
- "transit_subsidy_by_person_type",
- raw_person_type,
- factory=lambda: (
+ chart_data = self.query(
+ lambda: (
label_category_data(
complete_category_counts(
filter_person_type_counts(normalized_summary, raw_person_type),
- category_col=subsidy_category_col,
+ category=subsidy_category_col,
category_values=raw_subsidy_values,
value_cols=("person_count", "pct"),
),
@@ -311,33 +298,31 @@ def render_subsidy_chart(self, display_person_type: str, raw_person_type: str |
if subsidy_category_col == "transit_subsidy_status"
else complete_category_counts(
filter_person_type_counts(normalized_summary, raw_person_type),
- category_col=subsidy_category_col,
+ category=subsidy_category_col,
category_values=display_values,
value_cols=("person_count", "pct"),
)
- ),
+ )
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col=(
+ x=(
"transit_subsidy_display"
if subsidy_category_col == "transit_subsidy_status"
else subsidy_category_col
),
- y_col="person_count",
+ y="person_count",
title=(
"Transit Subsidy Type Among Workers - "
f"{'All Workers' if raw_person_type == ALL_PERSON_TYPES else display_person_type}"
),
- xaxis_title="Transit Subsidy Status",
- yaxis_title=(
+ x_title="Transit Subsidy Status",
+ y_title=(
"All Workers"
if raw_person_type == ALL_PERSON_TYPES
else f"{display_person_type}"
),
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=(
+ category_order=(
self.config.ordered_labels("transit_subsidy", raw_subsidy_values)
if subsidy_category_col == "transit_subsidy_status"
else display_values
@@ -351,30 +336,25 @@ def render_body(self):
display_person_type, raw_person_type = self._selected_person_type()
return [
pn.Row(
- self.render_license_chart(display_person_type, raw_person_type),
- self.render_bike_chart(display_person_type, raw_person_type),
+ self.noted_view(
+ "individual_choices.license",
+ self.render_license_chart(display_person_type, raw_person_type),
+ ),
+ self.noted_view(
+ "individual_choices.bicycle_comfort",
+ self.render_bike_chart(display_person_type, raw_person_type),
+ ),
sizing_mode="stretch_width",
),
pn.Row(
- self.render_pass_chart(display_person_type, raw_person_type),
- self.render_subsidy_chart(display_person_type, raw_person_type),
+ self.noted_view(
+ "individual_choices.transit_pass",
+ self.render_pass_chart(display_person_type, raw_person_type),
+ ),
+ self.noted_view(
+ "individual_choices.transit_subsidy",
+ self.render_subsidy_chart(display_person_type, raw_person_type),
+ ),
sizing_mode="stretch_width",
),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="individual_choices",
- title="Individual Choices",
- group_id="long_term_choices",
- order=25,
- page_cls=IndividualChoicesPage,
- required_summary_ids=(
- "license_holding_status_distribution",
- "bicycle_comfort_level_distribution",
- "transit_pass_ownership_by_person_type",
- "transit_subsidy_by_person_type",
- ),
-)
-
-IndividualChoicesPage.definition = PAGE
diff --git a/dashboard/pages/long_term_choices/mandatory_location_choice.py b/dashboard/pages/long_term_choices/mandatory_location_choice.py
index 65816ea..fc645fd 100644
--- a/dashboard/pages/long_term_choices/mandatory_location_choice.py
+++ b/dashboard/pages/long_term_choices/mandatory_location_choice.py
@@ -2,621 +2,22 @@
from __future__ import annotations
-import panel as pn
-import polars as pl
+from dashboard import DashboardPage, dashboard_page
-from dashboard.components import bar_chart, data_table, density_chart, selector_row
-from dashboard.helpers.geography_helpers import (
- ALL_WITHIN_LEVEL_VALUE,
- export_geography_options,
- filter_geography,
- filter_geography_level,
- filter_origin_geography,
- geography_level_options,
- geography_options_for_level,
- is_all_geographies,
- normalize_geography_data,
-)
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
-from dashboard.pages.long_term_choices._mandatory_location_choice_support import (
- adapt_commuting_flows,
- adapt_external_workplace,
- distance_distribution_chart_data,
- external_workplace_percent_data,
- filter_selected_geography,
- mandatory_distance_comparison_table,
- selected_telecommute_values,
- telecommute_chart_data,
- work_from_home_chart_data,
-)
-
-
-class MandatoryLocationChoicePage(DashboardPage):
- """Geography-driven page for mandatory worker, commute, and distance summaries."""
-
- def on_global_state_changed(self) -> None:
- """Invalidate page-local caches when the dashboard's global state changes."""
- self.clear_filtered_view_cache()
- self._current_data = self._collect_data()
-
- def build_page(self) -> pn.viewable.Viewable:
- """Build the persistent selectors and stable section containers."""
- self._current_data: dict[str, object] = {}
- self.geo_level_sel = self.selector(
- "geography_level",
- widget=pn.widgets.Select(
- name="Geography Level",
- options=["Total"],
- value="Total",
- ),
- label="Geography Level",
- )
- self.geography_sel = self.selector(
- "geography",
- widget=pn.widgets.Select(
- name="Geography",
- options=[ALL_WITHIN_LEVEL_VALUE],
- value=ALL_WITHIN_LEVEL_VALUE,
- ),
- label="Geography",
- )
- self._remote_work_section = self.section(
- "remote_work",
- selectors=("geography_level", "geography"),
- render=self.render_remote_work_section,
- )
- self._distance_section = self.section(
- "distance_distribution",
- selectors=("geography_level", "geography"),
- render=self.render_distance_distribution_section,
- )
- self._worker_section = self.section(
- "worker_geography",
- selectors=("geography_level", "geography"),
- render=self.render_worker_geography_section,
- )
- self._commuting_flows_section = self.section(
- "commuting_flows",
- selectors=("geography_level", "geography"),
- render=self.render_commuting_flows_section,
- )
- self._mandatory_distance_table_section = self.section(
- "mandatory_distance_table",
- selectors=("geography_level", "geography"),
- render=self.render_mandatory_distance_table_section,
- )
-
- return self.new_section(
- pn.pane.Markdown("## Mandatory Location Choice"),
- selector_row(self.geo_level_sel, self.geography_sel),
- self._remote_work_section,
- self._distance_section,
- self._worker_section,
- self._commuting_flows_section,
- self._mandatory_distance_table_section,
- )
-
- def sync_controls(self) -> None:
- """Keep geography selectors aligned with the currently available summaries."""
- if not self._current_data:
- self._current_data = self._collect_data()
-
- geo_opts = self._current_data["geo_opts"]
- self.geo_level_sel.options = geo_opts
- if self.geo_level_sel.value not in geo_opts:
- self.geo_level_sel.value = geo_opts[0]
-
- geography_opts_by_level = self._current_data["geography_opts_by_level"]
- if self.state.export_mode:
- geography_opts = export_geography_options(
- geography_opts_by_level,
- config=self.config,
- )
- else:
- geography_opts = geography_opts_by_level.get(
- str(self.geo_level_sel.value),
- [ALL_WITHIN_LEVEL_VALUE],
- )
- self.geography_sel.options = geography_opts
- if self.geography_sel.value not in geography_opts:
- self.geography_sel.value = geography_opts[0]
-
- def _selected_geography(self) -> tuple[str, str]:
- """Return the effective geography selection, honoring export-mode flattening."""
- geo_level = str(self.geo_level_sel.value)
- geography = str(self.geography_sel.value)
- if not self.state.export_mode:
- return geo_level, geography
-
- geography_opts_by_level = self._current_data.get("geography_opts_by_level", {})
- valid_options = set(geography_opts_by_level.get(geo_level, [ALL_WITHIN_LEVEL_VALUE]))
- if geography in valid_options:
- return geo_level, geography
- return geo_level, ALL_WITHIN_LEVEL_VALUE
-
- def _collect_data(self) -> dict[str, object]:
- """Collect and normalize every summary used by the page."""
- if not self.state.run_labels:
- return {
- "mode": "no_runs",
- "geo_opts": ["Total"],
- "geography_opts_by_level": {"Total": [ALL_WITHIN_LEVEL_VALUE]},
- }
-
- summaries = self.optional_summaries_dict(
- "internal_external_worker_by_geography",
- "external_worker_workplace_locations",
- "commuting_flows",
- "work_from_home_rate_by_geography",
- "telecommute_frequency_distribution",
- "work_location_distance_distribution_by_geography",
- "school_location_distance_distribution_by_geography",
- "university_location_distance_distribution_by_geography",
- "average_mandatory_tour_distance_by_purpose_and_geography",
- )
-
- if not any(summary is not None for summary in summaries.values()):
- return {
- "mode": "unavailable",
- "geo_opts": ["Total"],
- "geography_opts_by_level": {"Total": [ALL_WITHIN_LEVEL_VALUE]},
- }
-
- internal_external = normalize_geography_data(
- summaries["internal_external_worker_by_geography"]
- )
- external_workplace = adapt_external_workplace(
- summaries["external_worker_workplace_locations"]
- )
- commuting_flows = adapt_commuting_flows(summaries["commuting_flows"])
- work_from_home = normalize_geography_data(
- summaries["work_from_home_rate_by_geography"]
- )
- telecommute = normalize_geography_data(
- summaries["telecommute_frequency_distribution"]
- )
- work_distance = normalize_geography_data(
- summaries["work_location_distance_distribution_by_geography"]
- )
- school_distance = normalize_geography_data(
- summaries["school_location_distance_distribution_by_geography"]
- )
- university_distance = normalize_geography_data(
- summaries["university_location_distance_distribution_by_geography"]
- )
- average_distance = normalize_geography_data(
- summaries["average_mandatory_tour_distance_by_purpose_and_geography"]
- )
-
- geo_opts = geography_level_options(
- internal_external or None,
- commuting_flows or None,
- work_from_home or None,
- config=self.config,
- total_label="Total",
- )
- geography_option_sources = (
- internal_external or None,
- commuting_flows or None,
- work_distance or None,
- school_distance or None,
- university_distance or None,
- average_distance or None,
- )
- geography_opts_by_level = {
- geo_level: geography_options_for_level(
- geo_level,
- *geography_option_sources,
- config=self.config,
- )
- for geo_level in geo_opts
- }
- return {
- "mode": "ready",
- "geo_opts": geo_opts,
- "geography_opts_by_level": geography_opts_by_level,
- "internal_external": internal_external or None,
- "external_workplace": external_workplace or None,
- "commuting_flows": commuting_flows or None,
- "work_from_home": work_from_home or None,
- "telecommute": telecommute or None,
- "work_distance": work_distance or None,
- "school_distance": school_distance or None,
- "university_distance": university_distance or None,
- "average_distance": average_distance or None,
- }
-
- def _render_ready_state(self) -> SectionContent | None:
- """Return a shared top-level placeholder for no-runs or no-summary states."""
- mode = self._current_data["mode"]
- if mode == "no_runs":
- return [self.no_runs_message()]
- if mode == "unavailable":
- return [self.summary_only_unavailable_card()]
- return None
-
- def render_worker_geography_section(self) -> SectionContent:
- """Render internal/external worker comparisons and external workplace charts."""
- placeholder = self._render_ready_state()
- if placeholder is not None:
- return placeholder
-
- geo_level, geography = self._selected_geography()
- worker_views: list[pn.viewable.Viewable] = []
- internal_external = self._current_data["internal_external"]
- if internal_external is not None:
- internal_external_table = self.get_filtered_view(
- "mandatory_internal_external",
- (geo_level, geography),
- factory=lambda: filter_selected_geography(
- internal_external,
- geo_level,
- geography,
- ),
- )
- worker_views.append(
- data_table(internal_external_table, "Internal vs. External Workers")
- )
- else:
- worker_views.append(
- self.data_not_available_card(
- detail="The internal/external worker summary is unavailable.",
- missing_items=["internal_external_worker_by_geography"],
- )
- )
-
- worker_views.append(self.render_external_workplace_chart(geo_level, geography))
- return worker_views
-
- def render_external_workplace_chart(
- self,
- geo_level: str,
- geography: str,
- ) -> pn.viewable.Viewable:
- """Render workplace locations for workers with external jobs."""
- external_workplace = self._current_data["external_workplace"]
- if external_workplace is None:
- return self.data_not_available_card(
- detail="The external workplace summary is unavailable.",
- missing_items=["external_worker_workplace_locations"],
- )
+from ._mandatory_location_choice import *
+from ._mandatory_location_choice.composition import MandatoryLocationCompositionMixin
+from ._mandatory_location_choice.domains import MandatoryLocationDomainMixin
+from ._mandatory_location_choice.features import MandatoryLocationFeatureMixin
- external_workplace_level_data = self.get_filtered_view(
- "mandatory_external_workplace_level",
- geo_level,
- factory=lambda: filter_geography_level(external_workplace, geo_level),
- )
- workplace_location_values = sorted(
- {
- str(value)
- for _, df in external_workplace_level_data
- for value in (
- df["workplace_location"].cast(pl.Utf8).to_list()
- if "workplace_location" in df.columns
- else []
- )
- }
- )
- filtered_external_workplace = self.get_filtered_view(
- "mandatory_external_workplace",
- (geo_level, geography),
- factory=lambda: filter_geography(external_workplace_level_data, geography),
- )
- chart_data = filtered_external_workplace
- if self.as_percent:
- chart_data = self.get_filtered_view(
- "mandatory_external_workplace_percent",
- (geo_level, geography),
- factory=lambda: external_workplace_percent_data(
- filtered_external_workplace,
- geo_level,
- ),
- )
- return bar_chart(
- chart_data,
- x_col="workplace_location",
- y_col=(
- "external_worker_percent"
- if self.as_percent and is_all_geographies(geo_level)
- else "person_count"
- ),
- title="External Worker Workplace Location",
- xaxis_title="Workplace Location",
- yaxis_title=(
- "Workers with External Workplaces (%)"
- if self.as_percent and is_all_geographies(geo_level)
- else "External Workers"
- ),
- pct_col="pct",
- as_percent=False if is_all_geographies(geo_level) else self.as_percent,
- xaxis_categoryarray=workplace_location_values,
- )
-
- def render_commuting_flows_section(self) -> SectionContent:
- """Render origin-to-destination commuting flow tables."""
- if self._current_data["mode"] != "ready":
- return []
-
- geo_level, geography = self._selected_geography()
- commuting_flows = self._current_data["commuting_flows"]
- if commuting_flows is None:
- return [
- self.data_not_available_card(
- detail="The commuting flows summary is unavailable.",
- missing_items=["commuting_flows"],
- )
- ]
-
- commuting_table = self.get_filtered_view(
- "mandatory_commuting_flows",
- (geo_level, geography),
- factory=lambda: filter_origin_geography(
- filter_geography_level(commuting_flows, geo_level),
- geography,
- ),
- )
- return [data_table(commuting_table, "Commuting Flows")]
-
- def render_distance_distribution_section(self) -> SectionContent:
- """Render the three mandatory distance distributions side by side."""
- if self._current_data["mode"] != "ready":
- return []
-
- geo_level, geography = self._selected_geography()
- return [
- pn.pane.Markdown("### Mandatory Location Distance"),
- pn.Row(
- self.render_distance_distribution_chart(
- geo_level,
- geography,
- summary_data=self._current_data["work_distance"],
- cache_key="mandatory_work_distance_distribution",
- title="Workplace Location Distance Distribution",
- yaxis_title="Workplace Locations",
- summary_id="work_location_distance_distribution_by_geography",
- ),
- self.render_distance_distribution_chart(
- geo_level,
- geography,
- summary_data=self._current_data["school_distance"],
- cache_key="mandatory_school_distance_distribution",
- title="School Location Distance Distribution",
- yaxis_title="School Locations",
- summary_id="school_location_distance_distribution_by_geography",
- ),
- self.render_distance_distribution_chart(
- geo_level,
- geography,
- summary_data=self._current_data["university_distance"],
- cache_key="mandatory_university_distance_distribution",
- title="University Location Distance Distribution",
- yaxis_title="University Locations",
- summary_id="university_location_distance_distribution_by_geography",
- ),
- sizing_mode="stretch_width",
- ),
- ]
-
- def render_distance_distribution_chart(
- self,
- geo_level: str,
- geography: str,
- *,
- summary_data: list[tuple[str, pl.DataFrame]] | None,
- cache_key: str,
- title: str,
- yaxis_title: str,
- summary_id: str,
- ) -> pn.viewable.Viewable:
- """Render one distance-distribution chart or a targeted unavailable card."""
- if summary_data is None:
- return self.data_not_available_card(
- detail="The selected distance distribution summary is unavailable.",
- missing_items=[summary_id],
- )
-
- filtered_summary = self.get_filtered_view(
- cache_key,
- (geo_level, geography),
- factory=lambda: filter_selected_geography(
- summary_data,
- geo_level,
- geography,
- ),
- )
- distance_data = self.get_filtered_view(
- f"{cache_key}_chart",
- (geo_level, geography),
- factory=lambda: distance_distribution_chart_data(filtered_summary),
- )
- if not any(not df.is_empty() for _, df in distance_data):
- return self.data_not_available_card(
- detail=(
- f"No distance distribution data is available for geography "
- f"`{geography}` at level `{geo_level}`."
- ),
- missing_items=[summary_id],
- )
-
- return density_chart(
- distance_data,
- x_col="distance_bin",
- y_col="person_count",
- title=title,
- xaxis_title="Distance (miles)",
- yaxis_title=yaxis_title,
- normalize=False,
- as_percent=self.as_percent,
- )
-
- def render_remote_work_section(self) -> SectionContent:
- """Render work-from-home and telecommute summaries."""
- if self._current_data["mode"] != "ready":
- return []
-
- geo_level, geography = self._selected_geography()
- return [
- pn.pane.Markdown("### Remote Work"),
- pn.Row(
- self.render_work_from_home_chart(geo_level, geography),
- self.render_telecommute_chart(geo_level, geography),
- ),
- ]
-
- def render_work_from_home_chart(
- self,
- geo_level: str,
- geography: str,
- ) -> pn.viewable.Viewable:
- """Render work-from-home counts or rates by geography."""
- work_from_home = self._current_data["work_from_home"]
- if work_from_home is None:
- return self.data_not_available_card(
- detail="The work-from-home summary is unavailable.",
- missing_items=["work_from_home_rate_by_geography"],
- )
-
- wfh_data = self.get_filtered_view(
- "mandatory_wfh",
- (geo_level, geography),
- factory=lambda: work_from_home_chart_data(
- work_from_home,
- geo_level,
- geography,
- ),
- )
- return bar_chart(
- wfh_data,
- x_col="geography_label",
- y_col=(
- "work_from_home_percent"
- if self.as_percent
- else "work_from_home_worker_count"
- ),
- title=(
- "Work From Home Rate by Geography"
- if self.as_percent
- else "Workers Working From Home by Geography"
- ),
- xaxis_title="Geography",
- yaxis_title=(
- "Workers Working From Home (%)"
- if self.as_percent
- else "Workers Working From Home"
- ),
- as_percent=False,
- )
-
- def render_telecommute_chart(
- self,
- geo_level: str,
- geography: str,
- ) -> pn.viewable.Viewable:
- """Render telecommute frequency for workers who do not work from home."""
- telecommute = self._current_data["telecommute"]
- if telecommute is None:
- return self.data_not_available_card(
- detail="The telecommute summary is unavailable.",
- missing_items=["telecommute_frequency_distribution"],
- )
-
- telecommute_level_data = self.get_filtered_view(
- "mandatory_telecommute_level",
- geo_level,
- factory=lambda: filter_geography_level(telecommute, geo_level),
- )
- telecommute_values = selected_telecommute_values(
- telecommute_level_data,
- config=self.config,
- )
- filtered_telecommute = self.get_filtered_view(
- "mandatory_telecommute",
- (geo_level, geography),
- factory=lambda: filter_geography(telecommute_level_data, geography),
- )
- chart_data = self.get_filtered_view(
- "mandatory_telecommute_chart",
- (geo_level, geography),
- factory=lambda: telecommute_chart_data(
- filtered_telecommute,
- telecommute_values,
- config=self.config,
- ),
- )
- return bar_chart(
- chart_data,
- x_col="telecommute_frequency_label",
- y_col="person_count",
- title="Telecommute Rate",
- xaxis_title="Telecommute Frequency",
- yaxis_title="Workers Who Do Not Work From Home",
- as_percent=self.as_percent,
- xaxis_categoryarray=self.config.ordered_labels(
- "telecommute_frequency",
- telecommute_values,
- ),
- )
-
- def render_mandatory_distance_table_section(self) -> SectionContent:
- """Render the percent-difference table for average mandatory tour distance."""
- if self._current_data["mode"] != "ready":
- return []
-
- geo_level, geography = self._selected_geography()
- average_distance = self._current_data["average_distance"]
- if average_distance is None:
- return [
- self.data_not_available_card(
- detail="The average mandatory tour distance summary is unavailable.",
- missing_items=[
- "average_mandatory_tour_distance_by_purpose_and_geography"
- ],
- )
- ]
-
- comparison_df = self.get_filtered_view(
- "mandatory_distance_comparison_table",
- (geo_level, geography),
- factory=lambda: mandatory_distance_comparison_table(
- average_distance,
- geo_level,
- geography,
- config=self.config,
- ),
- )
- if comparison_df.is_empty():
- return [
- self.data_not_available_card(
- detail=(
- f"No average mandatory tour distance data is available for "
- f"geography `{geography}` at level `{geo_level}`."
- ),
- missing_items=[
- "average_mandatory_tour_distance_by_purpose_and_geography"
- ],
- )
- ]
-
- return [
- data_table(
- [("Comparison", comparison_df)],
- "Average Mandatory Tour Distance vs Base Run",
- )
- ]
-
-
-PAGE = DashboardPageDefinition(
+@dashboard_page(
page_id="mandatory_location_choice",
title="Mandatory Location Choice",
group_id="long_term_choices",
order=27,
- page_cls=MandatoryLocationChoicePage,
required_summary_ids=(
"internal_external_worker_by_geography",
"external_worker_workplace_locations",
- "commuting_flows",
"work_location_distance_distribution_by_geography",
"school_location_distance_distribution_by_geography",
"university_location_distance_distribution_by_geography",
@@ -625,5 +26,10 @@ def render_mandatory_distance_table_section(self) -> SectionContent:
"average_mandatory_tour_distance_by_purpose_and_geography",
),
)
-
-MandatoryLocationChoicePage.definition = PAGE
+class MandatoryLocationChoicePage(
+ MandatoryLocationCompositionMixin,
+ MandatoryLocationDomainMixin,
+ MandatoryLocationFeatureMixin,
+ DashboardPage,
+):
+ pass
diff --git a/dashboard/pages/long_term_choices/shadow_pricing.py b/dashboard/pages/long_term_choices/shadow_pricing.py
index 998924a..3446160 100644
--- a/dashboard/pages/long_term_choices/shadow_pricing.py
+++ b/dashboard/pages/long_term_choices/shadow_pricing.py
@@ -5,17 +5,22 @@
import panel as pn
import polars as pl
-from dashboard.components import data_table, density_chart, selector_row
+from dashboard.rendering import data_table, selector_row
+from dashboard.data_access import RunTables
from dashboard.helpers.comparison_helpers import format_percent_error_table
from dashboard.helpers.geography_helpers import (
- ALL_GEOGRAPHIES_LABEL,
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
filter_geography_level,
geography_column_options,
+ geography_type_options,
is_all_geographies,
+ normalize_geography_level_value,
normalize_geography_data,
+ with_display_geography_columns,
)
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
+from dashboard.page_base import SectionContent
def filter_student_type(
@@ -23,13 +28,9 @@ def filter_student_type(
student_type: str,
) -> list[tuple[str, pl.DataFrame]]:
"""Filter school-shadow-pricing summaries to one exact student type."""
- if not data_list:
- return []
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in data_list:
- if df is None or len(df) == 0:
- continue
- filtered = df
+
+ def prepare(frame: pl.DataFrame) -> pl.DataFrame:
+ filtered = frame
if "student_type" in filtered.columns:
filtered = filtered.with_columns(pl.col("student_type").cast(pl.Utf8))
if student_type == "All":
@@ -49,42 +50,74 @@ def filter_student_type(
]
agg_exprs: list[pl.Expr] = []
if "target_count" in filtered.columns:
- agg_exprs.append(pl.col("target_count").sum().alias("target_count"))
+ agg_exprs.append(
+ pl.col("target_count").sum().alias("target_count")
+ )
if "modeled_count" in filtered.columns:
- agg_exprs.append(pl.col("modeled_count").sum().alias("modeled_count"))
+ agg_exprs.append(
+ pl.col("modeled_count").sum().alias("modeled_count")
+ )
if "residual_count" in filtered.columns:
- agg_exprs.append(pl.col("residual_count").sum().alias("residual_count"))
+ agg_exprs.append(
+ pl.col("residual_count").sum().alias("residual_count")
+ )
if "absolute_residual_count" in filtered.columns:
agg_exprs.append(
- pl.col("absolute_residual_count").sum().alias("absolute_residual_count")
+ pl.col("absolute_residual_count")
+ .sum()
+ .alias("absolute_residual_count")
)
if "geography_count" in filtered.columns:
- agg_exprs.append(pl.col("geography_count").sum().alias("geography_count"))
+ agg_exprs.append(
+ pl.col("geography_count").sum().alias("geography_count")
+ )
filtered = (
filtered.group_by(group_cols)
.agg(agg_exprs)
.with_columns(
pl.lit("All").alias("student_type"),
pl.when(pl.col("target_count") != 0)
- .then(pl.col("residual_count") / pl.col("target_count") * 100.0)
+ .then(
+ pl.col("residual_count")
+ / pl.col("target_count")
+ * 100.0
+ )
.otherwise(None)
.alias("percent_error")
- if {"target_count", "residual_count"}.issubset(filtered.columns)
- else pl.lit(None).alias("percent_error")
+ if {"target_count", "residual_count"}.issubset(
+ filtered.columns
+ )
+ else pl.lit(None).alias("percent_error"),
)
)
else:
filtered = filtered.filter(pl.col("student_type") == student_type)
- out.append((label, filtered))
- return out
+ return filtered
+ return RunTables.from_runs(data_list).map(prepare)
+
+@dashboard_page(
+ page_id="shadow_pricing",
+ title="Employment\\Enrollment Match By Geography",
+ group_id="long_term_choices",
+ order=28,
+ required_summary_ids=(
+ "workplace_shadow_pricing_residuals",
+ "workplace_shadow_pricing_residual_histogram",
+ "school_shadow_pricing_residuals",
+ "school_shadow_pricing_residual_histogram",
+ ),
+)
class ShadowPricingPage(DashboardPage):
"""Render workplace and school shadow-pricing residual diagnostics."""
def _maz_tables_disabled(self) -> bool:
"""Return whether MAZ tables should be hidden by configuration."""
- return str(self.geo_level_sel.value).lower() == "maz" and not self.config.enable_maz_geographies
+ return (
+ self.selected_geography_level_raw().lower() == "maz"
+ and not self.config.enable_maz_geographies
+ )
def _all_geographies_distribution_card(self, *, subject: str) -> pn.Card:
"""Explain the aggregate-level histogram special case."""
@@ -92,7 +125,7 @@ def _all_geographies_distribution_card(self, *, subject: str) -> pn.Card:
title=f"{subject} Residual Distribution Unavailable",
detail=(
f'The residual for "All Geographies" is a point mass that cannot be plotted as a '
- f'distribution. Please refer to the table below for the {subject.lower()} shadow '
+ f"distribution. Please refer to the table below for the {subject.lower()} shadow "
'pricing values for "All Geographies".'
),
)
@@ -100,23 +133,18 @@ def _all_geographies_distribution_card(self, *, subject: str) -> pn.Card:
def build_page(self) -> pn.viewable.Viewable:
"""Build the page with geography and student selectors."""
self._current_data: dict[str, object] = {}
- self.geo_level_sel = self.selector(
+ self._geo_level_raw_by_label: dict[str, str | None] = {
+ ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"
+ }
+ self.geo_level_sel = self.select(
"geography_level",
- widget=pn.widgets.Select(
- name="Geography Level",
- options=[ALL_GEOGRAPHIES_LABEL],
- value=ALL_GEOGRAPHIES_LABEL,
- ),
- label="Geography Level",
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._geography_options,
)
- self.student_type_sel = self.selector(
+ self.student_type_sel = self.select(
"student_type",
- widget=pn.widgets.Select(
- name="Student Type",
- options=["All"],
- value="All",
- ),
- label="Student Type",
+ "Student Type",
+ options=self._student_options,
)
self._workplace_plot_section = self.section(
"workplace_plot",
@@ -139,54 +167,92 @@ def build_page(self) -> pn.viewable.Viewable:
render=self.render_school_table_section,
)
return self.new_section(
- pn.pane.Markdown("## Shadow Pricing"),
- self.new_section(self._workplace_plot_section, self._workplace_table_section),
- self.new_section(self._school_plot_section, self._school_table_section),
+ pn.pane.Markdown("## Employment\\Enrollment Match By Geography"),
+ self.new_section(
+ pn.pane.Markdown("### Workplace Residual Distribution"),
+ self.noted_section(
+ "shadow_pricing.workplace_plot", self._workplace_plot_section
+ ),
+ pn.pane.Markdown("### Workplace Residual Details"),
+ self.noted_section(
+ "shadow_pricing.workplace_table", self._workplace_table_section
+ ),
+ ),
+ self.new_section(
+ pn.pane.Markdown("### School Residual Distribution"),
+ self.noted_section(
+ "shadow_pricing.school_plot", self._school_plot_section
+ ),
+ pn.pane.Markdown("### School Residual Details"),
+ self.noted_section(
+ "shadow_pricing.school_table", self._school_table_section
+ ),
+ ),
)
- def sync_controls(self) -> None:
- """Refresh summary state and reconcile selector domains."""
+ def _refresh_data(self) -> None:
self._current_data = self._collect_data()
- geo_opts = self._current_data["geo_opts"]
- self.geo_level_sel.options = geo_opts
- if self.geo_level_sel.value not in geo_opts:
- self.geo_level_sel.value = geo_opts[0]
- student_opts = self._current_data["student_opts"]
- self.student_type_sel.options = student_opts
- if self.student_type_sel.value not in student_opts:
- self.student_type_sel.value = student_opts[0]
+ def _geography_options(self) -> list[str]:
+ """Return geography levels and refresh their raw mapping."""
+ self._refresh_data()
+ geo_opts = self._current_data["geo_opts"]
+ self._geo_level_raw_by_label = self._current_data["geo_raw_by_label"]
+ return geo_opts
+
+ def _student_options(self) -> list[str]:
+ """Return student types from the current shadow-pricing summaries."""
+ if not self._current_data:
+ self._refresh_data()
+ return self._current_data["student_opts"]
+
+ def selected_geography_level_raw(self) -> str:
+ """Return the raw geography type selected in the display selector."""
+ selected = str(self.geo_level_sel.value)
+ raw_value = self._geo_level_raw_by_label.get(selected, selected)
+ return (
+ "all_geographies"
+ if raw_value is None
+ else normalize_geography_level_value(str(raw_value))
+ )
def _collect_data(self) -> dict[str, object]:
"""Collect and normalize every summary used on the page."""
if not self.state.run_labels:
return {
"mode": "no_runs",
- "geo_opts": [ALL_GEOGRAPHIES_LABEL],
+ "geo_opts": [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": {ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"},
"student_opts": ["All"],
}
workplace_summary = normalize_geography_data(
- self.optional_summary("workplace_shadow_pricing_residuals")
+ self.data.summary("workplace_shadow_pricing_residuals", required=False)
)
school_summary = normalize_geography_data(
- self.optional_summary("school_shadow_pricing_residuals")
+ self.data.summary("school_shadow_pricing_residuals", required=False)
)
workplace_hist = normalize_geography_data(
- self.optional_summary("workplace_shadow_pricing_residual_histogram")
+ self.data.summary(
+ "workplace_shadow_pricing_residual_histogram", required=False
+ )
)
school_hist = normalize_geography_data(
- self.optional_summary("school_shadow_pricing_residual_histogram")
+ self.data.summary(
+ "school_shadow_pricing_residual_histogram", required=False
+ )
+ )
+ geo_opts, geo_raw_by_label = geography_type_options(
+ workplace_hist or school_hist or workplace_summary or school_summary,
+ config=self.config,
+ include_all_types=False,
+ include_disabled_maz=True,
)
return {
"mode": "ready",
- "geo_opts": geography_column_options(
- workplace_hist or school_hist or workplace_summary or school_summary,
- "geography_level",
- config=self.config,
- total_label=ALL_GEOGRAPHIES_LABEL,
- include_all_geographies=True,
- ),
+ "geo_opts": geo_opts or [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": geo_raw_by_label
+ or {ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"},
"student_opts": geography_column_options(
school_hist or school_summary,
"student_type",
@@ -207,36 +273,38 @@ def render_workplace_plot_section(self) -> SectionContent:
if workplace_hist is None:
return [
self.data_not_available_card(
- detail="The workplace shadow pricing residual histogram summary is unavailable.",
+ detail="The workplace employment residual histogram summary is unavailable.",
missing_items=["workplace_shadow_pricing_residual_histogram"],
)
]
- geo_level = str(self.geo_level_sel.value)
+ geo_level = self.selected_geography_level_raw()
if is_all_geographies(geo_level):
return [
- pn.pane.Markdown("### Workplace Shadow Pricing"),
+ pn.pane.Markdown("### Workplace Employment"),
selector_row(self.geo_level_sel),
self._all_geographies_distribution_card(subject="Workplace"),
]
- workplace_data = self.get_filtered_view(
- "shadow_pricing_workplace_hist",
- geo_level,
- factory=lambda: filter_geography_level(workplace_hist, geo_level),
+ workplace_data = self.query(
+ lambda: filter_geography_level(workplace_hist, geo_level)
)
+ if not any("bin_start" in df.columns for _, df in workplace_data):
+ return [
+ pn.pane.Markdown("### Workplace Employment"),
+ selector_row(self.geo_level_sel),
+ self._all_geographies_distribution_card(subject="Workplace"),
+ ]
return [
- pn.pane.Markdown("### Workplace Shadow Pricing"),
+ pn.pane.Markdown("### Workplace Employment"),
selector_row(self.geo_level_sel),
- density_chart(
+ self.plot.density(
workplace_data,
- x_col="bin_start",
- y_col="geography_count",
+ x="bin_start",
+ y="geography_count",
title="Workplace Residual Distribution",
- xaxis_title="Residual (Modeled - Target)",
- yaxis_title="Geographies",
- normalize=False,
- as_percent=self.as_percent,
+ x_title="Residual (Modeled - Target)",
+ y_title="Geographies",
),
]
@@ -251,38 +319,43 @@ def render_workplace_table_section(self) -> SectionContent:
if self._maz_tables_disabled():
return [
self.data_not_available_card(
- title="Workplace Shadow Pricing Residuals by Geography",
- detail="MAZ-level shadow pricing tables are hidden when visualizer.enable_maz_geographies is false.",
+ title="Workplace Employment Residuals by Geography",
+ detail="MAZ-level residual tables are hidden when visualizer.enable_maz_geographies is false.",
)
]
- geo_level = str(self.geo_level_sel.value)
- workplace_data = self.get_filtered_view(
- "shadow_pricing_workplace",
- geo_level,
- factory=lambda: filter_geography_level(workplace_summary, geo_level),
+ geo_level = self.selected_geography_level_raw()
+ workplace_data = self.query(
+ lambda: filter_geography_level(workplace_summary, geo_level)
)
return [
data_table(
- [(label, self.render_workplace_table(df)) for label, df in workplace_data],
- "Workplace Shadow Pricing Residuals by Geography",
+ [
+ (label, self.render_workplace_table(df))
+ for label, df in workplace_data
+ ],
+ "Workplace Employment Residuals by Geography",
)
]
def render_workplace_table(self, df: pl.DataFrame) -> pl.DataFrame:
"""Select and format workplace residual columns for display."""
- geography_col = "geography_id" if "geography_id" in df.columns else "geography"
+ display_df = with_display_geography_columns(df, config=self.config)
+ columns = [
+ column
+ for column in (
+ "Geography Type",
+ "Geography Name",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
+ )
+ if column in display_df.columns
+ ]
return format_percent_error_table(
- df.select(
- [
- geography_col,
- "target_count",
- "modeled_count",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- ).rename({geography_col: "geography_id"})
+ display_df.select(columns) if columns else display_df
)
def render_school_plot_section(self) -> SectionContent:
@@ -294,40 +367,42 @@ def render_school_plot_section(self) -> SectionContent:
if school_hist is None:
return [
self.data_not_available_card(
- detail="The school shadow pricing residual histogram summary is unavailable.",
+ detail="The school enrollment residual histogram summary is unavailable.",
missing_items=["school_shadow_pricing_residual_histogram"],
)
]
- geo_level = str(self.geo_level_sel.value)
+ geo_level = self.selected_geography_level_raw()
student_type = str(self.student_type_sel.value)
if is_all_geographies(geo_level):
return [
- pn.pane.Markdown("### School Shadow Pricing"),
+ pn.pane.Markdown("### School Enrollment"),
selector_row(self.student_type_sel),
self._all_geographies_distribution_card(subject="School"),
]
- school_data = self.get_filtered_view(
- "shadow_pricing_school_hist",
- (geo_level, student_type),
- factory=lambda: filter_student_type(
+ school_data = self.query(
+ lambda: filter_student_type(
filter_geography_level(school_hist, geo_level),
student_type,
- ),
+ )
)
+ if not any("bin_start" in df.columns for _, df in school_data):
+ return [
+ pn.pane.Markdown("### School Enrollment"),
+ selector_row(self.student_type_sel),
+ self._all_geographies_distribution_card(subject="School"),
+ ]
return [
- pn.pane.Markdown("### School Shadow Pricing"),
+ pn.pane.Markdown("### School Enrollment"),
selector_row(self.student_type_sel),
- density_chart(
+ self.plot.density(
school_data,
- x_col="bin_start",
- y_col="geography_count",
+ x="bin_start",
+ y="geography_count",
title="School Residual Distribution",
- xaxis_title="Residual (Modeled - Target)",
- yaxis_title="Geographies",
- normalize=False,
- as_percent=self.as_percent,
+ x_title="Residual (Modeled - Target)",
+ y_title="Geographies",
),
]
@@ -342,58 +417,43 @@ def render_school_table_section(self) -> SectionContent:
if self._maz_tables_disabled():
return [
self.data_not_available_card(
- title="School Shadow Pricing Residuals by Geography",
- detail="MAZ-level shadow pricing tables are hidden when visualizer.enable_maz_geographies is false.",
+ title="School Enrollment Residuals by Geography",
+ detail="MAZ-level school enrollment tables are hidden when visualizer.enable_maz_geographies is false.",
)
]
- geo_level = str(self.geo_level_sel.value)
+ geo_level = self.selected_geography_level_raw()
student_type = str(self.student_type_sel.value)
- school_data = self.get_filtered_view(
- "shadow_pricing_school",
- (geo_level, student_type),
- factory=lambda: filter_student_type(
+ school_data = self.query(
+ lambda: filter_student_type(
filter_geography_level(school_summary, geo_level),
student_type,
- ),
+ )
)
return [
data_table(
[(label, self.render_school_table(df)) for label, df in school_data],
- "School Shadow Pricing Residuals by Geography",
+ "School Enrollment Residuals by Geography",
)
]
def render_school_table(self, df: pl.DataFrame) -> pl.DataFrame:
"""Select and format school residual columns for display."""
- geography_col = "geography_id" if "geography_id" in df.columns else "geography"
+ display_df = with_display_geography_columns(df, config=self.config)
+ columns = [
+ column
+ for column in (
+ "Geography Type",
+ "Geography Name",
+ "student_type",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
+ )
+ if column in display_df.columns
+ ]
return format_percent_error_table(
- df.select(
- [
- geography_col,
- "student_type",
- "target_count",
- "modeled_count",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- ).rename({geography_col: "geography_id"})
+ display_df.select(columns) if columns else display_df
)
-
-
-PAGE = DashboardPageDefinition(
- page_id="shadow_pricing",
- title="Shadow Pricing",
- group_id="long_term_choices",
- order=28,
- page_cls=ShadowPricingPage,
- required_summary_ids=(
- "workplace_shadow_pricing_residuals",
- "workplace_shadow_pricing_residual_histogram",
- "school_shadow_pricing_residuals",
- "school_shadow_pricing_residual_histogram",
- ),
-)
-
-ShadowPricingPage.definition = PAGE
diff --git a/dashboard/pages/long_term_choices/vehicle_ownership_type.py b/dashboard/pages/long_term_choices/vehicle_ownership_type.py
index 52b01d6..fce65d2 100644
--- a/dashboard/pages/long_term_choices/vehicle_ownership_type.py
+++ b/dashboard/pages/long_term_choices/vehicle_ownership_type.py
@@ -5,11 +5,13 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, kpi_box
-from dashboard.helpers.category_helpers import nonempty
-from dashboard.helpers.geography_helpers import rename_present
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import cap_numeric_category_data, nonempty
+from dashboard import DashboardPage, dashboard_page
+
+ALL_HOUSEHOLD_SIZES = "All"
+HOUSEHOLD_SIZE_OPTIONS = [ALL_HOUSEHOLD_SIZES, "1", "2", "3", "4", "5+"]
def _cast_category(
@@ -17,26 +19,9 @@ def _cast_category(
category_col: str,
) -> list[tuple[str, pl.DataFrame]]:
"""Cast one chart category column to strings for stable display ordering."""
- return [
- (label, df.with_columns(pl.col(category_col).cast(pl.Utf8)))
- for label, df in nonempty(data_list)
- ]
-
-
-def _normalize_vehicle_summary_columns(
- data_list: list[tuple[str, pl.DataFrame]],
- *,
- canonical_col: str,
- legacy_col: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Accept legacy summary column names while exposing one canonical chart column."""
- return [
- (
- label,
- rename_present(df, {legacy_col: canonical_col}),
- )
- for label, df in nonempty(data_list)
- ]
+ return RunTables.from_runs(data_list).with_columns(
+ pl.col(category_col).cast(pl.Utf8)
+ )
def _av_kpi_values(
@@ -51,12 +36,72 @@ def _av_kpi_values(
return values
+def _auto_ownership_has_household_size(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+) -> bool:
+ return any("household_size" in df.columns for _, df in nonempty(data_list or []))
+
+
+def _auto_ownership_household_size_options(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+) -> list[str]:
+ if _auto_ownership_has_household_size(data_list):
+ return HOUSEHOLD_SIZE_OPTIONS.copy()
+ return [ALL_HOUSEHOLD_SIZES]
+
+
+def _auto_ownership_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ household_size: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Filter to one household-size bucket and aggregate vehicle-count bins."""
+
+ def prepare(frame: pl.DataFrame) -> pl.DataFrame:
+ filtered = frame
+ if "household_size" in filtered.columns:
+ filtered = filtered.with_columns(pl.col("household_size").cast(pl.Utf8))
+ if household_size != ALL_HOUSEHOLD_SIZES:
+ filtered = filtered.filter(pl.col("household_size") == household_size)
+ return (
+ filtered.group_by("household_vehicle_count")
+ .agg(household_count=pl.col("household_count").sum())
+ .sort(pl.col("household_vehicle_count").cast(pl.Int64, strict=False))
+ )
+
+ out = RunTables.from_runs(data_list).map(prepare)
+ return cap_numeric_category_data(
+ out,
+ category="household_vehicle_count",
+ cap_value=4,
+ value_cols=("household_count",),
+ )
+
+
+@dashboard_page(
+ page_id="vehicle_ownership_type",
+ title="Vehicle Ownership and Type",
+ group_id="long_term_choices",
+ order=26,
+ required_summary_ids=(
+ "auto_ownership_distribution",
+ "autonomous_vehicle_ownership_totals",
+ "vehicle_age_distribution",
+ "vehicle_fuel_type_distribution",
+ "vehicle_body_type_distribution",
+ ),
+)
class VehicleOwnershipTypePage(DashboardPage):
- """Reference page for multi-section summary-only pages without selectors."""
+ """Vehicle ownership summary page."""
def build_page(self) -> pn.viewable.Viewable:
+ self.hhsize_sel = self.select(
+ "household_size",
+ "Household Size",
+ options=self._household_size_options,
+ )
self._ownership_section = self.section(
"vehicle_ownership_summary",
+ selectors=("household_size",),
render=self.render_ownership_summary,
)
self._vehicle_mix_section = self.section(
@@ -71,72 +116,93 @@ def build_page(self) -> pn.viewable.Viewable:
)
def _optional_summaries(self) -> dict[str, list[tuple[str, pl.DataFrame]] | None]:
- return self.optional_summaries_dict(*self.required_summary_ids)
+ return self.data.summaries(*self.required_summary_ids)
def _summary_only_unavailable(self) -> pn.Card:
return self.summary_only_unavailable_card()
+ def _household_size_options(self) -> list[str]:
+ data = self.data.summary(
+ "auto_ownership_distribution",
+ self.weighting_key,
+ )
+ return _auto_ownership_household_size_options(data)
+
def render_ownership_summary(self):
if not self.state.run_labels:
return [self.no_runs_message()]
summaries = self._optional_summaries()
- if not any(summary is not None for summary in summaries.values()):
+ if not any(summaries.values()):
return [self._summary_only_unavailable()]
top_row: list[pn.viewable.Viewable] = []
auto_ownership = summaries["auto_ownership_distribution"]
av_ownership = summaries["autonomous_vehicle_ownership_totals"]
- top_row.append(self.render_auto_ownership_chart(auto_ownership))
+ top_row.append(
+ self.noted_view(
+ "vehicle_ownership.auto_ownership",
+ self.render_auto_ownership_chart(auto_ownership),
+ )
+ )
- top_row.append(self.render_autonomous_vehicle_kpi(av_ownership))
+ top_row.append(
+ self.noted_view(
+ "vehicle_ownership.autonomous_vehicle_kpi",
+ self.render_autonomous_vehicle_kpi(av_ownership),
+ )
+ )
- return [pn.Row(*top_row, sizing_mode="stretch_width")]
+ return [
+ selector_row(self.hhsize_sel),
+ pn.Row(*top_row, sizing_mode="stretch_width"),
+ ]
def render_vehicle_mix(self):
if not self.state.run_labels:
return []
summaries = self._optional_summaries()
- if not any(summary is not None for summary in summaries.values()):
- return []
-
vehicle_views: list[pn.viewable.Viewable] = []
chart_specs = [
(
"vehicle_age_distribution",
"age",
- "vehicle_age",
"Vehicle Age",
"Vehicle Age",
),
(
"vehicle_fuel_type_distribution",
"fuel_type",
- "vehicle_fuel_type",
"Vehicle Fuel Type",
"Fuel Type",
),
(
"vehicle_body_type_distribution",
"body_type",
- "vehicle_body_type",
"Vehicle Body Type",
"Body Type",
),
]
+ note_ids = {
+ "vehicle_age_distribution": "vehicle_ownership.vehicle_age",
+ "vehicle_fuel_type_distribution": "vehicle_ownership.vehicle_fuel",
+ "vehicle_body_type_distribution": "vehicle_ownership.vehicle_body",
+ }
- for summary_id, canonical_col, legacy_col, title, xaxis_title in chart_specs:
+ for summary_id, canonical_col, title, xaxis_title in chart_specs:
summary = summaries[summary_id]
vehicle_views.append(
- self.render_vehicle_attribute_chart(
- summary,
- summary_id=summary_id,
- canonical_col=canonical_col,
- legacy_col=legacy_col,
- title=title,
- xaxis_title=xaxis_title,
+ self.noted_view(
+ note_ids[summary_id],
+ self.render_vehicle_attribute_chart(
+ summary,
+ summary_id=summary_id,
+ canonical_col=canonical_col,
+ title=title,
+ xaxis_title=xaxis_title,
+ ),
)
)
@@ -144,25 +210,27 @@ def render_vehicle_mix(self):
def render_auto_ownership_chart(self, summary_data):
"""Render household auto ownership or an unavailable card."""
- if summary_data is None:
+ if not summary_data:
return self.data_not_available_card(
detail="The auto ownership summary is unavailable.",
missing_items=["auto_ownership_distribution"],
)
- return bar_chart(
- _cast_category(summary_data, "household_vehicle_count"),
- x_col="household_vehicle_count",
- y_col="household_count",
- title="Auto Ownership by Household Size",
- xaxis_title="Household Vehicles",
- yaxis_title="Households",
- pct_col="pct",
- as_percent=self.as_percent,
+ household_size = str(self.hhsize_sel.value)
+ return self.plot.bar(
+ _auto_ownership_chart_data(
+ summary_data,
+ household_size,
+ ),
+ x="household_vehicle_count",
+ y="household_count",
+ title=f"Auto Ownership by Household Size - {household_size}",
+ x_title="Household Vehicles",
+ y_title="Households",
)
def render_autonomous_vehicle_kpi(self, summary_data):
"""Render the autonomous vehicle ownership KPI or an unavailable state."""
- if summary_data is None:
+ if not summary_data:
return self.data_not_available_card(
detail="The autonomous vehicle ownership summary is unavailable.",
missing_items=["autonomous_vehicle_ownership_totals"],
@@ -173,7 +241,7 @@ def render_autonomous_vehicle_kpi(self, summary_data):
detail="The autonomous vehicle ownership summary is empty.",
missing_items=["autonomous_vehicle_ownership_totals"],
)
- return kpi_box(
+ return self.plot.kpi(
"Autonomous Vehicle Ownership",
av_values,
format_fn=lambda value: f"{value:,.0f}",
@@ -185,46 +253,20 @@ def render_vehicle_attribute_chart(
*,
summary_id: str,
canonical_col: str,
- legacy_col: str,
title: str,
xaxis_title: str,
):
"""Render one vehicle attribute distribution chart or its unavailable state."""
- if summary_data is None:
+ if not summary_data:
return self.data_not_available_card(
detail=f"The {title.lower()} summary is unavailable.",
missing_items=[summary_id],
)
- normalized = _normalize_vehicle_summary_columns(
- summary_data,
- canonical_col=canonical_col,
- legacy_col=legacy_col,
- )
- return bar_chart(
- _cast_category(normalized, canonical_col),
- x_col=canonical_col,
- y_col="vehicle_count",
+ return self.plot.bar(
+ _cast_category(summary_data, canonical_col),
+ x=canonical_col,
+ y="vehicle_count",
title=title,
- xaxis_title=xaxis_title,
- yaxis_title="Vehicles",
- pct_col="pct",
- as_percent=self.as_percent,
+ x_title=xaxis_title,
+ y_title="Vehicles",
)
-
-
-PAGE = DashboardPageDefinition(
- page_id="vehicle_ownership_type",
- title="Vehicle Ownership and Type",
- group_id="long_term_choices",
- order=26,
- page_cls=VehicleOwnershipTypePage,
- required_summary_ids=(
- "auto_ownership_distribution",
- "autonomous_vehicle_ownership_totals",
- "vehicle_age_distribution",
- "vehicle_fuel_type_distribution",
- "vehicle_body_type_distribution",
- ),
-)
-
-VehicleOwnershipTypePage.definition = PAGE
diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py
index 250d411..16b7e88 100644
--- a/dashboard/pages/overview.py
+++ b/dashboard/pages/overview.py
@@ -5,17 +5,18 @@
import panel as pn
import polars as pl
-from dashboard.components import (
- _to_pandas,
- bar_chart,
- format_numeric_frame_for_display,
- kpi_box,
+from dashboard.rendering import (
+ to_pandas,
+ column_titles,
+ drop_index_columns,
+ format_numeric_frame,
)
from dashboard.helpers.comparison_helpers import (
build_base_run_percent_difference_table,
)
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.helpers.category_helpers import cap_numeric_category_data
+from dashboard import DashboardPage, dashboard_page
+from dashboard.page_base import SectionContent
KPI_METRICS = [
("person_count", "Population"),
@@ -83,13 +84,26 @@ def person_type_chart_data(
def hh_size_chart_data(
hhsize_list: list[tuple[str, pl.DataFrame]],
) -> list[tuple[str, pl.DataFrame]]:
- """Cast household size labels for chart display."""
- return [
- (label, df.with_columns(pl.col("household_size").cast(pl.Utf8)))
- for label, df in hhsize_list
- ]
+ """Cap household size labels at 5+ for chart display."""
+ return cap_numeric_category_data(
+ hhsize_list,
+ category="household_size",
+ cap_value=5,
+ value_cols=("household_count",),
+ )
+@dashboard_page(
+ page_id="overview",
+ title="Overview",
+ order=10,
+ required_summary_ids=(
+ "population_totals",
+ "person_type_distribution",
+ "household_size_distribution",
+ "auto_vmt_totals",
+ ),
+)
class OverviewPage(DashboardPage):
"""Render top-line KPIs plus two demographic distributions."""
@@ -102,15 +116,18 @@ def build_page(self) -> pn.viewable.Viewable:
)
return self.new_section(
pn.pane.Markdown("## Overview"),
+ pn.pane.Markdown("### Key Performance Indicators"),
self._kpi_section,
+ pn.pane.Markdown("### Demographic Distributions"),
self._demographics_section,
)
def _kpi_result(self):
"""Resolve the summary inputs required for the KPI cards and comparison table."""
- return self.resolve_summary_visualization(
- "overview_kpis",
- summary_requirements={
+ return self.data.summaries(
+ "population_totals",
+ "auto_vmt_totals",
+ columns={
"population_totals": (
"person_count",
"household_count",
@@ -125,20 +142,16 @@ def _kpi_result(self):
def _demographic_results(self):
"""Resolve the two demographic charts independently for better fallbacks."""
return (
- self.resolve_summary_visualization(
- "overview_person_type_distribution",
- summary_requirements={
- "person_type_distribution": ("person_type_label", "person_count")
- },
+ self.data.summary(
+ "person_type_distribution",
+ columns=("person_type_label", "person_count"),
),
- self.resolve_summary_visualization(
- "overview_household_size_distribution",
- summary_requirements={
- "household_size_distribution": (
- "household_size",
- "household_count",
- )
- },
+ self.data.summary(
+ "household_size_distribution",
+ columns=(
+ "household_size",
+ "household_count",
+ ),
),
)
@@ -150,7 +163,7 @@ def _kpi_card(
label: str,
) -> pn.viewable.Viewable:
"""Render one KPI card from the run-indexed totals table."""
- return kpi_box(
+ return self.plot.kpi(
label=label,
values=[
(run_label, metric_value(tot_df, metric))
@@ -165,58 +178,51 @@ def render_percent_difference_table(
"""Render the KPI percent-difference table when comparison rows exist."""
if len(pct_df) == 0:
return pn.pane.Markdown("")
+ display_df = format_numeric_frame(
+ drop_index_columns(pct_df),
+ numeric_precision=2,
+ )
return pn.widgets.Tabulator(
- _to_pandas(
- format_numeric_frame_for_display(
- pct_df,
- numeric_precision=2,
- )
- ),
+ to_pandas(display_df),
sizing_mode="stretch_width",
height=260,
+ titles=column_titles(display_df.columns),
+ show_index=False,
)
def render_person_type_chart(self, ptype_result) -> pn.viewable.Viewable:
"""Render the person type distribution chart when its summary is available."""
return (
- bar_chart(
- person_type_chart_data(
- ptype_result.usable_by_input["person_type_distribution"]
- ),
- x_col="person_type_label",
- y_col="person_count",
+ self.plot.bar(
+ person_type_chart_data(ptype_result),
+ x="person_type_label",
+ y="person_count",
title="Person Type Distribution",
- xaxis_title="Person Type",
- yaxis_title="Persons",
- pct_col="pct",
- as_percent=self.as_percent,
+ x_title="Person Type",
+ y_title="Persons",
)
- if ptype_result.has_usable_runs
- else self.unavailable_visualization(
- ptype_result,
+ if ptype_result
+ else self.data_not_available_card(
detail="Person type distribution is unavailable.",
+ missing_items=["person_type_distribution"],
)
)
def render_household_size_chart(self, hhsize_result) -> pn.viewable.Viewable:
"""Render the household size distribution chart when its summary is available."""
return (
- bar_chart(
- hh_size_chart_data(
- hhsize_result.usable_by_input["household_size_distribution"]
- ),
- x_col="household_size",
- y_col="household_count",
+ self.plot.bar(
+ hh_size_chart_data(hhsize_result),
+ x="household_size",
+ y="household_count",
title="Household Size Distribution",
- xaxis_title="Household Size",
- yaxis_title="Households",
- pct_col="pct",
- as_percent=self.as_percent,
+ x_title="Household Size",
+ y_title="Households",
)
- if hhsize_result.has_usable_runs
- else self.unavailable_visualization(
- hhsize_result,
+ if hhsize_result
+ else self.data_not_available_card(
detail="Household size distribution is unavailable.",
+ missing_items=["household_size_distribution"],
)
)
@@ -225,68 +231,71 @@ def render_kpis(self) -> SectionContent:
if not self.state.run_labels:
return [self.no_runs_message()]
- objects: list[pn.viewable.Viewable] = [
- pn.pane.Markdown("### Key Performance Indicators")
- ]
+ objects: list[pn.viewable.Viewable] = []
kpi_result = self._kpi_result()
- if kpi_result.has_usable_runs:
- totals_list = kpi_result.usable_by_input["population_totals"]
- vmt_list = kpi_result.usable_by_input["auto_vmt_totals"]
- pct_df = self.get_filtered_view(
- "overview_pct",
- tuple(label for label, _ in totals_list),
- factory=lambda: percent_difference_table(totals_list, vmt_list),
- )
- vmt_box = kpi_box(
+ if all(kpi_result.values()):
+ totals_list = kpi_result["population_totals"]
+ vmt_list = kpi_result["auto_vmt_totals"]
+ pct_df = self.query(lambda: percent_difference_table(totals_list, vmt_list))
+ vmt_box = self.plot.kpi(
label="VMT",
values=[
(run_label, metric_value(tot_df, "auto_vmt"))
for run_label, tot_df in vmt_list
],
)
- objects.extend(
- [
- pn.Row(
- self._kpi_card(
- totals_list,
- metric="person_count",
- label="Population",
- ),
- self._kpi_card(
- totals_list,
- metric="household_count",
- label="Households",
- ),
- vmt_box,
- sizing_mode="stretch_width",
+ kpi_cards = pn.Column(
+ pn.Row(
+ self._kpi_card(
+ totals_list,
+ metric="person_count",
+ label="Population",
),
- pn.Row(
- self._kpi_card(
- totals_list,
- metric="tour_count",
- label="Tours",
- ),
- self._kpi_card(
- totals_list,
- metric="trip_count",
- label="Trips",
- ),
- self._kpi_card(
- totals_list,
- metric="stop_count",
- label="Stops",
- ),
- sizing_mode="stretch_width",
+ self._kpi_card(
+ totals_list,
+ metric="household_count",
+ label="Households",
),
+ vmt_box,
+ sizing_mode="stretch_width",
+ ),
+ pn.Row(
+ self._kpi_card(
+ totals_list,
+ metric="tour_count",
+ label="Tours",
+ ),
+ self._kpi_card(
+ totals_list,
+ metric="trip_count",
+ label="Trips",
+ ),
+ self._kpi_card(
+ totals_list,
+ metric="stop_count",
+ label="Stops",
+ ),
+ sizing_mode="stretch_width",
+ ),
+ sizing_mode="stretch_width",
+ )
+ objects.extend(
+ [
+ self.noted_view("overview.kpis", kpi_cards),
pn.pane.Markdown("### Percent Difference vs Base Run"),
- self.render_percent_difference_table(pct_df),
+ self.noted_view(
+ "overview.percent_difference",
+ self.render_percent_difference_table(pct_df),
+ ),
]
)
else:
objects.append(
- self.unavailable_visualization(
- kpi_result,
+ self.data_not_available_card(
detail="Overview KPIs require the population totals and auto VMT summary tables.",
+ missing_items=[
+ key for key, value in kpi_result.items() if not value
+ ],
)
)
return objects
@@ -298,25 +307,14 @@ def render_demographics(self) -> SectionContent:
ptype_result, hhsize_result = self._demographic_results()
return [
- pn.pane.Markdown("### Demographic Distributions"),
pn.Row(
- self.render_person_type_chart(ptype_result),
- self.render_household_size_chart(hhsize_result),
+ self.noted_view(
+ "overview.person_type",
+ self.render_person_type_chart(ptype_result),
+ ),
+ self.noted_view(
+ "overview.household_size",
+ self.render_household_size_chart(hhsize_result),
+ ),
),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="overview",
- title="Overview",
- order=10,
- page_cls=OverviewPage,
- required_summary_ids=(
- "population_totals",
- "person_type_distribution",
- "household_size_distribution",
- "auto_vmt_totals",
- ),
-)
-
-OverviewPage.definition = PAGE
diff --git a/dashboard/pages/raw_trip_demo.py b/dashboard/pages/raw_trip_demo.py
index 162428c..48f9a70 100644
--- a/dashboard/pages/raw_trip_demo.py
+++ b/dashboard/pages/raw_trip_demo.py
@@ -5,15 +5,13 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
-from processor.models import RunData
+from dashboard import DashboardPage, dashboard_page
+from dashboard.data_access import RunTables
def trip_mode_distribution(
- prepared_runs: list[tuple[str, RunData]],
-) -> list[tuple[str, pl.DataFrame]]:
+ trip_tables: RunTables,
+) -> RunTables:
"""Aggregate prepared trip records into one trip-mode distribution per run."""
def _one_run(trips: pl.DataFrame) -> pl.DataFrame:
@@ -38,9 +36,17 @@ def _one_run(trips: pl.DataFrame) -> pl.DataFrame:
.sort("trip_mode")
)
- return [(label, _one_run(run.trips)) for label, run in prepared_runs]
+ return trip_tables.map(_one_run)
+@dashboard_page(
+ page_id="raw_trip_demo",
+ title="Prepared Trip Demo",
+ order=900,
+ default_enabled=False,
+ prepared_data_mode="required",
+ required_prepared_tables=("trips",),
+)
class RawTripDemoPage(DashboardPage):
"""Example page for future prepared-data pages to follow."""
@@ -57,30 +63,26 @@ def render_body(self):
"""Render the prepared-run trip mode demo or an unavailable placeholder."""
if not self.state.run_labels:
return [self.no_runs_message()]
+ note = self.section_note("raw_trip_demo.trip_modes", self._body)
- prepared_result = self.resolve_prepared_visualization(
- "raw_trip_demo_trip_modes",
- table_requirements={"trips": ("trip_mode",)},
+ trip_tables = self.data.prepared(
+ "trips",
+ columns=("trip_mode",),
)
- if not prepared_result.has_usable_runs:
+ if not trip_tables:
return [
pn.pane.Markdown("## Prepared Trip Demo"),
- self.unavailable_visualization(
- prepared_result,
+ self.data_not_available_card(
detail=(
"This demo page intentionally requires disaggregate prepared trip "
"records and does not render from summary tables."
),
+ missing_items=["trips"],
),
+ note,
]
- prepared_runs = prepared_result.usable_by_input["trips"]
- trip_mode_list = self.get_filtered_view(
- "raw_trip_demo_trip_modes",
- self.weighting_key,
- tuple(label for label, _ in prepared_runs),
- factory=lambda: trip_mode_distribution(prepared_runs),
- )
+ trip_mode_list = self.query(lambda: trip_mode_distribution(trip_tables))
return [
pn.pane.Markdown("## Prepared Trip Demo"),
pn.pane.Markdown(
@@ -88,6 +90,7 @@ def render_body(self):
"trip records directly from the loaded prepared runs."
),
self.render_trip_mode_chart(trip_mode_list),
+ note,
]
def render_trip_mode_chart(
@@ -95,25 +98,11 @@ def render_trip_mode_chart(
trip_mode_list: list[tuple[str, pl.DataFrame]],
) -> pn.viewable.Viewable:
"""Render the prepared-run trip mode distribution."""
- return bar_chart(
+ return self.plot.bar(
trip_mode_list,
- x_col="trip_mode",
- y_col="freq",
+ x="trip_mode",
+ y="freq",
title="Trip Mode Distribution From Raw Trips",
- xaxis_title="Trip Mode",
- yaxis_title="Trips",
- as_percent=self.as_percent,
+ x_title="Trip Mode",
+ y_title="Trips",
)
-
-
-PAGE = DashboardPageDefinition(
- page_id="raw_trip_demo",
- title="Prepared Trip Demo",
- order=900,
- default_enabled=False,
- prepared_data_mode="required",
- required_prepared_tables=("trips",),
- page_cls=RawTripDemoPage,
-)
-
-RawTripDemoPage.definition = PAGE
diff --git a/dashboard/pages/skim_summaries/_page_controls.py b/dashboard/pages/skim_summaries/_page_controls.py
new file mode 100644
index 0000000..f8982e9
--- /dev/null
+++ b/dashboard/pages/skim_summaries/_page_controls.py
@@ -0,0 +1,46 @@
+"""Shared selector and automatic-range state transitions for skim pages."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Any
+
+
+def repair_selector_options(widget, options: Sequence[Any]) -> None:
+ """Replace one selector domain and repair an invalid current value."""
+ resolved = list(options)
+ if not resolved:
+ raise ValueError(f"Selector {widget.name!r} requires at least one option.")
+ widget.options = resolved
+ if widget.value not in resolved:
+ widget.value = resolved[0]
+
+
+def sync_auto_range_state(
+ page_state: dict[str, object],
+ *,
+ state_prefix: str,
+ context_key: tuple[object, ...],
+ bounds: tuple[float, float] | None,
+ current_range: tuple[float, float] | None,
+) -> tuple[float, float] | None:
+ """Record an observed range and return it only when controls should reset."""
+ context_state_key = f"{state_prefix}_range_context"
+ auto_state_key = f"{state_prefix}_auto_range"
+ if bounds is None:
+ page_state[context_state_key] = context_key
+ page_state[auto_state_key] = None
+ return None
+
+ target = tuple(bounds)
+ last_context = page_state.get(context_state_key)
+ last_auto_range = page_state.get(auto_state_key)
+ should_reset = (
+ last_context != context_key
+ or last_auto_range is None
+ or current_range is None
+ or tuple(current_range) == tuple(last_auto_range)
+ )
+ page_state[context_state_key] = context_key
+ page_state[auto_state_key] = target
+ return target if should_reset else None
diff --git a/dashboard/pages/skim_summaries/_shared.py b/dashboard/pages/skim_summaries/_shared.py
index a6fa15b..e980e6e 100644
--- a/dashboard/pages/skim_summaries/_shared.py
+++ b/dashboard/pages/skim_summaries/_shared.py
@@ -16,6 +16,8 @@
DEFAULT_BIN_COUNT = 500
DIRECTION_SUFFIXES = ("_outbound", "_inbound")
ALL_MODES = "All Modes"
+CHOSEN_MODE_SCENARIO = "chosen_mode"
+ALL_RECORDS_SCENARIO = "all_records"
SKIM_FAMILY_ORDER = (
"Auto Skims",
"Transit Skims",
@@ -144,6 +146,8 @@ def component_display_name(
"skim_walk_time_inbound": "Total Walk Access/Egress Time (min)",
"skim_transit_tiv_outbound": "Transit In-Vehicle Time (min)",
"skim_transit_tiv_inbound": "Transit In-Vehicle Time (min)",
+ "skim_bike_distance": "TAZ Skim Bike Distance (mi)",
+ "skim_bike_maz_distance": "MAZ Network Bike Distance (mi)",
}
if value in special_labels:
return special_labels[value]
@@ -183,6 +187,27 @@ def nonempty_series(
]
+def skim_scenario_available(
+ data_list: list[tuple[str, DashboardSummarySeries, pl.DataFrame]] | None,
+ scenario: str,
+) -> bool:
+ for _, _, df in nonempty_series(data_list):
+ if "skim_scenario" not in df.columns:
+ if scenario == CHOSEN_MODE_SCENARIO:
+ return True
+ continue
+ values = (
+ df.select(pl.col("skim_scenario").cast(pl.Utf8))
+ .drop_nulls()
+ .unique()
+ .get_column("skim_scenario")
+ .to_list()
+ )
+ if scenario in values:
+ return True
+ return False
+
+
def skim_family_for_mode(mode: str) -> str | None:
mode_str = str(mode)
for family, modes in SKIM_FAMILY_MODE_MAP.items():
@@ -230,18 +255,38 @@ def _matching_run_entry(
return None
-def _configured_outputs_by_mode(
+def _skimjoin_settings_for_series(
config: Config,
series: DashboardSummarySeries,
- *,
- target_table: str,
-) -> dict[str, set[str]]:
+):
run_entry = _matching_run_entry(config, series)
- skimjoin_settings = (
+ return (
resolve_run_skimjoin_settings(config, run_entry)
if run_entry is not None
else config.skimjoin
)
+
+
+def _ignored_modes_for_series(
+ config: Config,
+ series: DashboardSummarySeries,
+) -> set[str]:
+ skimjoin_settings = _skimjoin_settings_for_series(config, series)
+ normalized = getattr(skimjoin_settings, "normalized_config", None)
+ return {
+ str(mode)
+ for mode in getattr(normalized, "ignore_modes", ()) or ()
+ if str(mode) != ALL_MODES
+ }
+
+
+def _configured_outputs_by_mode(
+ config: Config,
+ series: DashboardSummarySeries,
+ *,
+ target_table: str,
+) -> dict[str, set[str]]:
+ skimjoin_settings = _skimjoin_settings_for_series(config, series)
normalized = getattr(skimjoin_settings, "normalized_config", None)
if normalized is None:
return {}
@@ -267,10 +312,21 @@ def _skim_family_definitions(
series,
target_table=target_table,
)
+ ignored_modes = _ignored_modes_for_series(config, series)
+ if ignored_modes:
+ configured_outputs = {
+ mode: outputs
+ for mode, outputs in configured_outputs.items()
+ if mode not in ignored_modes
+ }
available_modes = _available_modes_from_data(
[(label, series, df)],
mode_column=mode_column,
)
+ if ignored_modes:
+ available_modes = [
+ mode for mode in available_modes if mode not in ignored_modes
+ ]
family_definitions: dict[str, dict[str, tuple[str, ...]]] = {}
if configured_outputs and available_modes:
all_modes = [
@@ -396,6 +452,7 @@ def family_stats_table(
mode_column: str,
target_table: str,
direction: str | None = None,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> list[tuple[str, pl.DataFrame]]:
family_definitions_by_label = _skim_family_definitions(
config,
@@ -418,8 +475,14 @@ def family_stats_table(
filtered = df.with_columns(
pl.col("component").cast(pl.Utf8),
pl.col(mode_column).cast(pl.Utf8),
+ (
+ pl.col("skim_scenario").cast(pl.Utf8)
+ if "skim_scenario" in df.columns
+ else pl.lit(CHOSEN_MODE_SCENARIO)
+ ).alias("__skim_scenario"),
).filter(
- pl.col(mode_column).is_in(list(family_modes))
+ (pl.col("__skim_scenario") == skim_scenario)
+ & pl.col(mode_column).is_in(list(family_modes))
& (pl.col(mode_column) != ALL_MODES)
)
if configured_outputs:
@@ -459,10 +522,15 @@ def mode_options(
*,
mode_column: str,
component: str | None,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> list[str]:
options: list[str] = []
for _, df in nonempty(data_list):
filtered = df
+ if "skim_scenario" in filtered.columns:
+ filtered = filtered.filter(
+ pl.col("skim_scenario").cast(pl.Utf8) == skim_scenario
+ )
if (
component
and component != "No components available"
@@ -488,7 +556,9 @@ def mode_options(
options.append(value)
if not options:
return ["No modes available"]
- return [ALL_MODES, *options]
+ if skim_scenario == CHOSEN_MODE_SCENARIO:
+ return [ALL_MODES, *options]
+ return options
def tour_mode_options(
@@ -496,10 +566,15 @@ def tour_mode_options(
*,
mode_column: str,
component_base: str | None,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> list[str]:
options: list[str] = []
for _, df in nonempty(data_list):
filtered = df
+ if "skim_scenario" in filtered.columns:
+ filtered = filtered.filter(
+ pl.col("skim_scenario").cast(pl.Utf8) == skim_scenario
+ )
if component_base and component_base != "No components available":
outbound = directional_component_name(component_base, "outbound")
inbound = directional_component_name(component_base, "inbound")
@@ -525,7 +600,9 @@ def tour_mode_options(
options.append(value)
if not options:
return ["No modes available"]
- return [ALL_MODES, *options]
+ if skim_scenario == CHOSEN_MODE_SCENARIO:
+ return [ALL_MODES, *options]
+ return options
def filter_stats(
@@ -556,28 +633,56 @@ def prepared_component_values(
mode_column: str,
mode_value: str,
component: str,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> list[tuple[str, np.ndarray, np.ndarray]]:
resolved: list[tuple[str, np.ndarray, np.ndarray]] = []
for label, run in prepared_runs or []:
- df = getattr(run, table_name)
- if df is None or df.is_empty():
- continue
- required_columns = {mode_column, component}
- if not required_columns.issubset(df.columns):
- continue
- filtered = df.with_columns(pl.col(mode_column).cast(pl.Utf8)).filter(
- pl.col(component).is_not_null()
- )
- if mode_value != ALL_MODES:
- filtered = filtered.filter(pl.col(mode_column) == mode_value)
- filtered = filtered.select(
- pl.col(component).cast(pl.Float64).alias(component),
- (
- pl.col("finalweight").cast(pl.Float64)
- if "finalweight" in df.columns
- else pl.lit(1.0)
- ).alias("finalweight"),
- )
+ if skim_scenario == ALL_RECORDS_SCENARIO:
+ sidecar_name = (
+ "trip_hypothetical_skims"
+ if table_name == "trips"
+ else "tour_hypothetical_skims"
+ )
+ df = getattr(run, sidecar_name)
+ if df is None or df.is_empty():
+ continue
+ required_columns = {
+ "hypothetical_mode",
+ "component",
+ "value",
+ "finalweight",
+ }
+ if not required_columns.issubset(df.columns):
+ continue
+ filtered = df.filter(pl.col("component").cast(pl.Utf8) == component)
+ if mode_value != ALL_MODES:
+ filtered = filtered.filter(
+ pl.col("hypothetical_mode").cast(pl.Utf8) == mode_value
+ )
+ filtered = filtered.filter(pl.col("value").is_not_null()).select(
+ pl.col("value").cast(pl.Float64).alias(component),
+ pl.col("finalweight").cast(pl.Float64).alias("finalweight"),
+ )
+ else:
+ df = getattr(run, table_name)
+ if df is None or df.is_empty():
+ continue
+ required_columns = {mode_column, component}
+ if not required_columns.issubset(df.columns):
+ continue
+ filtered = df.with_columns(pl.col(mode_column).cast(pl.Utf8)).filter(
+ pl.col(component).is_not_null()
+ )
+ if mode_value != ALL_MODES:
+ filtered = filtered.filter(pl.col(mode_column) == mode_value)
+ filtered = filtered.select(
+ pl.col(component).cast(pl.Float64).alias(component),
+ (
+ pl.col("finalweight").cast(pl.Float64)
+ if "finalweight" in df.columns
+ else pl.lit(1.0)
+ ).alias("finalweight"),
+ )
if filtered.is_empty():
continue
values = filtered.get_column(component).to_numpy()
@@ -595,6 +700,7 @@ def distribution_bins(
component: str,
x_range: tuple[float, float] | None = None,
bin_count: int = DEFAULT_BIN_COUNT,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> list[tuple[str, pl.DataFrame]]:
value_sets = prepared_component_values(
prepared_runs,
@@ -602,6 +708,7 @@ def distribution_bins(
mode_column=mode_column,
mode_value=mode_value,
component=component,
+ skim_scenario=skim_scenario,
)
if not value_sets:
return []
@@ -667,6 +774,7 @@ def distribution_data_bounds(
mode_column: str,
mode_value: str,
component: str,
+ skim_scenario: str = CHOSEN_MODE_SCENARIO,
) -> tuple[float, float] | None:
value_sets = prepared_component_values(
prepared_runs,
@@ -674,6 +782,7 @@ def distribution_data_bounds(
mode_column=mode_column,
mode_value=mode_value,
component=component,
+ skim_scenario=skim_scenario,
)
if not value_sets:
return None
diff --git a/dashboard/pages/skim_summaries/tour_skims.py b/dashboard/pages/skim_summaries/tour_skims.py
index 48129d6..deb8206 100644
--- a/dashboard/pages/skim_summaries/tour_skims.py
+++ b/dashboard/pages/skim_summaries/tour_skims.py
@@ -4,10 +4,11 @@
import panel as pn
-from dashboard.components import control_row, data_table, density_chart
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.rendering import control_row, data_table
+from dashboard import DashboardPage, dashboard_page
from dashboard.pages.skim_summaries._shared import (
+ ALL_RECORDS_SCENARIO,
+ CHOSEN_MODE_SCENARIO,
TOUR_STATS_SUMMARY_ID,
directional_component_name,
distribution_bins,
@@ -16,19 +17,52 @@
resolve_distribution_range,
skim_direction_options,
skim_family_options,
+ skim_scenario_available,
skim_summary_precision_overrides,
tour_component_base_options,
tour_mode_options,
)
+from dashboard.pages.skim_summaries._page_controls import (
+ repair_selector_options,
+ sync_auto_range_state,
+)
+
+TOP_SELECTOR_ROW_STYLESHEET = """
+:host(.tour-skim-top-selector) {
+ max-width: 240px;
+}
+
+:host(.tour-skim-top-selector-export) {
+ max-width: 190px;
+}
+:host(.tour-skim-direction-selector) {
+ max-width: 140px;
+}
+:host(.tour-skim-direction-selector-export) {
+ max-width: 110px;
+}
+"""
+
+
+@dashboard_page(
+ page_id="tour_skims",
+ title="Tour Skims",
+ order=50,
+ group_id="skim_summaries",
+ default_enabled=True,
+ prepared_data_mode="optional",
+ required_prepared_tables=("tours",),
+ required_summary_ids=(TOUR_STATS_SUMMARY_ID,),
+)
class TourSkimsPage(DashboardPage):
"""Summary and live-distribution page for tour skim families."""
def build_page(self) -> pn.viewable.Viewable:
"""Build the summary shell and the live directional distribution controls."""
- tour_stats = self.state.get_summary_series_set(
- TOUR_STATS_SUMMARY_ID, "weighted"
+ tour_stats = self.data.summary_series(
+ TOUR_STATS_SUMMARY_ID, weighting="weighted"
)
family_options = skim_family_options(
self.config,
@@ -41,6 +75,9 @@ def build_page(self) -> pn.viewable.Viewable:
initial_direction = direction_options[0]
component_base_options = tour_component_base_options(tour_stats)
initial_component_base = component_base_options[0]
+ scenario_options = ["Chosen Mode"]
+ if skim_scenario_available(tour_stats, ALL_RECORDS_SCENARIO):
+ scenario_options.append("All Tours")
self.tour_family_sel = self.selector(
"tour_skim_family",
@@ -51,6 +88,15 @@ def build_page(self) -> pn.viewable.Viewable:
),
label="Tour Skim Family",
)
+ self.tour_scenario_sel = self.selector(
+ "tour_skim_scenario",
+ widget=pn.widgets.Select(
+ name="Tour Skim Scenario",
+ options=scenario_options,
+ value=scenario_options[0],
+ ),
+ label="Tour Skim Scenario",
+ )
self.tour_direction_sel = self.selector(
"tour_skim_direction",
widget=pn.widgets.Select(
@@ -60,6 +106,12 @@ def build_page(self) -> pn.viewable.Viewable:
),
label="Direction",
)
+ self._apply_top_selector_sizing(self.tour_family_sel)
+ self._apply_top_selector_sizing(self.tour_scenario_sel)
+ self._apply_top_selector_sizing(
+ self.tour_direction_sel,
+ css_class="tour-skim-direction-selector",
+ )
self.tour_component_sel = self.selector(
"tour_distribution_component",
widget=pn.widgets.Select(
@@ -78,6 +130,7 @@ def build_page(self) -> pn.viewable.Viewable:
tour_stats,
mode_column="tour_mode",
component_base=initial_component_base,
+ skim_scenario=self._tour_skim_scenario_value(),
),
),
label="Tour Distribution Mode",
@@ -127,14 +180,17 @@ def build_page(self) -> pn.viewable.Viewable:
lambda event: self._reset_distribution_range("inbound")
)
- self._summary_section = self.section(
- "tour_skim_summary_section",
- selectors=("tour_skim_family", "tour_skim_direction"),
+ summary = self.feature("summary")
+ distribution = self.feature("distribution")
+ self._summary_section = summary.section(
+ "body",
+ selectors=("tour_skim_family", "tour_skim_scenario", "tour_skim_direction"),
render=self.render_summary_section,
)
- self._distribution_section = self.section(
- "tour_skim_distribution_section",
+ self._distribution_section = distribution.section(
+ "body",
selectors=(
+ "tour_skim_scenario",
"tour_distribution_component",
"tour_distribution_mode",
"outbound_min",
@@ -148,8 +204,9 @@ def build_page(self) -> pn.viewable.Viewable:
content = [
pn.pane.Markdown("## Tour Skims"),
- control_row(self.tour_family_sel, self.tour_direction_sel),
+ self._top_selector_row(),
self._summary_section,
+ self.section_note("tour_skims.summary_table", self._summary_section),
]
if not self.state.export_mode:
content.extend(
@@ -163,14 +220,19 @@ def build_page(self) -> pn.viewable.Viewable:
def _tour_summaries(self):
"""Return the skim tour statistics for the current weighting mode."""
- return self.state.get_summary_series_set(
- TOUR_STATS_SUMMARY_ID,
- self.weighting_key,
- )
+ return self.data.summary_series(TOUR_STATS_SUMMARY_ID)
def _tour_prepared_runs(self):
"""Return prepared runs in the weighting mode expected by live distributions."""
- return self.get_prepared_runs(weighted=(self.weighting_key == "weighted"))
+ return self.data.prepared_runs(weighting_mode=self.weighting_key)
+
+ def _tour_skim_scenario_value(self) -> str:
+ return (
+ ALL_RECORDS_SCENARIO
+ if getattr(self, "tour_scenario_sel", None) is not None
+ and self.tour_scenario_sel.value == "All Tours"
+ else CHOSEN_MODE_SCENARIO
+ )
def sync_controls(self) -> None:
"""Keep family, direction, component, mode, and x-range controls aligned."""
@@ -182,28 +244,26 @@ def sync_controls(self) -> None:
mode_column="tour_mode",
target_table="tours",
)
- self.tour_family_sel.options = family_options
- if self.tour_family_sel.value not in family_options:
- self.tour_family_sel.value = family_options[0]
+ repair_selector_options(self.tour_family_sel, family_options)
+
+ scenario_options = ["Chosen Mode"]
+ if skim_scenario_available(tour_stats, ALL_RECORDS_SCENARIO):
+ scenario_options.append("All Tours")
+ repair_selector_options(self.tour_scenario_sel, scenario_options)
direction_options = skim_direction_options(tour_stats)
- self.tour_direction_sel.options = direction_options
- if self.tour_direction_sel.value not in direction_options:
- self.tour_direction_sel.value = direction_options[0]
+ repair_selector_options(self.tour_direction_sel, direction_options)
component_base_options = tour_component_base_options(tour_stats)
- self.tour_component_sel.options = component_base_options
- if self.tour_component_sel.value not in component_base_options:
- self.tour_component_sel.value = component_base_options[0]
+ repair_selector_options(self.tour_component_sel, component_base_options)
mode_options = tour_mode_options(
tour_stats,
mode_column="tour_mode",
component_base=self.tour_component_sel.value,
+ skim_scenario=self._tour_skim_scenario_value(),
)
- self.tour_mode_sel.options = mode_options
- if self.tour_mode_sel.value not in mode_options:
- self.tour_mode_sel.value = mode_options[0]
+ repair_selector_options(self.tour_mode_sel, mode_options)
self._sync_distribution_range_controls("outbound")
self._sync_distribution_range_controls("inbound")
@@ -218,40 +278,26 @@ def _sync_distribution_range_controls(self, direction: str) -> None:
max_widget = getattr(self, f"{direction}_max_sel")
component = self._directional_component(direction)
context_key = (component, self.tour_mode_sel.value, self.weighting_key)
- state_key = f"{direction}_distribution_range_context"
- auto_key = f"{direction}_distribution_auto_range"
-
+ context_key = (*context_key, self._tour_skim_scenario_value())
bounds = distribution_data_bounds(
self._tour_prepared_runs(),
table_name="tours",
mode_column="tour_mode",
mode_value=self.tour_mode_sel.value,
component=component,
+ skim_scenario=self._tour_skim_scenario_value(),
)
- if bounds is None:
- self._page_state[state_key] = context_key
- self._page_state[auto_key] = None
- return
-
- last_context = self._page_state.get(state_key)
- last_auto_range = self._page_state.get(auto_key)
current_range = resolve_distribution_range(min_widget.value, max_widget.value)
- should_reset = (
- last_context != context_key
- or last_auto_range is None
- or current_range is None
- or (
- current_range is not None
- and last_auto_range is not None
- and tuple(current_range) == tuple(last_auto_range)
- )
+ target_range = sync_auto_range_state(
+ self._page_state,
+ state_prefix=f"{direction}_distribution",
+ context_key=context_key,
+ bounds=bounds,
+ current_range=current_range,
)
- if should_reset:
- min_widget.value = float(bounds[0])
- max_widget.value = float(bounds[1])
-
- self._page_state[state_key] = context_key
- self._page_state[auto_key] = tuple(bounds)
+ if target_range is not None:
+ min_widget.value = float(target_range[0])
+ max_widget.value = float(target_range[1])
def _reset_distribution_range(self, direction: str) -> None:
"""Restore one directional distribution x-range to its full observed extent."""
@@ -281,18 +327,16 @@ def render_summary_table(self):
title="Data Not Available",
)
- stats_data = self.get_filtered_view(
- "tour_skim_family_stats",
- family,
- direction,
- factory=lambda: family_stats_table(
+ stats_data = self.query(
+ lambda: family_stats_table(
self.config,
tour_stats,
family=family,
mode_column="tour_mode",
target_table="tours",
direction=direction,
- ),
+ skim_scenario=self._tour_skim_scenario_value(),
+ )
)
if not any(not df.is_empty() for _, df in stats_data):
return self.data_not_available_card(
@@ -331,21 +375,16 @@ def render_directional_distribution_chart(self, direction: str):
title=f"{title} Data Not Available",
)
- distribution_data = self.get_filtered_view(
- f"tour_skim_distribution_{direction}",
- component,
- mode,
- self.weighting_key,
- x_range[0],
- x_range[1],
- factory=lambda: distribution_bins(
+ distribution_data = self.query(
+ lambda: distribution_bins(
self._tour_prepared_runs(),
table_name="tours",
mode_column="tour_mode",
mode_value=mode,
component=component,
x_range=x_range,
- ),
+ skim_scenario=self._tour_skim_scenario_value(),
+ )
)
if not any(not df.is_empty() for _, df in distribution_data):
return self.data_not_available_card(
@@ -356,17 +395,15 @@ def render_directional_distribution_chart(self, direction: str):
title=f"{title} Data Not Available",
)
- return density_chart(
+ return self.plot.density(
distribution_data,
- x_col="bin_mid",
- y_col="freq",
+ x="bin_mid",
+ y="freq",
title=f"{title} Tour Distribution - {component} / {mode}",
- xaxis_title="Skim Value",
- yaxis_title="Tours",
- normalize=self.as_percent,
+ x_title="Skim Value",
+ y_title="Tours",
height=320,
- as_percent=False,
- xaxis_range=x_range,
+ x_range=x_range,
)
def render_distribution_section(self):
@@ -375,24 +412,53 @@ def render_distribution_section(self):
return [self.no_runs_message()]
return [
- control_row(self.outbound_min_sel, self.outbound_max_sel, self.outbound_reset_btn),
- self.render_directional_distribution_chart("outbound"),
- control_row(self.inbound_min_sel, self.inbound_max_sel, self.inbound_reset_btn),
- self.render_directional_distribution_chart("inbound"),
+ control_row(
+ self.outbound_min_sel, self.outbound_max_sel, self.outbound_reset_btn
+ ),
+ self.noted_view(
+ "tour_skims.distributions",
+ self.render_directional_distribution_chart("outbound"),
+ ),
+ control_row(
+ self.inbound_min_sel, self.inbound_max_sel, self.inbound_reset_btn
+ ),
+ self.noted_view(
+ "tour_skims.distributions",
+ self.render_directional_distribution_chart("inbound"),
+ ),
]
-
-PAGE = DashboardPageDefinition(
- page_id="tour_skims",
- title="Tour Skims",
- page_cls=TourSkimsPage,
- order=50,
- group_id="skim_summaries",
- child_order=10,
- default_enabled=True,
- prepared_data_mode="optional",
- required_prepared_tables=("tours",),
- required_summary_ids=(TOUR_STATS_SUMMARY_ID,),
-)
-
-TourSkimsPage.definition = PAGE
+ def _apply_top_selector_sizing(
+ self,
+ widget: pn.widgets.Widget,
+ *,
+ css_class: str = "tour-skim-top-selector",
+ ) -> None:
+ css_classes = list(getattr(widget, "css_classes", []) or [])
+ if css_class not in css_classes:
+ css_classes.append(css_class)
+ export_class = f"{css_class}-export"
+ if self.state.export_mode and export_class not in css_classes:
+ css_classes.append(export_class)
+ widget.css_classes = css_classes
+ stylesheets = list(getattr(widget, "stylesheets", []) or [])
+ if TOP_SELECTOR_ROW_STYLESHEET not in stylesheets:
+ stylesheets.append(TOP_SELECTOR_ROW_STYLESHEET)
+ widget.stylesheets = stylesheets
+
+ def _top_selector_row(self) -> pn.Row:
+ gap = "4px" if self.state.export_mode else "6px"
+ return pn.Row(
+ self.tour_family_sel,
+ self.tour_scenario_sel,
+ self.tour_direction_sel,
+ sizing_mode="stretch_width",
+ min_height=72,
+ margin=(0, 0, 8, 0),
+ styles={
+ "justify-content": "flex-start",
+ "align-items": "flex-start",
+ "flex-wrap": "nowrap",
+ "column-gap": gap,
+ },
+ )
diff --git a/dashboard/pages/skim_summaries/trip_skims.py b/dashboard/pages/skim_summaries/trip_skims.py
index 7f6ca0c..349cba1 100644
--- a/dashboard/pages/skim_summaries/trip_skims.py
+++ b/dashboard/pages/skim_summaries/trip_skims.py
@@ -4,29 +4,55 @@
import panel as pn
-from dashboard.components import control_row, data_table, density_chart
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.rendering import control_row, data_table
+from dashboard import DashboardPage, dashboard_page
from dashboard.pages.skim_summaries._shared import (
+ ALL_RECORDS_SCENARIO,
+ CHOSEN_MODE_SCENARIO,
TRIP_STATS_SUMMARY_ID,
component_options,
distribution_bins,
distribution_data_bounds,
family_stats_table,
mode_options,
+ skim_scenario_available,
resolve_distribution_range,
skim_family_options,
skim_summary_precision_overrides,
)
+from dashboard.pages.skim_summaries._page_controls import (
+ repair_selector_options,
+ sync_auto_range_state,
+)
+
+TOP_SELECTOR_ROW_STYLESHEET = """
+:host(.trip-skim-top-selector) {
+ max-width: 240px;
+}
+
+:host(.trip-skim-top-selector-export) {
+ max-width: 190px;
+}
+"""
+@dashboard_page(
+ page_id="trip_skims",
+ title="Trip Skims",
+ order=51,
+ group_id="skim_summaries",
+ default_enabled=True,
+ prepared_data_mode="optional",
+ required_prepared_tables=("trips",),
+ required_summary_ids=(TRIP_STATS_SUMMARY_ID,),
+)
class TripSkimsPage(DashboardPage):
"""Summary and live-distribution page for trip skim families."""
def build_page(self) -> pn.viewable.Viewable:
"""Build the skim summary shell and the live-only distribution controls."""
- trip_stats = self.state.get_summary_series_set(
- TRIP_STATS_SUMMARY_ID, "weighted"
+ trip_stats = self.data.summary_series(
+ TRIP_STATS_SUMMARY_ID, weighting="weighted"
)
trip_family_options = skim_family_options(
self.config,
@@ -37,6 +63,9 @@ def build_page(self) -> pn.viewable.Viewable:
initial_trip_family = trip_family_options[0]
trip_component_options = component_options(trip_stats)
initial_trip_component = trip_component_options[0]
+ trip_scenario_options = ["Chosen Mode"]
+ if skim_scenario_available(trip_stats, ALL_RECORDS_SCENARIO):
+ trip_scenario_options.append("All Trips")
self.trip_family_sel = self.selector(
"trip_skim_family",
@@ -47,6 +76,17 @@ def build_page(self) -> pn.viewable.Viewable:
),
label="Trip Skim Family",
)
+ self.trip_scenario_sel = self.selector(
+ "trip_skim_scenario",
+ widget=pn.widgets.Select(
+ name="Trip Skim Scenario",
+ options=trip_scenario_options,
+ value=trip_scenario_options[0],
+ ),
+ label="Trip Skim Scenario",
+ )
+ self._apply_top_selector_sizing(self.trip_family_sel)
+ self._apply_top_selector_sizing(self.trip_scenario_sel)
self.trip_component_sel = self.selector(
"trip_distribution_component",
widget=pn.widgets.Select(
@@ -65,6 +105,7 @@ def build_page(self) -> pn.viewable.Viewable:
trip_stats,
mode_column="trip_mode",
component=initial_trip_component,
+ skim_scenario=self._trip_skim_scenario_value(),
),
),
label="Trip Distribution Mode",
@@ -92,14 +133,17 @@ def build_page(self) -> pn.viewable.Viewable:
self.trip_mode_sel.value = self.trip_mode_sel.options[0]
self.trip_reset_btn.on_click(lambda event: self._reset_distribution_range())
- self._summary_section = self.section(
- "trip_skim_summary_section",
- selectors=("trip_skim_family",),
+ summary = self.feature("summary")
+ distribution = self.feature("distribution")
+ self._summary_section = summary.section(
+ "body",
+ selectors=("trip_skim_family", "trip_skim_scenario"),
render=self.render_summary_section,
)
- self._distribution_section = self.section(
- "trip_skim_distribution_section",
+ self._distribution_section = distribution.section(
+ "body",
selectors=(
+ "trip_skim_scenario",
"trip_distribution_component",
"trip_distribution_mode",
"trip_min",
@@ -111,8 +155,9 @@ def build_page(self) -> pn.viewable.Viewable:
content = [
pn.pane.Markdown("## Trip Skims"),
- control_row(self.trip_family_sel),
+ self._top_selector_row(),
self._summary_section,
+ self.section_note("trip_skims.summary_table", self._summary_section),
]
if not self.state.export_mode:
content.extend(
@@ -120,20 +165,28 @@ def build_page(self) -> pn.viewable.Viewable:
pn.pane.Markdown("### Live Trip Distributions"),
control_row(self.trip_component_sel, self.trip_mode_sel),
self._distribution_section,
+ self.section_note(
+ "trip_skims.distribution", self._distribution_section
+ ),
]
)
return self.new_section(*content)
def _trip_summaries(self):
"""Return the skim trip statistics for the current weighting mode."""
- return self.state.get_summary_series_set(
- TRIP_STATS_SUMMARY_ID,
- self.weighting_key,
- )
+ return self.data.summary_series(TRIP_STATS_SUMMARY_ID)
def _trip_prepared_runs(self):
"""Return prepared runs in the weighting mode expected by distribution charts."""
- return self.get_prepared_runs(weighted=(self.weighting_key == "weighted"))
+ return self.data.prepared_runs(weighting_mode=self.weighting_key)
+
+ def _trip_skim_scenario_value(self) -> str:
+ return (
+ ALL_RECORDS_SCENARIO
+ if getattr(self, "trip_scenario_sel", None) is not None
+ and self.trip_scenario_sel.value == "All Trips"
+ else CHOSEN_MODE_SCENARIO
+ )
def sync_controls(self) -> None:
"""Keep family, component, mode, and x-range controls in sync."""
@@ -145,23 +198,23 @@ def sync_controls(self) -> None:
mode_column="trip_mode",
target_table="trips",
)
- self.trip_family_sel.options = trip_family_options
- if self.trip_family_sel.value not in trip_family_options:
- self.trip_family_sel.value = trip_family_options[0]
+ repair_selector_options(self.trip_family_sel, trip_family_options)
+
+ trip_scenario_options = ["Chosen Mode"]
+ if skim_scenario_available(trip_stats, ALL_RECORDS_SCENARIO):
+ trip_scenario_options.append("All Trips")
+ repair_selector_options(self.trip_scenario_sel, trip_scenario_options)
trip_component_options = component_options(trip_stats)
- self.trip_component_sel.options = trip_component_options
- if self.trip_component_sel.value not in trip_component_options:
- self.trip_component_sel.value = trip_component_options[0]
+ repair_selector_options(self.trip_component_sel, trip_component_options)
trip_mode_options = mode_options(
trip_stats,
mode_column="trip_mode",
component=self.trip_component_sel.value,
+ skim_scenario=self._trip_skim_scenario_value(),
)
- self.trip_mode_sel.options = trip_mode_options
- if self.trip_mode_sel.value not in trip_mode_options:
- self.trip_mode_sel.value = trip_mode_options[0]
+ repair_selector_options(self.trip_mode_sel, trip_mode_options)
self._sync_distribution_range_controls()
@@ -171,6 +224,7 @@ def _sync_distribution_range_controls(self) -> None:
self.trip_component_sel.value,
self.trip_mode_sel.value,
self.weighting_key,
+ self._trip_skim_scenario_value(),
)
bounds = distribution_data_bounds(
self._trip_prepared_runs(),
@@ -178,36 +232,23 @@ def _sync_distribution_range_controls(self) -> None:
mode_column="trip_mode",
mode_value=self.trip_mode_sel.value,
component=self.trip_component_sel.value,
+ skim_scenario=self._trip_skim_scenario_value(),
)
- target_range = bounds
- if target_range is None:
- self._page_state["trip_distribution_range_context"] = context_key
- self._page_state["trip_distribution_auto_range"] = None
- return
-
- last_context = self._page_state.get("trip_distribution_range_context")
- last_auto_range = self._page_state.get("trip_distribution_auto_range")
current_range = resolve_distribution_range(
self.trip_min_sel.value,
self.trip_max_sel.value,
)
- should_reset = (
- last_context != context_key
- or last_auto_range is None
- or current_range is None
- or (
- current_range is not None
- and last_auto_range is not None
- and tuple(current_range) == tuple(last_auto_range)
- )
+ target_range = sync_auto_range_state(
+ self._page_state,
+ state_prefix="trip_distribution",
+ context_key=context_key,
+ bounds=bounds,
+ current_range=current_range,
)
- if should_reset:
+ if target_range is not None:
self.trip_min_sel.value = float(target_range[0])
self.trip_max_sel.value = float(target_range[1])
- self._page_state["trip_distribution_range_context"] = context_key
- self._page_state["trip_distribution_auto_range"] = tuple(target_range)
-
def _reset_distribution_range(self) -> None:
"""Restore the current trip distribution x-range to its full observed extent."""
auto_range = self._page_state.get("trip_distribution_auto_range")
@@ -239,16 +280,15 @@ def render_summary_section(self):
),
]
- trip_stats_data = self.get_filtered_view(
- "trip_skim_family_stats",
- family,
- factory=lambda: family_stats_table(
+ trip_stats_data = self.query(
+ lambda: family_stats_table(
self.config,
trip_stats,
family=family,
mode_column="trip_mode",
target_table="trips",
- ),
+ skim_scenario=self._trip_skim_scenario_value(),
+ )
)
if not any(not df.is_empty() for _, df in trip_stats_data):
return [
@@ -280,21 +320,16 @@ def render_distribution_section(self):
),
]
- trip_distribution_data = self.get_filtered_view(
- "trip_skim_distribution",
- component,
- trip_mode,
- self.weighting_key,
- trip_distribution_x_range[0],
- trip_distribution_x_range[1],
- factory=lambda: distribution_bins(
+ trip_distribution_data = self.query(
+ lambda: distribution_bins(
self._trip_prepared_runs(),
table_name="trips",
mode_column="trip_mode",
mode_value=trip_mode,
component=component,
x_range=trip_distribution_x_range,
- ),
+ skim_scenario=self._trip_skim_scenario_value(),
+ )
)
trip_distribution_view = (
@@ -341,31 +376,43 @@ def render_distribution_chart(
x_range: tuple[float, float],
) -> pn.viewable.Viewable:
"""Render the live prepared-trip skim distribution chart."""
- return density_chart(
+ return self.plot.density(
trip_distribution_data,
- x_col="bin_mid",
- y_col="freq",
+ x="bin_mid",
+ y="freq",
title=f"Trip Distribution - {component} / {trip_mode}",
- xaxis_title="Skim Value",
- yaxis_title="Trips",
- normalize=self.as_percent,
+ x_title="Skim Value",
+ y_title="Trips",
height=320,
- as_percent=False,
- xaxis_range=x_range,
+ x_range=x_range,
)
-
-PAGE = DashboardPageDefinition(
- page_id="trip_skims",
- title="Trip Skims",
- page_cls=TripSkimsPage,
- order=51,
- group_id="skim_summaries",
- child_order=20,
- default_enabled=True,
- prepared_data_mode="optional",
- required_prepared_tables=("trips",),
- required_summary_ids=(TRIP_STATS_SUMMARY_ID,),
-)
-
-TripSkimsPage.definition = PAGE
+ def _apply_top_selector_sizing(self, widget: pn.widgets.Widget) -> None:
+ css_classes = list(getattr(widget, "css_classes", []) or [])
+ base_class = "trip-skim-top-selector"
+ export_class = "trip-skim-top-selector-export"
+ if base_class not in css_classes:
+ css_classes.append(base_class)
+ if self.state.export_mode and export_class not in css_classes:
+ css_classes.append(export_class)
+ widget.css_classes = css_classes
+ stylesheets = list(getattr(widget, "stylesheets", []) or [])
+ if TOP_SELECTOR_ROW_STYLESHEET not in stylesheets:
+ stylesheets.append(TOP_SELECTOR_ROW_STYLESHEET)
+ widget.stylesheets = stylesheets
+
+ def _top_selector_row(self) -> pn.Row:
+ gap = "4px" if self.state.export_mode else "6px"
+ return pn.Row(
+ self.trip_family_sel,
+ self.trip_scenario_sel,
+ sizing_mode="stretch_width",
+ min_height=72,
+ margin=(0, 0, 8, 0),
+ styles={
+ "justify-content": "flex-start",
+ "align-items": "flex-start",
+ "flex-wrap": "nowrap",
+ "column-gap": gap,
+ },
+ )
diff --git a/dashboard/pages/tour_summaries/_tour_mode/__init__.py b/dashboard/pages/tour_summaries/_tour_mode/__init__.py
new file mode 100644
index 0000000..3c3e730
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/__init__.py
@@ -0,0 +1,6 @@
+"""Private implementation package for Tour Mode."""
+
+from .contracts import *
+from .transforms import *
+
+__all__ = [name for name in globals() if name.isupper() or not name.startswith("__")]
diff --git a/dashboard/pages/tour_summaries/_tour_mode/composition.py b/dashboard/pages/tour_summaries/_tour_mode/composition.py
new file mode 100644
index 0000000..21bbcbd
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/composition.py
@@ -0,0 +1,59 @@
+"""Page composition for Tour Mode."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ add_percent_of_total,
+ column_options,
+ common_column_options,
+ category_label_matches,
+ label_category_data,
+ nonempty,
+ ordered_category_values,
+)
+from dashboard.rendering import selector_row
+
+from .contracts import *
+from .transforms import *
+
+
+class TourModeCompositionMixin:
+ def build_page(self) -> pn.viewable.Viewable:
+ """Build the page shell and persistent selectors."""
+ self._purpose_to_raw: dict[str, str | None] = {}
+ self.purpose_sel = self.select(
+ "tour_purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
+ )
+ self.hide_drive_alone = self.selector(
+ "hide_drive_alone",
+ widget=pn.widgets.Checkbox(name="Hide Auto Modes", value=False),
+ label="Hide Auto Modes",
+ )
+ self.occupancy_sel = self.select(
+ "vehicle_occupancy",
+ "Vehicle Occupancy",
+ options=self._occupancy_options,
+ )
+ self._mode_section = self.section(
+ "tour_mode_modes",
+ selectors=("tour_purpose", "hide_drive_alone"),
+ render=self.render_modes_section,
+ )
+ self._vehicle_section = self.section(
+ "tour_mode_vehicles",
+ selectors=("vehicle_occupancy",),
+ render=self.render_vehicle_section,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## Tour Mode"),
+ pn.pane.Markdown(auto_sufficiency_definitions_markdown(self.config)),
+ selector_row(self.purpose_sel, self.hide_drive_alone),
+ self._mode_section,
+ self._vehicle_section,
+ )
diff --git a/dashboard/pages/tour_summaries/_tour_mode/contracts.py b/dashboard/pages/tour_summaries/_tour_mode/contracts.py
new file mode 100644
index 0000000..67c69f0
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/contracts.py
@@ -0,0 +1,9 @@
+"""Stable contracts for the Tour Mode page."""
+
+AUTO_SUFFICIENCY_LEVELS = [
+ "All",
+ "Zero Auto",
+ "Auto Deficient",
+ "Auto Sufficient",
+]
+AUTO_MODE_LABELS = ("Drive Alone", "Shared Ride 2", "Shared Ride 3+")
diff --git a/dashboard/pages/tour_summaries/_tour_mode/features.py b/dashboard/pages/tour_summaries/_tour_mode/features.py
new file mode 100644
index 0000000..850c1d7
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/features.py
@@ -0,0 +1,256 @@
+"""Feature rendering for Tour Mode."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ add_percent_of_total,
+ column_options,
+ common_column_options,
+ category_label_matches,
+ label_category_data,
+ nonempty,
+ ordered_category_values,
+)
+from dashboard.rendering import selector_row
+
+from .contracts import *
+from .transforms import *
+
+
+class TourModeFeatureMixin:
+ def render_modes_section(self):
+ """Render the tour mode charts by auto sufficiency."""
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+
+ summaries = self._summaries()
+ mode_summary = summaries["tour_mode_by_tour_purpose_and_auto_sufficiency"]
+ selected_purpose = str(self.purpose_sel.value)
+ raw_purpose = self._purpose_to_raw.get(selected_purpose, "all_tour_purposes")
+ if not mode_summary:
+ return [
+ pn.pane.Markdown("### Tour Mode"),
+ self.data_not_available_card(
+ detail="The tour mode summary is unavailable.",
+ missing_items=["tour_mode_by_tour_purpose_and_auto_sufficiency"],
+ ),
+ ]
+
+ mode_values = [
+ value
+ for value in ordered_category_values(
+ mode_summary,
+ "tour_mode",
+ category_id="mode",
+ config=self.config,
+ )
+ if value != "all_tour_modes"
+ ]
+ hidden_mode_values: set[str] = set()
+ if self.hide_drive_alone.value:
+ hidden_mode_values = {
+ value
+ for value in mode_values
+ if any(
+ category_label_matches(self.config, "mode", value, label)
+ for label in AUTO_MODE_LABELS
+ )
+ }
+ mode_values = [
+ value for value in mode_values if value not in hidden_mode_values
+ ]
+ return [
+ pn.pane.Markdown("### Tour Mode"),
+ *[
+ self.noted_view(
+ "tour_mode.mode",
+ self.render_tour_mode_chart(
+ mode_summary,
+ str(raw_purpose),
+ selected_purpose,
+ auto_sufficiency,
+ mode_values,
+ hidden_mode_values,
+ ),
+ )
+ for auto_sufficiency in AUTO_SUFFICIENCY_LEVELS
+ ],
+ ]
+
+ def render_tour_mode_chart(
+ self,
+ summary_data,
+ raw_purpose: str,
+ display_purpose: str,
+ auto_sufficiency: str,
+ mode_values: list[str],
+ hidden_mode_values: set[str],
+ ) -> pn.viewable.Viewable:
+ """Render one auto-sufficiency slice of the selected tour purpose."""
+ mode_data = self.query(
+ lambda: tour_mode_chart_data(
+ summary_data,
+ raw_purpose,
+ auto_sufficiency,
+ hidden_mode_values,
+ )
+ )
+ labeled = label_category_data(
+ mode_data,
+ source_col="tour_mode",
+ category_id="mode",
+ config=self.config,
+ target_col="tour_mode_label",
+ )
+ return self.plot.bar(
+ labeled,
+ x="tour_mode_label",
+ y="tour_count",
+ title=(
+ "Tour Mode - "
+ f"{auto_sufficiency_display_label(auto_sufficiency, self.config)}"
+ ),
+ x_title="Tour Mode",
+ y_title="Tours",
+ share_y="tour_count_percent",
+ category_order=self.config.ordered_labels("mode", mode_values),
+ )
+
+ def render_vehicle_section(self):
+ """Render the three allocated vehicle characteristic charts."""
+ summaries = self._summaries()
+ occupancy = str(self.occupancy_sel.value)
+ return [
+ pn.pane.Markdown("### Allocated Vehicle Characteristics"),
+ selector_row(self.occupancy_sel),
+ pn.Row(
+ self.noted_view(
+ "tour_mode.vehicle_age",
+ self.render_vehicle_age_chart(
+ summaries["allocated_vehicle_age_by_occupancy"],
+ occupancy,
+ ),
+ ),
+ self.noted_view(
+ "tour_mode.vehicle_fuel",
+ self.render_vehicle_fuel_chart(
+ summaries["allocated_vehicle_fuel_type_by_occupancy"],
+ occupancy,
+ ),
+ ),
+ self.noted_view(
+ "tour_mode.vehicle_body",
+ self.render_vehicle_body_chart(
+ summaries["allocated_vehicle_body_type_by_occupancy"],
+ occupancy,
+ ),
+ ),
+ ),
+ ]
+
+ def render_vehicle_age_chart(
+ self, summary_data, occupancy: str
+ ) -> pn.viewable.Viewable:
+ """Render allocated vehicle age by occupancy level."""
+ if not summary_data:
+ return self.data_not_available_card(
+ detail="The allocated vehicle age summary is unavailable.",
+ missing_items=["allocated_vehicle_age_by_occupancy"],
+ )
+ age_values = self.ordered_vehicle_values(summary_data, "age")
+ chart_data = self.query(
+ lambda: vehicle_attribute_data(
+ summary_data,
+ occupancy,
+ category="age",
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="age",
+ y="vehicle_count",
+ title="Allocated Vehicle Age by Occupancy Level",
+ x_title="Vehicle Age",
+ y_title="Allocated Vehicles",
+ category_order=age_values,
+ )
+
+ def render_vehicle_fuel_chart(
+ self, summary_data, occupancy: str
+ ) -> pn.viewable.Viewable:
+ """Render allocated vehicle fuel type by occupancy level."""
+ if not summary_data:
+ return self.data_not_available_card(
+ detail="The allocated vehicle fuel summary is unavailable.",
+ missing_items=["allocated_vehicle_fuel_type_by_occupancy"],
+ )
+ fuel_values = self.ordered_vehicle_values(summary_data, "fuel_type")
+ chart_data = self.query(
+ lambda: vehicle_attribute_data(
+ summary_data,
+ occupancy,
+ category="fuel_type",
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="fuel_type",
+ y="vehicle_count",
+ title="Allocated Vehicle Fuel Type by Occupancy Level",
+ x_title="Vehicle Fuel Type",
+ y_title="Allocated Vehicles",
+ category_order=fuel_values,
+ )
+
+ def render_vehicle_body_chart(
+ self, summary_data, occupancy: str
+ ) -> pn.viewable.Viewable:
+ """Render allocated vehicle body type by occupancy level."""
+ if not summary_data:
+ return self.data_not_available_card(
+ detail="The allocated vehicle body summary is unavailable.",
+ missing_items=["allocated_vehicle_body_type_by_occupancy"],
+ )
+ body_values = self.ordered_vehicle_values(summary_data, "body_type")
+ chart_data = self.query(
+ lambda: vehicle_attribute_data(
+ summary_data,
+ occupancy,
+ category="body_type",
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="body_type",
+ y="vehicle_count",
+ title="Allocated Vehicle Body Type by Occupancy Level",
+ x_title="Vehicle Body Type",
+ y_title="Allocated Vehicles",
+ category_order=body_values,
+ )
+
+ def ordered_vehicle_values(
+ self,
+ summary_data: list[tuple[str, pl.DataFrame]],
+ column: str,
+ ) -> list[str]:
+ """Return a stable x-axis order for vehicle attributes."""
+ values = {
+ str(value)
+ for _, df in nonempty(summary_data)
+ for value in (
+ df[column].cast(pl.Utf8).to_list() if column in df.columns else []
+ )
+ }
+ if column == "age":
+ return sorted(
+ values,
+ key=lambda value: (
+ 999 if value == "20+" else int(value) if value.isdigit() else 1000
+ ),
+ )
+ return sorted(values)
diff --git a/dashboard/pages/tour_summaries/_tour_mode/selector_domains.py b/dashboard/pages/tour_summaries/_tour_mode/selector_domains.py
new file mode 100644
index 0000000..40349ce
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/selector_domains.py
@@ -0,0 +1,72 @@
+"""Selector domains for Tour Mode."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ add_percent_of_total,
+ column_options,
+ common_column_options,
+ category_label_matches,
+ label_category_data,
+ nonempty,
+ ordered_category_values,
+)
+from dashboard.rendering import selector_row
+
+from .contracts import *
+from .transforms import *
+
+
+class TourModeSelectorDomainsMixin:
+ def _purpose_options(self) -> list[str]:
+ """Return purposes available for the current weighting mode."""
+ summaries = self.data.summary(
+ "tour_mode_by_tour_purpose_and_auto_sufficiency",
+ self.weighting_key,
+ )
+ options, self._purpose_to_raw = column_options(
+ summaries or [],
+ "tour_purpose",
+ category_id="tour_purpose",
+ config=self.config,
+ total_raw="all_tour_purposes",
+ total_label=self.TOTAL_PURPOSE_LABEL,
+ )
+ return options or [self.TOTAL_PURPOSE_LABEL]
+
+ def _occupancy_options(self) -> list[str]:
+ """Return vehicle occupancies available for the current weighting mode."""
+ age_summary = self.data.summary(
+ "allocated_vehicle_age_by_occupancy",
+ self.weighting_key,
+ )
+ fuel_summary = self.data.summary(
+ "allocated_vehicle_fuel_type_by_occupancy",
+ self.weighting_key,
+ )
+ body_summary = self.data.summary(
+ "allocated_vehicle_body_type_by_occupancy",
+ self.weighting_key,
+ )
+ options, _ = common_column_options(
+ age_summary,
+ fuel_summary,
+ body_summary,
+ column="occupancy",
+ total_raw="All",
+ total_label="All",
+ )
+ return options or ["All"]
+
+ def _summaries(self):
+ """Load every summary used by either page section."""
+ return self.data.summaries(
+ "tour_mode_by_tour_purpose_and_auto_sufficiency",
+ "allocated_vehicle_age_by_occupancy",
+ "allocated_vehicle_fuel_type_by_occupancy",
+ "allocated_vehicle_body_type_by_occupancy",
+ )
diff --git a/dashboard/pages/tour_summaries/_tour_mode/transforms.py b/dashboard/pages/tour_summaries/_tour_mode/transforms.py
new file mode 100644
index 0000000..5bf53c6
--- /dev/null
+++ b/dashboard/pages/tour_summaries/_tour_mode/transforms.py
@@ -0,0 +1,117 @@
+"""Pure transformations for the Tour Mode page."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import add_percent_of_total
+
+from .contracts import *
+
+
+def _auto_sufficiency_basis_terms(config) -> tuple[str, str]:
+ """Return explanatory and short display nouns for the configured basis."""
+ return {
+ "licensed_drivers": ("licensed drivers", "Drivers"),
+ "workers": ("workers", "Workers"),
+ "adults": ("adults", "Adults"),
+ }[config.prepare_auto_sufficiency.basis]
+
+
+def auto_sufficiency_display_label(auto_sufficiency: str, config) -> str:
+ """Return the dashboard-facing label for an auto-sufficiency slice."""
+ _, label_noun = _auto_sufficiency_basis_terms(config)
+ return {
+ "All": "All",
+ "Zero Auto": "Zero Auto",
+ "Auto Deficient": f"Fewer Vehicles Than {label_noun}",
+ "Auto Sufficient": f"At Least As Many Vehicles as {label_noun}",
+ }[auto_sufficiency]
+
+
+def auto_sufficiency_definitions_markdown(config) -> str:
+ """Describe the configured household basis behind the auto sufficiency split."""
+ basis_noun, _ = _auto_sufficiency_basis_terms(config)
+ deficient_label = auto_sufficiency_display_label("Auto Deficient", config)
+ sufficient_label = auto_sufficiency_display_label("Auto Sufficient", config)
+ return f"""
+ **Auto sufficiency definitions**
+
+ - **Zero Auto**: household has no vehicles.
+ - **{deficient_label}**: household has fewer vehicles than {basis_noun}.
+ - **{sufficient_label}**: household has at least as many vehicles as {basis_noun}.
+ """
+
+
+def vehicle_attribute_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ occupancy: str,
+ *,
+ category: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Filter one allocated-vehicle summary to the selected occupancy level."""
+
+ def sort_filtered(df: pl.DataFrame) -> pl.DataFrame:
+ if "age" in df.columns:
+ return (
+ df.with_columns(
+ pl.when(pl.col("age").cast(pl.Utf8) == "20+")
+ .then(999)
+ .otherwise(pl.col("age").cast(pl.Int64, strict=False))
+ .alias("_sort_age")
+ )
+ .sort("_sort_age")
+ .drop("_sort_age")
+ )
+ return df.sort(category) if category in df.columns else df
+
+ def shape(df: pl.DataFrame) -> pl.DataFrame:
+ filtered = df
+ if "occupancy" in filtered.columns:
+ filtered = filtered.with_columns(pl.col("occupancy").cast(pl.Utf8))
+ if occupancy == "All":
+ filtered = filtered.group_by(category).agg(
+ vehicle_count=pl.col("vehicle_count").sum()
+ )
+ else:
+ filtered = filtered.filter(pl.col("occupancy") == occupancy)
+ return sort_filtered(filtered)
+
+ return RunTables.from_runs(data_list).map(shape)
+
+
+def tour_mode_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ purpose: str,
+ auto_sufficiency: str,
+ hidden_mode_values: set[str] | None = None,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Build one tour mode distribution for a selected purpose and sufficiency slice."""
+ value_col = {
+ "All": "tour_count_all_households",
+ "Zero Auto": "tour_count_zero_auto",
+ "Auto Deficient": "tour_count_auto_deficient",
+ "Auto Sufficient": "tour_count_auto_sufficient",
+ }[auto_sufficiency]
+
+ def shape(df: pl.DataFrame) -> pl.DataFrame:
+ filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8)).filter(
+ pl.col("tour_purpose") == purpose
+ )
+ chart_df = filtered.select(
+ pl.col("tour_mode"),
+ pl.col(value_col).alias("tour_count"),
+ ).sort("tour_mode")
+ chart_df = add_percent_of_total(
+ [("run", chart_df)],
+ value_col="tour_count",
+ percent_col="tour_count_percent",
+ )[0][1]
+ if hidden_mode_values:
+ chart_df = chart_df.with_columns(pl.col("tour_mode").cast(pl.Utf8)).filter(
+ ~pl.col("tour_mode").is_in(sorted(hidden_mode_values))
+ )
+ return chart_df
+
+ return RunTables.from_runs(data_list).map(shape)
diff --git a/dashboard/pages/tour_summaries/internal_external_tours.py b/dashboard/pages/tour_summaries/internal_external_tours.py
index 4927b15..06c3805 100644
--- a/dashboard/pages/tour_summaries/internal_external_tours.py
+++ b/dashboard/pages/tour_summaries/internal_external_tours.py
@@ -3,30 +3,45 @@
from __future__ import annotations
import panel as pn
+import polars as pl
-from dashboard.components import data_table, selector_row
+from dashboard.rendering import data_table, selector_row
+from dashboard.data_access import RunTables
from dashboard.helpers.geography_helpers import (
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ ALL_GEOGRAPHY_TYPES_VALUE,
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
filter_geography_level,
- geography_level_options,
+ geography_type_options,
normalize_geography_data,
+ with_display_geography_columns,
)
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
+from dashboard.page_base import SectionContent
+@dashboard_page(
+ page_id="internal_external_tours",
+ title="Internal vs. External Tours",
+ group_id="tour_summaries",
+ order=46,
+ required_summary_ids=(
+ "internal_external_nonmandatory_tour_frequency_by_home_geography",
+ "external_nonmandatory_tour_locations",
+ ),
+)
class InternalExternalToursPage(DashboardPage):
"""Compare internal/external non-mandatory tours across geography levels."""
def build_page(self) -> pn.viewable.Viewable:
"""Build the page with one shared geography-level selector."""
- self.geo_level_sel = self.selector(
+ self._geo_level_raw_by_label: dict[str, str | None] = {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ }
+ self.geo_level_sel = self.select(
"geography_level",
- widget=pn.widgets.Select(
- name="Geography Level",
- options=["Total"],
- value="Total",
- ),
- label="Geography Level",
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._geography_options,
)
self._body = self.section(
"internal_external_tours_body",
@@ -40,40 +55,91 @@ def build_page(self) -> pn.viewable.Viewable:
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- """Keep the geography-level selector aligned with available summaries."""
- summaries = self.optional_summaries_dict(
+ def _geography_options(self) -> list[str]:
+ """Return available geography levels and refresh their raw mapping."""
+ summaries = self.data.summaries(
"internal_external_nonmandatory_tour_frequency_by_home_geography",
"external_nonmandatory_tour_locations",
)
- geo_opts = geography_level_options(
+ geo_opts, self._geo_level_raw_by_label = geography_type_options(
normalize_geography_data(
- summaries["internal_external_nonmandatory_tour_frequency_by_home_geography"]
+ summaries[
+ "internal_external_nonmandatory_tour_frequency_by_home_geography"
+ ]
)
or None,
normalize_geography_data(summaries["external_nonmandatory_tour_locations"])
or None,
config=self.config,
- total_label="Total",
+ include_all_types=True,
)
- self.geo_level_sel.options = geo_opts
- if self.geo_level_sel.value not in geo_opts:
- self.geo_level_sel.value = geo_opts[0]
+ return geo_opts
+
+ def selected_geography_level_raw(self) -> str:
+ """Return the raw geography type selected in the display selector."""
+ selected = str(self.geo_level_sel.value)
+ raw_value = self._geo_level_raw_by_label.get(selected, selected)
+ return ALL_GEOGRAPHY_TYPES_VALUE if raw_value is None else str(raw_value)
+
+ def _display_geography_table(
+ self,
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ geography_col: str = "geography",
+ ) -> list[tuple[str, pl.DataFrame]]:
+ """Return table data with friendly geography display columns first."""
+ raw_geography_columns = {
+ "geography_level",
+ "geography_type",
+ "geography",
+ "geography_id",
+ "home_geography",
+ }
+
+ def prepare(df: pl.DataFrame) -> pl.DataFrame:
+ display_df = with_display_geography_columns(
+ df,
+ config=self.config,
+ geography_col=geography_col,
+ )
+ ordered_columns = [
+ column
+ for column in (
+ "Geography Type",
+ "Geography Name",
+ *[
+ column
+ for column in display_df.columns
+ if column
+ not in {
+ "Geography Type",
+ "Geography Name",
+ *raw_geography_columns,
+ }
+ ],
+ )
+ if column in display_df.columns
+ ]
+ return display_df.select(ordered_columns) if ordered_columns else display_df
+
+ return RunTables.from_runs(data_list).map(prepare)
def render_body_section(self) -> SectionContent:
"""Render the two tour tables side by side for the selected level."""
if not self.state.run_labels:
return [self.no_runs_message()]
- geo_level = str(self.geo_level_sel.value)
- summaries = self.optional_summaries_dict(
+ geo_level = self.selected_geography_level_raw()
+ summaries = self.data.summaries(
"internal_external_nonmandatory_tour_frequency_by_home_geography",
"external_nonmandatory_tour_locations",
)
int_ext_widget = self.render_internal_external_table(
geo_level,
normalize_geography_data(
- summaries["internal_external_nonmandatory_tour_frequency_by_home_geography"]
+ summaries[
+ "internal_external_nonmandatory_tour_frequency_by_home_geography"
+ ]
),
)
external_locations_widget = self.render_external_locations_table(
@@ -82,8 +148,14 @@ def render_body_section(self) -> SectionContent:
)
return [
pn.Row(
- int_ext_widget,
- external_locations_widget,
+ self.noted_view(
+ "internal_external_tours.frequency_table",
+ int_ext_widget,
+ ),
+ self.noted_view(
+ "internal_external_tours.location_table",
+ external_locations_widget,
+ ),
sizing_mode="stretch_width",
)
]
@@ -102,13 +174,9 @@ def render_internal_external_table(
],
)
- table_data = self.get_filtered_view(
- "internal_external_nonmandatory_tours",
- geo_level,
- factory=lambda: filter_geography_level(summary_data, geo_level),
- )
+ table_data = self.query(lambda: filter_geography_level(summary_data, geo_level))
return data_table(
- table_data,
+ self._display_geography_table(table_data, geography_col="home_geography"),
"Internal vs. External Non-Mandatory Tour Frequency",
)
@@ -124,24 +192,18 @@ def render_external_locations_table(
missing_items=["external_nonmandatory_tour_locations"],
)
- table_data = self.get_filtered_view(
- "external_nonmandatory_tour_locations",
- geo_level,
- factory=lambda: filter_geography_level(summary_data, geo_level),
+ table_data = self.query(lambda: filter_geography_level(summary_data, geo_level))
+ if not any(not df.is_empty() for _, df in table_data):
+ return self.data_not_available_card(
+ detail=(
+ "No external non-mandatory tour location data is available for the "
+ "selected geography. This summary can render MPO, County, or other "
+ "configured tour-location geographies only when the prepared tour "
+ "data includes the corresponding location geography columns."
+ ),
+ missing_items=["external_nonmandatory_tour_locations"],
+ )
+ return data_table(
+ self._display_geography_table(table_data),
+ "External Non-Mandatory Tour Location",
)
- return data_table(table_data, "External Non-Mandatory Tour Location")
-
-
-PAGE = DashboardPageDefinition(
- page_id="internal_external_tours",
- title="Internal vs. External Tours",
- group_id="tour_summaries",
- order=46,
- page_cls=InternalExternalToursPage,
- required_summary_ids=(
- "internal_external_nonmandatory_tour_frequency_by_home_geography",
- "external_nonmandatory_tour_locations",
- ),
-)
-
-InternalExternalToursPage.definition = PAGE
diff --git a/dashboard/pages/tour_summaries/park_and_ride_location.py b/dashboard/pages/tour_summaries/park_and_ride_location.py
index c23a58a..43b3305 100644
--- a/dashboard/pages/tour_summaries/park_and_ride_location.py
+++ b/dashboard/pages/tour_summaries/park_and_ride_location.py
@@ -5,25 +5,41 @@
import panel as pn
import polars as pl
-from dashboard.components import data_table, density_chart, selector_row
+from dashboard.rendering import data_table, selector_row
from dashboard.helpers.comparison_helpers import format_percent_error_table
from dashboard.helpers.geography_helpers import (
- ALL_GEOGRAPHIES_LABEL,
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ filter_geography_level,
+ geography_type_options,
is_all_geographies,
- geography_column_options,
+ normalize_geography_level_value,
normalize_geography_data,
- filter_geography_level,
+ with_display_geography_columns,
)
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
+from dashboard.page_base import SectionContent
+@dashboard_page(
+ page_id="park_and_ride_location",
+ title="Park-and-Ride Location",
+ group_id="tour_summaries",
+ order=47,
+ required_summary_ids=(
+ "park_and_ride_location_residuals",
+ "park_and_ride_location_residual_histogram",
+ ),
+)
class ParkAndRideLocationPage(DashboardPage):
"""Show residual histograms and tables for park-and-ride locations."""
def _maz_tables_disabled(self) -> bool:
"""Return whether MAZ-level tables should be hidden by configuration."""
- return str(self.geo_level_sel.value).lower() == "maz" and not self.config.enable_maz_geographies
+ return (
+ self.selected_geography_level_raw().lower() == "maz"
+ and not self.config.enable_maz_geographies
+ )
def _all_geographies_distribution_card(self) -> pn.Card:
"""Explain why the aggregate residual cannot be shown as a distribution."""
@@ -39,14 +55,13 @@ def _all_geographies_distribution_card(self) -> pn.Card:
def build_page(self) -> pn.viewable.Viewable:
"""Build the page shell and stable plot/table sections."""
self._current_data: dict[str, object] = {}
- self.geo_level_sel = self.selector(
+ self._geo_level_raw_by_label: dict[str, str | None] = {
+ ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"
+ }
+ self.geo_level_sel = self.select(
"geography_level",
- widget=pn.widgets.Select(
- name="Geography Level",
- options=[ALL_GEOGRAPHIES_LABEL],
- value=ALL_GEOGRAPHIES_LABEL,
- ),
- label="Geography Level",
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._geography_options,
)
self._plot_section = self.section(
"pnr_plot",
@@ -60,39 +75,58 @@ def build_page(self) -> pn.viewable.Viewable:
)
return self.new_section(
pn.pane.Markdown("## Park-and-Ride Location"),
- self._plot_section,
- self._table_section,
+ pn.pane.Markdown("### Residual Distribution"),
+ self.noted_section("park_and_ride.residual_plot", self._plot_section),
+ pn.pane.Markdown("### Residual Details"),
+ self.noted_section("park_and_ride.residual_table", self._table_section),
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- """Refresh page-local summary state and selector options."""
+ def _geography_options(self) -> list[str]:
+ """Refresh page data and return available geography levels."""
self._current_data = self._collect_data()
geo_opts = self._current_data["geo_opts"]
- self.geo_level_sel.options = geo_opts
- if self.geo_level_sel.value not in geo_opts:
- self.geo_level_sel.value = geo_opts[0]
+ self._geo_level_raw_by_label = self._current_data["geo_raw_by_label"]
+ return geo_opts
+
+ def selected_geography_level_raw(self) -> str:
+ """Return the raw geography type selected in the display selector."""
+ selected = str(self.geo_level_sel.value)
+ raw_value = self._geo_level_raw_by_label.get(selected, selected)
+ return (
+ "all_geographies"
+ if raw_value is None
+ else normalize_geography_level_value(str(raw_value))
+ )
def _collect_data(self) -> dict[str, object]:
"""Collect and normalize park-and-ride summaries."""
if not self.state.run_labels:
- return {"mode": "no_runs", "geo_opts": [ALL_GEOGRAPHIES_LABEL]}
+ return {
+ "mode": "no_runs",
+ "geo_opts": [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": {ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"},
+ }
residuals = normalize_geography_data(
- self.optional_summary("park_and_ride_location_residuals")
+ self.data.summary("park_and_ride_location_residuals", required=False)
)
histogram = normalize_geography_data(
- self.optional_summary("park_and_ride_location_residual_histogram")
+ self.data.summary(
+ "park_and_ride_location_residual_histogram", required=False
+ )
+ )
+ geo_opts, geo_raw_by_label = geography_type_options(
+ histogram or residuals,
+ config=self.config,
+ include_all_types=False,
+ include_disabled_maz=True,
)
return {
"mode": "ready",
- "geo_opts": geography_column_options(
- histogram or residuals,
- "geography_level",
- config=self.config,
- total_label=ALL_GEOGRAPHIES_LABEL,
- include_all_geographies=True,
- ),
+ "geo_opts": geo_opts or [ALL_GEOGRAPHY_TYPES_LABEL],
+ "geo_raw_by_label": geo_raw_by_label
+ or {ALL_GEOGRAPHY_TYPES_LABEL: "all_geographies"},
"residuals": residuals or None,
"histogram": histogram or None,
}
@@ -111,29 +145,23 @@ def render_plot_section(self) -> SectionContent:
)
]
- geo_level = str(self.geo_level_sel.value)
+ geo_level = self.selected_geography_level_raw()
if is_all_geographies(geo_level):
return [
selector_row(self.geo_level_sel),
self._all_geographies_distribution_card(),
]
- filtered = self.get_filtered_view(
- "pnr_residual_histogram",
- geo_level,
- factory=lambda: filter_geography_level(histogram, geo_level),
- )
+ filtered = self.query(lambda: filter_geography_level(histogram, geo_level))
return [
selector_row(self.geo_level_sel),
- density_chart(
+ self.plot.density(
filtered,
- x_col="bin_start",
- y_col="geography_count",
+ x="bin_start",
+ y="geography_count",
title="Park-and-Ride Residual Distribution",
- xaxis_title="Residual (Modeled - Capacity)",
- yaxis_title="Geographies",
- normalize=False,
- as_percent=self.as_percent,
+ x_title="Residual (Modeled - Capacity)",
+ y_title="Geographies",
),
]
@@ -153,12 +181,8 @@ def render_table_section(self) -> SectionContent:
)
]
- geo_level = str(self.geo_level_sel.value)
- filtered = self.get_filtered_view(
- "pnr_residuals",
- geo_level,
- factory=lambda: filter_geography_level(residuals, geo_level),
- )
+ geo_level = self.selected_geography_level_raw()
+ filtered = self.query(lambda: filter_geography_level(residuals, geo_level))
return [
data_table(
[
@@ -174,41 +198,18 @@ def render_table_section(self) -> SectionContent:
def render_residual_table(self, df: pl.DataFrame) -> pl.DataFrame:
"""Select and format the table columns shared by every run."""
- return format_percent_error_table(
- df.select(
- [
- "geography",
- "pnr_tour_count",
- "pnr_lot_capacity",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- ).rename({"geography": "geography_id"})
- if "geography" in df.columns and "geography_id" not in df.columns
- else df.select(
- [
- "geography_id",
- "pnr_tour_count",
- "pnr_lot_capacity",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
+ display_df = with_display_geography_columns(df, config=self.config)
+ columns = [
+ column
+ for column in (
+ "Geography Type",
+ "Geography Name",
+ "pnr_tour_count",
+ "pnr_lot_capacity",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
)
- )
-
-
-PAGE = DashboardPageDefinition(
- page_id="park_and_ride_location",
- title="Park-and-Ride Location",
- group_id="tour_summaries",
- order=47,
- page_cls=ParkAndRideLocationPage,
- required_summary_ids=(
- "park_and_ride_location_residuals",
- "park_and_ride_location_residual_histogram",
- ),
-)
-
-ParkAndRideLocationPage.definition = PAGE
+ if column in display_df.columns
+ ]
+ return format_percent_error_table(display_df.select(columns))
diff --git a/dashboard/pages/tour_summaries/tour_distance.py b/dashboard/pages/tour_summaries/tour_distance.py
index 391f058..be9c146 100644
--- a/dashboard/pages/tour_summaries/tour_distance.py
+++ b/dashboard/pages/tour_summaries/tour_distance.py
@@ -5,26 +5,41 @@
import panel as pn
import polars as pl
-from dashboard.components import data_table, density_chart, selector_row
+from dashboard.rendering import data_table, selector_row
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
column_options,
nonempty,
ordered_category_values,
)
from dashboard.helpers.comparison_helpers import (
- build_base_run_percent_difference_table,
+ build_ab_comparison_row,
+ build_ab_comparison_table,
weighted_average_lookup,
)
from dashboard.helpers.geography_helpers import (
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ ALL_GEOGRAPHY_TYPES_VALUE,
+ ALL_WITHIN_LEVEL_VALUE,
+ GEOGRAPHY_NAME_SELECTOR_LABEL,
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
filter_geography,
filter_geography_level,
- geography_level_options,
- geography_options_for_level,
+ geography_name_options_for_type,
+ geography_name_selector_label,
+ geography_type_options,
normalize_geography_data,
)
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
from dashboard.helpers.time_distance_helpers import distance_sort_expr
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
+from dashboard.page_base import SectionContent
def tour_distance_chart_data(
@@ -32,26 +47,18 @@ def tour_distance_chart_data(
purpose: str,
) -> list[tuple[str, pl.DataFrame]]:
"""Prepare the distance distribution for one tour purpose."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8)).filter(
- pl.col("tour_purpose") == purpose
+ return (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col("tour_purpose").cast(pl.Utf8))
+ .where(tour_purpose=purpose)
+ .select(
+ pl.col("distance_bin").cast(pl.Utf8),
+ pl.col("tour_count"),
)
- out.append(
- (
- label,
- filtered.select(
- pl.col("distance_bin").cast(pl.Utf8),
- pl.col("tour_count"),
- )
- .with_columns(
- distance_sort_expr("distance_bin").alias("_sort_distance")
- )
- .sort("_sort_distance")
- .drop("_sort_distance"),
- )
- )
- return out
+ .with_columns(distance_sort_expr("distance_bin").alias("_sort_distance"))
+ .sort("_sort_distance")
+ .map(lambda frame: frame.drop("_sort_distance"))
+ )
def average_distance_comparison_table(
@@ -61,7 +68,7 @@ def average_distance_comparison_table(
purpose: str,
*,
config,
-) -> pl.DataFrame:
+) -> list[tuple[str, pl.DataFrame]]:
"""Compare average non-mandatory tour distances against the base run."""
filtered = filter_geography(
filter_geography_level(data_list, geography_level),
@@ -80,7 +87,7 @@ def average_distance_comparison_table(
runs = nonempty(filtered)
if not runs:
- return pl.DataFrame()
+ return []
purpose_values = ordered_category_values(
runs,
@@ -89,30 +96,60 @@ def average_distance_comparison_table(
config=config,
)
if not purpose_values:
- return pl.DataFrame()
+ return []
- run_labels = [label for label, _ in runs]
- base_run_label = run_labels[0]
- row_values: dict[str, dict[str, float | None]] = {}
- for raw_purpose in purpose_values:
- display_purpose = config.label_value("tour_purpose", raw_purpose)
- row_values[display_purpose] = {}
- for run_label, run_df in runs:
- row_values[display_purpose][run_label] = weighted_average_lookup(
- run_df,
- category_col="nonmandatory_tour_purpose",
- average_col="average_tour_distance",
- weight_col="tour_count",
- ).get(str(raw_purpose))
-
- return build_base_run_percent_difference_table(
- run_labels=run_labels,
- base_run_label=base_run_label,
- row_header="Non-Mandatory Tour Purpose",
- row_values=row_values,
+ _, base_run_df = runs[0]
+ base_lookup = weighted_average_lookup(
+ base_run_df,
+ category="nonmandatory_tour_purpose",
+ average_col="average_tour_distance",
+ weight_col="tour_count",
)
+ quantity_a_column = "Average Non-Mandatory Tour Distance"
+ quantity_b_column = "Base Run Average Non-Mandatory Tour Distance"
+ out: list[tuple[str, pl.DataFrame]] = []
+ for run_label, run_df in runs:
+ run_lookup = weighted_average_lookup(
+ run_df,
+ category="nonmandatory_tour_purpose",
+ average_col="average_tour_distance",
+ weight_col="tour_count",
+ )
+ rows = []
+ for raw_purpose in purpose_values:
+ display_purpose = config.label_value("tour_purpose", raw_purpose)
+ rows.append(
+ build_ab_comparison_row(
+ keys={"Non-Mandatory Tour Purpose": display_purpose},
+ quantity_a=run_lookup.get(str(raw_purpose)),
+ quantity_b=base_lookup.get(str(raw_purpose)),
+ quantity_a_column=quantity_a_column,
+ quantity_b_column=quantity_b_column,
+ )
+ )
+
+ table = build_ab_comparison_table(
+ rows,
+ key_columns=["Non-Mandatory Tour Purpose"],
+ quantity_a_column=quantity_a_column,
+ quantity_b_column=quantity_b_column,
+ )
+ if not table.is_empty():
+ out.append((run_label, table))
+ return out
+@dashboard_page(
+ page_id="tour_distance",
+ title="Tour Distance",
+ group_id="tour_summaries",
+ order=44,
+ required_summary_ids=(
+ "tour_distance_by_tour_purpose",
+ "average_mandatory_tour_distance_by_purpose_and_geography",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ ),
+)
class TourDistancePage(DashboardPage):
"""Render tour distance distributions and average-distance comparisons."""
@@ -121,70 +158,61 @@ class TourDistancePage(DashboardPage):
def build_page(self) -> pn.viewable.Viewable:
"""Build the persistent page layout and selector widgets."""
self._tour_purpose_to_raw: dict[str, str | None] = {}
- self._nonmandatory_purpose_to_raw: dict[str, str | None] = {}
- self.tour_purpose_sel = self.selector(
+ self._geo_level_raw_by_label: dict[str, str | None] = {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ }
+ self._geography_raw_by_label: dict[str, str | None] = {
+ ALL_WITHIN_LEVEL_VALUE: ALL_WITHIN_LEVEL_VALUE
+ }
+ self.tour_purpose_sel = self.select(
"tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=[self.TOTAL_PURPOSE_LABEL],
- value=self.TOTAL_PURPOSE_LABEL,
- ),
- label="Tour Purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
)
- self.geo_level_sel = self.selector(
+ self.geo_level_sel = self.select(
"geography_level",
- widget=pn.widgets.Select(
- name="Geography Level",
- options=["Total"],
- value="Total",
- ),
- label="Geography Level",
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._geography_level_options,
)
- self.geography_sel = self.selector(
+ self.geography_sel = self.select(
"geography",
- widget=pn.widgets.Select(
- name="Geography",
- options=[self.TOTAL_PURPOSE_LABEL],
- value=self.TOTAL_PURPOSE_LABEL,
- ),
- label="Geography",
+ GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=self._geography_options,
)
- self.nonmandatory_purpose_sel = self.selector(
- "nonmandatory_tour_purpose",
- widget=pn.widgets.Select(
- name="Non-Mandatory Tour Purpose",
- options=["All"],
- value="All",
- ),
- label="Non-Mandatory Tour Purpose",
+ self.tour_distance_range = DistanceRangeControls.create(
+ self,
+ "tour_distance",
+ max_options=capped_distance_max_options(),
+ reset_label="Reset distance range",
)
self._distance_section = self.section(
"tour_distance_distribution",
- selectors=("tour_purpose",),
+ selectors=("tour_purpose", *self.tour_distance_range.selector_ids),
render=self.render_distance_section,
)
self._average_section = self.section(
"tour_distance_averages",
- selectors=("geography_level", "geography", "nonmandatory_tour_purpose"),
+ selectors=("geography_level", "geography"),
render=self.render_average_section,
)
return self.new_section(
pn.pane.Markdown("## Tour Distance"),
- self._distance_section,
- self._average_section,
+ pn.pane.Markdown("### Tour Distance Distribution"),
+ self.noted_section(
+ "tour_distance.distribution", self._distance_section
+ ),
+ pn.pane.Markdown("### Average Tour Distance vs Base Run"),
+ self.noted_section("tour_distance.averages", self._average_section),
)
def _summaries(self) -> dict[str, object] | None:
"""Return the required summary bundle for this page."""
- return self.require_summaries(*self.required_summary_ids)
+ return self.data.summaries(*self.required_summary_ids)
- def sync_controls(self) -> None:
- """Recompute selector domains from the current summary tables."""
+ def _distance_sources(self):
summaries = self._summaries()
- if summaries is None:
- return
-
- distance_summary = summaries["tour_distance_by_tour_purpose"]
+ if not summaries:
+ return None, None, None
nonmandatory_average = normalize_geography_data(
summaries["average_nonmandatory_tour_distance_by_purpose_and_geography"]
)
@@ -192,70 +220,61 @@ def sync_controls(self) -> None:
summaries["average_mandatory_tour_distance_by_purpose_and_geography"]
)
- tour_purpose_options, self._tour_purpose_to_raw = column_options(
- distance_summary,
+ return (
+ summaries["tour_distance_by_tour_purpose"],
+ nonmandatory_average,
+ mandatory_average,
+ )
+
+ def _purpose_options(self) -> list[str]:
+ distance_summary, _, _ = self._distance_sources()
+ options, self._tour_purpose_to_raw = column_options(
+ distance_summary or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "tour_distance",
- "tour_distance_by_tour_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw="all_tour_purposes",
total_label=self.TOTAL_PURPOSE_LABEL,
)
- nonmandatory_options, self._nonmandatory_purpose_to_raw = column_options(
- nonmandatory_average,
- "nonmandatory_tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "tour_distance",
- "average_nonmandatory_tour_distance_by_purpose_and_geography",
- "nonmandatory_tour_purpose",
- self.weighting_key,
- ),
- total_raw="All",
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- geography_level_options_list = geography_level_options(
+ return options or [self.TOTAL_PURPOSE_LABEL]
+
+ def _geography_level_options(self) -> list[str]:
+ _, nonmandatory_average, mandatory_average = self._distance_sources()
+ options, self._geo_level_raw_by_label = geography_type_options(
nonmandatory_average or None,
mandatory_average or None,
config=self.config,
- total_label="Total",
+ include_all_types=True,
)
- geography_options = geography_options_for_level(
- str(self.geo_level_sel.value),
+ return options or [ALL_GEOGRAPHY_TYPES_LABEL]
+
+ def _geography_options(self) -> list[str]:
+ _, nonmandatory_average, mandatory_average = self._distance_sources()
+ geography_type = self.selected_geography_level_raw()
+ options, self._geography_raw_by_label = geography_name_options_for_type(
+ geography_type,
nonmandatory_average or None,
mandatory_average or None,
config=self.config,
)
+ if getattr(self, "geography_sel", None) is not None:
+ self.geography_sel.name = geography_name_selector_label(
+ geography_type,
+ config=self.config,
+ )
+ return options or [ALL_WITHIN_LEVEL_VALUE]
- for widget, options in (
- (self.tour_purpose_sel, tour_purpose_options or [self.TOTAL_PURPOSE_LABEL]),
- (
- self.nonmandatory_purpose_sel,
- nonmandatory_options or [self.TOTAL_PURPOSE_LABEL],
- ),
- (self.geo_level_sel, geography_level_options_list or ["Total"]),
- ):
- widget.options = options
- if widget.value not in options:
- widget.value = options[0]
+ def selected_geography_level_raw(self) -> str:
+ """Return the raw geography type selected in the display selector."""
+ selected = str(self.geo_level_sel.value)
+ raw_value = self._geo_level_raw_by_label.get(selected, selected)
+ return ALL_GEOGRAPHY_TYPES_VALUE if raw_value is None else str(raw_value)
- geography_options = geography_options_for_level(
- str(self.geo_level_sel.value),
- nonmandatory_average or None,
- mandatory_average or None,
- config=self.config,
- )
- self.geography_sel.options = geography_options
- if self.geography_sel.value not in geography_options:
- self.geography_sel.value = geography_options[0]
+ def selected_geography_raw(self) -> str:
+ """Return the raw geography id selected in the display selector."""
+ selected = str(self.geography_sel.value)
+ raw_value = self._geography_raw_by_label.get(selected, selected)
+ return ALL_WITHIN_LEVEL_VALUE if raw_value is None else str(raw_value)
def render_distance_section(self) -> SectionContent:
"""Render the tour distance distribution chart."""
@@ -270,35 +289,56 @@ def render_distance_section(self) -> SectionContent:
raw_purpose = self._tour_purpose_to_raw.get(
selected_purpose, "all_tour_purposes"
)
- distance_data = self.get_filtered_view(
- "tour_distance",
- raw_purpose,
- factory=lambda: tour_distance_chart_data(
+ distance_data = self.query(
+ lambda: tour_distance_chart_data(
summaries["tour_distance_by_tour_purpose"],
str(raw_purpose),
- ),
+ )
)
+ observed_bounds = distance_axis_bounds(distance_data)
+ bounds = (0.0, 40.0) if observed_bounds is not None else None
+ self.tour_distance_range.sync(
+ (raw_purpose, self.weighting_key),
+ bounds,
+ )
+ x_range = self.tour_distance_range.current_range()
+ if bounds is not None and x_range is None:
+ return [
+ selector_row(self.tour_purpose_sel),
+ self.tour_distance_range.row(),
+ self.data_not_available_card(
+ detail="Tour distance controls require finite values with min less than max.",
+ title="Tour Distance Data Not Available",
+ ),
+ ]
return [
- pn.pane.Markdown("### Tour Distance Distribution"),
selector_row(self.tour_purpose_sel),
- self.render_distance_chart(distance_data, selected_purpose),
+ self.tour_distance_range.row(),
+ self.render_distance_chart(
+ distance_data, selected_purpose, x_range=x_range
+ ),
]
def render_distance_chart(
self,
distance_data: list[tuple[str, pl.DataFrame]],
display_purpose: str,
+ *,
+ x_range: tuple[float, float] | None,
) -> pn.viewable.Viewable:
"""Render the distance distribution chart for one selected purpose."""
- return density_chart(
- distance_data,
- "distance_bin",
- "tour_count",
- f"Tour Distance Distribution - {display_purpose}",
- "Distance (miles)",
- normalize=False,
- yaxis_title="Tours",
- as_percent=self.as_percent,
+ axis_data = with_distance_axis(distance_data)
+ tickvals, ticktext = fixed_distance_axis_ticks()
+ return self.plot.density(
+ axis_data,
+ x="_distance_axis",
+ y="tour_count",
+ title=f"Tour Distance Distribution - {display_purpose}",
+ x_title="Distance (miles)",
+ y_title="Tours",
+ x_range=x_range,
+ tick_values=tickvals,
+ tick_text=ticktext,
)
def render_average_section(self) -> SectionContent:
@@ -310,55 +350,24 @@ def render_average_section(self) -> SectionContent:
nonmandatory_average = normalize_geography_data(
summaries["average_nonmandatory_tour_distance_by_purpose_and_geography"]
)
- geo_level = str(self.geo_level_sel.value)
- geography = str(self.geography_sel.value)
- raw_purpose = str(
- self._nonmandatory_purpose_to_raw.get(
- str(self.nonmandatory_purpose_sel.value),
- self.nonmandatory_purpose_sel.value,
- )
- )
- comparison_df = self.get_filtered_view(
- "average_nonmandatory_tour_distance",
- (geo_level, geography, raw_purpose),
- factory=lambda: average_distance_comparison_table(
+ geo_level = self.selected_geography_level_raw()
+ geography = self.selected_geography_raw()
+ comparison_tables = self.query(
+ lambda: average_distance_comparison_table(
nonmandatory_average,
geo_level,
geography,
- raw_purpose,
+ "All",
config=self.config,
- ),
+ )
)
return [
- pn.pane.Markdown("### Average Non-Mandatory Tour Distance vs Base Run"),
selector_row(self.geo_level_sel, self.geography_sel),
- pn.Column(
- selector_row(self.nonmandatory_purpose_sel),
- self.render_average_distance_table(comparison_df),
- ),
+ self.render_average_distance_table(comparison_tables),
]
def render_average_distance_table(
- self, comparison_df: pl.DataFrame
+ self, comparison_tables: list[tuple[str, pl.DataFrame]]
) -> pn.viewable.Viewable:
"""Render the average-distance comparison table."""
- return data_table(
- [("Comparison", comparison_df)],
- # "Average Non-Mandatory Tour Distance vs Base Run",
- )
-
-
-PAGE = DashboardPageDefinition(
- page_id="tour_distance",
- title="Tour Distance",
- group_id="tour_summaries",
- order=44,
- page_cls=TourDistancePage,
- required_summary_ids=(
- "tour_distance_by_tour_purpose",
- "average_mandatory_tour_distance_by_purpose_and_geography",
- "average_nonmandatory_tour_distance_by_purpose_and_geography",
- ),
-)
-
-TourDistancePage.definition = PAGE
+ return data_table(comparison_tables)
diff --git a/dashboard/pages/tour_summaries/tour_mode.py b/dashboard/pages/tour_summaries/tour_mode.py
index cd781ca..dec1f7e 100644
--- a/dashboard/pages/tour_summaries/tour_mode.py
+++ b/dashboard/pages/tour_summaries/tour_mode.py
@@ -2,465 +2,19 @@
from __future__ import annotations
-import panel as pn
-import polars as pl
+from dashboard import DashboardPage, dashboard_page
-from dashboard.components import bar_chart, selector_row
-from dashboard.helpers.category_helpers import (
- column_options,
- common_column_options,
- label_category_data,
- nonempty,
- ordered_category_values,
-)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
-
-AUTO_SUFFICIENCY_LEVELS = [
- "All",
- "Zero Auto",
- "Auto Deficient",
- "Auto Sufficient",
-]
-
-
-def auto_sufficiency_definitions_markdown(config) -> str:
- """Describe the configured household basis behind the auto sufficiency split."""
- basis_noun = {
- "licensed_drivers": "licensed drivers",
- "workers": "workers",
- "adults": "adults",
- }[config.prepare_auto_sufficiency.basis]
- return f"""
- **Auto sufficiency definitions**
-
- - **Zero Auto**: household has no vehicles.
- - **Auto Deficient**: household has fewer vehicles than {basis_noun}.
- - **Auto Sufficient**: household has at least as many vehicles as {basis_noun}.
- """
-
-
-def _auto_sufficiency_definitions_markdown(config) -> str:
- """Backward-compatible alias used by existing serializer and summary tests."""
- return auto_sufficiency_definitions_markdown(config)
-
-
-def vehicle_attribute_data(
- data_list: list[tuple[str, pl.DataFrame]],
- occupancy: str,
- *,
- category_col: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Filter one allocated-vehicle summary to the selected occupancy level."""
-
- def sort_filtered(df: pl.DataFrame) -> pl.DataFrame:
- if "age" in df.columns:
- return (
- df.with_columns(
- pl.when(pl.col("age").cast(pl.Utf8) == "20+")
- .then(999)
- .otherwise(pl.col("age").cast(pl.Int64, strict=False))
- .alias("_sort_age")
- )
- .sort("_sort_age")
- .drop("_sort_age")
- )
- return df.sort(category_col) if category_col in df.columns else df
-
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- filtered = df
- if "occupancy" in filtered.columns:
- filtered = filtered.with_columns(pl.col("occupancy").cast(pl.Utf8))
- if occupancy == "All":
- filtered = (
- filtered.group_by(category_col)
- .agg(vehicle_count=pl.col("vehicle_count").sum())
- )
- else:
- filtered = filtered.filter(pl.col("occupancy") == occupancy)
- out.append((label, sort_filtered(filtered)))
- return out
-
-
-def tour_mode_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- purpose: str,
- auto_sufficiency: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Build one tour mode distribution for a selected purpose and sufficiency slice."""
- value_col = {
- "All": "tour_count_all_households",
- "Zero Auto": "tour_count_zero_auto",
- "Auto Deficient": "tour_count_auto_deficient",
- "Auto Sufficient": "tour_count_auto_sufficient",
- }[auto_sufficiency]
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8)).filter(
- pl.col("tour_purpose") == purpose
- )
- out.append(
- (
- label,
- filtered.select(pl.col("tour_mode"), pl.col(value_col).alias("tour_count")).sort(
- "tour_mode"
- ),
- )
- )
- return out
-
-
-class TourModePage(DashboardPage):
- """Render tour mode splits and allocated vehicle characteristics."""
- TOTAL_PURPOSE_LABEL = "All Tour Purposes"
+from ._tour_mode import *
+from ._tour_mode.composition import TourModeCompositionMixin
+from ._tour_mode.features import TourModeFeatureMixin
+from ._tour_mode.selector_domains import TourModeSelectorDomainsMixin
- def build_page(self) -> pn.viewable.Viewable:
- """Build the page shell and persistent selectors."""
- self._purpose_to_raw: dict[str, str | None] = {}
- purpose_options = self._initial_purpose_options()
- occupancy_options = self._initial_occupancy_options()
- self.purpose_sel = self.selector(
- "tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_options,
- value=purpose_options[0],
- ),
- label="Tour Purpose",
- )
- self.occupancy_sel = self.selector(
- "vehicle_occupancy",
- widget=pn.widgets.Select(
- name="Vehicle Occupancy",
- options=occupancy_options,
- value=occupancy_options[0],
- ),
- label="Vehicle Occupancy",
- )
- self._mode_section = self.section(
- "tour_mode_modes",
- selectors=("tour_purpose",),
- render=self.render_modes_section,
- )
- self._vehicle_section = self.section(
- "tour_mode_vehicles",
- selectors=("vehicle_occupancy",),
- render=self.render_vehicle_section,
- )
- return self.new_section(
- pn.pane.Markdown("## Tour Mode"),
- pn.pane.Markdown(auto_sufficiency_definitions_markdown(self.config)),
- self._mode_section,
- self._vehicle_section,
- )
- def _initial_purpose_options(self) -> list[str]:
- """Populate the purpose selector before the first page refresh."""
- summaries = self.state.get_summary_table_set(
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- "weighted",
- )
- options, self._purpose_to_raw = column_options(
- summaries or [],
- "tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "tour_mode",
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- "tour_purpose",
- "weighted",
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- return options or [self.TOTAL_PURPOSE_LABEL]
-
- def _initial_occupancy_options(self) -> list[str]:
- """Populate the occupancy selector before the first page refresh."""
- age_summary = self.state.get_summary_table_set(
- "allocated_vehicle_age_by_occupancy",
- "weighted",
- )
- fuel_summary = self.state.get_summary_table_set(
- "allocated_vehicle_fuel_type_by_occupancy",
- "weighted",
- )
- body_summary = self.state.get_summary_table_set(
- "allocated_vehicle_body_type_by_occupancy",
- "weighted",
- )
- options, _ = common_column_options(
- age_summary,
- fuel_summary,
- body_summary,
- column="occupancy",
- total_raw="All",
- total_label="All",
- )
- return options or ["All"]
-
- def _summaries(self):
- """Load every summary used by either page section."""
- return self.optional_summaries_dict(
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- "allocated_vehicle_age_by_occupancy",
- "allocated_vehicle_fuel_type_by_occupancy",
- "allocated_vehicle_body_type_by_occupancy",
- )
-
- def sync_controls(self) -> None:
- """Keep selector domains synchronized with current summary availability."""
- summaries = self._summaries()
- purpose_options, self._purpose_to_raw = column_options(
- summaries["tour_mode_by_tour_purpose_and_auto_sufficiency"] or [],
- "tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "tour_mode",
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- "tour_purpose",
- self.weighting_key,
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- occupancy_options, _ = common_column_options(
- summaries["allocated_vehicle_age_by_occupancy"],
- summaries["allocated_vehicle_fuel_type_by_occupancy"],
- summaries["allocated_vehicle_body_type_by_occupancy"],
- column="occupancy",
- total_raw="All",
- total_label="All",
- )
- for widget, options in (
- (self.purpose_sel, purpose_options or [self.TOTAL_PURPOSE_LABEL]),
- (self.occupancy_sel, occupancy_options or ["All"]),
- ):
- widget.options = options
- if widget.value not in options:
- widget.value = options[0]
-
- def render_modes_section(self):
- """Render the tour mode charts by auto sufficiency."""
- if not self.state.run_labels:
- return [self.no_runs_message()]
-
- summaries = self._summaries()
- mode_summary = summaries["tour_mode_by_tour_purpose_and_auto_sufficiency"]
- selected_purpose = str(self.purpose_sel.value)
- raw_purpose = self._purpose_to_raw.get(selected_purpose, "all_tour_purposes")
- if mode_summary is None:
- return [
- pn.pane.Markdown("### Tour Mode"),
- selector_row(self.purpose_sel),
- self.data_not_available_card(
- detail="The tour mode summary is unavailable.",
- missing_items=["tour_mode_by_tour_purpose_and_auto_sufficiency"],
- ),
- ]
-
- mode_values = [
- value
- for value in ordered_category_values(
- mode_summary,
- "tour_mode",
- category_id="mode",
- config=self.config,
- )
- if value != "all_tour_modes"
- ]
- return [
- pn.pane.Markdown("### Tour Mode"),
- selector_row(self.purpose_sel),
- *[
- self.render_tour_mode_chart(
- mode_summary,
- str(raw_purpose),
- selected_purpose,
- auto_sufficiency,
- mode_values,
- )
- for auto_sufficiency in AUTO_SUFFICIENCY_LEVELS
- ],
- ]
-
- def render_tour_mode_chart(
- self,
- summary_data,
- raw_purpose: str,
- display_purpose: str,
- auto_sufficiency: str,
- mode_values: list[str],
- ) -> pn.viewable.Viewable:
- """Render one auto-sufficiency slice of the selected tour purpose."""
- mode_data = self.get_filtered_view(
- "tour_mode",
- (raw_purpose, auto_sufficiency),
- factory=lambda: tour_mode_chart_data(
- summary_data,
- raw_purpose,
- auto_sufficiency,
- ),
- )
- labeled = label_category_data(
- mode_data,
- source_col="tour_mode",
- category_id="mode",
- config=self.config,
- target_col="tour_mode_label",
- )
- return bar_chart(
- labeled,
- "tour_mode_label",
- "tour_count",
- f"Tour Mode - {auto_sufficiency}",
- "Tour Mode",
- yaxis_title="Tours",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=self.config.ordered_labels("mode", mode_values),
- )
-
- def render_vehicle_section(self):
- """Render the three allocated vehicle characteristic charts."""
- summaries = self._summaries()
- occupancy = str(self.occupancy_sel.value)
- return [
- pn.pane.Markdown("### Allocated Vehicle Characteristics"),
- selector_row(self.occupancy_sel),
- pn.Row(
- self.render_vehicle_age_chart(
- summaries["allocated_vehicle_age_by_occupancy"],
- occupancy,
- ),
- self.render_vehicle_fuel_chart(
- summaries["allocated_vehicle_fuel_type_by_occupancy"],
- occupancy,
- ),
- self.render_vehicle_body_chart(
- summaries["allocated_vehicle_body_type_by_occupancy"],
- occupancy,
- ),
- ),
- ]
-
- def render_vehicle_age_chart(self, summary_data, occupancy: str) -> pn.viewable.Viewable:
- """Render allocated vehicle age by occupancy level."""
- if summary_data is None:
- return self.data_not_available_card(
- detail="The allocated vehicle age summary is unavailable.",
- missing_items=["allocated_vehicle_age_by_occupancy"],
- )
- age_values = self.ordered_vehicle_values(summary_data, "age")
- chart_data = self.get_filtered_view(
- "allocated_vehicle_age",
- occupancy,
- factory=lambda: vehicle_attribute_data(
- summary_data,
- occupancy,
- category_col="age",
- ),
- )
- return bar_chart(
- chart_data,
- "age",
- "vehicle_count",
- "Allocated Vehicle Age by Occupancy Level",
- "Vehicle Age",
- yaxis_title="Allocated Vehicles",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=age_values,
- )
-
- def render_vehicle_fuel_chart(self, summary_data, occupancy: str) -> pn.viewable.Viewable:
- """Render allocated vehicle fuel type by occupancy level."""
- if summary_data is None:
- return self.data_not_available_card(
- detail="The allocated vehicle fuel summary is unavailable.",
- missing_items=["allocated_vehicle_fuel_type_by_occupancy"],
- )
- fuel_values = self.ordered_vehicle_values(summary_data, "fuel_type")
- chart_data = self.get_filtered_view(
- "allocated_vehicle_fuel",
- occupancy,
- factory=lambda: vehicle_attribute_data(
- summary_data,
- occupancy,
- category_col="fuel_type",
- ),
- )
- return bar_chart(
- chart_data,
- "fuel_type",
- "vehicle_count",
- "Allocated Vehicle Fuel Type by Occupancy Level",
- "Vehicle Fuel Type",
- yaxis_title="Allocated Vehicles",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=fuel_values,
- )
-
- def render_vehicle_body_chart(self, summary_data, occupancy: str) -> pn.viewable.Viewable:
- """Render allocated vehicle body type by occupancy level."""
- if summary_data is None:
- return self.data_not_available_card(
- detail="The allocated vehicle body summary is unavailable.",
- missing_items=["allocated_vehicle_body_type_by_occupancy"],
- )
- body_values = self.ordered_vehicle_values(summary_data, "body_type")
- chart_data = self.get_filtered_view(
- "allocated_vehicle_body",
- occupancy,
- factory=lambda: vehicle_attribute_data(
- summary_data,
- occupancy,
- category_col="body_type",
- ),
- )
- return bar_chart(
- chart_data,
- "body_type",
- "vehicle_count",
- "Allocated Vehicle Body Type by Occupancy Level",
- "Vehicle Body Type",
- yaxis_title="Allocated Vehicles",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=body_values,
- )
-
- def ordered_vehicle_values(
- self,
- summary_data: list[tuple[str, pl.DataFrame]],
- column: str,
- ) -> list[str]:
- """Return a stable x-axis order for vehicle attributes."""
- values = {
- str(value)
- for _, df in nonempty(summary_data)
- for value in (df[column].cast(pl.Utf8).to_list() if column in df.columns else [])
- }
- if column == "age":
- return sorted(
- values,
- key=lambda value: 999 if value == "20+" else int(value) if value.isdigit() else 1000,
- )
- return sorted(values)
-
-
-PAGE = DashboardPageDefinition(
+@dashboard_page(
page_id="tour_mode",
title="Tour Mode",
group_id="tour_summaries",
order=42,
- page_cls=TourModePage,
required_summary_ids=(
"tour_mode_by_tour_purpose_and_auto_sufficiency",
"allocated_vehicle_age_by_occupancy",
@@ -468,5 +22,12 @@ def ordered_vehicle_values(
"allocated_vehicle_body_type_by_occupancy",
),
)
+class TourModePage(
+ TourModeCompositionMixin,
+ TourModeSelectorDomainsMixin,
+ TourModeFeatureMixin,
+ DashboardPage,
+):
+ """Render tour mode splits and allocated vehicle characteristics."""
-TourModePage.definition = PAGE
+ TOTAL_PURPOSE_LABEL = "All Tour Purposes"
diff --git a/dashboard/pages/tour_summaries/tour_purpose.py b/dashboard/pages/tour_summaries/tour_purpose.py
index c0cb1f6..c84e334 100644
--- a/dashboard/pages/tour_summaries/tour_purpose.py
+++ b/dashboard/pages/tour_summaries/tour_purpose.py
@@ -5,14 +5,12 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart
from dashboard.helpers.category_helpers import (
label_category_data,
nonempty,
ordered_category_values,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
def render_distribution_chart(
@@ -23,7 +21,7 @@ def render_distribution_chart(
title: str,
xaxis_title: str,
config,
- as_percent: bool,
+ plot,
) -> pn.viewable.Viewable:
"""Build one labeled distribution chart from a summary table list."""
values = ordered_category_values(
@@ -39,19 +37,27 @@ def render_distribution_chart(
config=config,
target_col=f"{source_col}_label",
)
- return bar_chart(
+ return plot.bar(
labeled_data,
- x_col=f"{source_col}_label",
- y_col="tour_count",
+ x=f"{source_col}_label",
+ y="tour_count",
title=title,
- xaxis_title=xaxis_title,
- yaxis_title="Tours",
- pct_col="pct",
- as_percent=as_percent,
- xaxis_categoryarray=config.ordered_labels(category_id, values),
+ x_title=xaxis_title,
+ y_title="Tours",
+ category_order=config.ordered_labels(category_id, values),
)
+@dashboard_page(
+ page_id="tour_purpose",
+ title="Tour Purpose",
+ group_id="tour_summaries",
+ order=41,
+ required_summary_ids=(
+ "tour_category_distribution",
+ "tour_purpose_distribution",
+ ),
+)
class TourPurposePage(DashboardPage):
"""Simple reference page for summary-only chart sections with no selectors."""
@@ -67,47 +73,38 @@ def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
category_data = nonempty(summaries["tour_category_distribution"])
purpose_data = nonempty(summaries["tour_purpose_distribution"])
return [
pn.Row(
- render_distribution_chart(
- category_data,
- source_col="tour_category",
- category_id="tour_category",
- title="Tour Category",
- xaxis_title="Tour Category",
- config=self.config,
- as_percent=self.as_percent,
+ self.noted_view(
+ "tour_purpose.category",
+ render_distribution_chart(
+ category_data,
+ source_col="tour_category",
+ category_id="tour_category",
+ title="Tour Category",
+ xaxis_title="Tour Category",
+ config=self.config,
+ plot=self.plot,
+ ),
),
- render_distribution_chart(
- purpose_data,
- source_col="tour_purpose",
- category_id="tour_purpose",
- title="Tour Purpose",
- xaxis_title="Tour Purpose",
- config=self.config,
- as_percent=self.as_percent,
+ self.noted_view(
+ "tour_purpose.purpose",
+ render_distribution_chart(
+ purpose_data,
+ source_col="tour_purpose",
+ category_id="tour_purpose",
+ title="Tour Purpose",
+ xaxis_title="Tour Purpose",
+ config=self.config,
+ plot=self.plot,
+ ),
),
sizing_mode="stretch_width",
)
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="tour_purpose",
- title="Tour Purpose",
- group_id="tour_summaries",
- order=41,
- page_cls=TourPurposePage,
- required_summary_ids=(
- "tour_category_distribution",
- "tour_purpose_distribution",
- ),
-)
-
-TourPurposePage.definition = PAGE
diff --git a/dashboard/pages/tour_summaries/tour_stop_frequency.py b/dashboard/pages/tour_summaries/tour_stop_frequency.py
index 478bd1f..09dd0a1 100644
--- a/dashboard/pages/tour_summaries/tour_stop_frequency.py
+++ b/dashboard/pages/tour_summaries/tour_stop_frequency.py
@@ -5,12 +5,22 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, selector_row
-from dashboard.helpers.category_helpers import column_options, label_category_data, nonempty
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ capped_numeric_category_expr,
+ column_options,
+ label_category_data,
+ nonempty,
+ numeric_like_sort_expr,
+)
+from dashboard import DashboardPage, dashboard_page
-DIRECTION_OPTIONS = ["Both", "Outbound", "Inbound"]
+STOP_FREQUENCY_VALUES = {
+ "Both": ["0", "1", "2", "3", "4", "5", "6+"],
+ "Outbound": ["0", "1", "2", "3+"],
+ "Inbound": ["0", "1", "2", "3+"],
+}
def stop_frequency_chart_data(
@@ -24,65 +34,71 @@ def stop_frequency_chart_data(
"Outbound": "outbound_stop_count",
"Inbound": "inbound_stop_count",
}[direction]
- out = []
- for label, df in nonempty(data_list):
+ cap_value = 6 if direction == "Both" else 3
+
+ def build_frame(df: pl.DataFrame) -> pl.DataFrame:
filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
if purpose is None:
- if "all_tour_purposes" in filtered["tour_purpose"].cast(pl.Utf8).unique().to_list():
- filtered = filtered.filter(pl.col("tour_purpose") == "all_tour_purposes")
+ if (
+ "all_tour_purposes"
+ in filtered["tour_purpose"].cast(pl.Utf8).unique().to_list()
+ ):
+ filtered = filtered.filter(
+ pl.col("tour_purpose") == "all_tour_purposes"
+ )
else:
- filtered = filtered.group_by(stop_col).agg(
- tour_count=pl.col("tour_count").sum()
+ filtered = (
+ filtered.with_columns(
+ capped_numeric_category_expr(stop_col, cap_value)
+ )
+ .group_by(stop_col)
+ .agg(tour_count=pl.col("tour_count").sum())
)
filtered = (
- filtered.with_columns(pl.col(stop_col).cast(pl.Utf8).alias("stop_frequency"))
+ filtered.with_columns(
+ pl.col(stop_col).cast(pl.Utf8).alias("stop_frequency")
+ )
.select("stop_frequency", "tour_count")
- .sort("stop_frequency")
+ .sort(numeric_like_sort_expr("stop_frequency"))
)
- out.append((label, filtered))
- continue
+ return filtered
else:
filtered = filtered.filter(pl.col("tour_purpose") == purpose)
- filtered = filtered.group_by(stop_col).agg(tour_count=pl.col("tour_count").sum())
filtered = (
- filtered.with_columns(pl.col(stop_col).cast(pl.Utf8).alias("stop_frequency"))
+ filtered.with_columns(capped_numeric_category_expr(stop_col, cap_value))
+ .group_by(stop_col)
+ .agg(tour_count=pl.col("tour_count").sum())
+ )
+ filtered = (
+ filtered.with_columns(
+ pl.col(stop_col).cast(pl.Utf8).alias("stop_frequency")
+ )
.select("stop_frequency", "tour_count")
- .sort("stop_frequency")
+ .sort(numeric_like_sort_expr("stop_frequency"))
)
- out.append((label, filtered))
- return out
+ return filtered
+
+ return RunTables.from_runs(data_list).map(build_frame)
+@dashboard_page(
+ page_id="tour_stop_frequency",
+ title="Tour Stop Frequency",
+ group_id="tour_summaries",
+ order=45,
+ required_summary_ids=(
+ "tour_stop_frequency_by_tour_purpose",
+ "atwork_subtour_frequency_distribution",
+ ),
+)
class TourStopFrequencyPage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
def build_page(self) -> pn.viewable.Viewable:
- purpose_opts, self._purpose_to_raw = column_options(
- self.state.get_summary_table_set(
- "tour_stop_frequency_by_tour_purpose", "weighted"
- )
- or [],
- "tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "tour_stop_frequency",
- "tour_stop_frequency_by_tour_purpose",
- "tour_purpose",
- "weighted",
- ),
- total_raw=None,
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- self.purpose_sel = self.selector(
+ self.purpose_sel = self.select(
"tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_opts or [self.TOTAL_PURPOSE_LABEL],
- value=(purpose_opts or [self.TOTAL_PURPOSE_LABEL])[0],
- ),
- label="Tour Purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
)
self._body = self.section(
"tour_stop_frequency_body",
@@ -91,32 +107,22 @@ def build_page(self) -> pn.viewable.Viewable:
)
return self.new_section(
pn.pane.Markdown("## Tour Stop Frequency"),
+ pn.pane.Markdown("### Tour and At-Work Sub-Tour Frequency"),
self._body,
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
+ def _purpose_options(self) -> list[str]:
purpose_opts, self._purpose_to_raw = column_options(
- summaries["tour_stop_frequency_by_tour_purpose"],
+ self.data.summary("tour_stop_frequency_by_tour_purpose", self.weighting_key)
+ or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "tour_stop_frequency",
- "tour_stop_frequency_by_tour_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw=None,
total_label=self.TOTAL_PURPOSE_LABEL,
)
- self.purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.purpose_sel.value not in self.purpose_sel.options:
- self.purpose_sel.value = self.purpose_sel.options[0]
+ return purpose_opts or [self.TOTAL_PURPOSE_LABEL]
def _selected_purpose(self) -> tuple[str, str | None]:
display_purpose = self.purpose_sel.value
@@ -130,26 +136,12 @@ def render_direction_chart(
display_purpose: str,
direction: str,
) -> pn.viewable.Viewable:
- stop_data = self.get_filtered_view(
- "tour_stop_frequency",
- (raw_purpose, direction),
- factory=lambda: stop_frequency_chart_data(stop_list, raw_purpose, direction),
- )
- stop_col = {
- "Both": "total_stop_count",
- "Outbound": "outbound_stop_count",
- "Inbound": "inbound_stop_count",
- }[direction]
- raw_values = sorted(
- {
- str(value)
- for _, df in nonempty(stop_list)
- for value in (df[stop_col].cast(pl.Utf8).to_list() if stop_col in df.columns else [])
- },
- key=lambda value: int(value) if value.isdigit() else 999,
+ stop_data = self.query(
+ lambda: stop_frequency_chart_data(stop_list, raw_purpose, direction)
)
+ raw_values = STOP_FREQUENCY_VALUES[direction]
label_values = self.config.ordered_labels("stop_frequency", raw_values)
- return bar_chart(
+ return self.plot.bar(
label_category_data(
stop_data,
source_col="stop_frequency",
@@ -157,14 +149,12 @@ def render_direction_chart(
config=self.config,
target_col="stop_frequency_label",
),
- "stop_frequency_label",
- "tour_count",
- f"Tour Stop Frequency - Purpose: {display_purpose}, Direction: {direction}",
- "Stop Count",
- pct_col="pct",
- yaxis_title="Tours",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x="stop_frequency_label",
+ y="tour_count",
+ title=f"Tour Stop Frequency - Purpose: {display_purpose}, Direction: {direction}",
+ x_title="Stop Count",
+ y_title="Tours",
+ category_order=label_values,
)
def render_atwork_chart(
@@ -183,7 +173,7 @@ def render_atwork_chart(
)
],
)
- return bar_chart(
+ return self.plot.bar(
label_category_data(
atwork_list,
source_col="atwork_subtour_frequency_category",
@@ -191,53 +181,66 @@ def render_atwork_chart(
config=self.config,
target_col="atwork_subtour_frequency_label",
),
- "atwork_subtour_frequency_label",
- "atwork_subtour_count",
- "At-Work Sub-Tour Frequency",
- "At-Work Sub-Tour Frequency",
- pct_col="pct",
- yaxis_title="At-Work Sub-Tours",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x="atwork_subtour_frequency_label",
+ y="atwork_subtour_count",
+ title="At-Work Sub-Tour Frequency",
+ x_title="At-Work Sub-Tour Frequency",
+ y_title="At-Work Sub-Tours",
+ category_order=label_values,
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
stop_list = summaries["tour_stop_frequency_by_tour_purpose"]
atwork_list = nonempty(summaries["atwork_subtour_frequency_distribution"])
display_purpose, raw_purpose = self._selected_purpose()
- charts = [
+ both_chart = self.noted_view(
+ "tour_stop_frequency.direction",
self.render_direction_chart(
stop_list,
raw_purpose=raw_purpose,
display_purpose=display_purpose,
- direction=direction,
- )
- for direction in DIRECTION_OPTIONS
+ direction="Both",
+ ),
+ )
+ outbound_chart = self.noted_view(
+ "tour_stop_frequency.direction",
+ self.render_direction_chart(
+ stop_list,
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ direction="Outbound",
+ ),
+ )
+ inbound_chart = self.noted_view(
+ "tour_stop_frequency.direction",
+ self.render_direction_chart(
+ stop_list,
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ direction="Inbound",
+ ),
+ )
+ directional_row = pn.Row(
+ outbound_chart,
+ inbound_chart,
+ sizing_mode="stretch_width",
+ )
+ charts = [
+ both_chart,
+ directional_row,
]
return [
pn.pane.Markdown("### Tour Stop Frequency"),
selector_row(self.purpose_sel),
*charts,
pn.pane.Markdown("### At-Work Sub-Tour Frequency"),
- self.render_atwork_chart(atwork_list),
+ self.noted_view(
+ "tour_stop_frequency.atwork",
+ self.render_atwork_chart(atwork_list),
+ ),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="tour_stop_frequency",
- title="Tour Stop Frequency",
- group_id="tour_summaries",
- order=45,
- page_cls=TourStopFrequencyPage,
- required_summary_ids=(
- "tour_stop_frequency_by_tour_purpose",
- "atwork_subtour_frequency_distribution",
- ),
-)
-
-TourStopFrequencyPage.definition = PAGE
diff --git a/dashboard/pages/tour_summaries/tour_time.py b/dashboard/pages/tour_summaries/tour_time.py
index 9e3cc95..d4ef3a6 100644
--- a/dashboard/pages/tour_summaries/tour_time.py
+++ b/dashboard/pages/tour_summaries/tour_time.py
@@ -5,15 +5,15 @@
import panel as pn
import polars as pl
-from dashboard.components import density_chart, selector_row
-from dashboard.helpers.category_helpers import column_options, nonempty
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import column_options
from dashboard.helpers.time_distance_helpers import (
max_timebin,
timebin_duration_hours,
timebin_label,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
def tour_time_chart_data(
@@ -26,75 +26,60 @@ def tour_time_chart_data(
]:
"""Build departure, arrival, and duration distributions for one tour purpose."""
observed_max_timebin = max_timebin(data_list)
- dep_data = []
- arr_data = []
- dur_data = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8)).filter(
- pl.col("tour_purpose") == purpose
- )
- dep_data.append(
- (
- label,
- filtered.select("time_bin", "departure_tour_count")
- .sort("time_bin")
- .with_columns(
- pl.col("time_bin")
- .map_elements(
- lambda value: timebin_label(int(value), observed_max_timebin),
- return_dtype=pl.Utf8,
- )
- .alias("clock_time")
- ),
- )
- )
- arr_data.append(
- (
- label,
- filtered.select("time_bin", "arrival_tour_count")
- .sort("time_bin")
- .with_columns(
- pl.col("time_bin")
- .map_elements(
- lambda value: timebin_label(int(value), observed_max_timebin),
- return_dtype=pl.Utf8,
- )
- .alias("clock_time")
- ),
+ filtered = (
+ RunTables.from_runs(data_list)
+ .with_columns(pl.col("tour_purpose").cast(pl.Utf8))
+ .where(tour_purpose=purpose)
+ )
+
+ def clock_profile(value_col: str) -> list[tuple[str, pl.DataFrame]]:
+ return (
+ filtered.select("time_bin", value_col)
+ .sort("time_bin")
+ .with_columns(
+ pl.col("time_bin")
+ .map_elements(
+ lambda value: timebin_label(int(value), observed_max_timebin),
+ return_dtype=pl.Utf8,
+ )
+ .alias("clock_time")
)
)
- dur_data.append(
- (
- label,
- filtered.select("time_bin", "duration_tour_count")
- .sort("time_bin")
- .with_columns(
- pl.col("time_bin")
- .map_elements(
- lambda value: timebin_duration_hours(
- int(value), observed_max_timebin
- ),
- return_dtype=pl.Float64,
- )
- .alias("duration_hours")
- ),
+
+ duration = (
+ filtered.select("time_bin", "duration_tour_count")
+ .sort("time_bin")
+ .with_columns(
+ pl.col("time_bin")
+ .map_elements(
+ lambda value: timebin_duration_hours(int(value), observed_max_timebin),
+ return_dtype=pl.Float64,
)
+ .alias("duration_hours")
)
- return dep_data, arr_data, dur_data
+ )
+ return (
+ clock_profile("departure_tour_count"),
+ clock_profile("arrival_tour_count"),
+ duration,
+ )
+@dashboard_page(
+ page_id="tour_time",
+ title="Tour Time",
+ group_id="tour_summaries",
+ order=43,
+ required_summary_ids=("tour_time_of_day_by_tour_purpose",),
+)
class TourTimePage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
def build_page(self) -> pn.viewable.Viewable:
- self.purpose_sel = self.selector(
+ self.purpose_sel = self.select(
"tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=self._purpose_options(),
- value=self._purpose_options()[0],
- ),
- label="Tour Purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
)
self._body = self.section(
"tour_time_body",
@@ -109,9 +94,9 @@ def build_page(self) -> pn.viewable.Viewable:
)
def _purpose_options(self) -> list[str]:
- data = self.state.get_summary_table_set(
+ data = self.data.summary(
"tour_time_of_day_by_tour_purpose",
- "weighted",
+ self.weighting_key,
)
if data is None:
self._purpose_to_raw = {self.TOTAL_PURPOSE_LABEL: "all_tour_purposes"}
@@ -121,41 +106,11 @@ def _purpose_options(self) -> list[str]:
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "tour_time",
- "tour_time_of_day_by_tour_purpose",
- "tour_purpose",
- "weighted",
- ),
total_raw="all_tour_purposes",
total_label=self.TOTAL_PURPOSE_LABEL,
)
return options or [self.TOTAL_PURPOSE_LABEL]
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
- purpose_opts, self._purpose_to_raw = column_options(
- summaries["tour_time_of_day_by_tour_purpose"],
- "tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "tour_time",
- "tour_time_of_day_by_tour_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- self.purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.purpose_sel.value not in self.purpose_sel.options:
- self.purpose_sel.value = self.purpose_sel.options[0]
-
def _selected_purpose(self) -> tuple[str, str]:
display_purpose = self.purpose_sel.value
raw_purpose = self._purpose_to_raw.get(display_purpose, "all_tour_purposes")
@@ -168,49 +123,43 @@ def render_time_charts(
raw_purpose: str,
display_purpose: str,
) -> tuple[pn.viewable.Viewable, pn.viewable.Viewable, pn.viewable.Viewable]:
- dep_data, arr_data, dur_data = self.get_filtered_view(
- "tour_time",
- raw_purpose,
- factory=lambda: tour_time_chart_data(tod_list, raw_purpose),
+ dep_data, arr_data, dur_data = self.query(
+ lambda: tour_time_chart_data(tod_list, raw_purpose)
)
return (
- density_chart(
+ self.plot.density(
dep_data,
- "clock_time",
- "departure_tour_count",
- f"Tour Departure Time Distribution - {display_purpose}",
- "Clock Time (start at 03:00)",
- normalize=False,
- yaxis_title="Tours",
- as_percent=self.as_percent,
+ x="clock_time",
+ y="departure_tour_count",
+ title=f"Tour Departure Time Distribution - {display_purpose}",
+ x_title="Clock Time (start at 03:00)",
+ y_title="Tours",
+ hover_x_title="Clock Time",
),
- density_chart(
+ self.plot.density(
arr_data,
- "clock_time",
- "arrival_tour_count",
- f"Tour Arrival Time Distribution - {display_purpose}",
- "Clock Time (start at 03:00)",
- normalize=False,
- yaxis_title="Tours",
- as_percent=self.as_percent,
+ x="clock_time",
+ y="arrival_tour_count",
+ title=f"Tour Arrival Time Distribution - {display_purpose}",
+ x_title="Clock Time (start at 03:00)",
+ y_title="Tours",
+ hover_x_title="Clock Time",
),
- density_chart(
+ self.plot.density(
dur_data,
- "duration_hours",
- "duration_tour_count",
- f"Tour Duration Distribution - {display_purpose}",
- "Tour Duration (hours)",
- normalize=False,
- yaxis_title="Tours",
- as_percent=self.as_percent,
+ x="duration_hours",
+ y="duration_tour_count",
+ title=f"Tour Duration Distribution - {display_purpose}",
+ x_title="Tour Duration (hours)",
+ y_title="Tours",
),
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
display_purpose, raw_purpose = self._selected_purpose()
departure_chart, arrival_chart, duration_chart = self.render_time_charts(
@@ -218,16 +167,8 @@ def render_body(self):
raw_purpose=raw_purpose,
display_purpose=display_purpose,
)
- return [departure_chart, arrival_chart, duration_chart]
-
-
-PAGE = DashboardPageDefinition(
- page_id="tour_time",
- title="Tour Time",
- group_id="tour_summaries",
- order=43,
- page_cls=TourTimePage,
- required_summary_ids=("tour_time_of_day_by_tour_purpose",),
-)
-
-TourTimePage.definition = PAGE
+ return [
+ self.noted_view("tour_time.departure", departure_chart),
+ self.noted_view("tour_time.arrival", arrival_chart),
+ self.noted_view("tour_time.duration", duration_chart),
+ ]
diff --git a/dashboard/pages/trip_summaries/parking_location.py b/dashboard/pages/trip_summaries/parking_location.py
index 13e27e0..cc5ad30 100644
--- a/dashboard/pages/trip_summaries/parking_location.py
+++ b/dashboard/pages/trip_summaries/parking_location.py
@@ -5,10 +5,9 @@
import panel as pn
import polars as pl
-from dashboard.components import data_table, scatter_chart
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
-from processor.models import RunData
+from dashboard import DashboardPage, dashboard_page
+from dashboard.rendering import data_table
+from dashboard.data_access import RunTables
PARKING_CAPACITY_COLUMNS = (
"PRKSPACES",
@@ -27,43 +26,54 @@ def _parking_capacity_col(land_use: pl.DataFrame) -> str | None:
def parking_scatter_data(
parking_summary: list[tuple[str, pl.DataFrame]],
- prepared_runs: list[tuple[str, RunData]],
+ land_use_tables: list[tuple[str, pl.DataFrame]],
) -> list[tuple[str, pl.DataFrame]]:
- prepared_by_label = {label: run for label, run in prepared_runs}
- out: list[tuple[str, pl.DataFrame]] = []
- for label, summary_df in parking_summary:
- run = prepared_by_label.get(label)
- if run is None:
- continue
- capacity_col = _parking_capacity_col(run.land_use)
+ capacity_tables: list[tuple[str, pl.DataFrame]] = []
+ for label, land_use in land_use_tables:
+ capacity_col = _parking_capacity_col(land_use)
if capacity_col is None:
continue
- land_use = (
- run.land_use.select(
- pl.col("MAZ").cast(pl.Utf8).alias("geography_id"),
- pl.col(capacity_col).cast(pl.Float64).alias("parking_capacity"),
+ capacity_tables.append(
+ (
+ label,
+ land_use.select(
+ pl.col("MAZ").cast(pl.Utf8).alias("geography_id"),
+ pl.col(capacity_col).cast(pl.Float64).alias("parking_capacity"),
+ )
+ .group_by("geography_id")
+ .agg(parking_capacity=pl.col("parking_capacity").sum()),
)
- .group_by("geography_id")
- .agg(parking_capacity=pl.col("parking_capacity").sum())
)
- parking_counts = summary_df.filter(
+
+ parking_counts = RunTables.from_runs(parking_summary).map(
+ lambda frame: frame.filter(
pl.col("geography_type").cast(pl.Utf8) == "maz"
).select(
pl.col("geography_id").cast(pl.Utf8),
pl.col("trip_count").cast(pl.Float64),
)
- joined = (
- land_use.join(parking_counts, on="geography_id", how="full", coalesce=True)
- .with_columns(
- pl.col("parking_capacity").fill_null(0.0),
- pl.col("trip_count").fill_null(0.0),
- )
- .sort("geography_id")
+ )
+ return (
+ RunTables.from_runs(capacity_tables)
+ .join(parking_counts, on="geography_id", how="full", coalesce=True)
+ .with_columns(
+ pl.col("parking_capacity").fill_null(0.0),
+ pl.col("trip_count").fill_null(0.0),
)
- out.append((label, joined))
- return out
+ .sort("geography_id")
+ )
+@dashboard_page(
+ page_id="parking_location",
+ title="Parking Location",
+ group_id="trip_summaries",
+ order=51,
+ default_enabled=False,
+ prepared_data_mode="required",
+ required_summary_ids=("parking_locations",),
+ required_prepared_tables=("land_use",),
+)
class ParkingLocationPage(DashboardPage):
"""Join parking summaries with prepared land-use capacity data."""
@@ -85,39 +95,32 @@ def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- parking_result = self.resolve_summary_visualization(
- "parking_location_scatter",
- summary_requirements={
- "parking_locations": ("geography_type", "geography_id", "trip_count")
- },
+ parking_tables = self.data.summary(
+ "parking_locations",
+ columns=("geography_type", "geography_id", "trip_count"),
)
- prepared_result = self.resolve_prepared_visualization(
- "parking_location_land_use",
- table_requirements={"land_use": ("MAZ",)},
- weighted=self.weighting_key == "weighted",
+ land_use_tables = self.data.prepared(
+ "land_use",
+ columns=("MAZ",),
+ weighting_mode=self.weighting_key,
)
- if not parking_result.has_usable_runs or not prepared_result.has_usable_runs:
+ if not parking_tables or not land_use_tables:
detail = (
"Parking location scatterplots require parking trip summaries and prepared "
"land use tables with parking capacity columns."
)
- result = (
- parking_result
- if not parking_result.has_usable_runs
- else prepared_result
+ missing = []
+ if not parking_tables:
+ missing.append("parking_locations")
+ if not land_use_tables:
+ missing.append("land_use")
+ return [self.data_not_available_card(detail=detail, missing_items=missing)]
+
+ scatter_data = self.query(
+ lambda: parking_scatter_data(
+ parking_tables,
+ land_use_tables,
)
- return [self.unavailable_visualization(result, detail=detail)]
-
- scatter_data = self.get_filtered_view(
- "parking_location_scatter",
- tuple(
- label
- for label, _ in parking_result.usable_by_input["parking_locations"]
- ),
- factory=lambda: parking_scatter_data(
- parking_result.usable_by_input["parking_locations"],
- prepared_result.usable_by_input["land_use"],
- ),
)
if not scatter_data:
return [
@@ -131,8 +134,14 @@ def render_body(self):
]
return [
- self.render_scatter_chart(scatter_data),
- self.render_comparison_table(scatter_data),
+ self.noted_view(
+ "parking_location.scatter",
+ self.render_scatter_chart(scatter_data),
+ ),
+ self.noted_view(
+ "parking_location.table",
+ self.render_comparison_table(scatter_data),
+ ),
]
def render_scatter_chart(
@@ -140,13 +149,13 @@ def render_scatter_chart(
scatter_data: list[tuple[str, pl.DataFrame]],
) -> pn.viewable.Viewable:
"""Render the parking capacity versus trips scatterplot."""
- return scatter_chart(
+ return self.plot.scatter(
scatter_data,
- x_col="parking_capacity",
- y_col="trip_count",
+ x="parking_capacity",
+ y="trip_count",
title="Parking Capacity vs Trips Parked by Zone",
- xaxis_title="Parking Capacity",
- yaxis_title="Trips Parked",
+ x_title="Parking Capacity",
+ y_title="Trips Parked",
drop_zero_y=False,
)
@@ -156,18 +165,3 @@ def render_comparison_table(
) -> pn.viewable.Viewable:
"""Render the joined capacity/trips table below the chart."""
return data_table(scatter_data, "Parking Capacity vs Trips Parked")
-
-
-PAGE = DashboardPageDefinition(
- page_id="parking_location",
- title="Parking Location",
- group_id="trip_summaries",
- order=51,
- default_enabled=False,
- page_cls=ParkingLocationPage,
- prepared_data_mode="required",
- required_summary_ids=("parking_locations",),
- required_prepared_tables=("land_use",),
-)
-
-ParkingLocationPage.definition = PAGE
diff --git a/dashboard/pages/trip_summaries/trip_mode.py b/dashboard/pages/trip_summaries/trip_mode.py
index e7e7da8..22dcb8e 100644
--- a/dashboard/pages/trip_summaries/trip_mode.py
+++ b/dashboard/pages/trip_summaries/trip_mode.py
@@ -5,16 +5,19 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, selector_row
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
+ add_percent_of_total,
+ category_label_matches,
column_options,
complete_category_counts,
label_category_data,
- nonempty,
ordered_category_values,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
+
+AUTO_MODE_LABELS = ("Drive Alone", "Shared Ride 2", "Shared Ride 3+")
def filtered_trip_mode_data(
@@ -22,105 +25,157 @@ def filtered_trip_mode_data(
tour_purpose: str,
*,
tour_mode: str | None = None,
+ hidden_mode_values: set[str] | None = None,
) -> list[tuple[str, pl.DataFrame]]:
"""Filter trip mode summaries to one selected tour purpose and optional tour mode."""
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(
+ view = (
+ RunTables.from_runs(data_list)
+ .with_columns(
pl.col("tour_purpose").cast(pl.Utf8),
pl.col("tour_mode").cast(pl.Utf8),
pl.col("trip_mode").cast(pl.Utf8),
- ).filter(pl.col("tour_purpose") == tour_purpose)
- filtered = (
- filtered.filter(pl.col("tour_mode") == "all_tour_modes")
- if tour_mode is None
- else filtered.filter(pl.col("tour_mode") == tour_mode)
)
- out.append((label, filtered))
- return out
+ .where(tour_purpose=tour_purpose)
+ )
+ view = view.where(tour_mode=tour_mode or "all_tour_modes")
+ if hidden_mode_values:
+ view = view.map(
+ lambda frame: frame.filter(
+ ~pl.col("trip_mode").is_in(sorted(hidden_mode_values))
+ )
+ )
+ return view
+
+def trip_mode_percent_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ all_trip_mode_values: list[str],
+ hidden_trip_mode_values: set[str],
+) -> list[tuple[str, pl.DataFrame]]:
+ """Complete trip-mode rows, compute full-denominator percents, then hide rows."""
+ completed = complete_category_counts(
+ data_list,
+ category="trip_mode",
+ category_values=all_trip_mode_values,
+ value_cols=("trip_count", "pct"),
+ )
+ with_percent = add_percent_of_total(
+ completed,
+ value_col="trip_count",
+ percent_col="trip_count_percent",
+ )
+ if not hidden_trip_mode_values:
+ return with_percent
+ hidden_values = sorted(hidden_trip_mode_values)
+ return RunTables.from_runs(with_percent).map(
+ lambda frame: frame.filter(~pl.col("trip_mode").is_in(hidden_values))
+ )
+
+@dashboard_page(
+ page_id="trip_mode",
+ title="Trip Mode",
+ group_id="trip_summaries",
+ order=48,
+ required_summary_ids=("trip_mode_by_tour_purpose_and_tour_mode",),
+)
class TripModePage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
+ def _tour_purpose_title_label(self, display_purpose: str) -> str:
+ """Return a title-ready tour-purpose label."""
+ if display_purpose == self.TOTAL_PURPOSE_LABEL:
+ return "All Tours"
+ purpose_label = str(display_purpose)
+ if not purpose_label.casefold().endswith(" tours"):
+ purpose_label = f"{purpose_label} Tours"
+ return purpose_label
+
+ def _overall_chart_title(self, display_purpose: str) -> str:
+ """Return the overall trip-mode chart title."""
+ return f"Trip Mode Distribution for {self._tour_purpose_title_label(display_purpose)}"
+
+ def _tour_mode_chart_title(self, tour_mode: str, display_purpose: str) -> str:
+ """Return the tour-mode-specific trip-mode chart title."""
+ mode_label = self.config.label_value("mode", tour_mode)
+ if display_purpose == self.TOTAL_PURPOSE_LABEL:
+ return f"Trip Mode Distribution for All {mode_label} Tours"
+ return (
+ "Trip Mode Distribution for "
+ f"{mode_label} {self._tour_purpose_title_label(display_purpose)}"
+ )
+
def build_page(self) -> pn.viewable.Viewable:
- purpose_opts, self._tour_purpose_to_raw = column_options(
- self.state.get_summary_table_set(
- "trip_mode_by_tour_purpose_and_tour_mode", "weighted"
- )
- or [],
+ self.tour_purpose_sel = self.select(
"tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "trip_mode",
- "trip_mode_by_tour_purpose_and_tour_mode",
- "tour_purpose",
- "weighted",
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
+ "Tour Purpose",
+ options=self._purpose_options,
)
- self.tour_purpose_sel = self.selector(
- "tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_opts or [self.TOTAL_PURPOSE_LABEL],
- value=(purpose_opts or [self.TOTAL_PURPOSE_LABEL])[0],
- ),
- label="Tour Purpose",
+ self.hide_drive_alone = self.selector(
+ "hide_drive_alone",
+ widget=pn.widgets.Checkbox(name="Hide Auto Modes", value=False),
+ label="Hide Auto Modes",
)
self._body = self.section(
"trip_summary_mode_body",
- selectors=("tour_purpose",),
+ selectors=("tour_purpose", "hide_drive_alone"),
render=self.render_body,
)
return self.new_section(
pn.pane.Markdown("## Trip Mode"),
- selector_row(self.tour_purpose_sel),
+ selector_row(self.tour_purpose_sel, self.hide_drive_alone),
self._body,
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
+ def _purpose_options(self) -> list[str]:
purpose_opts, self._tour_purpose_to_raw = column_options(
- summaries["trip_mode_by_tour_purpose_and_tour_mode"],
+ self.data.summary(
+ "trip_mode_by_tour_purpose_and_tour_mode", self.weighting_key
+ )
+ or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "trip_mode",
- "trip_mode_by_tour_purpose_and_tour_mode",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw="all_tour_purposes",
total_label=self.TOTAL_PURPOSE_LABEL,
)
- self.tour_purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.tour_purpose_sel.value not in self.tour_purpose_sel.options:
- self.tour_purpose_sel.value = self.tour_purpose_sel.options[0]
+ return purpose_opts or [self.TOTAL_PURPOSE_LABEL]
def _selected_purpose(self) -> tuple[str, str]:
display_purpose = self.tour_purpose_sel.value
- raw_purpose = self._tour_purpose_to_raw.get(display_purpose, "all_tour_purposes")
+ raw_purpose = self._tour_purpose_to_raw.get(
+ display_purpose, "all_tour_purposes"
+ )
return display_purpose, str(raw_purpose)
def _mode_axes(
self,
trip_mode_list: list[tuple[str, pl.DataFrame]],
- ) -> tuple[list[str], list[str], list[str]]:
- trip_mode_values = ordered_category_values(
+ ) -> tuple[list[str], list[str], list[str], list[str], set[str]]:
+ all_trip_mode_values = ordered_category_values(
trip_mode_list,
"trip_mode",
category_id="mode",
config=self.config,
)
+ trip_mode_values = all_trip_mode_values.copy()
+ hidden_trip_mode_values: set[str] = set()
+ if self.hide_drive_alone.value:
+ hidden_trip_mode_values = {
+ value
+ for value in all_trip_mode_values
+ if any(
+ category_label_matches(self.config, "mode", value, label)
+ for label in AUTO_MODE_LABELS
+ )
+ }
+ trip_mode_values = [
+ value
+ for value in trip_mode_values
+ if value not in hidden_trip_mode_values
+ ]
tour_modes = [
value
for value in ordered_category_values(
@@ -132,81 +187,100 @@ def _mode_axes(
if value != "all_tour_modes"
]
trip_mode_labels = self.config.ordered_labels("mode", trip_mode_values)
- return trip_mode_values, trip_mode_labels, tour_modes
+ return (
+ all_trip_mode_values,
+ trip_mode_values,
+ trip_mode_labels,
+ tour_modes,
+ hidden_trip_mode_values,
+ )
def render_mode_chart(
self,
trip_mode_list: list[tuple[str, pl.DataFrame]],
*,
raw_purpose: str,
+ all_trip_mode_values: list[str],
trip_mode_values: list[str],
trip_mode_label_values: list[str],
+ hidden_trip_mode_values: set[str],
+ display_purpose: str,
tour_mode: str | None = None,
) -> pn.viewable.Viewable:
- cache_key = (
- "trip_mode_overall" if tour_mode is None else "trip_mode_grid",
- raw_purpose,
- tour_mode,
- )
- mode_data = self.get_filtered_view(
- *cache_key,
- factory=lambda: label_category_data(
- complete_category_counts(
+ mode_data = self.query(
+ lambda: label_category_data(
+ trip_mode_percent_data(
filtered_trip_mode_data(
trip_mode_list,
raw_purpose,
tour_mode=tour_mode,
),
- category_col="trip_mode",
- category_values=trip_mode_values,
- value_cols=("trip_count", "pct"),
+ all_trip_mode_values=all_trip_mode_values,
+ hidden_trip_mode_values=hidden_trip_mode_values,
),
category_id="mode",
config=self.config,
source_col="trip_mode",
target_col="trip_mode_label",
- ),
+ )
)
chart_title = (
- f"Trip Mode Distribution - {self.config.label_value('mode', tour_mode)}"
+ self._tour_mode_chart_title(tour_mode, display_purpose)
if tour_mode is not None
- else f"Trip Mode Distribution - {self.tour_purpose_sel.value}"
+ else self._overall_chart_title(display_purpose)
)
- return bar_chart(
+ return self.plot.bar(
mode_data,
- x_col="trip_mode_label",
- y_col="trip_count",
+ x="trip_mode_label",
+ y="trip_count",
title=chart_title,
- xaxis_title="Trip Mode",
- yaxis_title="Trips",
- pct_col="pct",
- as_percent=self.as_percent,
+ x_title="Trip Mode",
+ y_title="Trips",
+ share_y="trip_count_percent",
height=320 if tour_mode is not None else 400,
- xaxis_categoryarray=trip_mode_label_values,
+ category_order=trip_mode_label_values,
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
trip_mode_list = summaries["trip_mode_by_tour_purpose_and_tour_mode"]
display_purpose, raw_purpose = self._selected_purpose()
- trip_mode_values, trip_mode_label_values, tour_modes = self._mode_axes(trip_mode_list)
- overall_chart = self.render_mode_chart(
- trip_mode_list,
- raw_purpose=raw_purpose,
- trip_mode_values=trip_mode_values,
- trip_mode_label_values=trip_mode_label_values,
- )
- grid_cards = [
+ (
+ all_trip_mode_values,
+ trip_mode_values,
+ trip_mode_label_values,
+ tour_modes,
+ hidden_trip_mode_values,
+ ) = self._mode_axes(trip_mode_list)
+ overall_chart = self.noted_view(
+ "trip_mode.distributions",
self.render_mode_chart(
trip_mode_list,
raw_purpose=raw_purpose,
+ all_trip_mode_values=all_trip_mode_values,
trip_mode_values=trip_mode_values,
trip_mode_label_values=trip_mode_label_values,
- tour_mode=tour_mode,
+ hidden_trip_mode_values=hidden_trip_mode_values,
+ display_purpose=display_purpose,
+ ),
+ )
+ grid_cards = [
+ self.noted_view(
+ "trip_mode.distributions",
+ self.render_mode_chart(
+ trip_mode_list,
+ raw_purpose=raw_purpose,
+ all_trip_mode_values=all_trip_mode_values,
+ trip_mode_values=trip_mode_values,
+ trip_mode_label_values=trip_mode_label_values,
+ hidden_trip_mode_values=hidden_trip_mode_values,
+ display_purpose=display_purpose,
+ tour_mode=tour_mode,
+ ),
)
for tour_mode in tour_modes
]
@@ -219,15 +293,3 @@ def render_body(self):
pn.pane.Markdown("### Trip Mode by Tour Mode"),
*grid_rows,
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="trip_mode",
- title="Trip Mode",
- group_id="trip_summaries",
- order=48,
- page_cls=TripModePage,
- required_summary_ids=("trip_mode_by_tour_purpose_and_tour_mode",),
-)
-
-TripModePage.definition = PAGE
diff --git a/dashboard/pages/trip_summaries/trip_stop_distance.py b/dashboard/pages/trip_summaries/trip_stop_distance.py
index 57bf0a1..6f41bc0 100644
--- a/dashboard/pages/trip_summaries/trip_stop_distance.py
+++ b/dashboard/pages/trip_summaries/trip_stop_distance.py
@@ -5,11 +5,21 @@
import panel as pn
import polars as pl
-from dashboard.components import density_chart, selector_row
-from dashboard.helpers.category_helpers import column_options, nonempty
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ cap_numeric_category_frame,
+ column_options,
+)
+from dashboard.helpers.distance_range import (
+ DistanceRangeControls,
+ capped_distance_max_options,
+ distance_axis_bounds,
+ fixed_distance_axis_ticks,
+ with_distance_axis,
+)
from dashboard.helpers.time_distance_helpers import distance_sort_expr
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
def distance_chart_data(
@@ -17,59 +27,75 @@ def distance_chart_data(
tour_purpose: str,
x_col: str,
y_col: str,
+ *,
+ cap_at: int | None = None,
) -> list[tuple[str, pl.DataFrame]]:
"""Filter one distribution summary to a tour purpose and order the distance bins."""
- out = []
- for label, df in nonempty(data_list):
- out.append(
- (
- label,
- df.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
- .filter(pl.col("tour_purpose") == tour_purpose)
- .select(
- pl.col(x_col).alias("distance_bin"),
- pl.col(y_col).alias("freq"),
- )
- .with_columns(distance_sort_expr("distance_bin").alias("_sort_distance"))
- .sort("_sort_distance")
- .drop("_sort_distance"),
+
+ def shape(frame: pl.DataFrame) -> pl.DataFrame:
+ chart = (
+ frame.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
+ .filter(pl.col("tour_purpose") == tour_purpose)
+ .select(
+ pl.col(x_col).alias("distance_bin"),
+ pl.col(y_col).alias("freq"),
+ )
+ )
+ if cap_at is not None:
+ chart = cap_numeric_category_frame(
+ chart,
+ category="distance_bin",
+ cap_value=cap_at,
+ value_cols=("freq",),
+ )
+ return (
+ chart.with_columns(
+ distance_sort_expr("distance_bin").alias("_sort_distance")
)
+ .sort("_sort_distance")
+ .drop("_sort_distance")
)
- return out
+ return RunTables.from_runs(data_list).map(shape)
+
+@dashboard_page(
+ page_id="trip_stop_distance",
+ title="Trip and Stop Distance",
+ group_id="trip_summaries",
+ order=50,
+ required_summary_ids=(
+ "trip_distance_by_purpose",
+ "stop_out_of_direction_distance_by_tour_purpose",
+ ),
+)
class TripStopDistancePage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
+ def _tour_slice_title_label(self, display_purpose: str) -> str:
+ """Return a display label such as All Tours or Work Tours."""
+ if display_purpose == self.TOTAL_PURPOSE_LABEL:
+ return "All Tours"
+ purpose_label = str(display_purpose)
+ if not purpose_label.casefold().endswith(" tours"):
+ purpose_label = f"{purpose_label} Tours"
+ return purpose_label
+
def build_page(self) -> pn.viewable.Viewable:
- purpose_opts, self._tour_purpose_to_raw = column_options(
- self.state.get_summary_table_set("trip_distance_by_purpose", "weighted") or [],
+ self.tour_purpose_sel = self.select(
"tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_distance",
- "trip_distance_by_purpose",
- "tour_purpose",
- "weighted",
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
+ "Tour Purpose",
+ options=self._purpose_options,
)
- purpose_opts = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- self.tour_purpose_sel = self.selector(
- "tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_opts,
- value=purpose_opts[0],
- ),
- label="Tour Purpose",
+ self.trip_stop_distance_range = DistanceRangeControls.create(
+ self,
+ "trip_stop_distance",
+ max_options=capped_distance_max_options(),
+ reset_label="Reset distance range",
)
self._body = self.section(
"trip_stop_distance_body",
- selectors=("tour_purpose",),
+ selectors=("tour_purpose", *self.trip_stop_distance_range.selector_ids),
render=self.render_body,
)
return self.new_section(
@@ -79,39 +105,28 @@ def build_page(self) -> pn.viewable.Viewable:
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
+ def _purpose_options(self) -> list[str]:
purpose_opts, self._tour_purpose_to_raw = column_options(
- summaries["trip_distance_by_purpose"],
+ self.data.summary("trip_distance_by_purpose", self.weighting_key) or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_distance",
- "trip_distance_by_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw="all_tour_purposes",
total_label=self.TOTAL_PURPOSE_LABEL,
)
- self.tour_purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.tour_purpose_sel.value not in self.tour_purpose_sel.options:
- self.tour_purpose_sel.value = self.tour_purpose_sel.options[0]
+ return purpose_opts or [self.TOTAL_PURPOSE_LABEL]
def _selected_purpose(self) -> tuple[str, str]:
display_purpose = self.tour_purpose_sel.value
- raw_purpose = self._tour_purpose_to_raw.get(display_purpose, "all_tour_purposes")
+ raw_purpose = self._tour_purpose_to_raw.get(
+ display_purpose, "all_tour_purposes"
+ )
return display_purpose, raw_purpose
def render_distance_chart(
self,
*,
summary_data: list[tuple[str, pl.DataFrame]],
- cache_key: str,
raw_purpose: str,
display_purpose: str,
x_col: str,
@@ -119,67 +134,106 @@ def render_distance_chart(
title: str,
xaxis_title: str,
yaxis_title: str,
+ cap_at: int | None = None,
+ x_range: tuple[float, float] | None = None,
) -> pn.viewable.Viewable:
- chart_data = self.get_filtered_view(
- cache_key,
- raw_purpose,
- factory=lambda: distance_chart_data(summary_data, raw_purpose, x_col, y_col),
+ chart_data = self.query(
+ lambda: distance_chart_data(
+ summary_data,
+ raw_purpose,
+ x_col,
+ y_col,
+ cap_at=cap_at,
+ )
)
- return density_chart(
- chart_data,
- x_col="distance_bin",
- y_col="freq",
- title=f"{title} - {display_purpose}",
- xaxis_title=xaxis_title,
- yaxis_title=yaxis_title,
- normalize=False,
- as_percent=self.as_percent,
+ axis_data = with_distance_axis(chart_data)
+ tickvals, ticktext = fixed_distance_axis_ticks()
+ return self.plot.density(
+ axis_data,
+ x="_distance_axis",
+ y="freq",
+ title=f"{title} for {self._tour_slice_title_label(display_purpose)}",
+ x_title=xaxis_title,
+ y_title=yaxis_title,
+ x_range=x_range,
+ tick_values=tickvals,
+ tick_text=ticktext,
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
display_purpose, raw_purpose = self._selected_purpose()
+ trip_distance_data = self.query(
+ lambda: distance_chart_data(
+ summaries["trip_distance_by_purpose"],
+ raw_purpose,
+ "distance_bin",
+ "trip_count",
+ cap_at=None,
+ )
+ )
+ stop_distance_data = self.query(
+ lambda: distance_chart_data(
+ summaries["stop_out_of_direction_distance_by_tour_purpose"],
+ raw_purpose,
+ "distance_bin",
+ "stop_count",
+ cap_at=40,
+ )
+ )
+ observed_bounds = distance_axis_bounds(
+ [*trip_distance_data, *stop_distance_data]
+ )
+ bounds = (0.0, 40.0) if observed_bounds is not None else None
+ self.trip_stop_distance_range.sync(
+ (raw_purpose, self.weighting_key),
+ bounds,
+ )
+ x_range = self.trip_stop_distance_range.current_range()
+ if bounds is not None and x_range is None:
+ return [
+ self.trip_stop_distance_range.row(),
+ self.data_not_available_card(
+ detail="Trip and stop distance controls require finite values with min less than max.",
+ title="Trip and Stop Distance Data Not Available",
+ ),
+ ]
return [
- self.render_distance_chart(
- summary_data=summaries["trip_distance_by_purpose"],
- cache_key="trip_distance",
- raw_purpose=raw_purpose,
- display_purpose=display_purpose,
- x_col="distance_bin",
- y_col="trip_count",
- title="Trip Distance Distribution",
- xaxis_title="Distance (miles)",
- yaxis_title="Trips",
+ self.trip_stop_distance_range.row(),
+ self.noted_view(
+ "trip_stop_distance.trip_distance",
+ self.render_distance_chart(
+ summary_data=summaries["trip_distance_by_purpose"],
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ x_col="distance_bin",
+ y_col="trip_count",
+ title="Trip Distance Distribution",
+ xaxis_title="Distance (miles)",
+ yaxis_title="Trips",
+ x_range=x_range,
+ ),
),
- self.render_distance_chart(
- summary_data=summaries["stop_out_of_direction_distance_by_tour_purpose"],
- cache_key="stop_out_of_direction_distance",
- raw_purpose=raw_purpose,
- display_purpose=display_purpose,
- x_col="distance_bin",
- y_col="stop_count",
- title="Stop Out-of-Direction Distance Distribution",
- xaxis_title="Out-of-Direction Distance (miles)",
- yaxis_title="Stops",
+ self.noted_view(
+ "trip_stop_distance.out_of_direction",
+ self.render_distance_chart(
+ summary_data=summaries[
+ "stop_out_of_direction_distance_by_tour_purpose"
+ ],
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ x_col="distance_bin",
+ y_col="stop_count",
+ title="Stop Out-of-Direction Distance Distribution",
+ xaxis_title="Out-of-Direction Distance (miles)",
+ yaxis_title="Stops",
+ cap_at=40,
+ x_range=x_range,
+ ),
),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="trip_stop_distance",
- title="Trip and Stop Distance",
- group_id="trip_summaries",
- order=50,
- page_cls=TripStopDistancePage,
- required_summary_ids=(
- "trip_distance_by_purpose",
- "stop_out_of_direction_distance_by_tour_purpose",
- ),
-)
-
-TripStopDistancePage.definition = PAGE
diff --git a/dashboard/pages/trip_summaries/trip_stop_purpose.py b/dashboard/pages/trip_summaries/trip_stop_purpose.py
index bfdbd4e..bde4db8 100644
--- a/dashboard/pages/trip_summaries/trip_stop_purpose.py
+++ b/dashboard/pages/trip_summaries/trip_stop_purpose.py
@@ -5,15 +5,15 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, control_row, control_row_spacer
+from dashboard.rendering import control_row, control_row_spacer
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
column_options,
label_category_data,
nonempty,
ordered_category_values,
)
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
def order_chart_data(
@@ -26,28 +26,25 @@ def order_chart_data(
if not ordered_values:
return data_list
order_index = {str(value): idx for idx, value in enumerate(ordered_values)}
- out: list[tuple[str, pl.DataFrame]] = []
- for label, df in data_list:
- if df is None or column not in df.columns:
- out.append((label, df))
- continue
- out.append(
- (
- label,
- df.with_columns(
- pl.col(column)
- .cast(pl.Utf8)
- .map_elements(
- lambda value: order_index.get(str(value), len(order_index)),
- return_dtype=pl.Int64,
- )
- .alias("_category_order")
+
+ def order(frame: pl.DataFrame) -> pl.DataFrame:
+ if column not in frame.columns:
+ return frame
+ return (
+ frame.with_columns(
+ pl.col(column)
+ .cast(pl.Utf8)
+ .map_elements(
+ lambda value: order_index.get(str(value), len(order_index)),
+ return_dtype=pl.Int64,
)
- .sort("_category_order")
- .drop("_category_order"),
+ .alias("_category_order")
)
+ .sort("_category_order")
+ .drop("_category_order")
)
- return out
+
+ return RunTables.from_runs(data_list).map(order)
def stop_purpose_chart_data(
@@ -55,9 +52,9 @@ def stop_purpose_chart_data(
tour_purpose: str | None,
) -> list[tuple[str, pl.DataFrame]]:
"""Build stop-purpose distributions for the selected tour purpose."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
+
+ def shape(frame: pl.DataFrame) -> pl.DataFrame:
+ filtered = frame.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
if tour_purpose is None:
filtered = (
filtered.group_by("stop_destination_purpose")
@@ -67,8 +64,9 @@ def stop_purpose_chart_data(
)
else:
filtered = filtered.filter(pl.col("tour_purpose") == tour_purpose)
- out.append((label, filtered))
- return out
+ return filtered
+
+ return RunTables.from_runs(data_list).map(shape)
def trip_purpose_chart_data(
@@ -76,12 +74,17 @@ def trip_purpose_chart_data(
tour_purpose: str | None,
) -> list[tuple[str, pl.DataFrame]]:
"""Build trip-purpose distributions for the selected tour purpose."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
+
+ def shape(frame: pl.DataFrame) -> pl.DataFrame:
+ filtered = frame.with_columns(pl.col("tour_purpose").cast(pl.Utf8))
if tour_purpose is None:
- if "all_tour_purposes" in filtered["tour_purpose"].cast(pl.Utf8).unique().to_list():
- filtered = filtered.filter(pl.col("tour_purpose") == "all_tour_purposes")
+ if (
+ "all_tour_purposes"
+ in filtered["tour_purpose"].cast(pl.Utf8).unique().to_list()
+ ):
+ filtered = filtered.filter(
+ pl.col("tour_purpose") == "all_tour_purposes"
+ )
else:
filtered = (
filtered.group_by("trip_purpose")
@@ -89,44 +92,51 @@ def trip_purpose_chart_data(
.with_columns(pl.col("trip_purpose").cast(pl.Utf8))
.sort("trip_purpose")
)
- out.append((label, filtered))
- continue
+ return filtered
else:
filtered = filtered.filter(pl.col("tour_purpose") == tour_purpose)
- out.append((label, filtered))
- return out
+ return filtered
+ return RunTables.from_runs(data_list).map(shape)
+
+@dashboard_page(
+ page_id="trip_stop_purpose",
+ title="Trip and Stop Purpose",
+ group_id="trip_summaries",
+ order=47,
+ required_summary_ids=(
+ "trip_purpose_distribution",
+ "stop_destination_purpose_by_tour_purpose",
+ ),
+)
class TripStopPurposePage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
+ def _trip_purpose_title(self, display_purpose: str) -> str:
+ """Return the chart title for the selected tour-purpose slice."""
+ purpose_label = self._tour_slice_title_label(display_purpose)
+ return f"Trip Purpose for {purpose_label}"
+
+ def _stop_purpose_title(self, display_purpose: str) -> str:
+ """Return the stop-destination chart title for the selected tour-purpose slice."""
+ purpose_label = self._tour_slice_title_label(display_purpose)
+ return f"Stop Destination Purpose for {purpose_label}"
+
+ def _tour_slice_title_label(self, display_purpose: str) -> str:
+ """Return a display label such as All Tours or Work Tours."""
+ if display_purpose in {self.TOTAL_PURPOSE_LABEL, "All Trip Purposes"}:
+ return "All Tours"
+ purpose_label = str(display_purpose)
+ if not purpose_label.casefold().endswith(" tours"):
+ purpose_label = f"{purpose_label} Tours"
+ return purpose_label
+
def build_page(self) -> pn.viewable.Viewable:
- purpose_opts, self._tour_purpose_to_raw = column_options(
- self.state.get_summary_table_set(
- "stop_destination_purpose_by_tour_purpose", "weighted"
- )
- or [],
+ self.tour_purpose_sel = self.select(
"tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_purpose",
- "stop_destination_purpose_by_tour_purpose",
- "tour_purpose",
- "weighted",
- ),
- total_raw=None,
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- self.tour_purpose_sel = self.selector(
- "tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_opts or [self.TOTAL_PURPOSE_LABEL],
- value=(purpose_opts or [self.TOTAL_PURPOSE_LABEL])[0],
- ),
- label="Tour Purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
)
self._body = self.section(
"trip_stop_purpose_body",
@@ -139,28 +149,19 @@ def build_page(self) -> pn.viewable.Viewable:
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
+ def _purpose_options(self) -> list[str]:
purpose_opts, self._tour_purpose_to_raw = column_options(
- summaries["stop_destination_purpose_by_tour_purpose"],
+ self.data.summary(
+ "stop_destination_purpose_by_tour_purpose", self.weighting_key
+ )
+ or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_purpose",
- "stop_destination_purpose_by_tour_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw=None,
total_label=self.TOTAL_PURPOSE_LABEL,
)
- self.tour_purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.tour_purpose_sel.value not in self.tour_purpose_sel.options:
- self.tour_purpose_sel.value = self.tour_purpose_sel.options[0]
+ return purpose_opts or [self.TOTAL_PURPOSE_LABEL]
def _selected_purpose(self) -> tuple[str, str | None]:
display_purpose = self.tour_purpose_sel.value
@@ -173,10 +174,8 @@ def render_trip_purpose_chart(
raw_tour_purpose: str | None,
display_purpose: str,
) -> pn.viewable.Viewable:
- chart_data = self.get_filtered_view(
- "trip_purpose",
- raw_tour_purpose,
- factory=lambda: trip_purpose_chart_data(trip_purpose_list, raw_tour_purpose),
+ chart_data = self.query(
+ lambda: trip_purpose_chart_data(trip_purpose_list, raw_tour_purpose)
)
raw_values = ordered_category_values(
nonempty(trip_purpose_list),
@@ -196,16 +195,14 @@ def render_trip_purpose_chart(
column="trip_purpose",
ordered_values=label_values,
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="trip_purpose",
- y_col="trip_count",
- title=f"Trip Purpose - {display_purpose}",
- xaxis_title="Trip Purpose",
- yaxis_title="Trips",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x="trip_purpose",
+ y="trip_count",
+ title=self._trip_purpose_title(display_purpose),
+ x_title="Trip Purpose",
+ y_title="Trips",
+ category_order=label_values,
)
def render_stop_purpose_chart(
@@ -215,10 +212,8 @@ def render_stop_purpose_chart(
raw_tour_purpose: str | None,
display_purpose: str,
) -> pn.viewable.Viewable:
- chart_data = self.get_filtered_view(
- "stop_destination_purpose",
- raw_tour_purpose,
- factory=lambda: stop_purpose_chart_data(stop_purpose_list, raw_tour_purpose),
+ chart_data = self.query(
+ lambda: stop_purpose_chart_data(stop_purpose_list, raw_tour_purpose)
)
raw_values = ordered_category_values(
nonempty(stop_purpose_list),
@@ -238,23 +233,21 @@ def render_stop_purpose_chart(
column="stop_destination_purpose",
ordered_values=label_values,
)
- return bar_chart(
+ return self.plot.bar(
chart_data,
- x_col="stop_destination_purpose",
- y_col="stop_count",
- title=f"Stop Destination Purpose by Tour Purpose - {display_purpose}",
- xaxis_title="Stop Destination Purpose",
- yaxis_title="Stops",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=label_values,
+ x="stop_destination_purpose",
+ y="stop_count",
+ title=self._stop_purpose_title(display_purpose),
+ x_title="Stop Destination Purpose",
+ y_title="Stops",
+ category_order=label_values,
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
display_purpose, raw_tour_purpose = self._selected_purpose()
return [
@@ -265,33 +258,24 @@ def render_body(self):
sizing_mode="stretch_width",
),
pn.Row(
- self.render_trip_purpose_chart(
- summaries["trip_purpose_distribution"],
- raw_tour_purpose=raw_tour_purpose,
- display_purpose=display_purpose,
+ self.noted_view(
+ "trip_stop_purpose.trip_purpose",
+ self.render_trip_purpose_chart(
+ summaries["trip_purpose_distribution"],
+ raw_tour_purpose=raw_tour_purpose,
+ display_purpose=display_purpose,
+ ),
),
- self.render_stop_purpose_chart(
- summaries["stop_destination_purpose_by_tour_purpose"],
- raw_tour_purpose=raw_tour_purpose,
- display_purpose=display_purpose,
+ self.noted_view(
+ "trip_stop_purpose.stop_purpose",
+ self.render_stop_purpose_chart(
+ summaries["stop_destination_purpose_by_tour_purpose"],
+ raw_tour_purpose=raw_tour_purpose,
+ display_purpose=display_purpose,
+ ),
),
sizing_mode="stretch_width",
),
sizing_mode="stretch_width",
)
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="trip_stop_purpose",
- title="Trip and Stop Purpose",
- group_id="trip_summaries",
- order=47,
- page_cls=TripStopPurposePage,
- required_summary_ids=(
- "trip_purpose_distribution",
- "stop_destination_purpose_by_tour_purpose",
- ),
-)
-
-TripStopPurposePage.definition = PAGE
diff --git a/dashboard/pages/trip_summaries/trip_stop_time.py b/dashboard/pages/trip_summaries/trip_stop_time.py
index 5ac1675..0300587 100644
--- a/dashboard/pages/trip_summaries/trip_stop_time.py
+++ b/dashboard/pages/trip_summaries/trip_stop_time.py
@@ -5,11 +5,11 @@
import panel as pn
import polars as pl
-from dashboard.components import density_chart, selector_row
-from dashboard.helpers.category_helpers import column_options, nonempty
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import column_options
from dashboard.helpers.time_distance_helpers import max_timebin, timebin_label
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard import DashboardPage, dashboard_page
def profile_chart_frame(
@@ -42,65 +42,36 @@ def trip_stop_time_chart_data(
) -> tuple[list[tuple[str, pl.DataFrame]], list[tuple[str, pl.DataFrame]]]:
"""Build chart-ready trip and stop departure distributions for one purpose."""
observed_max_timebin = max_timebin(data_list)
- trip_data = []
- stop_data = []
- for label, df in nonempty(data_list):
- trip_data.append(
- (
- label,
- profile_chart_frame(
- df,
- value_col="departure_trip_count",
- purpose=tour_purpose,
- observed_max_timebin=observed_max_timebin,
- ),
+ view = RunTables.from_runs(data_list)
+
+ def profile(value_col: str) -> list[tuple[str, pl.DataFrame]]:
+ return view.map(
+ lambda frame: profile_chart_frame(
+ frame,
+ value_col=value_col,
+ purpose=tour_purpose,
+ observed_max_timebin=observed_max_timebin,
)
)
- stop_data.append(
- (
- label,
- profile_chart_frame(
- df,
- value_col="departure_stop_count",
- purpose=tour_purpose,
- observed_max_timebin=observed_max_timebin,
- ),
- )
- )
- return trip_data, stop_data
+
+ return profile("departure_trip_count"), profile("departure_stop_count")
+@dashboard_page(
+ page_id="trip_stop_time",
+ title="Trip and Stop Time",
+ group_id="trip_summaries",
+ order=49,
+ required_summary_ids=("trip_departure_time_by_purpose",),
+)
class TripStopTimePage(DashboardPage):
TOTAL_PURPOSE_LABEL = "All Tour Purposes"
def build_page(self) -> pn.viewable.Viewable:
- purpose_opts, self._purpose_to_raw = column_options(
- self.state.get_summary_table_set("trip_departure_time_by_purpose", "weighted")
- or [],
- "tour_purpose",
- category_id="tour_purpose",
- config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_time",
- "trip_departure_time_by_purpose",
- "tour_purpose",
- "weighted",
- ),
- total_raw="all_tour_purposes",
- total_label=self.TOTAL_PURPOSE_LABEL,
- )
- if not purpose_opts:
- purpose_opts = [self.TOTAL_PURPOSE_LABEL]
- self._purpose_to_raw = {self.TOTAL_PURPOSE_LABEL: "all_tour_purposes"}
- self.tour_purpose_sel = self.selector(
+ self.tour_purpose_sel = self.select(
"tour_purpose",
- widget=pn.widgets.Select(
- name="Tour Purpose",
- options=purpose_opts,
- value=purpose_opts[0],
- ),
- label="Tour Purpose",
+ "Tour Purpose",
+ options=self._purpose_options,
)
self._body = self.section(
"trip_stop_time_body",
@@ -114,28 +85,20 @@ def build_page(self) -> pn.viewable.Viewable:
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return
+ def _purpose_options(self) -> list[str]:
purpose_opts, self._purpose_to_raw = column_options(
- summaries["trip_departure_time_by_purpose"],
+ self.data.summary("trip_departure_time_by_purpose", self.weighting_key)
+ or [],
"tour_purpose",
category_id="tour_purpose",
config=self.config,
- state=self.state,
- cache_key=(
- "trip_stop_time",
- "trip_departure_time_by_purpose",
- "tour_purpose",
- self.weighting_key,
- ),
total_raw="all_tour_purposes",
total_label=self.TOTAL_PURPOSE_LABEL,
)
- self.tour_purpose_sel.options = purpose_opts or [self.TOTAL_PURPOSE_LABEL]
- if self.tour_purpose_sel.value not in self.tour_purpose_sel.options:
- self.tour_purpose_sel.value = self.tour_purpose_sel.options[0]
+ if not purpose_opts:
+ purpose_opts = [self.TOTAL_PURPOSE_LABEL]
+ self._purpose_to_raw = {self.TOTAL_PURPOSE_LABEL: "all_tour_purposes"}
+ return purpose_opts
def _selected_purpose(self) -> tuple[str, str]:
display_purpose = self.tour_purpose_sel.value
@@ -148,66 +111,53 @@ def render_time_chart(
*,
raw_purpose: str,
display_purpose: str,
- cache_key: str,
title: str,
y_col: str,
yaxis_title: str,
) -> pn.viewable.Viewable:
- trip_data, stop_data = self.get_filtered_view(
- "trip_stop_departure_time",
- raw_purpose,
- tuple(label for label, _ in data_list),
- factory=lambda: trip_stop_time_chart_data(data_list, raw_purpose),
+ trip_data, stop_data = self.query(
+ lambda: trip_stop_time_chart_data(data_list, raw_purpose)
)
chart_data = trip_data if y_col == "departure_trip_count" else stop_data
- return density_chart(
+ return self.plot.density(
chart_data,
- x_col="clock_time",
- y_col=y_col,
+ x="clock_time",
+ y=y_col,
title=f"{title} - {display_purpose}",
- xaxis_title="Clock Time (start at 03:00)",
- normalize=False,
- yaxis_title=yaxis_title,
- as_percent=self.as_percent,
+ x_title="Clock Time (start at 03:00)",
+ y_title=yaxis_title,
+ hover_x_title="Clock Time",
)
def render_body(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
+ summaries = self.data.summaries(*self.required_summary_ids)
+ if not all(summaries.values()):
return [self.summary_only_unavailable_card()]
display_purpose, raw_purpose = self._selected_purpose()
tod_list = summaries["trip_departure_time_by_purpose"]
return [
- self.render_time_chart(
- tod_list,
- raw_purpose=raw_purpose,
- display_purpose=display_purpose,
- cache_key="trip_departure",
- title="Trip Departure Time Distribution",
- y_col="departure_trip_count",
- yaxis_title="Trips",
+ self.noted_view(
+ "trip_stop_time.trips",
+ self.render_time_chart(
+ tod_list,
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ title="Trip Departure Time Distribution",
+ y_col="departure_trip_count",
+ yaxis_title="Trips",
+ ),
),
- self.render_time_chart(
- tod_list,
- raw_purpose=raw_purpose,
- display_purpose=display_purpose,
- cache_key="stop_departure",
- title="Stop Departure Time Distribution",
- y_col="departure_stop_count",
- yaxis_title="Stops",
+ self.noted_view(
+ "trip_stop_time.stops",
+ self.render_time_chart(
+ tod_list,
+ raw_purpose=raw_purpose,
+ display_purpose=display_purpose,
+ title="Stop Departure Time Distribution",
+ y_col="departure_stop_count",
+ yaxis_title="Stops",
+ ),
),
]
-
-
-PAGE = DashboardPageDefinition(
- page_id="trip_stop_time",
- title="Trip and Stop Time",
- group_id="trip_summaries",
- order=49,
- page_cls=TripStopTimePage,
- required_summary_ids=("trip_departure_time_by_purpose",),
-)
-
-TripStopTimePage.definition = PAGE
diff --git a/dashboard/pages/validation/_traffic/__init__.py b/dashboard/pages/validation/_traffic/__init__.py
new file mode 100644
index 0000000..28fc69d
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/__init__.py
@@ -0,0 +1,6 @@
+"""Private implementation package for Traffic Validation."""
+
+from .contracts import *
+from .transforms import *
+
+__all__ = [name for name in globals() if name.isupper() or not name.startswith("__")]
diff --git a/dashboard/pages/validation/_traffic/composition.py b/dashboard/pages/validation/_traffic/composition.py
new file mode 100644
index 0000000..230e17f
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/composition.py
@@ -0,0 +1,109 @@
+"""Page composition for Traffic Validation."""
+
+from __future__ import annotations
+
+import panel as pn
+
+from dashboard.rendering import selector_row
+
+from .contracts import *
+
+
+class TrafficPageCompositionMixin:
+ def build_page(self) -> pn.viewable.Viewable:
+ self.demo_facility_raw_by_label = {"All": "All"}
+ self.demo_period_sel = self.selector(
+ "demo_period",
+ widget=pn.widgets.Select(
+ name="Period",
+ options=list(DEMO_TRAFFIC_TIME_PERIODS),
+ value="Day",
+ ),
+ label="Period",
+ )
+ self.demo_facility_sel = self.select(
+ "demo_facility_type",
+ "Facility Type",
+ options=self._facility_options,
+ )
+ self.demo_top_period_sel = self.selector(
+ "demo_top_period",
+ widget=pn.widgets.Select(
+ name="Period",
+ options=list(DEMO_TRAFFIC_TIME_PERIODS),
+ value="Day",
+ ),
+ label="Period",
+ )
+ self.demo_top_n_sel = self.selector(
+ "demo_top_n",
+ widget=pn.widgets.Select(
+ name="Top N by Modeled Volume",
+ options=[10, 25, 50, 100],
+ value=25,
+ ),
+ label="Top N by Modeled Volume",
+ )
+ observed_fit = self.feature("observed_model_fit")
+ facility = self.feature("facility_summaries")
+ links = self.feature("link_tables")
+ screenlines = self.feature("screenlines")
+ self._external_volume_body = observed_fit.section(
+ "body",
+ selectors=(
+ "demo_period",
+ "demo_facility_type",
+ ),
+ render=self.render_demo_traffic_section,
+ )
+ self._facility_summary_body = facility.section(
+ "body",
+ render=self.render_demo_facility_summary_section,
+ )
+ self._link_volume_body = links.section(
+ "volume",
+ selectors=("demo_period",),
+ render=self.render_demo_link_volume_section,
+ )
+ self._external_top_body = links.section(
+ "top",
+ selectors=(
+ "demo_facility_type",
+ "demo_top_period",
+ "demo_top_n",
+ ),
+ render=self.render_demo_top_count_section,
+ )
+ self._screenline_body = screenlines.section(
+ "body",
+ render=self.render_screenline_flow_section,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## Traffic Validation"),
+ self.noted_section(
+ "traffic.facility_summary",
+ self._facility_summary_body,
+ ),
+ pn.pane.Markdown("### Traffic Volume Summaries"),
+ selector_row(
+ self.demo_period_sel,
+ self.demo_facility_sel,
+ ),
+ self._external_volume_body,
+ self.section_note(
+ "traffic.count_locations",
+ self._external_volume_body,
+ ),
+ self._link_volume_body,
+ self.section_note("traffic.link_volume", self._link_volume_body),
+ pn.pane.Markdown("### Top Count Locations by Modeled Volume"),
+ selector_row(self.demo_top_period_sel, self.demo_top_n_sel),
+ self._external_top_body,
+ self.section_note(
+ "traffic.top_count_locations",
+ self._external_top_body,
+ ),
+ pn.pane.Markdown("### Screenline Flow Summaries"),
+ self.noted_section("traffic.screenlines", self._screenline_body),
+ sizing_mode="stretch_width",
+ )
diff --git a/dashboard/pages/validation/_traffic/contracts.py b/dashboard/pages/validation/_traffic/contracts.py
new file mode 100644
index 0000000..7ec86bf
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/contracts.py
@@ -0,0 +1,10 @@
+"""Stable contracts for Traffic Validation."""
+
+DEMO_TRAFFIC_TIME_PERIODS = {
+ "AM": "am_vol",
+ "MD": "md_vol",
+ "PM": "pm_vol",
+ "Day": "day_vol",
+}
+
+FACILITY_TYPE_CATEGORY_ID = "facility_type"
diff --git a/dashboard/pages/validation/_traffic/features.py b/dashboard/pages/validation/_traffic/features.py
new file mode 100644
index 0000000..916fc10
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/features.py
@@ -0,0 +1,302 @@
+"""Feature rendering for Traffic Validation."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.category_helpers import label_category_data
+from dashboard.rendering import data_table
+
+from .contracts import *
+from .transforms import *
+
+
+class TrafficFeatureMixin:
+ def render_validation_chart(
+ self,
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ title: str,
+ detail: str,
+ missing_summary_id: str,
+ ) -> pn.viewable.Viewable:
+ if data_list is None:
+ return self.data_not_available_card(
+ detail=detail,
+ missing_items=[missing_summary_id],
+ )
+ chart_data = self.query(lambda: validation_chart_data(data_list))
+ return self.plot.scatter(
+ chart_data,
+ x="observed_volume",
+ y="modeled_volume",
+ title=title,
+ x_title="Observed Traffic Volume",
+ y_title="Modeled Traffic Volume",
+ )
+
+ def render_screenline_flow_section(self):
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+
+ return [
+ self.render_validation_chart(
+ self.data.summary("screenline_flow_comparisons", self.weighting_key),
+ title="Screenline Flow Comparisons",
+ detail="Screenline flow comparisons are unavailable.",
+ missing_summary_id="screenline_flow_comparisons",
+ )
+ ]
+
+ def render_demo_facility_summary_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return []
+
+ count_list = self.data.summary(
+ "count_location_counts_validation_summary", self.weighting_key
+ )
+ volume_list = self.data.summary(
+ "count_location_volumes_validation_summary", self.weighting_key
+ )
+ scatter_list = self.data.summary(
+ "count_location_scatter_validation_summary", self.weighting_key
+ )
+ fit_list = self.data.summary(
+ "count_location_fit_validation_summary", self.weighting_key
+ )
+ if not any((count_list, volume_list, scatter_list, fit_list)):
+ return []
+
+ # Keep this overview on unfiltered daily totals. The controls below it
+ # belong only to the Traffic Volume Summaries sections.
+ period = "Day"
+ volume_col = DEMO_TRAFFIC_TIME_PERIODS[str(period)]
+ facility_type = "All"
+ if scatter_list:
+ scatter_data = self.query(
+ lambda: demo_count_scatter_data(
+ scatter_list,
+ period=str(period),
+ facility_type=facility_type,
+ )
+ )
+ elif count_list and volume_list:
+ scatter_data = self.query(
+ lambda: demo_count_scatter_data_from_sources(
+ count_list,
+ volume_list,
+ volume_col=volume_col,
+ facility_type=facility_type,
+ )
+ )
+ else:
+ return []
+
+ if not scatter_data:
+ return []
+ facility_comparison = self.query(
+ lambda: demo_facility_comparison_table(
+ scatter_data,
+ fit_list,
+ period=str(period),
+ facility_type=facility_type,
+ config=self.config,
+ )
+ )
+ if not facility_comparison:
+ return []
+ return [
+ data_table(
+ facility_comparison,
+ title="Count Location Summary by Facility Type",
+ numeric_precision_by_column={"RMSE": 3, "R^2": 3},
+ column_sorters={"n": "number", "RMSE": "number", "R^2": "number"},
+ )
+ ]
+
+ def render_demo_traffic_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+
+ count_list = self.data.summary(
+ "count_location_counts_validation_summary", self.weighting_key
+ )
+ volume_list = self.data.summary(
+ "count_location_volumes_validation_summary", self.weighting_key
+ )
+ scatter_list = self.data.summary(
+ "count_location_scatter_validation_summary", self.weighting_key
+ )
+ fit_list = self.data.summary(
+ "count_location_fit_validation_summary", self.weighting_key
+ )
+ if not any((count_list, volume_list, scatter_list, fit_list)):
+ return []
+ period = self.demo_period_sel.value
+ volume_col = DEMO_TRAFFIC_TIME_PERIODS[str(period)]
+ facility_type = self.selected_facility_type_raw()
+ section: list[pn.viewable.Viewable] = []
+ if scatter_list:
+ scatter_data = self.query(
+ lambda: demo_count_scatter_data(
+ scatter_list,
+ period=str(period),
+ facility_type=facility_type,
+ )
+ )
+ fit_data = self.query(
+ lambda: demo_count_fit_line_data(
+ fit_list,
+ period=str(period),
+ facility_type=facility_type,
+ )
+ )
+ section.append(
+ self.plot.scatter(
+ scatter_data,
+ x="observed_volume",
+ y="modeled_volume",
+ title=f"Count Location Observed vs Modeled - {period}",
+ x_title="Observed Count",
+ y_title="Modeled Volume",
+ fit_overlays=fit_data,
+ one_to_one=True,
+ panel_aspect_ratio=1.0,
+ )
+ )
+ elif count_list and volume_list:
+ scatter_data = self.query(
+ lambda: demo_count_scatter_data_from_sources(
+ count_list,
+ volume_list,
+ volume_col=volume_col,
+ facility_type=facility_type,
+ )
+ )
+ section.append(
+ self.plot.scatter(
+ scatter_data,
+ x="observed_volume",
+ y="modeled_volume",
+ title=f"Count Location Observed vs Modeled - {period}",
+ x_title="Observed Count",
+ y_title="Modeled Volume",
+ one_to_one=True,
+ panel_aspect_ratio=1.0,
+ )
+ )
+ else:
+ section.append(
+ self.data_not_available_card(
+ detail=(
+ "Count-location validation counts and volumes are both "
+ "required for this scatter plot."
+ ),
+ missing_items=[
+ "count_location_counts_validation_summary",
+ "count_location_volumes_validation_summary",
+ ],
+ )
+ )
+ return section
+
+ def render_demo_link_volume_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return []
+
+ link_list = self.data.summary("link_validation_summary", self.weighting_key)
+ if link_list is None:
+ return [
+ self.data_not_available_card(
+ detail="Link validation summaries are unavailable.",
+ missing_items=["link_validation_summary"],
+ )
+ ]
+
+ period = self.demo_period_sel.value
+ aggregate_data = self.query(
+ lambda: demo_link_aggregate_data(
+ link_list,
+ volume_col=DEMO_TRAFFIC_TIME_PERIODS[str(period)],
+ facility_type="All",
+ config=self.config,
+ )
+ )
+ return [
+ self.plot.bar(
+ aggregate_data,
+ x="facility_type_label",
+ y="volume",
+ title=f"Link Volume by Facility Type - {period}",
+ x_title="Facility Type",
+ y_title="Volume",
+ category_order=[
+ option
+ for option in self.demo_facility_sel.options
+ if option != "All"
+ ],
+ show_legend=True,
+ )
+ ]
+
+ def render_demo_top_count_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+
+ link_list = self.data.summary("link_validation_summary", self.weighting_key)
+ count_list = self.data.summary(
+ "count_location_counts_validation_summary", self.weighting_key
+ )
+ volume_list = self.data.summary(
+ "count_location_volumes_validation_summary", self.weighting_key
+ )
+ if not any((link_list, count_list, volume_list)):
+ return []
+
+ facility_type = self.selected_facility_type_raw()
+ top_period = self.demo_top_period_sel.value
+ top_volume_col = DEMO_TRAFFIC_TIME_PERIODS[str(top_period)]
+ top_n = int(self.demo_top_n_sel.value)
+
+ if count_list is not None and volume_list is not None:
+ volume_comparison = self.query(
+ lambda: label_category_data(
+ demo_volume_comparison_table(
+ count_list,
+ volume_list,
+ link_list=link_list,
+ volume_col=top_volume_col,
+ facility_type=facility_type,
+ top_n=top_n,
+ ),
+ source_col="facility_type",
+ category_id=FACILITY_TYPE_CATEGORY_ID,
+ config=self.config,
+ target_col="facility_type",
+ )
+ )
+ return [
+ pn.pane.Markdown(
+ "#### Observed vs Modeled Volumes - "
+ f"{top_period} (Top {top_n} by Modeled Volume)"
+ ),
+ data_table(
+ volume_comparison,
+ column_sorters={"Difference": "number"},
+ ),
+ ]
+ if link_list is not None:
+ return [
+ self.data_not_available_card(
+ detail=(
+ "Count-location validation counts and volumes are both "
+ "required for this comparison table."
+ ),
+ missing_items=[
+ "count_location_counts_validation_summary",
+ "count_location_volumes_validation_summary",
+ ],
+ )
+ ]
+ return []
diff --git a/dashboard/pages/validation/_traffic/selector_domains.py b/dashboard/pages/validation/_traffic/selector_domains.py
new file mode 100644
index 0000000..112e4bd
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/selector_domains.py
@@ -0,0 +1,39 @@
+"""Selector domains for Traffic Validation."""
+
+from __future__ import annotations
+
+from .transforms import demo_facility_options
+
+
+class TrafficSelectorDomainsMixin:
+ def _facility_options(self) -> list[str]:
+ """Return facility labels and refresh their raw-value mapping."""
+ demo_link_list = self.data.summary(
+ "link_validation_summary", self.weighting_key
+ )
+ demo_count_list = self.data.summary(
+ "count_location_counts_validation_summary", self.weighting_key
+ )
+ demo_volume_list = self.data.summary(
+ "count_location_volumes_validation_summary", self.weighting_key
+ )
+ demo_scatter_list = self.data.summary(
+ "count_location_scatter_validation_summary", self.weighting_key
+ )
+ demo_fit_list = self.data.summary(
+ "count_location_fit_validation_summary", self.weighting_key
+ )
+ facility_opts, self.demo_facility_raw_by_label = demo_facility_options(
+ demo_link_list,
+ demo_count_list,
+ demo_volume_list,
+ demo_scatter_list,
+ demo_fit_list,
+ config=self.config,
+ )
+ return facility_opts
+
+ def selected_facility_type_raw(self) -> str:
+ selected = str(self.demo_facility_sel.value)
+ raw_value = self.demo_facility_raw_by_label.get(selected, selected)
+ return "All" if raw_value is None else str(raw_value)
diff --git a/dashboard/pages/validation/_traffic/transforms.py b/dashboard/pages/validation/_traffic/transforms.py
new file mode 100644
index 0000000..5c5e6c4
--- /dev/null
+++ b/dashboard/pages/validation/_traffic/transforms.py
@@ -0,0 +1,501 @@
+"""Pure transformations for Traffic Validation."""
+
+from __future__ import annotations
+
+import math
+
+import polars as pl
+
+from dashboard.helpers.category_helpers import (
+ label_category_frame,
+ nonempty,
+ raw_display_options,
+)
+from runtime.config import Config
+
+from .contracts import *
+
+
+def validation_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+) -> list[tuple[str, pl.DataFrame]]:
+ """Aggregate one validation summary list to one observed/modeled point per id."""
+ out = []
+ for label, df in nonempty(data_list):
+ filtered = df
+ id_col = None
+ if "count_location_id" in filtered.columns:
+ id_col = "count_location_id"
+ elif "screenline_id" in filtered.columns:
+ id_col = "screenline_id"
+ if id_col is not None:
+ filtered = (
+ filtered.group_by(id_col)
+ .agg(
+ observed_volume=pl.col("observed_volume").sum(),
+ modeled_volume=pl.col("modeled_volume").sum(),
+ )
+ .sort(id_col)
+ )
+ out.append((label, filtered))
+ return out
+
+
+def demo_facility_options(
+ *data_lists: list[tuple[str, pl.DataFrame]] | None,
+ config: Config,
+) -> tuple[list[str], dict[str, str | None]]:
+ values: list[str] = []
+ seen: set[str] = set()
+ for data_list in data_lists:
+ for _, df in nonempty(data_list or []):
+ for column in ("facility_type", "FACTYPE"):
+ if column not in df.columns:
+ continue
+ for value in df[column].drop_nulls().cast(pl.Utf8).to_list():
+ value_str = str(value)
+ if value_str == "All" or value_str in seen:
+ continue
+ values.append(value_str)
+ seen.add(value_str)
+ ordered_values = config.ordered_values(FACILITY_TYPE_CATEGORY_ID, values)
+ return raw_display_options(
+ ordered_values,
+ category_id=FACILITY_TYPE_CATEGORY_ID,
+ config=config,
+ total_raw="All",
+ total_label="All",
+ )
+
+
+def _filter_facility(df: pl.DataFrame, facility_type: str) -> pl.DataFrame:
+ if facility_type == "All" or "FACTYPE" not in df.columns:
+ return df
+ return df.with_columns(pl.col("FACTYPE").cast(pl.Utf8)).filter(
+ pl.col("FACTYPE") == facility_type
+ )
+
+
+def demo_count_scatter_data(
+ scatter_list: list[tuple[str, pl.DataFrame]],
+ *,
+ period: str,
+ facility_type: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(scatter_list):
+ required = {"facility_type", "period", "observed_volume", "modeled_volume"}
+ if not required.issubset(set(df.columns)):
+ continue
+ filtered = df.with_columns(
+ pl.col("facility_type").cast(pl.Utf8),
+ pl.col("period").cast(pl.Utf8),
+ ).filter(pl.col("period") == period)
+ if facility_type != "All":
+ filtered = filtered.filter(pl.col("facility_type") == facility_type)
+ out.append(
+ (
+ label,
+ filtered.select(
+ "id",
+ "facility_type",
+ "period",
+ "observed_volume",
+ "modeled_volume",
+ ).sort("id"),
+ )
+ )
+ return out
+
+
+def demo_count_scatter_data_from_sources(
+ count_list: list[tuple[str, pl.DataFrame]],
+ volume_list: list[tuple[str, pl.DataFrame]],
+ *,
+ volume_col: str,
+ facility_type: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ volume_by_label = dict(volume_list)
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, count_df in nonempty(count_list):
+ volume_df = volume_by_label.get(label)
+ if volume_df is None or volume_df.is_empty():
+ continue
+ count_df = _filter_facility(count_df, facility_type)
+ volume_df = _filter_facility(volume_df, facility_type)
+ if volume_col not in count_df.columns or volume_col not in volume_df.columns:
+ continue
+ joined = (
+ count_df.select(
+ "id",
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.col(volume_col).alias("observed_volume"),
+ )
+ .join(
+ volume_df.select(
+ "id",
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.col(volume_col).alias("modeled_volume"),
+ ),
+ on=["id", "facility_type"],
+ how="inner",
+ )
+ .sort("id")
+ )
+ out.append((label, joined))
+ return out
+
+
+def demo_count_fit_line_data(
+ fit_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ period: str,
+ facility_type: str,
+) -> list[tuple[str, pl.DataFrame]]:
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(fit_list or []):
+ required = {
+ "facility_type",
+ "period",
+ "slope",
+ "intercept",
+ "r_squared",
+ "n_locations",
+ "observed_min",
+ "observed_max",
+ "equation_label",
+ "r_squared_label",
+ }
+ if not required.issubset(set(df.columns)):
+ continue
+ selected = (
+ df.with_columns(
+ pl.col("facility_type").cast(pl.Utf8),
+ pl.col("period").cast(pl.Utf8),
+ )
+ .filter(
+ (pl.col("period") == period)
+ & (pl.col("facility_type") == str(facility_type))
+ & pl.col("slope").is_not_null()
+ & pl.col("intercept").is_not_null()
+ & pl.col("observed_min").is_not_null()
+ & pl.col("observed_max").is_not_null()
+ )
+ .head(1)
+ )
+ if selected.is_empty():
+ continue
+ row = selected.row(0, named=True)
+ observed_min = float(row["observed_min"])
+ observed_max = float(row["observed_max"])
+ slope = float(row["slope"])
+ intercept = float(row["intercept"])
+ annotation = (
+ f"{label}
{row['equation_label']}
"
+ f"{row['r_squared_label']}
n = {int(row['n_locations'])}"
+ )
+ out.append(
+ (
+ label,
+ pl.DataFrame(
+ {
+ "observed_volume": [observed_min, observed_max],
+ "modeled_volume": [
+ slope * observed_min + intercept,
+ slope * observed_max + intercept,
+ ],
+ "annotation": [annotation, annotation],
+ }
+ ),
+ )
+ )
+ return out
+
+
+def _r_squared_from_points(points: pl.DataFrame) -> float | None:
+ if points.height < 2:
+ return None
+ x = [float(value) for value in points["observed_volume"].to_list()]
+ y = [float(value) for value in points["modeled_volume"].to_list()]
+ x_mean = sum(x) / len(x)
+ y_mean = sum(y) / len(y)
+ ss_xx = sum((value - x_mean) ** 2 for value in x)
+ if math.isclose(ss_xx, 0.0):
+ return None
+ ss_xy = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y))
+ slope = ss_xy / ss_xx
+ intercept = y_mean - slope * x_mean
+ fitted = [slope * xi + intercept for xi in x]
+ sse = sum((yi - yhat) ** 2 for yi, yhat in zip(y, fitted))
+ ss_yy = sum((yi - y_mean) ** 2 for yi in y)
+ if math.isclose(ss_yy, 0.0):
+ return 1.0 if math.isclose(sse, 0.0) else 0.0
+ return max(0.0, min(1.0, 1.0 - sse / ss_yy))
+
+
+def _fit_r_squared_lookup(
+ fit_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ label: str,
+ period: str,
+ facility_type: str,
+) -> dict[str, float]:
+ if not fit_list:
+ return {}
+ fit_by_label = dict(fit_list)
+ fit_df = fit_by_label.get(label)
+ if fit_df is None or fit_df.is_empty():
+ return {}
+ required = {"facility_type", "period", "r_squared"}
+ if not required.issubset(set(fit_df.columns)):
+ return {}
+ selected = fit_df.with_columns(
+ pl.col("facility_type").cast(pl.Utf8),
+ pl.col("period").cast(pl.Utf8),
+ ).filter(
+ (pl.col("period") == period)
+ & (pl.col("facility_type") != "All")
+ & pl.col("r_squared").is_not_null()
+ )
+ if facility_type != "All":
+ selected = selected.filter(pl.col("facility_type") == facility_type)
+ return {
+ str(row["facility_type"]): float(row["r_squared"])
+ for row in selected.select("facility_type", "r_squared").iter_rows(named=True)
+ }
+
+
+def demo_facility_comparison_table(
+ scatter_data: list[tuple[str, pl.DataFrame]],
+ fit_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ period: str,
+ facility_type: str,
+ config: Config,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Aggregate count-location observed/modeled comparisons by facility type."""
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(scatter_data):
+ required = {"facility_type", "observed_volume", "modeled_volume"}
+ if df is None or df.is_empty() or not required.issubset(set(df.columns)):
+ continue
+ select_exprs = [
+ pl.col("facility_type").cast(pl.Utf8),
+ pl.col("observed_volume").cast(pl.Float64),
+ pl.col("modeled_volume").cast(pl.Float64),
+ ]
+ if "id" in df.columns:
+ select_exprs.insert(0, pl.col("id").cast(pl.Utf8).alias("id"))
+ points = (
+ df.select(*select_exprs)
+ .drop_nulls(["facility_type", "observed_volume", "modeled_volume"])
+ .filter(pl.col("facility_type") != "All")
+ )
+ if facility_type != "All":
+ points = points.filter(pl.col("facility_type") == facility_type)
+ if points.is_empty():
+ continue
+
+ r_squared_lookup = _fit_r_squared_lookup(
+ fit_list,
+ label=label,
+ period=period,
+ facility_type=facility_type,
+ )
+ facility_types = config.ordered_values(
+ FACILITY_TYPE_CATEGORY_ID,
+ [str(value) for value in points["facility_type"].unique().to_list()],
+ )
+ rows: list[dict[str, object]] = []
+ for raw_facility_type in facility_types:
+ facility_points = points.filter(
+ pl.col("facility_type") == raw_facility_type
+ )
+ if facility_points.is_empty():
+ continue
+ observed = [
+ float(value) for value in facility_points["observed_volume"].to_list()
+ ]
+ modeled = [
+ float(value) for value in facility_points["modeled_volume"].to_list()
+ ]
+ total_observed = sum(observed)
+ total_modeled = sum(modeled)
+ n_locations = (
+ facility_points["id"].n_unique()
+ if "id" in facility_points.columns
+ else facility_points.height
+ )
+ differences = [model - observe for observe, model in zip(observed, modeled)]
+ rmse = math.sqrt(
+ sum(difference**2 for difference in differences) / len(differences)
+ )
+ percent_value = (
+ None
+ if total_observed == 0.0
+ else ((total_modeled - total_observed) / total_observed) * 100.0
+ )
+ percent_difference = (
+ "" if percent_value is None else f"{percent_value:.2f}%"
+ )
+ rows.append(
+ {
+ "Facility Type": config.label_value(
+ FACILITY_TYPE_CATEGORY_ID,
+ raw_facility_type,
+ ),
+ "n": int(n_locations),
+ "Total Observed Count": total_observed,
+ "Total Modeled Count": total_modeled,
+ "% Difference": percent_difference,
+ "RMSE": rmse,
+ "R^2": r_squared_lookup.get(raw_facility_type)
+ if raw_facility_type in r_squared_lookup
+ else _r_squared_from_points(facility_points),
+ }
+ )
+ if rows:
+ out.append((label, pl.DataFrame(rows)))
+ return out
+
+
+def demo_link_aggregate_data(
+ link_list: list[tuple[str, pl.DataFrame]],
+ *,
+ volume_col: str,
+ facility_type: str,
+ config: Config | None = None,
+) -> list[tuple[str, pl.DataFrame]]:
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(link_list):
+ if volume_col not in df.columns:
+ continue
+ filtered = _filter_facility(df, facility_type)
+ chart_df = (
+ filtered.with_columns(pl.col("FACTYPE").cast(pl.Utf8))
+ .group_by("FACTYPE")
+ .agg(pl.col(volume_col).sum().alias("volume"))
+ .sort("FACTYPE")
+ )
+ if config is not None:
+ chart_df = label_category_frame(
+ chart_df,
+ source_col="FACTYPE",
+ category_id=FACILITY_TYPE_CATEGORY_ID,
+ config=config,
+ target_col="facility_type_label",
+ )
+ out.append((label, chart_df))
+ return out
+
+
+def demo_volume_comparison_table(
+ count_list: list[tuple[str, pl.DataFrame]],
+ volume_list: list[tuple[str, pl.DataFrame]],
+ *,
+ link_list: list[tuple[str, pl.DataFrame]] | None = None,
+ volume_col: str,
+ facility_type: str,
+ top_n: int,
+) -> list[tuple[str, pl.DataFrame]]:
+ volume_by_label = dict(volume_list)
+ link_by_label = dict(link_list or [])
+ quantity_a_column = "Observed Link Volume"
+ quantity_b_column = "Modeled Link Volume"
+ difference_column = "Difference"
+ percent_difference_column = "% Difference"
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, count_df in nonempty(count_list):
+ volume_df = volume_by_label.get(label)
+ if volume_df is None or volume_df.is_empty():
+ continue
+ if volume_col not in count_df.columns or volume_col not in volume_df.columns:
+ continue
+ required = {"id", "FACTYPE"}
+ if not required.issubset(count_df.columns) or not required.issubset(
+ volume_df.columns
+ ):
+ continue
+
+ count_filtered = _filter_facility(count_df, facility_type)
+ volume_filtered = _filter_facility(volume_df, facility_type)
+ joined = (
+ count_filtered.select(
+ pl.col("id").cast(pl.Int64, strict=False),
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.col(volume_col).cast(pl.Float64).alias("_quantity_a"),
+ )
+ .join(
+ volume_filtered.select(
+ pl.col("id").cast(pl.Int64, strict=False),
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.col(volume_col).cast(pl.Float64).alias("_quantity_b"),
+ ),
+ on=["id", "facility_type"],
+ how="inner",
+ )
+ .filter(
+ pl.col("id").is_not_null()
+ & pl.col("facility_type").is_not_null()
+ & pl.col("_quantity_a").is_not_null()
+ & pl.col("_quantity_b").is_not_null()
+ )
+ )
+ link_df = link_by_label.get(label)
+ has_link_metadata = link_df is not None and {
+ "id",
+ "From_Node",
+ "To_Node",
+ }.issubset(link_df.columns)
+ if has_link_metadata:
+ link_metadata = (
+ link_df.select(
+ pl.col("id").cast(pl.Int64, strict=False),
+ pl.col("From_Node"),
+ pl.col("To_Node"),
+ )
+ .drop_nulls("id")
+ .unique("id")
+ )
+ joined = joined.join(link_metadata, on="id", how="left")
+
+ joined = joined.sort(["_quantity_b", "id"], descending=[True, False]).head(
+ top_n
+ )
+ metadata_columns: list[str] = []
+ if has_link_metadata:
+ for column in ("From_Node", "To_Node"):
+ if joined.select(pl.col(column).is_not_null().any()).item():
+ metadata_columns.append(column)
+ rows = []
+ for row in joined.iter_rows(named=True):
+ observed = float(row["_quantity_a"])
+ modeled = float(row["_quantity_b"])
+ difference = modeled - observed
+ percent_difference = (
+ "" if observed == 0.0 else f"{(difference / observed) * 100.0:.2f}%"
+ )
+ table_row = {
+ "link_id": row["id"],
+ "facility_type": row["facility_type"],
+ quantity_a_column: observed,
+ quantity_b_column: modeled,
+ difference_column: difference,
+ percent_difference_column: percent_difference,
+ }
+ for metadata_column in metadata_columns:
+ table_row[metadata_column] = row.get(metadata_column)
+ rows.append(table_row)
+ columns = [
+ "link_id",
+ "facility_type",
+ *metadata_columns,
+ quantity_a_column,
+ quantity_b_column,
+ difference_column,
+ percent_difference_column,
+ ]
+ table = pl.DataFrame(rows).select(columns) if rows else pl.DataFrame()
+ if not table.is_empty():
+ out.append((label, table))
+ return out
diff --git a/dashboard/pages/validation/_vmt/__init__.py b/dashboard/pages/validation/_vmt/__init__.py
new file mode 100644
index 0000000..20715b1
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/__init__.py
@@ -0,0 +1,22 @@
+"""Private implementation package for the VMT validation page."""
+
+from .contracts import *
+from .overview import vmt_overview_table_data
+from .segmented import (
+ _chart_category_order,
+ _ordered_values,
+ _selector_values,
+ non_motorized_mode_options,
+ non_motorized_vmt_chart_data,
+ personal_auto_mode_options,
+ personal_auto_vmt_chart_data,
+)
+from .wide_tod import (
+ demo_commercial_filter_options,
+ demo_commercial_vehicle_chart_data,
+ external_travel_chart_data,
+ external_travel_filter_options,
+ wide_tod_chart_data,
+)
+
+__all__ = [name for name in globals() if name.isupper() or not name.startswith("__")]
diff --git a/dashboard/pages/validation/_vmt/composition.py b/dashboard/pages/validation/_vmt/composition.py
new file mode 100644
index 0000000..4b1f6af
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/composition.py
@@ -0,0 +1,359 @@
+"""Page composition for the VMT validation dashboard."""
+
+from __future__ import annotations
+
+import panel as pn
+
+from dashboard.helpers.geography_helpers import (
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ ALL_GEOGRAPHY_TYPES_VALUE,
+ GEOGRAPHY_NAME_SELECTOR_LABEL,
+ GEOGRAPHY_TYPE_SELECTOR_LABEL,
+)
+from dashboard.rendering import selector_row
+
+from . import *
+
+
+class VmtPageCompositionMixin:
+ def build_page(self) -> pn.viewable.Viewable:
+ self.personal_vmt_geo_type_raw_by_label = {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ }
+ self.personal_vmt_geo_raw_by_label = {"All Geographies": "All"}
+ self.personal_vmt_mode_raw_by_label = {"All": "All"}
+ self.non_motorized_vmt_geo_type_raw_by_label = {
+ ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE
+ }
+ self.non_motorized_vmt_geo_raw_by_label = {"All Geographies": "All"}
+ self.non_motorized_vmt_mode_raw_by_label = {"All": "All"}
+ self.demo_commercial_vehicle_type_raw_by_label = {"All": "All"}
+ self.external_travel_trip_purpose_raw_by_label = {"All": "All"}
+ self.personal_vmt_breakdown_sel = self.selector(
+ "personal_auto_vmt_breakdown",
+ widget=pn.widgets.Select(
+ name="Breakdown",
+ options=list(PERSONAL_AUTO_VMT_BREAKDOWN_COLUMNS),
+ value="Time Period",
+ ),
+ label="Breakdown",
+ )
+ self.personal_vmt_geography_type_sel = self.selector(
+ "personal_auto_vmt_geography_type",
+ widget=pn.widgets.Select(
+ name=GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=[ALL_GEOGRAPHY_TYPES_LABEL],
+ value=ALL_GEOGRAPHY_TYPES_LABEL,
+ ),
+ label=GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._personal_geography_type_options,
+ )
+ self.personal_vmt_geography_sel = self.selector(
+ "personal_auto_vmt_geography",
+ widget=pn.widgets.Select(
+ name=GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=["All Geographies"],
+ value="All Geographies",
+ ),
+ label=GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=self._personal_geography_options,
+ )
+ self.personal_vmt_time_period_sel = self.selector(
+ "personal_auto_vmt_time_period",
+ widget=pn.widgets.Select(
+ name="Time Period",
+ options=["All"],
+ value="All",
+ ),
+ label="Time Period",
+ options=self._personal_time_period_options,
+ )
+ self.personal_vmt_mode_sel = self.selector(
+ "personal_auto_vmt_mode",
+ widget=pn.widgets.Select(
+ name="Mode",
+ options=["All"],
+ value="All",
+ ),
+ label="Mode",
+ options=self._personal_mode_options,
+ )
+ self.personal_vmt_income_segment_sel = self.selector(
+ "personal_auto_vmt_income_segment",
+ widget=pn.widgets.Select(
+ name="Income Segment",
+ options=["All"],
+ value="All",
+ ),
+ label="Income Segment",
+ options=self._personal_income_options,
+ )
+ self.personal_vmt_household_size_sel = self.selector(
+ "personal_auto_vmt_household_size",
+ widget=pn.widgets.Select(
+ name="Household Size",
+ options=["All"],
+ value="All",
+ ),
+ label="Household Size",
+ options=self._personal_household_size_options,
+ )
+ self.non_motorized_vmt_breakdown_sel = self.selector(
+ "non_motorized_vmt_breakdown",
+ widget=pn.widgets.Select(
+ name="Breakdown",
+ options=list(PERSONAL_AUTO_VMT_BREAKDOWN_COLUMNS),
+ value="Time Period",
+ ),
+ label="Breakdown",
+ )
+ self.non_motorized_vmt_geography_type_sel = self.selector(
+ "non_motorized_vmt_geography_type",
+ widget=pn.widgets.Select(
+ name=GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=[ALL_GEOGRAPHY_TYPES_LABEL],
+ value=ALL_GEOGRAPHY_TYPES_LABEL,
+ ),
+ label=GEOGRAPHY_TYPE_SELECTOR_LABEL,
+ options=self._non_motorized_geography_type_options,
+ )
+ self.non_motorized_vmt_geography_sel = self.selector(
+ "non_motorized_vmt_geography",
+ widget=pn.widgets.Select(
+ name=GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=["All Geographies"],
+ value="All Geographies",
+ ),
+ label=GEOGRAPHY_NAME_SELECTOR_LABEL,
+ options=self._non_motorized_geography_options,
+ )
+ self.non_motorized_vmt_time_period_sel = self.selector(
+ "non_motorized_vmt_time_period",
+ widget=pn.widgets.Select(
+ name="Time Period",
+ options=["All"],
+ value="All",
+ ),
+ label="Time Period",
+ options=self._non_motorized_time_period_options,
+ )
+ self.non_motorized_vmt_mode_sel = self.selector(
+ "non_motorized_vmt_mode",
+ widget=pn.widgets.Select(
+ name="Mode",
+ options=["All"],
+ value="All",
+ ),
+ label="Mode",
+ options=self._non_motorized_mode_options,
+ )
+ self.non_motorized_vmt_income_segment_sel = self.selector(
+ "non_motorized_vmt_income_segment",
+ widget=pn.widgets.Select(
+ name="Income Segment",
+ options=["All"],
+ value="All",
+ ),
+ label="Income Segment",
+ options=self._non_motorized_income_options,
+ )
+ self.non_motorized_vmt_household_size_sel = self.selector(
+ "non_motorized_vmt_household_size",
+ widget=pn.widgets.Select(
+ name="Household Size",
+ options=["All"],
+ value="All",
+ ),
+ label="Household Size",
+ options=self._non_motorized_household_size_options,
+ )
+ self.demo_commercial_metric_sel = self.selector(
+ "demo_commercial_metric",
+ widget=pn.widgets.Select(
+ name="Commercial Vehicle Metric",
+ options=["Trips", "VMT"],
+ value="Trips",
+ ),
+ label="Commercial Vehicle Metric",
+ )
+ self.demo_commercial_breakdown_sel = self.selector(
+ "demo_commercial_breakdown",
+ widget=pn.widgets.Select(
+ name="Commercial Vehicle Breakdown",
+ options=EXTERNAL_COMMERCIAL_BREAKDOWN_OPTIONS,
+ value=EXTERNAL_COMMERCIAL_BREAKDOWN_OPTIONS[0],
+ ),
+ label="Commercial Vehicle Breakdown",
+ )
+ self.demo_commercial_vehicle_type_sel = self.selector(
+ "demo_commercial_vehicle_type",
+ widget=pn.widgets.Select(
+ name="Commercial Vehicle Type",
+ options=["All"],
+ value="All",
+ ),
+ label="Commercial Vehicle Type",
+ options=self._commercial_vehicle_type_options,
+ )
+ self.demo_commercial_time_period_sel = self.selector(
+ "demo_commercial_time_period",
+ widget=pn.widgets.Select(
+ name="Time Period",
+ options=[EXTERNAL_COMMERCIAL_DAILY_PERIOD],
+ value=EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ ),
+ label="Time Period",
+ options=self._commercial_time_period_options,
+ )
+ self.external_travel_metric_sel = self.selector(
+ "external_travel_metric",
+ widget=pn.widgets.Select(
+ name="External Travel Metric",
+ options=["Trips", "VMT"],
+ value="Trips",
+ ),
+ label="External Travel Metric",
+ )
+ self.external_travel_breakdown_sel = self.selector(
+ "external_travel_breakdown",
+ widget=pn.widgets.Select(
+ name="External Travel Breakdown",
+ options=EXTERNAL_TRAVEL_BREAKDOWN_OPTIONS,
+ value=EXTERNAL_TRAVEL_BREAKDOWN_OPTIONS[0],
+ ),
+ label="External Travel Breakdown",
+ )
+ self.external_travel_trip_purpose_sel = self.selector(
+ "external_travel_trip_purpose",
+ widget=pn.widgets.Select(
+ name="Trip Purpose",
+ options=["All"],
+ value="All",
+ ),
+ label="Trip Purpose",
+ options=self._external_trip_purpose_options,
+ )
+ self.external_travel_time_period_sel = self.selector(
+ "external_travel_time_period",
+ widget=pn.widgets.Select(
+ name="Time Period",
+ options=[EXTERNAL_COMMERCIAL_DAILY_PERIOD],
+ value=EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ ),
+ label="Time Period",
+ options=self._external_time_period_options,
+ )
+ comparison = self.feature("comparison")
+ personal_auto = self.feature("personal_auto")
+ non_motorized = self.feature("non_motorized")
+ commercial = self.feature("commercial")
+ external = self.feature("external")
+ bicycle = self.feature("bicycle")
+ self._personal_vmt_body = personal_auto.section(
+ "body",
+ selectors=(
+ "personal_auto_vmt_breakdown",
+ "personal_auto_vmt_geography_type",
+ "personal_auto_vmt_geography",
+ "personal_auto_vmt_time_period",
+ "personal_auto_vmt_mode",
+ "personal_auto_vmt_income_segment",
+ "personal_auto_vmt_household_size",
+ ),
+ render=self.render_personal_auto_vmt_section,
+ )
+ self._non_motorized_vmt_body = non_motorized.section(
+ "body",
+ selectors=(
+ "non_motorized_vmt_breakdown",
+ "non_motorized_vmt_geography_type",
+ "non_motorized_vmt_geography",
+ "non_motorized_vmt_time_period",
+ "non_motorized_vmt_mode",
+ "non_motorized_vmt_income_segment",
+ "non_motorized_vmt_household_size",
+ ),
+ render=self.render_non_motorized_vmt_section,
+ )
+ self._body = commercial.section(
+ "body",
+ selectors=(
+ "demo_commercial_metric",
+ "demo_commercial_breakdown",
+ "demo_commercial_vehicle_type",
+ "demo_commercial_time_period",
+ ),
+ render=self.render_commercial_vmt_section,
+ )
+ self._external_vmt_body = external.section(
+ "body",
+ selectors=(
+ "external_travel_metric",
+ "external_travel_breakdown",
+ "external_travel_trip_purpose",
+ "external_travel_time_period",
+ ),
+ render=self.render_external_vmt_section,
+ )
+ self._bicycle_body = bicycle.section(
+ "body",
+ render=self.render_bicycle_section,
+ )
+ self._vmt_overview_body = comparison.section(
+ "body",
+ render=self.render_vmt_overview_section,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## VMT Validation"),
+ pn.pane.Markdown("### VMT Overview"),
+ self.noted_section("vmt.overview", self._vmt_overview_body),
+ pn.pane.Markdown("### Personal Auto VMT"),
+ selector_row(
+ self.personal_vmt_breakdown_sel,
+ self.personal_vmt_geography_type_sel,
+ self.personal_vmt_geography_sel,
+ ),
+ selector_row(
+ self.personal_vmt_time_period_sel,
+ self.personal_vmt_mode_sel,
+ self.personal_vmt_income_segment_sel,
+ self.personal_vmt_household_size_sel,
+ ),
+ self._personal_vmt_body,
+ self.section_note("vmt.personal_auto", self._personal_vmt_body),
+ pn.pane.Markdown("### Non-Motorized VMT"),
+ selector_row(
+ self.non_motorized_vmt_breakdown_sel,
+ self.non_motorized_vmt_geography_type_sel,
+ self.non_motorized_vmt_geography_sel,
+ ),
+ selector_row(
+ self.non_motorized_vmt_time_period_sel,
+ self.non_motorized_vmt_mode_sel,
+ self.non_motorized_vmt_income_segment_sel,
+ self.non_motorized_vmt_household_size_sel,
+ ),
+ self._non_motorized_vmt_body,
+ self.section_note("vmt.non_motorized", self._non_motorized_vmt_body),
+ pn.pane.Markdown("### External VMT and Travel"),
+ selector_row(
+ self.external_travel_metric_sel,
+ self.external_travel_breakdown_sel,
+ self.external_travel_trip_purpose_sel,
+ self.external_travel_time_period_sel,
+ ),
+ self._external_vmt_body,
+ self.section_note("vmt.external_travel", self._external_vmt_body),
+ pn.pane.Markdown("### Commercial VMT and Travel"),
+ selector_row(
+ self.demo_commercial_metric_sel,
+ self.demo_commercial_breakdown_sel,
+ self.demo_commercial_vehicle_type_sel,
+ self.demo_commercial_time_period_sel,
+ ),
+ self._body,
+ self.section_note("vmt.commercial_travel", self._body),
+ pn.pane.Markdown("### Bicycle VMT"),
+ self.noted_section("vmt.bicycle", self._bicycle_body),
+ sizing_mode="stretch_width",
+ )
diff --git a/dashboard/pages/validation/_vmt/contracts.py b/dashboard/pages/validation/_vmt/contracts.py
new file mode 100644
index 0000000..8a5cc1e
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/contracts.py
@@ -0,0 +1,72 @@
+"""Stable VMT summary, selector, and chart contracts."""
+
+EXTERNAL_TOD_ORDER = ["EA", "AM", "MD", "PM", "EV", "EV1", "EV2"]
+EXTERNAL_COMMERCIAL_COLUMNS = ["car", "mu", "su"]
+EXTERNAL_COMMERCIAL_BREAKDOWN_OPTIONS = ["Time Period", "Commercial Vehicle Type"]
+EXTERNAL_COMMERCIAL_DAILY_PERIOD = "Daily"
+EXTERNAL_COMMERCIAL_TIME_ORDER = [*EXTERNAL_TOD_ORDER, EXTERNAL_COMMERCIAL_DAILY_PERIOD]
+COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID = "commercial_vehicle_type"
+EXTERNAL_TRAVEL_COLUMNS = [
+ "hbcoll",
+ "hbo",
+ "hbr",
+ "hbs",
+ "hbsch",
+ "hbw",
+ "nhbnw",
+ "nhbw",
+ "truck",
+]
+EXTERNAL_TRAVEL_TOTAL_COLUMN = "Total"
+EXTERNAL_TRAVEL_BREAKDOWN_OPTIONS = ["Time Period", "Trip Purpose"]
+EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID = "trip_purpose"
+PERSONAL_AUTO_VMT_SUMMARY_ID = "auto_vmt_by_home_geography_income_hhsize_time_period"
+NON_MOTORIZED_VMT_SUMMARY_ID = (
+ "non_motorized_vmt_by_home_geography_income_hhsize_time_period"
+)
+EXTERNAL_VMT_SUMMARY_ID = "external_vmt_validation_summary"
+COMMERCIAL_VMT_SUMMARY_ID = "commercial_vehicle_vmt_validation_summary"
+PERSONAL_AUTO_VMT_REQUIRED_COLUMNS = (
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "auto_vmt",
+ "trip_count",
+ "distance_source",
+ "time_period_source",
+)
+NON_MOTORIZED_VMT_REQUIRED_COLUMNS = (
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "mode",
+ "non_motorized_vmt",
+ "trip_count",
+ "distance_source",
+ "time_period_source",
+)
+PERSONAL_AUTO_VMT_ALL_MODES = "All Auto"
+PERSONAL_AUTO_VMT_MODE_CATEGORY_ID = "mode"
+NON_MOTORIZED_VMT_MODE_ORDER = ["WALK", "BIKE", "EBIKE"]
+PERSONAL_AUTO_VMT_BREAKDOWN_COLUMNS = {
+ "Time Period": "time_period",
+ "Mode": "mode",
+ "Income Segment": "income_segment",
+ "Household Size": "household_size",
+ "Home Geography": "geography_id",
+}
+PERSONAL_AUTO_VMT_BREAKDOWN_AXIS_TITLES = {
+ "Time Period": "Time Period",
+ "Mode": "Mode",
+ "Income Segment": "Income Segment",
+ "Household Size": "Household Size",
+ "Home Geography": "Home Geography",
+}
+PERSONAL_AUTO_VMT_TIME_ORDER = ["EA", "AM", "MD", "PM", "EV", "EV1", "EV2", "Daily"]
+PERSONAL_AUTO_VMT_MODE_ORDER = ["SOV", "HOV2", "HOV3"]
+PERSONAL_AUTO_VMT_TOP_GEOGRAPHIES = 25
+VMT_OVERVIEW_ROWS = ("Personal Auto", "Non-Motorized", "External", "Commercial")
diff --git a/dashboard/pages/validation/_vmt/features.py b/dashboard/pages/validation/_vmt/features.py
new file mode 100644
index 0000000..e645f37
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/features.py
@@ -0,0 +1,468 @@
+"""User-visible feature rendering for the VMT validation page."""
+
+from __future__ import annotations
+
+import panel as pn
+import polars as pl
+
+from dashboard.helpers.category_helpers import label_category_data, nonempty
+from dashboard.rendering import data_table
+
+from .contracts import *
+from .overview import vmt_overview_table_data
+from .segmented import (
+ _chart_category_order,
+ _ordered_values,
+ non_motorized_vmt_chart_data,
+ personal_auto_vmt_chart_data,
+)
+from .wide_tod import (
+ demo_commercial_vehicle_chart_data,
+ external_travel_chart_data,
+)
+
+
+class VmtOverviewFeatureMixin:
+ def render_vmt_overview_section(self) -> list[pn.viewable.Viewable]:
+ overview_data = vmt_overview_table_data(
+ personal_auto_vmt=self.data.summary(
+ PERSONAL_AUTO_VMT_SUMMARY_ID,
+ self.weighting_key,
+ ),
+ non_motorized_vmt=self.data.summary(
+ NON_MOTORIZED_VMT_SUMMARY_ID,
+ self.weighting_key,
+ ),
+ external_vmt=self.data.summary(
+ EXTERNAL_VMT_SUMMARY_ID,
+ self.weighting_key,
+ ),
+ commercial_vmt=self.data.summary(
+ COMMERCIAL_VMT_SUMMARY_ID,
+ self.weighting_key,
+ ),
+ )
+ if not overview_data:
+ return []
+ return [
+ data_table(
+ overview_data,
+ height=180,
+ numeric_precision_by_column={
+ "VMT": 2,
+ "% Share of Total": 4,
+ },
+ column_sorters={
+ "VMT": "number",
+ "% Share of Total": "number",
+ },
+ )
+ ]
+
+ def render_bicycle_chart(self) -> pn.viewable.Viewable:
+ bicycle_vmt = self.data.summary(
+ "bicycle_vmt_by_facility_type",
+ self.weighting_key,
+ )
+ if bicycle_vmt is None:
+ return self.data_not_available_card(
+ detail="Bicycle VMT summaries are unavailable.",
+ missing_items=["bicycle_vmt_by_facility_type"],
+ )
+ return self.plot.bar(
+ nonempty(bicycle_vmt),
+ x="facility_type",
+ y="bicycle_vmt",
+ title="Bicycle VMT by Facility Type",
+ x_title="Bicycle Facility Type",
+ y_title="Bicycle VMT",
+ )
+
+ def render_bicycle_section(self):
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+ return [self.render_bicycle_chart()]
+
+
+class SegmentedVmtFeatureMixin:
+ def render_personal_auto_vmt_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return []
+ personal_vmt = self.data.summary(
+ PERSONAL_AUTO_VMT_SUMMARY_ID,
+ columns=PERSONAL_AUTO_VMT_REQUIRED_COLUMNS,
+ )
+ if not personal_vmt:
+ return [
+ self.data_not_available_card(
+ detail="Personal auto VMT by home geography, income segment, household size, and time period is unavailable.",
+ missing_items=[PERSONAL_AUTO_VMT_SUMMARY_ID],
+ )
+ ]
+ breakdown = str(self.personal_vmt_breakdown_sel.value)
+ geography_type = self.selected_personal_vmt_geography_type_raw()
+ geography_id = self.selected_personal_vmt_geography_raw()
+ time_period = str(self.personal_vmt_time_period_sel.value)
+ mode = self.selected_personal_vmt_mode_raw()
+ income_segment = str(self.personal_vmt_income_segment_sel.value)
+ household_size = str(self.personal_vmt_household_size_sel.value)
+ mode_values = [
+ value
+ for _, df in personal_vmt
+ if "mode" in df.columns
+ for value in df["mode"].drop_nulls().cast(pl.Utf8).to_list()
+ ]
+ mode_order = self.config.ordered_values(
+ PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ list(dict.fromkeys(mode_values)),
+ )
+ chart_data = self.query(
+ lambda: personal_auto_vmt_chart_data(
+ personal_vmt,
+ breakdown=breakdown,
+ geography_type=geography_type,
+ geography_id=geography_id,
+ time_period=time_period,
+ mode=mode,
+ income_segment=income_segment,
+ household_size=household_size,
+ mode_order=mode_order,
+ )
+ )
+ if breakdown == "Mode":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id=PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ config=self.config,
+ target_col="category",
+ )
+ elif breakdown == "Home Geography":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id="geography",
+ config=self.config,
+ target_col="category",
+ )
+ category_values = [
+ value
+ for _, df in chart_data
+ for value in (
+ df["category"].to_list()
+ if "category" in df.columns and not df.is_empty()
+ else []
+ )
+ ]
+ if breakdown == "Time Period":
+ xaxis_categoryarray = _ordered_values(
+ list(dict.fromkeys(str(value) for value in category_values)),
+ preferred=PERSONAL_AUTO_VMT_TIME_ORDER,
+ )
+ elif breakdown == "Mode":
+ xaxis_categoryarray = [
+ self.config.label_value(PERSONAL_AUTO_VMT_MODE_CATEGORY_ID, value)
+ for value in mode_order
+ if self.config.label_value(PERSONAL_AUTO_VMT_MODE_CATEGORY_ID, value)
+ in {str(category) for category in category_values}
+ ]
+ else:
+ xaxis_categoryarray = list(
+ dict.fromkeys(str(value) for value in category_values)
+ )
+ use_time_period_percent = self.as_percent and breakdown == "Time Period"
+ chart = self.plot.bar(
+ chart_data,
+ x="category",
+ y="auto_vmt_percent" if use_time_period_percent else "auto_vmt",
+ title=f"Personal Auto VMT by {breakdown}",
+ x_title=PERSONAL_AUTO_VMT_BREAKDOWN_AXIS_TITLES[breakdown],
+ y_title=(
+ "Percent of Vehicle Miles Traveled (%)"
+ if use_time_period_percent
+ else "Vehicle Miles Traveled"
+ ),
+ value_mode="count" if use_time_period_percent else "dashboard",
+ category_order=xaxis_categoryarray,
+ )
+ return [chart]
+
+ def render_non_motorized_vmt_section(self) -> list[pn.viewable.Viewable]:
+ if not self.state.run_labels:
+ return []
+ non_motorized_vmt = self.data.summary(
+ NON_MOTORIZED_VMT_SUMMARY_ID,
+ columns=NON_MOTORIZED_VMT_REQUIRED_COLUMNS,
+ )
+ if not non_motorized_vmt:
+ return [
+ self.data_not_available_card(
+ detail="Non-motorized VMT by home geography, income segment, household size, and time period is unavailable.",
+ missing_items=[NON_MOTORIZED_VMT_SUMMARY_ID],
+ )
+ ]
+ breakdown = str(self.non_motorized_vmt_breakdown_sel.value)
+ geography_type = self.selected_non_motorized_vmt_geography_type_raw()
+ geography_id = self.selected_non_motorized_vmt_geography_raw()
+ time_period = str(self.non_motorized_vmt_time_period_sel.value)
+ mode = self.selected_non_motorized_vmt_mode_raw()
+ income_segment = str(self.non_motorized_vmt_income_segment_sel.value)
+ household_size = str(self.non_motorized_vmt_household_size_sel.value)
+ mode_values = [
+ value
+ for _, df in non_motorized_vmt
+ if "mode" in df.columns
+ for value in df["mode"].drop_nulls().cast(pl.Utf8).to_list()
+ ]
+ mode_order = self.config.ordered_values(
+ PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ list(dict.fromkeys(mode_values)),
+ )
+ chart_data = self.query(
+ lambda: non_motorized_vmt_chart_data(
+ non_motorized_vmt,
+ breakdown=breakdown,
+ geography_type=geography_type,
+ geography_id=geography_id,
+ time_period=time_period,
+ mode=mode,
+ income_segment=income_segment,
+ household_size=household_size,
+ mode_order=mode_order or NON_MOTORIZED_VMT_MODE_ORDER,
+ )
+ )
+ if breakdown == "Mode":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id=PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ config=self.config,
+ target_col="category",
+ )
+ elif breakdown == "Home Geography":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id="geography",
+ config=self.config,
+ target_col="category",
+ )
+ category_values = [
+ value
+ for _, df in chart_data
+ for value in (
+ df["category"].to_list()
+ if "category" in df.columns and not df.is_empty()
+ else []
+ )
+ ]
+ if breakdown == "Time Period":
+ xaxis_categoryarray = _ordered_values(
+ list(dict.fromkeys(str(value) for value in category_values)),
+ preferred=PERSONAL_AUTO_VMT_TIME_ORDER,
+ )
+ elif breakdown == "Mode":
+ xaxis_categoryarray = [
+ self.config.label_value(PERSONAL_AUTO_VMT_MODE_CATEGORY_ID, value)
+ for value in mode_order
+ if self.config.label_value(PERSONAL_AUTO_VMT_MODE_CATEGORY_ID, value)
+ in {str(category) for category in category_values}
+ ]
+ else:
+ xaxis_categoryarray = list(
+ dict.fromkeys(str(value) for value in category_values)
+ )
+ use_time_period_percent = self.as_percent and breakdown == "Time Period"
+ chart = self.plot.bar(
+ chart_data,
+ x="category",
+ y=(
+ "non_motorized_vmt_percent"
+ if use_time_period_percent
+ else "non_motorized_vmt"
+ ),
+ title=f"Non-Motorized VMT by {breakdown}",
+ x_title=PERSONAL_AUTO_VMT_BREAKDOWN_AXIS_TITLES[breakdown],
+ y_title=(
+ "Percent of Non-Motorized Miles Traveled (%)"
+ if use_time_period_percent
+ else "Non-Motorized Miles Traveled"
+ ),
+ value_mode="count" if use_time_period_percent else "dashboard",
+ category_order=xaxis_categoryarray,
+ )
+ return [chart]
+
+
+class CommercialVmtFeatureMixin:
+ def render_body(self):
+ return self.render_commercial_vmt_section()
+
+ def render_commercial_vmt_section(self):
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+ summary_ids = [
+ "commercial_vehicle_validation_summary",
+ "commercial_vehicle_vmt_validation_summary",
+ ]
+ if not any(
+ self.data.summary(summary_id, self.weighting_key)
+ for summary_id in summary_ids
+ ):
+ return []
+ return [self.render_demo_commercial_chart()]
+
+ def render_demo_commercial_chart(self) -> pn.viewable.Viewable:
+ summary_id = (
+ "commercial_vehicle_vmt_validation_summary"
+ if self.demo_commercial_metric_sel.value == "VMT"
+ else "commercial_vehicle_validation_summary"
+ )
+ data = self.data.summary(summary_id, self.weighting_key)
+ if data is None:
+ return self.data_not_available_card(
+ detail="Commercial vehicle summaries are unavailable.",
+ missing_items=[summary_id],
+ )
+ metric = self.demo_commercial_metric_sel.value
+ breakdown = str(self.demo_commercial_breakdown_sel.value)
+ time_period = str(self.demo_commercial_time_period_sel.value)
+ commercial_vehicle_type = self.selected_demo_commercial_vehicle_type_raw()
+ chart_data = self.query(
+ lambda: demo_commercial_vehicle_chart_data(
+ data,
+ breakdown=breakdown,
+ time_period=time_period,
+ commercial_vehicle_type=commercial_vehicle_type,
+ tod_col="tod",
+ value_columns=EXTERNAL_COMMERCIAL_COLUMNS,
+ )
+ )
+ if breakdown == "Commercial Vehicle Type":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id=COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID,
+ config=self.config,
+ target_col="category",
+ )
+ xaxis_categoryarray = (
+ _chart_category_order(chart_data, preferred=EXTERNAL_COMMERCIAL_TIME_ORDER)
+ if breakdown == "Time Period"
+ else _chart_category_order(
+ chart_data,
+ preferred=self.config.ordered_labels(
+ COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID,
+ EXTERNAL_COMMERCIAL_COLUMNS,
+ ),
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="category",
+ y=(
+ "value_percent"
+ if self.as_percent and breakdown == "Time Period"
+ else "value"
+ ),
+ title=f"Commercial Vehicle {metric} by {breakdown}",
+ x_title=breakdown,
+ y_title=(
+ f"Percent of {metric} (%)"
+ if self.as_percent and breakdown == "Time Period"
+ else metric
+ ),
+ value_mode="count" if breakdown == "Time Period" else "dashboard",
+ category_order=xaxis_categoryarray,
+ show_legend=True,
+ )
+
+
+class ExternalVmtFeatureMixin:
+ def render_external_travel_chart(self) -> pn.viewable.Viewable:
+ summary_id = (
+ "external_vmt_validation_summary"
+ if self.external_travel_metric_sel.value == "VMT"
+ else "external_trip_validation_summary"
+ )
+ data = self.data.summary(summary_id, self.weighting_key)
+ if data is None:
+ return self.data_not_available_card(
+ detail="External travel summaries are unavailable.",
+ missing_items=[summary_id],
+ )
+ metric = self.external_travel_metric_sel.value
+ breakdown = str(self.external_travel_breakdown_sel.value)
+ time_period = str(self.external_travel_time_period_sel.value)
+ trip_purpose = self.selected_external_travel_trip_purpose_raw()
+ chart_data = self.query(
+ lambda: external_travel_chart_data(
+ data,
+ breakdown=breakdown,
+ time_period=time_period,
+ trip_purpose=trip_purpose,
+ tod_col="tod",
+ value_columns=EXTERNAL_TRAVEL_COLUMNS,
+ )
+ )
+ if breakdown == "Trip Purpose":
+ chart_data = label_category_data(
+ chart_data,
+ source_col="category",
+ category_id=EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID,
+ config=self.config,
+ target_col="category",
+ )
+ xaxis_categoryarray = (
+ _chart_category_order(chart_data, preferred=EXTERNAL_COMMERCIAL_TIME_ORDER)
+ if breakdown == "Time Period"
+ else _chart_category_order(
+ chart_data,
+ preferred=self.config.ordered_labels(
+ EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID,
+ EXTERNAL_TRAVEL_COLUMNS,
+ ),
+ )
+ )
+ return self.plot.bar(
+ chart_data,
+ x="category",
+ y=(
+ "value_percent"
+ if self.as_percent and breakdown == "Time Period"
+ else "value"
+ ),
+ title=f"External {metric} by {breakdown}",
+ x_title=breakdown,
+ y_title=(
+ f"Percent of {metric} (%)"
+ if self.as_percent and breakdown == "Time Period"
+ else metric
+ ),
+ value_mode="count" if breakdown == "Time Period" else "dashboard",
+ category_order=xaxis_categoryarray,
+ show_legend=True,
+ )
+
+ def render_external_vmt_section(self) -> list[pn.viewable.Viewable]:
+ summary_ids = [
+ "external_trip_validation_summary",
+ "external_vmt_validation_summary",
+ ]
+ if not any(
+ self.data.summary(summary_id, self.weighting_key)
+ for summary_id in summary_ids
+ ):
+ return []
+ content: list[pn.viewable.Viewable] = [
+ self.render_external_travel_chart(),
+ ]
+ return content
+
+
+__all__ = [
+ "CommercialVmtFeatureMixin",
+ "ExternalVmtFeatureMixin",
+ "SegmentedVmtFeatureMixin",
+ "VmtOverviewFeatureMixin",
+]
diff --git a/dashboard/pages/validation/_vmt/overview.py b/dashboard/pages/validation/_vmt/overview.py
new file mode 100644
index 0000000..b0f435e
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/overview.py
@@ -0,0 +1,149 @@
+"""VMT overview totals and share calculations."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.helpers.category_helpers import nonempty
+from dashboard.helpers.geography_helpers import ALL_GEOGRAPHY_TYPES_VALUE
+
+from .contracts import (
+ EXTERNAL_COMMERCIAL_COLUMNS,
+ EXTERNAL_TRAVEL_COLUMNS,
+ EXTERNAL_TRAVEL_TOTAL_COLUMN,
+ VMT_OVERVIEW_ROWS,
+)
+
+
+def _sum_float_column(df: pl.DataFrame, column: str) -> float:
+ if df.is_empty() or column not in df.columns:
+ return 0.0
+ value = df.select(pl.col(column).cast(pl.Float64).sum()).item()
+ return float(value or 0.0)
+
+
+def _total_segmented_vmt(df: pl.DataFrame, value_col: str) -> float:
+ if df.is_empty() or value_col not in df.columns:
+ return 0.0
+
+ filtered = df
+ if {"geography_type", "geography_id"}.issubset(filtered.columns):
+ all_geography_rows = filtered.filter(
+ (pl.col("geography_type").cast(pl.Utf8) == ALL_GEOGRAPHY_TYPES_VALUE)
+ & (pl.col("geography_id").cast(pl.Utf8) == ALL_GEOGRAPHY_TYPES_VALUE)
+ )
+ if not all_geography_rows.is_empty():
+ filtered = all_geography_rows
+
+ if "time_period" in filtered.columns:
+ daily_rows = filtered.filter(pl.col("time_period").cast(pl.Utf8) == "Daily")
+ if not daily_rows.is_empty():
+ filtered = daily_rows
+
+ return _sum_float_column(filtered, value_col)
+
+
+def _total_wide_tod_vmt(
+ df: pl.DataFrame,
+ *,
+ value_columns: list[str],
+ total_column: str | None = None,
+ tod_col: str = "tod",
+) -> float:
+ if df.is_empty():
+ return 0.0
+
+ filtered = df
+ if tod_col in filtered.columns:
+ daily_rows = filtered.filter(
+ pl.col(tod_col).cast(pl.Utf8).str.to_lowercase() == "daily"
+ )
+ if not daily_rows.is_empty():
+ filtered = daily_rows
+
+ if total_column and total_column in filtered.columns:
+ return _sum_float_column(filtered, total_column)
+
+ available_columns = [
+ column for column in value_columns if column in filtered.columns
+ ]
+ if not available_columns:
+ return 0.0
+ value = filtered.select(
+ pl.sum_horizontal(
+ [pl.col(column).cast(pl.Float64) for column in available_columns]
+ )
+ .sum()
+ .alias("vmt")
+ ).item()
+ return float(value or 0.0)
+
+
+def vmt_overview_table_data(
+ *,
+ personal_auto_vmt: list[tuple[str, pl.DataFrame]] | None,
+ non_motorized_vmt: list[tuple[str, pl.DataFrame]] | None,
+ external_vmt: list[tuple[str, pl.DataFrame]] | None,
+ commercial_vmt: list[tuple[str, pl.DataFrame]] | None,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Build one VMT/share overview table per run label."""
+ personal_auto_vmt = personal_auto_vmt or []
+ non_motorized_vmt = non_motorized_vmt or []
+ external_vmt = external_vmt or []
+ commercial_vmt = commercial_vmt or []
+ labels = list(
+ dict.fromkeys(
+ label
+ for data_list in (
+ personal_auto_vmt,
+ non_motorized_vmt,
+ external_vmt,
+ commercial_vmt,
+ )
+ for label, _ in data_list
+ )
+ )
+ personal_by_label = dict(personal_auto_vmt)
+ non_motorized_by_label = dict(non_motorized_vmt)
+ external_by_label = dict(external_vmt)
+ commercial_by_label = dict(commercial_vmt)
+
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label in labels:
+ totals = {
+ "Personal Auto": _total_segmented_vmt(
+ personal_by_label.get(label, pl.DataFrame()),
+ "auto_vmt",
+ ),
+ "Non-Motorized": _total_segmented_vmt(
+ non_motorized_by_label.get(label, pl.DataFrame()),
+ "non_motorized_vmt",
+ ),
+ "External": _total_wide_tod_vmt(
+ external_by_label.get(label, pl.DataFrame()),
+ value_columns=EXTERNAL_TRAVEL_COLUMNS,
+ total_column=EXTERNAL_TRAVEL_TOTAL_COLUMN,
+ ),
+ "Commercial": _total_wide_tod_vmt(
+ commercial_by_label.get(label, pl.DataFrame()),
+ value_columns=EXTERNAL_COMMERCIAL_COLUMNS,
+ ),
+ }
+ grand_total = sum(totals.values())
+ share_values = [
+ (totals[row] / grand_total * 100.0) if grand_total > 0 else 0.0
+ for row in VMT_OVERVIEW_ROWS
+ ]
+ out.append(
+ (
+ label,
+ pl.DataFrame(
+ {
+ "Category": list(VMT_OVERVIEW_ROWS),
+ "VMT": [totals[row] for row in VMT_OVERVIEW_ROWS],
+ "% Share of Total": share_values,
+ }
+ ),
+ )
+ )
+ return out
diff --git a/dashboard/pages/validation/_vmt/segmented.py b/dashboard/pages/validation/_vmt/segmented.py
new file mode 100644
index 0000000..0781382
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/segmented.py
@@ -0,0 +1,338 @@
+"""Personal-auto and non-motorized VMT transformations and option domains."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.helpers.category_helpers import (
+ column_value_union,
+ nonempty,
+ raw_display_options,
+)
+
+from .contracts import (
+ NON_MOTORIZED_VMT_MODE_ORDER,
+ PERSONAL_AUTO_VMT_ALL_MODES,
+ PERSONAL_AUTO_VMT_BREAKDOWN_COLUMNS,
+ PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ PERSONAL_AUTO_VMT_MODE_ORDER,
+ PERSONAL_AUTO_VMT_TIME_ORDER,
+ PERSONAL_AUTO_VMT_TOP_GEOGRAPHIES,
+)
+
+
+def _prefer_daily_rows_for_all_time_periods(
+ df: pl.DataFrame,
+ *,
+ breakdown_col: str,
+ selected_time_period: str,
+) -> pl.DataFrame:
+ """Use Daily total rows for all-day non-period breakdowns when available."""
+ if (
+ df.is_empty()
+ or breakdown_col == "time_period"
+ or selected_time_period != "All"
+ or "time_period" not in df.columns
+ ):
+ return df
+
+ group_cols = [
+ column
+ for column in (
+ "geography_type",
+ "geography_id",
+ "mode",
+ "income_segment",
+ "household_size",
+ )
+ if column in df.columns
+ ]
+ daily_rows = df.filter(pl.col("time_period") == "Daily")
+ if daily_rows.is_empty() or not group_cols:
+ return df
+
+ daily_groups = daily_rows.select(group_cols).unique()
+ period_rows_without_daily_total = df.filter(pl.col("time_period") != "Daily").join(
+ daily_groups, on=group_cols, how="anti"
+ )
+ return pl.concat([daily_rows, period_rows_without_daily_total], how="vertical")
+
+
+def _with_time_period_percent_of_daily(
+ chart_df: pl.DataFrame,
+) -> pl.DataFrame:
+ """Add percent VMT values using Daily as the denominator when present."""
+ if chart_df.is_empty() or not {"category", "auto_vmt"}.issubset(chart_df.columns):
+ return chart_df
+
+ daily_total = chart_df.filter(pl.col("category") == "Daily")["auto_vmt"].sum()
+ denominator = (
+ daily_total if daily_total and daily_total > 0 else chart_df["auto_vmt"].sum()
+ )
+ if not denominator or denominator <= 0:
+ return chart_df.with_columns(pl.lit(0.0).alias("auto_vmt_percent"))
+
+ return chart_df.with_columns(
+ (pl.col("auto_vmt") / denominator * 100.0).alias("auto_vmt_percent")
+ )
+
+
+def _ordered_values(
+ values: list[str],
+ *,
+ preferred: list[str] | None = None,
+) -> list[str]:
+ """Return stable selector/chart values with preferred values first."""
+ preferred = preferred or []
+ preferred_index = {value: index for index, value in enumerate(preferred)}
+ return sorted(
+ values,
+ key=lambda value: (
+ 0 if value in preferred_index else 1,
+ preferred_index.get(value, 0),
+ value.lower(),
+ value,
+ ),
+ )
+
+
+def _selector_values(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ column: str,
+ *,
+ include_all: bool = False,
+ preferred: list[str] | None = None,
+) -> list[str]:
+ values = _ordered_values(
+ column_value_union(data_list or [], column),
+ preferred=preferred,
+ )
+ return ["All", *values] if include_all else values
+
+
+def _chart_category_order(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ preferred: list[str],
+ category_col: str = "category",
+) -> list[str]:
+ values = [
+ str(value)
+ for _, df in data_list
+ if category_col in df.columns and not df.is_empty()
+ for value in df[category_col].drop_nulls().cast(pl.Utf8).to_list()
+ ]
+ return _ordered_values(list(dict.fromkeys(values)), preferred=preferred)
+
+
+def personal_auto_mode_options(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ config,
+) -> tuple[list[str], dict[str, str | None]]:
+ values = column_value_union(data_list or [], "mode")
+ ordered_values = config.ordered_values(PERSONAL_AUTO_VMT_MODE_CATEGORY_ID, values)
+ return raw_display_options(
+ ordered_values,
+ category_id=PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ config=config,
+ total_raw="All",
+ total_label="All",
+ )
+
+
+def non_motorized_mode_options(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ config,
+) -> tuple[list[str], dict[str, str | None]]:
+ values = [str(value) for value in column_value_union(data_list or [], "mode")]
+ preferred = [value for value in NON_MOTORIZED_VMT_MODE_ORDER if value in values]
+ remaining = config.ordered_values(
+ PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ [value for value in values if value not in preferred],
+ )
+ return raw_display_options(
+ [*preferred, *remaining],
+ category_id=PERSONAL_AUTO_VMT_MODE_CATEGORY_ID,
+ config=config,
+ total_raw="All",
+ total_label="All",
+ )
+
+
+def non_motorized_vmt_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ breakdown: str,
+ geography_type: str,
+ geography_id: str,
+ time_period: str,
+ income_segment: str,
+ household_size: str,
+ mode: str = "All",
+ mode_order: list[str] | None = None,
+ top_geographies: int = PERSONAL_AUTO_VMT_TOP_GEOGRAPHIES,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Filter and aggregate non-motorized VMT rows for the selected breakdown."""
+ normalized: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ if "non_motorized_vmt" not in df.columns:
+ continue
+ normalized.append((label, df.rename({"non_motorized_vmt": "auto_vmt"})))
+ chart_data = personal_auto_vmt_chart_data(
+ normalized,
+ breakdown=breakdown,
+ geography_type=geography_type,
+ geography_id=geography_id,
+ time_period=time_period,
+ mode=mode,
+ income_segment=income_segment,
+ household_size=household_size,
+ mode_order=mode_order or NON_MOTORIZED_VMT_MODE_ORDER,
+ top_geographies=top_geographies,
+ )
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in chart_data:
+ rename_map = {"auto_vmt": "non_motorized_vmt"}
+ if "auto_vmt_percent" in df.columns:
+ rename_map["auto_vmt_percent"] = "non_motorized_vmt_percent"
+ out.append((label, df.rename(rename_map)))
+ return out
+
+
+def personal_auto_vmt_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ breakdown: str,
+ geography_type: str,
+ geography_id: str,
+ time_period: str,
+ income_segment: str,
+ household_size: str,
+ mode: str = "All",
+ mode_order: list[str] | None = None,
+ top_geographies: int = PERSONAL_AUTO_VMT_TOP_GEOGRAPHIES,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Filter and aggregate personal auto VMT rows for the selected breakdown."""
+ breakdown_col = PERSONAL_AUTO_VMT_BREAKDOWN_COLUMNS[breakdown]
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ missing = [
+ column
+ for column in (
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "auto_vmt",
+ "trip_count",
+ )
+ if column not in df.columns
+ ]
+ if missing:
+ continue
+ filtered = df.with_columns(
+ pl.col("geography_type").cast(pl.Utf8),
+ pl.col("geography_id").cast(pl.Utf8),
+ pl.col("income_segment").cast(pl.Utf8),
+ pl.col("household_size").cast(pl.Utf8),
+ pl.col("time_period").cast(pl.Utf8),
+ (
+ pl.col("mode").cast(pl.Utf8).fill_null(PERSONAL_AUTO_VMT_ALL_MODES)
+ if "mode" in df.columns
+ else pl.lit(PERSONAL_AUTO_VMT_ALL_MODES)
+ ).alias("mode"),
+ )
+ if geography_type != "All":
+ filtered = filtered.filter(pl.col("geography_type") == geography_type)
+ if breakdown_col != "geography_id" and geography_id != "All":
+ filtered = filtered.filter(pl.col("geography_id") == geography_id)
+ if breakdown_col != "time_period" and time_period != "All":
+ filtered = filtered.filter(pl.col("time_period") == time_period)
+ if breakdown_col != "mode" and mode != "All":
+ filtered = filtered.filter(pl.col("mode") == mode)
+ if breakdown_col != "income_segment" and income_segment != "All":
+ filtered = filtered.filter(pl.col("income_segment") == income_segment)
+ if breakdown_col != "household_size" and household_size != "All":
+ filtered = filtered.filter(pl.col("household_size") == household_size)
+ filtered = _prefer_daily_rows_for_all_time_periods(
+ filtered,
+ breakdown_col=breakdown_col,
+ selected_time_period=time_period,
+ )
+
+ if filtered.is_empty():
+ out.append(
+ (
+ label,
+ pl.DataFrame(
+ {
+ "category": pl.Series([], dtype=pl.Utf8),
+ "auto_vmt": pl.Series([], dtype=pl.Float64),
+ "trip_count": pl.Series([], dtype=pl.Float64),
+ }
+ ),
+ )
+ )
+ continue
+
+ chart_df = (
+ filtered.group_by(breakdown_col)
+ .agg(
+ pl.col("auto_vmt").sum().alias("auto_vmt"),
+ pl.col("trip_count").sum().alias("trip_count"),
+ )
+ .rename({breakdown_col: "category"})
+ .with_columns(pl.col("category").cast(pl.Utf8))
+ )
+ if breakdown_col == "geography_id":
+ chart_df = chart_df.sort("auto_vmt", descending=True).head(top_geographies)
+ elif breakdown_col == "time_period":
+ chart_df = (
+ chart_df.with_columns(
+ pl.col("category")
+ .replace_strict(
+ {
+ value: index
+ for index, value in enumerate(PERSONAL_AUTO_VMT_TIME_ORDER)
+ },
+ default=len(PERSONAL_AUTO_VMT_TIME_ORDER),
+ return_dtype=pl.Int64,
+ )
+ .alias("_sort_order")
+ )
+ .sort("_sort_order", "category")
+ .drop("_sort_order")
+ )
+ chart_df = _with_time_period_percent_of_daily(chart_df)
+ elif breakdown_col == "mode":
+ mode_order = mode_order or PERSONAL_AUTO_VMT_MODE_ORDER
+ chart_df = (
+ chart_df.with_columns(
+ pl.col("category")
+ .replace_strict(
+ {value: index for index, value in enumerate(mode_order)},
+ default=len(mode_order),
+ return_dtype=pl.Int64,
+ )
+ .alias("_sort_order")
+ )
+ .sort("_sort_order", "category")
+ .drop("_sort_order")
+ )
+ elif breakdown_col == "household_size":
+ chart_df = (
+ chart_df.with_columns(
+ pl.col("category")
+ .cast(pl.Float64, strict=False)
+ .alias("_sort_order")
+ )
+ .sort("_sort_order", "category", nulls_last=True)
+ .drop("_sort_order")
+ )
+ else:
+ chart_df = chart_df.sort("category")
+ out.append((label, chart_df))
+ return out
diff --git a/dashboard/pages/validation/_vmt/selector_domains.py b/dashboard/pages/validation/_vmt/selector_domains.py
new file mode 100644
index 0000000..a81e589
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/selector_domains.py
@@ -0,0 +1,266 @@
+"""Dynamic selector domains for VMT page features."""
+
+from __future__ import annotations
+
+from dashboard.helpers.geography_helpers import (
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ ALL_GEOGRAPHY_TYPES_VALUE,
+ geography_name_options_for_type,
+ geography_name_selector_label,
+ geography_type_options,
+)
+
+from .contracts import (
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ NON_MOTORIZED_VMT_SUMMARY_ID,
+ PERSONAL_AUTO_VMT_SUMMARY_ID,
+ PERSONAL_AUTO_VMT_TIME_ORDER,
+)
+from .segmented import (
+ _selector_values,
+ non_motorized_mode_options,
+ personal_auto_mode_options,
+)
+from .wide_tod import (
+ demo_commercial_filter_options,
+ external_travel_filter_options,
+)
+
+
+class VmtSelectorDomainsMixin:
+ """Supply dynamic option providers and the small remaining UI state sync."""
+
+ def _personal_vmt_data(self):
+ return self.data.summary(PERSONAL_AUTO_VMT_SUMMARY_ID, self.weighting_key)
+
+ def _non_motorized_vmt_data(self):
+ return self.data.summary(NON_MOTORIZED_VMT_SUMMARY_ID, self.weighting_key)
+
+ def _personal_geography_type_options(self) -> list[str]:
+ options, mapping = geography_type_options(
+ self._personal_vmt_data(),
+ config=self.config,
+ include_all_types=True,
+ )
+ if not options:
+ options = [ALL_GEOGRAPHY_TYPES_LABEL]
+ mapping = {ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE}
+ self.personal_vmt_geo_type_raw_by_label = mapping
+ return options
+
+ def _personal_geography_options(self) -> list[str]:
+ geography_type = self.selected_personal_vmt_geography_type_raw()
+ options, mapping = geography_name_options_for_type(
+ geography_type,
+ self._personal_vmt_data(),
+ config=self.config,
+ )
+ if not options:
+ options = ["All Geographies"]
+ mapping = {"All Geographies": "All"}
+ self.personal_vmt_geo_raw_by_label = mapping
+ if hasattr(self, "personal_vmt_geography_sel"):
+ self.personal_vmt_geography_sel.name = geography_name_selector_label(
+ geography_type,
+ config=self.config,
+ )
+ return options
+
+ def _personal_time_period_options(self) -> list[str]:
+ return _selector_values(
+ self._personal_vmt_data(),
+ "time_period",
+ include_all=True,
+ preferred=PERSONAL_AUTO_VMT_TIME_ORDER,
+ ) or ["All"]
+
+ def _personal_mode_options(self) -> list[str]:
+ options, mapping = personal_auto_mode_options(
+ self._personal_vmt_data(),
+ config=self.config,
+ )
+ if not options:
+ options = ["All"]
+ mapping = {"All": "All"}
+ self.personal_vmt_mode_raw_by_label = mapping
+ return options
+
+ def _personal_income_options(self) -> list[str]:
+ return _selector_values(
+ self._personal_vmt_data(),
+ "income_segment",
+ include_all=True,
+ ) or ["All"]
+
+ def _personal_household_size_options(self) -> list[str]:
+ return _selector_values(
+ self._personal_vmt_data(),
+ "household_size",
+ include_all=True,
+ ) or ["All"]
+
+ def _non_motorized_geography_type_options(self) -> list[str]:
+ options, mapping = geography_type_options(
+ self._non_motorized_vmt_data(),
+ config=self.config,
+ include_all_types=True,
+ )
+ if not options:
+ options = [ALL_GEOGRAPHY_TYPES_LABEL]
+ mapping = {ALL_GEOGRAPHY_TYPES_LABEL: ALL_GEOGRAPHY_TYPES_VALUE}
+ self.non_motorized_vmt_geo_type_raw_by_label = mapping
+ return options
+
+ def _non_motorized_geography_options(self) -> list[str]:
+ geography_type = self.selected_non_motorized_vmt_geography_type_raw()
+ options, mapping = geography_name_options_for_type(
+ geography_type,
+ self._non_motorized_vmt_data(),
+ config=self.config,
+ )
+ if not options:
+ options = ["All Geographies"]
+ mapping = {"All Geographies": "All"}
+ self.non_motorized_vmt_geo_raw_by_label = mapping
+ if hasattr(self, "non_motorized_vmt_geography_sel"):
+ self.non_motorized_vmt_geography_sel.name = geography_name_selector_label(
+ geography_type,
+ config=self.config,
+ )
+ return options
+
+ def _non_motorized_time_period_options(self) -> list[str]:
+ return _selector_values(
+ self._non_motorized_vmt_data(),
+ "time_period",
+ include_all=True,
+ preferred=PERSONAL_AUTO_VMT_TIME_ORDER,
+ ) or ["All"]
+
+ def _non_motorized_mode_options(self) -> list[str]:
+ options, mapping = non_motorized_mode_options(
+ self._non_motorized_vmt_data(),
+ config=self.config,
+ )
+ if not options:
+ options = ["All"]
+ mapping = {"All": "All"}
+ self.non_motorized_vmt_mode_raw_by_label = mapping
+ return options
+
+ def _non_motorized_income_options(self) -> list[str]:
+ return _selector_values(
+ self._non_motorized_vmt_data(),
+ "income_segment",
+ include_all=True,
+ ) or ["All"]
+
+ def _non_motorized_household_size_options(self) -> list[str]:
+ return _selector_values(
+ self._non_motorized_vmt_data(),
+ "household_size",
+ include_all=True,
+ ) or ["All"]
+
+ def _commercial_data(self):
+ summary_id = (
+ "commercial_vehicle_vmt_validation_summary"
+ if self.demo_commercial_metric_sel.value == "VMT"
+ else "commercial_vehicle_validation_summary"
+ )
+ return self.data.summary(summary_id, self.weighting_key)
+
+ def _commercial_domains(self):
+ time_options, (vehicle_options, mapping) = demo_commercial_filter_options(
+ self._commercial_data(),
+ config=self.config,
+ )
+ self.demo_commercial_vehicle_type_raw_by_label = mapping
+ return time_options or [EXTERNAL_COMMERCIAL_DAILY_PERIOD], vehicle_options or [
+ "All"
+ ]
+
+ def _commercial_time_period_options(self) -> list[str]:
+ return self._commercial_domains()[0]
+
+ def _commercial_vehicle_type_options(self) -> list[str]:
+ return self._commercial_domains()[1]
+
+ def _external_data(self):
+ summary_id = (
+ "external_vmt_validation_summary"
+ if self.external_travel_metric_sel.value == "VMT"
+ else "external_trip_validation_summary"
+ )
+ return self.data.summary(summary_id, self.weighting_key)
+
+ def _external_domains(self):
+ time_options, (purpose_options, mapping) = external_travel_filter_options(
+ self._external_data(),
+ config=self.config,
+ )
+ self.external_travel_trip_purpose_raw_by_label = mapping
+ return time_options or [EXTERNAL_COMMERCIAL_DAILY_PERIOD], purpose_options or [
+ "All"
+ ]
+
+ def _external_time_period_options(self) -> list[str]:
+ return self._external_domains()[0]
+
+ def _external_trip_purpose_options(self) -> list[str]:
+ return self._external_domains()[1]
+
+ @staticmethod
+ def _disable_breakdown_filter(breakdown: str, widgets: dict[str, object]) -> None:
+ for widget in widgets.values():
+ widget.disabled = False
+ widget = widgets.get(breakdown)
+ if widget is None:
+ return
+ if "All" in widget.options:
+ widget.value = "All"
+ elif widget.options:
+ widget.value = widget.options[0]
+ widget.disabled = True
+
+ def sync_controls(self) -> None:
+ """Apply only behavior that is not an option-domain concern."""
+ self.personal_vmt_geography_type_sel.disabled = False
+ self.personal_vmt_geography_sel.disabled = False
+ self._disable_breakdown_filter(
+ str(self.personal_vmt_breakdown_sel.value),
+ {
+ "Time Period": self.personal_vmt_time_period_sel,
+ "Mode": self.personal_vmt_mode_sel,
+ "Income Segment": self.personal_vmt_income_segment_sel,
+ "Household Size": self.personal_vmt_household_size_sel,
+ },
+ )
+ self.non_motorized_vmt_geography_type_sel.disabled = False
+ self.non_motorized_vmt_geography_sel.disabled = False
+ self._disable_breakdown_filter(
+ str(self.non_motorized_vmt_breakdown_sel.value),
+ {
+ "Time Period": self.non_motorized_vmt_time_period_sel,
+ "Mode": self.non_motorized_vmt_mode_sel,
+ "Income Segment": self.non_motorized_vmt_income_segment_sel,
+ "Household Size": self.non_motorized_vmt_household_size_sel,
+ },
+ )
+ self._disable_breakdown_filter(
+ str(self.demo_commercial_breakdown_sel.value),
+ {
+ "Time Period": self.demo_commercial_time_period_sel,
+ "Commercial Vehicle Type": self.demo_commercial_vehicle_type_sel,
+ },
+ )
+ self._disable_breakdown_filter(
+ str(self.external_travel_breakdown_sel.value),
+ {
+ "Time Period": self.external_travel_time_period_sel,
+ "Trip Purpose": self.external_travel_trip_purpose_sel,
+ },
+ )
+
+
+__all__ = ["VmtSelectorDomainsMixin"]
diff --git a/dashboard/pages/validation/_vmt/wide_tod.py b/dashboard/pages/validation/_vmt/wide_tod.py
new file mode 100644
index 0000000..4f81055
--- /dev/null
+++ b/dashboard/pages/validation/_vmt/wide_tod.py
@@ -0,0 +1,346 @@
+"""Commercial and external wide time-of-day VMT transformations."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from dashboard.helpers.category_helpers import nonempty, raw_display_options
+
+from .contracts import (
+ COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID,
+ EXTERNAL_COMMERCIAL_COLUMNS,
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ EXTERNAL_COMMERCIAL_TIME_ORDER,
+ EXTERNAL_TOD_ORDER,
+ EXTERNAL_TRAVEL_COLUMNS,
+ EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID,
+ EXTERNAL_TRAVEL_TOTAL_COLUMN,
+)
+from .segmented import _ordered_values
+
+
+def _with_value_percent_of_daily(chart_df: pl.DataFrame) -> pl.DataFrame:
+ """Add percent values using Daily as the denominator when present."""
+ if chart_df.is_empty() or not {"category", "value"}.issubset(chart_df.columns):
+ return chart_df
+
+ daily_total = chart_df.filter(
+ pl.col("category") == EXTERNAL_COMMERCIAL_DAILY_PERIOD
+ )["value"].sum()
+ denominator = (
+ daily_total if daily_total and daily_total > 0 else chart_df["value"].sum()
+ )
+ if not denominator or denominator <= 0:
+ return chart_df.with_columns(pl.lit(0.0).alias("value_percent"))
+
+ return chart_df.with_columns(
+ (pl.col("value") / denominator * 100.0).alias("value_percent")
+ )
+
+
+def wide_tod_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ tod_col: str,
+ value_columns: list[str],
+ exclude_total_period: bool = True,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Return long chart-ready rows from external wide time-of-day summaries."""
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ available_columns = [column for column in value_columns if column in df.columns]
+ if tod_col not in df.columns or not available_columns:
+ continue
+ filtered = df.with_columns(pl.col(tod_col).cast(pl.Utf8))
+ if exclude_total_period:
+ filtered = filtered.filter(pl.col(tod_col).str.to_lowercase() != "daily")
+ chart_df = filtered.unpivot(
+ index=tod_col,
+ on=available_columns,
+ variable_name="category",
+ value_name="value",
+ ).rename({tod_col: "tod"})
+ out.append((label, chart_df))
+ return out
+
+
+def demo_commercial_vehicle_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ breakdown: str,
+ time_period: str = EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ commercial_vehicle_type: str = "All",
+ tod_col: str = "tod",
+ value_columns: list[str] | None = None,
+ exclude_total_period: bool = True,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Aggregate demo commercial vehicle summaries for one chart breakdown."""
+ value_columns = value_columns or EXTERNAL_COMMERCIAL_COLUMNS
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ available_columns = [column for column in value_columns if column in df.columns]
+ if tod_col not in df.columns or not available_columns:
+ continue
+ filtered = df.with_columns(pl.col(tod_col).cast(pl.Utf8))
+ daily_rows = filtered.filter(
+ pl.col(tod_col).str.to_lowercase()
+ == EXTERNAL_COMMERCIAL_DAILY_PERIOD.lower()
+ )
+ if breakdown != "Time Period":
+ if (
+ time_period == EXTERNAL_COMMERCIAL_DAILY_PERIOD
+ and not daily_rows.is_empty()
+ ):
+ filtered = daily_rows
+ elif exclude_total_period:
+ filtered = filtered.filter(
+ pl.col(tod_col).str.to_lowercase() != "daily"
+ )
+ long_df = filtered.unpivot(
+ index=tod_col,
+ on=available_columns,
+ variable_name="commercial_vehicle_type",
+ value_name="value",
+ ).rename({tod_col: "time_period"})
+ if breakdown != "Time Period" and time_period not in (
+ "All",
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ ):
+ long_df = long_df.filter(pl.col("time_period") == time_period)
+ if breakdown != "Commercial Vehicle Type" and commercial_vehicle_type != "All":
+ long_df = long_df.filter(
+ pl.col("commercial_vehicle_type") == commercial_vehicle_type
+ )
+ if breakdown == "Commercial Vehicle Type":
+ chart_df = (
+ long_df.group_by("commercial_vehicle_type")
+ .agg(pl.col("value").sum().alias("value"))
+ .rename({"commercial_vehicle_type": "category"})
+ .with_columns(pl.col("category").cast(pl.Utf8))
+ )
+ order = value_columns
+ else:
+ chart_df = (
+ long_df.group_by("time_period")
+ .agg(pl.col("value").sum().alias("value"))
+ .rename({"time_period": "category"})
+ .with_columns(pl.col("category").cast(pl.Utf8))
+ )
+ if (
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD not in chart_df["category"].to_list()
+ and not chart_df.is_empty()
+ ):
+ chart_df = pl.concat(
+ [
+ chart_df,
+ pl.DataFrame(
+ {
+ "category": [EXTERNAL_COMMERCIAL_DAILY_PERIOD],
+ "value": [chart_df["value"].sum()],
+ }
+ ),
+ ],
+ how="vertical",
+ )
+ chart_df = _with_value_percent_of_daily(chart_df)
+ order = EXTERNAL_COMMERCIAL_TIME_ORDER
+ chart_df = (
+ chart_df.with_columns(
+ pl.col("category")
+ .replace_strict(
+ {value: index for index, value in enumerate(order)},
+ default=len(order),
+ return_dtype=pl.Int64,
+ )
+ .alias("_sort_order")
+ )
+ .sort("_sort_order", "category")
+ .drop("_sort_order")
+ )
+ out.append((label, chart_df))
+ return out
+
+
+def external_travel_chart_data(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ breakdown: str,
+ time_period: str = EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ trip_purpose: str = "All",
+ tod_col: str = "tod",
+ value_columns: list[str] | None = None,
+ total_column: str = EXTERNAL_TRAVEL_TOTAL_COLUMN,
+ exclude_total_period: bool = True,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Aggregate external travel summaries for one chart breakdown."""
+ value_columns = value_columns or EXTERNAL_TRAVEL_COLUMNS
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label, df in nonempty(data_list):
+ available_columns = [column for column in value_columns if column in df.columns]
+ filter_columns = (
+ available_columns
+ if total_column not in df.columns
+ else [*available_columns, total_column]
+ )
+ if tod_col not in df.columns or not filter_columns:
+ continue
+ filtered = df.with_columns(pl.col(tod_col).cast(pl.Utf8))
+ daily_rows = filtered.filter(
+ pl.col(tod_col).str.to_lowercase()
+ == EXTERNAL_COMMERCIAL_DAILY_PERIOD.lower()
+ )
+ if breakdown != "Time Period":
+ if (
+ time_period == EXTERNAL_COMMERCIAL_DAILY_PERIOD
+ and not daily_rows.is_empty()
+ ):
+ filtered = daily_rows
+ elif exclude_total_period:
+ filtered = filtered.filter(
+ pl.col(tod_col).str.to_lowercase() != "daily"
+ )
+ long_df = filtered.unpivot(
+ index=tod_col,
+ on=filter_columns,
+ variable_name="trip_purpose",
+ value_name="value",
+ ).rename({tod_col: "time_period"})
+ if breakdown != "Time Period" and time_period not in (
+ "All",
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD,
+ ):
+ long_df = long_df.filter(pl.col("time_period") == time_period)
+ if breakdown != "Trip Purpose":
+ if trip_purpose != "All" or total_column in filter_columns:
+ purpose_column = total_column if trip_purpose == "All" else trip_purpose
+ long_df = long_df.filter(pl.col("trip_purpose") == purpose_column)
+ else:
+ long_df = long_df.filter(pl.col("trip_purpose") != total_column)
+ if breakdown == "Trip Purpose":
+ chart_df = (
+ long_df.group_by("trip_purpose")
+ .agg(pl.col("value").sum().alias("value"))
+ .rename({"trip_purpose": "category"})
+ .with_columns(pl.col("category").cast(pl.Utf8))
+ )
+ order = value_columns
+ else:
+ chart_df = (
+ long_df.group_by("time_period")
+ .agg(pl.col("value").sum().alias("value"))
+ .rename({"time_period": "category"})
+ .with_columns(pl.col("category").cast(pl.Utf8))
+ )
+ if (
+ EXTERNAL_COMMERCIAL_DAILY_PERIOD not in chart_df["category"].to_list()
+ and not chart_df.is_empty()
+ ):
+ chart_df = pl.concat(
+ [
+ chart_df,
+ pl.DataFrame(
+ {
+ "category": [EXTERNAL_COMMERCIAL_DAILY_PERIOD],
+ "value": [chart_df["value"].sum()],
+ }
+ ),
+ ],
+ how="vertical",
+ )
+ chart_df = _with_value_percent_of_daily(chart_df)
+ order = EXTERNAL_COMMERCIAL_TIME_ORDER
+ chart_df = (
+ chart_df.with_columns(
+ pl.col("category")
+ .replace_strict(
+ {value: index for index, value in enumerate(order)},
+ default=len(order),
+ return_dtype=pl.Int64,
+ )
+ .alias("_sort_order")
+ )
+ .sort("_sort_order", "category")
+ .drop("_sort_order")
+ )
+ out.append((label, chart_df))
+ return out
+
+
+def demo_commercial_filter_options(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ config,
+ tod_col: str = "tod",
+ value_columns: list[str] | None = None,
+) -> tuple[list[str], tuple[list[str], dict[str, str | None]]]:
+ """Return time-period and vehicle-type filter options for demo commercial data."""
+ value_columns = value_columns or EXTERNAL_COMMERCIAL_COLUMNS
+ time_periods: set[str] = set()
+ vehicle_types: set[str] = set()
+ for _, df in nonempty(data_list or []):
+ if tod_col in df.columns:
+ time_periods.update(
+ str(value)
+ for value in df[tod_col].drop_nulls().cast(pl.Utf8).to_list()
+ if str(value).lower() != "daily"
+ )
+ vehicle_types.update(column for column in value_columns if column in df.columns)
+ ordered_time_periods = _ordered_values(
+ list(time_periods),
+ preferred=EXTERNAL_TOD_ORDER,
+ )
+ ordered_vehicle_types = config.ordered_values(
+ COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID,
+ [
+ *[column for column in value_columns if column in vehicle_types],
+ *sorted(column for column in vehicle_types if column not in value_columns),
+ ],
+ )
+ vehicle_options = raw_display_options(
+ ordered_vehicle_types,
+ category_id=COMMERCIAL_VEHICLE_TYPE_CATEGORY_ID,
+ config=config,
+ total_raw="All",
+ total_label="All",
+ )
+ return [EXTERNAL_COMMERCIAL_DAILY_PERIOD, *ordered_time_periods], vehicle_options
+
+
+def external_travel_filter_options(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ config,
+ tod_col: str = "tod",
+ value_columns: list[str] | None = None,
+) -> tuple[list[str], tuple[list[str], dict[str, str | None]]]:
+ """Return time-period and trip-purpose filter options for external travel data."""
+ value_columns = value_columns or EXTERNAL_TRAVEL_COLUMNS
+ time_periods: set[str] = set()
+ trip_purposes: set[str] = set()
+ for _, df in nonempty(data_list or []):
+ if tod_col in df.columns:
+ time_periods.update(
+ str(value)
+ for value in df[tod_col].drop_nulls().cast(pl.Utf8).to_list()
+ if str(value).lower() != "daily"
+ )
+ trip_purposes.update(column for column in value_columns if column in df.columns)
+ ordered_time_periods = _ordered_values(
+ list(time_periods),
+ preferred=EXTERNAL_TOD_ORDER,
+ )
+ ordered_trip_purposes = config.ordered_values(
+ EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID,
+ [
+ *[column for column in value_columns if column in trip_purposes],
+ *sorted(column for column in trip_purposes if column not in value_columns),
+ ],
+ )
+ purpose_options = raw_display_options(
+ ordered_trip_purposes,
+ category_id=EXTERNAL_TRAVEL_PURPOSE_CATEGORY_ID,
+ config=config,
+ total_raw="All",
+ total_label="All",
+ )
+ return [EXTERNAL_COMMERCIAL_DAILY_PERIOD, *ordered_time_periods], purpose_options
diff --git a/dashboard/pages/validation/regional.py b/dashboard/pages/validation/regional.py
new file mode 100644
index 0000000..f6e7e14
--- /dev/null
+++ b/dashboard/pages/validation/regional.py
@@ -0,0 +1,556 @@
+"""Regional validation page for demo flow summaries."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import panel as pn
+import polars as pl
+import plotly.graph_objects as go
+
+from dashboard.rendering import selector_row
+from dashboard.helpers.category_helpers import nonempty
+from dashboard import DashboardPage, dashboard_page
+
+TOTAL_FLOW_LABELS = {"total", "all", "all_geographies"}
+FLOW_COMPARISON_OPTIONS = [
+ "Observed",
+ "Difference",
+ "Percent Difference",
+ "Absolute Percent Difference",
+ "Modeled",
+]
+FLOW_VALUE_COLUMNS = {
+ "Modeled": "modeled",
+ "Observed": "observed",
+ "Difference": "difference",
+ "Percent Difference": "percent_difference",
+ "Absolute Percent Difference": "absolute_percent_difference",
+}
+
+
+@dataclass(frozen=True)
+class FlowOption:
+ summary_id: str
+ modeled_geography_types: tuple[str, ...]
+
+
+FLOW_OPTIONS = {
+ "District flows": FlowOption(
+ summary_id="county_flows_validation_summary",
+ modeled_geography_types=("district", "home_district"),
+ ),
+ "County flows": FlowOption(
+ summary_id="county_flows_joja_validation_summary",
+ modeled_geography_types=("county", "home_county"),
+ ),
+}
+
+
+def _is_total_label(value: object) -> bool:
+ return str(value).strip().lower() in TOTAL_FLOW_LABELS
+
+
+def _is_total_expr(column: str) -> pl.Expr:
+ return (
+ pl.col(column)
+ .cast(pl.Utf8)
+ .str.strip_chars()
+ .str.to_lowercase()
+ .is_in(TOTAL_FLOW_LABELS)
+ )
+
+
+def normalize_flow_matrix(df: pl.DataFrame, *, include_totals: bool) -> pl.DataFrame:
+ """Return a flow matrix with an explicit origin column."""
+ if "" in df.columns:
+ matrix = df.rename({"": "Origin"})
+ elif "Origin" in df.columns:
+ matrix = df
+ else:
+ first_column = df.columns[0]
+ matrix = df.rename({first_column: "Origin"})
+ if not include_totals:
+ non_total_columns = [
+ column
+ for column in matrix.columns
+ if column == "Origin" or str(column).lower() != "total"
+ ]
+ matrix = matrix.select(non_total_columns).filter(
+ pl.col("Origin").cast(pl.Utf8).str.to_lowercase() != "total"
+ )
+ return matrix
+
+
+def flow_matrix_to_long(
+ df: pl.DataFrame,
+ *,
+ include_totals: bool,
+ value_col: str,
+) -> pl.DataFrame:
+ """Return a long OD table from a demo wide flow matrix."""
+ matrix = normalize_flow_matrix(df, include_totals=include_totals)
+ destinations = [column for column in matrix.columns if column != "Origin"]
+ if not destinations:
+ return pl.DataFrame(
+ {
+ "Origin": pl.Series([], dtype=pl.Utf8),
+ "Destination": pl.Series([], dtype=pl.Utf8),
+ value_col: pl.Series([], dtype=pl.Float64),
+ }
+ )
+ return (
+ matrix.with_columns(pl.col("Origin").cast(pl.Utf8))
+ .unpivot(
+ index="Origin",
+ on=destinations,
+ variable_name="Destination",
+ value_name=value_col,
+ )
+ .with_columns(
+ pl.col("Destination").cast(pl.Utf8),
+ pl.col(value_col).cast(pl.Float64, strict=False).fill_null(0.0),
+ )
+ )
+
+
+def _flow_label_order(values: list[str], *, include_totals: bool) -> list[str]:
+ """Return stable matrix axis labels, keeping totals at the end."""
+ unique_values = list(dict.fromkeys(str(value) for value in values))
+ non_totals = sorted(value for value in unique_values if not _is_total_label(value))
+ totals = [value for value in unique_values if _is_total_label(value)]
+ if include_totals and totals:
+ return [*non_totals, totals[0]]
+ return non_totals
+
+
+def modeled_flow_long(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ geography_type: str,
+ include_totals: bool,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Return modeled commuting flows as long OD rows for one geography type."""
+ out: list[tuple[str, pl.DataFrame]] = []
+ required = {
+ "origin_geography_type",
+ "origin_geography_id",
+ "destination_geography_type",
+ "destination_geography_id",
+ "commuter_count",
+ }
+ for label, df in nonempty(data_list or []):
+ if not required.issubset(df.columns):
+ continue
+ base = (
+ df.with_columns(
+ pl.col("origin_geography_type").cast(pl.Utf8),
+ pl.col("origin_geography_id").cast(pl.Utf8),
+ pl.col("destination_geography_type").cast(pl.Utf8),
+ pl.col("destination_geography_id").cast(pl.Utf8),
+ pl.col("commuter_count").cast(pl.Float64, strict=False).fill_null(0.0),
+ )
+ .filter(
+ (pl.col("origin_geography_type") == geography_type)
+ & (pl.col("destination_geography_type") == geography_type)
+ )
+ .rename(
+ {
+ "origin_geography_id": "Origin",
+ "destination_geography_id": "Destination",
+ "commuter_count": "modeled",
+ }
+ )
+ .select("Origin", "Destination", "modeled")
+ .filter(~_is_total_expr("Origin") & ~_is_total_expr("Destination"))
+ .group_by("Origin", "Destination")
+ .agg(pl.col("modeled").sum().alias("modeled"))
+ )
+ if include_totals and not base.is_empty():
+ row_totals = (
+ base.group_by("Origin")
+ .agg(pl.col("modeled").sum().alias("modeled"))
+ .with_columns(pl.lit("Total").alias("Destination"))
+ .select("Origin", "Destination", "modeled")
+ )
+ column_totals = (
+ base.group_by("Destination")
+ .agg(pl.col("modeled").sum().alias("modeled"))
+ .with_columns(pl.lit("Total").alias("Origin"))
+ .select("Origin", "Destination", "modeled")
+ )
+ grand_total = pl.DataFrame(
+ {
+ "Origin": ["Total"],
+ "Destination": ["Total"],
+ "modeled": [base["modeled"].sum()],
+ }
+ )
+ base = pl.concat(
+ [base, row_totals, column_totals, grand_total],
+ how="vertical",
+ )
+ out.append((label, base))
+ return out
+
+
+def available_modeled_geography_type(
+ data_list: list[tuple[str, pl.DataFrame]] | None,
+ candidates: tuple[str, ...],
+) -> str | None:
+ """Return the first configured modeled geography type present in the data."""
+ available: set[str] = set()
+ for _, df in nonempty(data_list or []):
+ if {
+ "origin_geography_type",
+ "destination_geography_type",
+ }.issubset(df.columns):
+ origin_types = {
+ str(value)
+ for value in df["origin_geography_type"].drop_nulls().cast(pl.Utf8)
+ }
+ destination_types = {
+ str(value)
+ for value in df["destination_geography_type"].drop_nulls().cast(pl.Utf8)
+ }
+ available.update(origin_types & destination_types)
+ for candidate in candidates:
+ if candidate in available:
+ return candidate
+ return None
+
+
+def flow_comparison_data(
+ observed_data: list[tuple[str, pl.DataFrame]] | None,
+ modeled_data: list[tuple[str, pl.DataFrame]] | None,
+ *,
+ geography_type: str,
+ include_totals: bool,
+) -> list[tuple[str, pl.DataFrame]]:
+ """Align observed and modeled OD flows into comparison-ready long rows."""
+ observed_runs = [
+ (
+ label,
+ flow_matrix_to_long(
+ df, include_totals=include_totals, value_col="observed"
+ ),
+ )
+ for label, df in nonempty(observed_data or [])
+ ]
+ modeled_runs = modeled_flow_long(
+ modeled_data,
+ geography_type=geography_type,
+ include_totals=include_totals,
+ )
+ if not observed_runs:
+ return []
+
+ observed_by_label = {label: df for label, df in observed_runs}
+ default_observed = observed_runs[0][1]
+ labels = [label for label, _ in modeled_runs] or [
+ label for label, _ in observed_runs
+ ]
+ modeled_by_label = {label: df for label, df in modeled_runs}
+ out: list[tuple[str, pl.DataFrame]] = []
+ for label in labels:
+ observed = observed_by_label.get(label, default_observed)
+ modeled = modeled_by_label.get(
+ label,
+ pl.DataFrame(
+ {
+ "Origin": pl.Series([], dtype=pl.Utf8),
+ "Destination": pl.Series([], dtype=pl.Utf8),
+ "modeled": pl.Series([], dtype=pl.Float64),
+ }
+ ),
+ )
+ origins = _flow_label_order(
+ [
+ *(observed["Origin"].to_list() if "Origin" in observed.columns else []),
+ *(modeled["Origin"].to_list() if "Origin" in modeled.columns else []),
+ ],
+ include_totals=include_totals,
+ )
+ destinations = _flow_label_order(
+ [
+ *(
+ observed["Destination"].to_list()
+ if "Destination" in observed.columns
+ else []
+ ),
+ *(
+ modeled["Destination"].to_list()
+ if "Destination" in modeled.columns
+ else []
+ ),
+ ],
+ include_totals=include_totals,
+ )
+ if not origins or not destinations:
+ continue
+ scaffold = pl.DataFrame({"Origin": origins}).join(
+ pl.DataFrame({"Destination": destinations}),
+ how="cross",
+ )
+ comparison = (
+ scaffold.join(observed, on=["Origin", "Destination"], how="left")
+ .join(modeled, on=["Origin", "Destination"], how="left")
+ .with_columns(
+ pl.col("observed").fill_null(0.0).cast(pl.Float64),
+ pl.col("modeled").fill_null(0.0).cast(pl.Float64),
+ )
+ .with_columns(
+ (pl.col("modeled") - pl.col("observed")).alias("difference"),
+ pl.when(pl.col("observed") != 0)
+ .then(
+ (pl.col("modeled") - pl.col("observed"))
+ / pl.col("observed")
+ * 100.0
+ )
+ .otherwise(None)
+ .alias("percent_difference"),
+ )
+ .with_columns(
+ pl.col("percent_difference").abs().alias("absolute_percent_difference")
+ )
+ )
+ out.append((label, comparison))
+ return out
+
+
+def flow_heatmap(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ include_totals: bool,
+ title: str,
+) -> pn.viewable.Viewable:
+ tabs = pn.Tabs()
+ for label, df in nonempty(data_list):
+ matrix = normalize_flow_matrix(df, include_totals=include_totals)
+ destinations = [column for column in matrix.columns if column != "Origin"]
+ z = matrix.select(destinations).to_numpy().tolist()
+ text = [[f"{value:,.0f}" for value in row] for row in z]
+ fig = go.Figure(
+ data=go.Heatmap(
+ z=z,
+ text=text,
+ texttemplate="%{text}",
+ textfont=dict(size=12),
+ x=destinations,
+ y=matrix["Origin"].cast(pl.Utf8).to_list(),
+ colorscale="Blues",
+ hovertemplate=(
+ "Origin: %{y}
Destination: %{x}
Flow: %{z:,.0f}"
+ ),
+ )
+ )
+ fig.update_layout(
+ title=dict(text=title, x=0.01, xanchor="left", y=0.98, yanchor="top"),
+ height=420,
+ xaxis_title="Destination",
+ yaxis_title="Origin",
+ margin=dict(l=70, r=20, t=80, b=70),
+ font=dict(family="Inter, Segoe UI, Arial, sans-serif", size=12),
+ )
+ tabs.append((label, pn.pane.Plotly(fig, sizing_mode="stretch_width")))
+ return tabs
+
+
+def flow_comparison_heatmap(
+ data_list: list[tuple[str, pl.DataFrame]],
+ *,
+ metric: str,
+ title: str,
+) -> pn.viewable.Viewable:
+ """Render aligned observed/modeled flow comparisons as heatmaps."""
+ value_col = FLOW_VALUE_COLUMNS[metric]
+ tabs = pn.Tabs()
+ for label, df in nonempty(data_list):
+ if df.is_empty():
+ continue
+ origins = _flow_label_order(df["Origin"].to_list(), include_totals=True)
+ destinations = _flow_label_order(
+ df["Destination"].to_list(),
+ include_totals=True,
+ )
+ lookup = {
+ (row["Origin"], row["Destination"]): row[value_col]
+ for row in df.select("Origin", "Destination", value_col).to_dicts()
+ }
+ z = [
+ [lookup.get((origin, destination)) for destination in destinations]
+ for origin in origins
+ ]
+ if metric in {"Percent Difference", "Absolute Percent Difference"}:
+ text = [
+ ["" if value is None else f"{float(value):,.1f}%" for value in row]
+ for row in z
+ ]
+ else:
+ text = [
+ ["" if value is None else f"{float(value):,.0f}" for value in row]
+ for row in z
+ ]
+ colorscale = (
+ "RdBu_r" if metric in {"Difference", "Percent Difference"} else "Blues"
+ )
+ z_values = [
+ abs(float(value)) for row in z for value in row if value is not None
+ ]
+ zmax = max(z_values) if z_values else None
+ heatmap_kwargs = {
+ "z": z,
+ "text": text,
+ "texttemplate": "%{text}",
+ "textfont": {"size": 12},
+ "x": destinations,
+ "y": origins,
+ "colorscale": colorscale,
+ "hovertemplate": (
+ "Origin: %{y}
Destination: %{x}
"
+ f"{metric}: %{{text}}"
+ ),
+ }
+ if metric in {"Difference", "Percent Difference"} and zmax is not None:
+ heatmap_kwargs.update(zmid=0, zmin=-zmax, zmax=zmax)
+ fig = go.Figure(data=go.Heatmap(**heatmap_kwargs))
+ fig.update_layout(
+ title=dict(text=title, x=0.01, xanchor="left", y=0.98, yanchor="top"),
+ height=460,
+ xaxis_title="Destination",
+ yaxis_title="Origin",
+ margin=dict(l=70, r=20, t=80, b=70),
+ font=dict(family="Inter, Segoe UI, Arial, sans-serif", size=12),
+ )
+ tabs.append((label, pn.pane.Plotly(fig, sizing_mode="stretch_width")))
+ return tabs
+
+
+@dashboard_page(
+ page_id="regional_validation",
+ title="Regional Validation",
+ group_id="validation",
+ order=55,
+ default_enabled=False,
+ optional_summary_ids=(
+ "county_flows_validation_summary",
+ "county_flows_joja_validation_summary",
+ "commuting_flows",
+ ),
+)
+class RegionalValidationPage(DashboardPage):
+ def build_page(self) -> pn.viewable.Viewable:
+ self.flow_matrix_sel = self.select(
+ "flow_matrix",
+ "Flow Matrix",
+ options=self._available_flow_options,
+ )
+ self.comparison_metric_sel = self.select(
+ "comparison_metric",
+ "Comparison Metric",
+ options=FLOW_COMPARISON_OPTIONS,
+ )
+ self.include_totals_sel = self.selector(
+ "include_totals",
+ widget=pn.widgets.Checkbox(
+ name="Include Totals",
+ value=False,
+ ),
+ label="Include Totals",
+ )
+ self._body = self.section(
+ "regional_validation_body",
+ selectors=("flow_matrix", "comparison_metric", "include_totals"),
+ render=self.render_body,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## Regional Validation"),
+ selector_row(
+ self.flow_matrix_sel,
+ self.comparison_metric_sel,
+ self.include_totals_sel,
+ ),
+ self._body,
+ self.section_note("regional_validation.flows", self._body),
+ sizing_mode="stretch_width",
+ )
+
+ def _available_flow_options(self) -> list[str]:
+ options = [
+ label
+ for label, flow_option in FLOW_OPTIONS.items()
+ if any(
+ not df.is_empty()
+ for _, df in nonempty(
+ self.data.summary(
+ flow_option.summary_id,
+ self.weighting_key,
+ )
+ or []
+ )
+ )
+ ]
+ return options or list(FLOW_OPTIONS)
+
+ def render_flow_section(self) -> pn.viewable.Viewable:
+ flow_label = str(self.flow_matrix_sel.value)
+ flow_option = FLOW_OPTIONS[flow_label]
+ observed_data = self.data.summary(
+ flow_option.summary_id,
+ self.weighting_key,
+ )
+ if observed_data is None:
+ return self.data_not_available_card(
+ detail="External regional flow summaries are unavailable.",
+ missing_items=[flow_option.summary_id],
+ )
+ include_totals = bool(self.include_totals_sel.value)
+ metric = str(self.comparison_metric_sel.value)
+ if metric == "Observed":
+ return pn.Column(
+ flow_heatmap(
+ observed_data,
+ include_totals=include_totals,
+ title=f"Observed {flow_label}",
+ ),
+ sizing_mode="stretch_width",
+ )
+ modeled_data = self.data.summary(
+ "commuting_flows",
+ self.weighting_key,
+ )
+ geography_type = available_modeled_geography_type(
+ modeled_data,
+ flow_option.modeled_geography_types,
+ )
+ if modeled_data is None or geography_type is None:
+ return self.data_not_available_card(
+ detail=(
+ "Modeled commuting flows are unavailable for the selected "
+ "regional flow geography."
+ ),
+ missing_items=["commuting_flows"],
+ )
+ comparison_data = self.query(
+ lambda: flow_comparison_data(
+ observed_data,
+ modeled_data,
+ geography_type=geography_type,
+ include_totals=include_totals,
+ )
+ )
+ return pn.Column(
+ flow_comparison_heatmap(
+ comparison_data,
+ metric=metric,
+ title=f"{metric} {flow_label}",
+ ),
+ sizing_mode="stretch_width",
+ )
+
+ def render_body(self):
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+ return [
+ self.render_flow_section(),
+ ]
diff --git a/dashboard/pages/validation/traffic.py b/dashboard/pages/validation/traffic.py
index 93694fd..e538325 100644
--- a/dashboard/pages/validation/traffic.py
+++ b/dashboard/pages/validation/traffic.py
@@ -2,196 +2,32 @@
from __future__ import annotations
-import panel as pn
-import polars as pl
+from dashboard import DashboardPage, dashboard_page
-from dashboard.components import scatter_chart, selector_row
-from dashboard.helpers.category_helpers import common_column_options, nonempty
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from ._traffic import *
+from ._traffic.composition import TrafficPageCompositionMixin
+from ._traffic.features import TrafficFeatureMixin
+from ._traffic.selector_domains import TrafficSelectorDomainsMixin
-def validation_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- direction: str,
- count_period: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Filter one validation summary list and aggregate to one observed/modeled point per id."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df
- if "direction" in filtered.columns and direction != "All":
- filtered = filtered.with_columns(pl.col("direction").cast(pl.Utf8)).filter(
- pl.col("direction") == direction
- )
- if "count_period" in filtered.columns and count_period != "All":
- filtered = filtered.with_columns(
- pl.col("count_period").cast(pl.Utf8)
- ).filter(pl.col("count_period") == count_period)
-
- id_col = None
- if "count_location_id" in filtered.columns:
- id_col = "count_location_id"
- elif "screenline_id" in filtered.columns:
- id_col = "screenline_id"
- if id_col is not None:
- filtered = (
- filtered.group_by(id_col)
- .agg(
- observed_volume=pl.col("observed_volume").sum(),
- modeled_volume=pl.col("modeled_volume").sum(),
- )
- .sort(id_col)
- )
- out.append((label, filtered))
- return out
-
-
-class TrafficValidationPage(DashboardPage):
- def build_page(self) -> pn.viewable.Viewable:
- direction_opts, _ = common_column_options(
- self.state.get_summary_table_set("traffic_count_comparisons", "weighted"),
- self.state.get_summary_table_set("screenline_flow_comparisons", "weighted"),
- column="direction",
- total_raw="All",
- total_label="All",
- )
- period_opts, _ = common_column_options(
- self.state.get_summary_table_set("traffic_count_comparisons", "weighted"),
- self.state.get_summary_table_set("screenline_flow_comparisons", "weighted"),
- column="count_period",
- total_raw="All",
- total_label="All",
- )
- self.direction_sel = self.selector(
- "direction",
- widget=pn.widgets.Select(
- name="Direction",
- options=direction_opts or ["All"],
- value=(direction_opts or ["All"])[0],
- ),
- label="Direction",
- )
- self.count_period_sel = self.selector(
- "count_period",
- widget=pn.widgets.Select(
- name="Count Period",
- options=period_opts or ["All"],
- value=(period_opts or ["All"])[0],
- ),
- label="Count Period",
- )
- self._body = self.section(
- "traffic_body",
- selectors=("direction", "count_period"),
- render=self.render_body,
- )
- return self.new_section(
- pn.pane.Markdown("## Traffic Validation"),
- selector_row(self.direction_sel, self.count_period_sel),
- self._body,
- sizing_mode="stretch_width",
- )
-
- def sync_controls(self) -> None:
- traffic_list = self.state.get_summary_table_set(
- "traffic_count_comparisons",
- self.weighting_key,
- )
- screenline_list = self.state.get_summary_table_set(
- "screenline_flow_comparisons",
- self.weighting_key,
- )
- direction_opts, _ = common_column_options(
- traffic_list,
- screenline_list,
- column="direction",
- total_raw="All",
- total_label="All",
- )
- period_opts, _ = common_column_options(
- traffic_list,
- screenline_list,
- column="count_period",
- total_raw="All",
- total_label="All",
- )
- self.direction_sel.options = direction_opts or ["All"]
- if self.direction_sel.value not in self.direction_sel.options:
- self.direction_sel.value = self.direction_sel.options[0]
- self.count_period_sel.options = period_opts or ["All"]
- if self.count_period_sel.value not in self.count_period_sel.options:
- self.count_period_sel.value = self.count_period_sel.options[0]
-
- def render_validation_chart(
- self,
- data_list: list[tuple[str, pl.DataFrame]] | None,
- *,
- cache_key: str,
- title: str,
- detail: str,
- missing_summary_id: str,
- ) -> pn.viewable.Viewable:
- if data_list is None:
- return self.data_not_available_card(
- detail=detail,
- missing_items=[missing_summary_id],
- )
- direction = self.direction_sel.value
- count_period = self.count_period_sel.value
- chart_data = self.get_filtered_view(
- cache_key,
- (direction, count_period),
- factory=lambda: validation_chart_data(data_list, direction, count_period),
- )
- return scatter_chart(
- chart_data,
- x_col="observed_volume",
- y_col="modeled_volume",
- title=title,
- xaxis_title="Observed Traffic Volume",
- yaxis_title="Modeled Traffic Volume",
- )
-
- def render_body(self):
- if not self.state.run_labels:
- return [self.no_runs_message()]
-
- return [
- pn.Row(
- self.render_validation_chart(
- self.state.get_summary_table_set(
- "traffic_count_comparisons", self.weighting_key
- ),
- cache_key="traffic_count_comparisons",
- title="Traffic Count Comparisons",
- detail="Traffic count comparisons are unavailable.",
- missing_summary_id="traffic_count_comparisons",
- ),
- self.render_validation_chart(
- self.state.get_summary_table_set(
- "screenline_flow_comparisons", self.weighting_key
- ),
- cache_key="screenline_flow_comparisons",
- title="Screenline Flow Comparisons",
- detail="Screenline flow comparisons are unavailable.",
- missing_summary_id="screenline_flow_comparisons",
- ),
- sizing_mode="stretch_width",
- )
- ]
-
-
-PAGE = DashboardPageDefinition(
+@dashboard_page(
page_id="traffic",
title="Traffic Validation",
group_id="validation",
order=52,
- page_cls=TrafficValidationPage,
- required_summary_ids=(
- "traffic_count_comparisons",
- "screenline_flow_comparisons",
+ required_summary_ids=("screenline_flow_comparisons",),
+ optional_summary_ids=(
+ "link_validation_summary",
+ "count_location_counts_validation_summary",
+ "count_location_volumes_validation_summary",
+ "count_location_scatter_validation_summary",
+ "count_location_fit_validation_summary",
),
)
-
-TrafficValidationPage.definition = PAGE
+class TrafficValidationPage(
+ TrafficPageCompositionMixin,
+ TrafficFeatureMixin,
+ TrafficSelectorDomainsMixin,
+ DashboardPage,
+):
+ pass
diff --git a/dashboard/pages/validation/transit.py b/dashboard/pages/validation/transit.py
index 47ddc3b..0dcaf3d 100644
--- a/dashboard/pages/validation/transit.py
+++ b/dashboard/pages/validation/transit.py
@@ -5,10 +5,14 @@
import panel as pn
import polars as pl
-from dashboard.components import bar_chart, control_row, control_row_spacer, selector_row
-from dashboard.helpers.category_helpers import common_column_options, column_options, nonempty
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from dashboard.rendering import selector_row
+from dashboard.data_access import RunTables
+from dashboard.helpers.category_helpers import (
+ common_column_options,
+ column_options,
+ nonempty,
+)
+from dashboard import DashboardPage, dashboard_page
def filter_transit_data(
@@ -17,17 +21,21 @@ def filter_transit_data(
access_mode: str | None = None,
) -> list[tuple[str, pl.DataFrame]]:
"""Filter transit summaries and aggregate by operator for the chart-specific metric."""
- out = []
- for label, df in nonempty(data_list):
- filtered = df
+
+ def prepare(frame: pl.DataFrame) -> pl.DataFrame:
+ filtered = frame
if "technology" in filtered.columns and technology != "All":
filtered = filtered.with_columns(pl.col("technology").cast(pl.Utf8)).filter(
pl.col("technology") == technology
)
- if access_mode is not None and "access_mode" in filtered.columns and access_mode != "All":
- filtered = filtered.with_columns(pl.col("access_mode").cast(pl.Utf8)).filter(
- pl.col("access_mode") == access_mode
- )
+ if (
+ access_mode is not None
+ and "access_mode" in filtered.columns
+ and access_mode != "All"
+ ):
+ filtered = filtered.with_columns(
+ pl.col("access_mode").cast(pl.Utf8)
+ ).filter(pl.col("access_mode") == access_mode)
if {"operator", "boardings"}.issubset(filtered.columns):
filtered = (
filtered.group_by("operator")
@@ -42,85 +50,78 @@ def filter_transit_data(
.with_columns(pl.col("operator").cast(pl.Utf8))
.sort("operator")
)
- out.append((label, filtered))
- return out
+ return filtered
+
+ return RunTables.from_runs(data_list).map(prepare)
+@dashboard_page(
+ page_id="transit",
+ title="Transit Validation",
+ group_id="validation",
+ order=53,
+ required_summary_ids=(
+ "transit_boardings_by_operator_and_technology",
+ "transit_transfer_rate",
+ ),
+)
class TransitValidationPage(DashboardPage):
def build_page(self) -> pn.viewable.Viewable:
- tech_opts, _ = common_column_options(
- self.state.get_summary_table_set(
- "transit_boardings_by_operator_and_technology", "weighted"
- ),
- self.state.get_summary_table_set("transit_transfer_rate", "weighted"),
- column="technology",
- total_raw="All",
- total_label="All",
- )
- access_opts, _ = column_options(
- self.state.get_summary_table_set("transit_transfer_rate", "weighted") or [],
- "access_mode",
- total_raw="All",
- total_label="All",
- )
- self.technology_sel = self.selector(
+ self.technology_sel = self.select(
"technology",
- widget=pn.widgets.Select(
- name="Transit Technology",
- options=tech_opts or ["All"],
- value=(tech_opts or ["All"])[0],
- ),
- label="Transit Technology",
+ "Transit Technology",
+ options=self._technology_options,
)
- self.access_mode_sel = self.selector(
+ self.access_mode_sel = self.select(
"access_mode",
- widget=pn.widgets.Select(
- name="Access Mode",
- options=access_opts or ["All"],
- value=(access_opts or ["All"])[0],
- ),
- label="Access Mode",
+ "Access Mode",
+ options=self._access_mode_options,
+ )
+ self._boardings_body = self.section(
+ "transit_boardings_body",
+ selectors=("technology",),
+ render=self.render_boardings_section,
)
- self._body = self.section(
- "transit_body",
+ self._transfer_body = self.section(
+ "transit_transfer_body",
selectors=("technology", "access_mode"),
- render=self.render_body,
+ render=self.render_transfer_section,
)
return self.new_section(
pn.pane.Markdown("## Transit Validation"),
- selector_row(self.technology_sel),
- self._body,
+ pn.pane.Markdown("### Transit Boardings"),
+ selector_row(self.technology_sel, self.access_mode_sel),
+ self._boardings_body,
+ self.section_note(
+ "transit_validation.boardings", self._boardings_body
+ ),
+ pn.pane.Markdown("### Transfer Rate"),
+ self.noted_section(
+ "transit_validation.transfer_rate", self._transfer_body
+ ),
sizing_mode="stretch_width",
)
- def sync_controls(self) -> None:
- boarding_list = self.state.get_summary_table_set(
- "transit_boardings_by_operator_and_technology",
- self.weighting_key,
- )
- transfer_list = self.state.get_summary_table_set(
- "transit_transfer_rate",
- self.weighting_key,
- )
- tech_opts, _ = common_column_options(
- boarding_list,
- transfer_list,
+ def _technology_options(self) -> list[str]:
+ options, _ = common_column_options(
+ self.data.summary(
+ "transit_boardings_by_operator_and_technology", self.weighting_key
+ ),
+ self.data.summary("transit_transfer_rate", self.weighting_key),
column="technology",
total_raw="All",
total_label="All",
)
- access_opts, _ = column_options(
- transfer_list or [],
+ return options or ["All"]
+
+ def _access_mode_options(self) -> list[str]:
+ options, _ = column_options(
+ self.data.summary("transit_transfer_rate", self.weighting_key) or [],
"access_mode",
total_raw="All",
total_label="All",
)
- self.technology_sel.options = tech_opts or ["All"]
- if self.technology_sel.value not in self.technology_sel.options:
- self.technology_sel.value = self.technology_sel.options[0]
- self.access_mode_sel.options = access_opts or ["All"]
- if self.access_mode_sel.value not in self.access_mode_sel.options:
- self.access_mode_sel.value = self.access_mode_sel.options[0]
+ return options or ["All"]
def _operator_values(
self,
@@ -142,7 +143,7 @@ def _operator_values(
def render_boardings_chart(
self, operator_values: list[str]
) -> pn.viewable.Viewable:
- boarding_list = self.state.get_summary_table_set(
+ boarding_list = self.data.summary(
"transit_boardings_by_operator_and_technology",
self.weighting_key,
)
@@ -152,27 +153,21 @@ def render_boardings_chart(
missing_items=["transit_boardings_by_operator_and_technology"],
)
technology = self.technology_sel.value
- boarding_data = self.get_filtered_view(
- "transit_boardings",
- technology,
- factory=lambda: filter_transit_data(boarding_list, technology),
+ boarding_data = self.query(
+ lambda: filter_transit_data(boarding_list, technology)
)
- return bar_chart(
+ return self.plot.bar(
boarding_data,
- x_col="operator",
- y_col="boardings",
+ x="operator",
+ y="boardings",
title=f"Total Transit Boardings by Operator - {technology}",
- xaxis_title="Operator",
- yaxis_title="Transit Boardings",
- pct_col="pct",
- as_percent=self.as_percent,
- xaxis_categoryarray=operator_values,
+ x_title="Operator",
+ y_title="Transit Boardings",
+ category_order=operator_values,
)
- def render_transfer_chart(
- self, operator_values: list[str]
- ) -> pn.viewable.Viewable:
- transfer_list = self.state.get_summary_table_set(
+ def render_transfer_chart(self, operator_values: list[str]) -> pn.viewable.Viewable:
+ transfer_list = self.data.summary(
"transit_transfer_rate",
self.weighting_key,
)
@@ -183,59 +178,44 @@ def render_transfer_chart(
)
technology = self.technology_sel.value
access_mode = self.access_mode_sel.value
- transfer_data = self.get_filtered_view(
- "transit_transfer_rate",
- (technology, access_mode),
- factory=lambda: filter_transit_data(transfer_list, technology, access_mode),
+ transfer_data = self.query(
+ lambda: filter_transit_data(transfer_list, technology, access_mode)
)
- return bar_chart(
+ return self.plot.bar(
transfer_data,
- x_col="operator",
- y_col="transfer_rate",
+ x="operator",
+ y="transfer_rate",
title=f"Transit Transfer Rate - {technology}, {access_mode}",
- xaxis_title="Operator",
- yaxis_title="Boardings per Linked Trip",
- as_percent=False,
- xaxis_categoryarray=operator_values,
+ x_title="Operator",
+ y_title="Boardings per Linked Trip",
+ value_mode="count",
+ category_order=operator_values,
)
- def render_body(self):
+ def render_boardings_section(self):
if not self.state.run_labels:
return [self.no_runs_message()]
- boarding_list = self.state.get_summary_table_set(
+ boarding_list = self.data.summary(
"transit_boardings_by_operator_and_technology",
self.weighting_key,
)
- transfer_list = self.state.get_summary_table_set(
+ transfer_list = self.data.summary(
"transit_transfer_rate",
self.weighting_key,
)
operator_values = self._operator_values(boarding_list, transfer_list)
- return [
- pn.Row(
- pn.Column(
- control_row_spacer(),
- self.render_boardings_chart(operator_values),
- ),
- pn.Column(
- control_row(self.access_mode_sel),
- self.render_transfer_chart(operator_values),
- ),
- sizing_mode="stretch_width",
- )
- ]
-
+ return [self.render_boardings_chart(operator_values)]
-PAGE = DashboardPageDefinition(
- page_id="transit",
- title="Transit Validation",
- group_id="validation",
- order=53,
- page_cls=TransitValidationPage,
- required_summary_ids=(
- "transit_boardings_by_operator_and_technology",
- "transit_transfer_rate",
- ),
-)
-
-TransitValidationPage.definition = PAGE
+ def render_transfer_section(self):
+ if not self.state.run_labels:
+ return [self.no_runs_message()]
+ boarding_list = self.data.summary(
+ "transit_boardings_by_operator_and_technology",
+ self.weighting_key,
+ )
+ transfer_list = self.data.summary(
+ "transit_transfer_rate",
+ self.weighting_key,
+ )
+ operator_values = self._operator_values(boarding_list, transfer_list)
+ return [self.render_transfer_chart(operator_values)]
diff --git a/dashboard/pages/validation/vmt.py b/dashboard/pages/validation/vmt.py
index c669fd4..af0e6ac 100644
--- a/dashboard/pages/validation/vmt.py
+++ b/dashboard/pages/validation/vmt.py
@@ -1,151 +1,136 @@
-"""VMT validation page with commercial and bicycle VMT charts."""
+"""VMT validation page with personal auto, commercial, and bicycle VMT charts."""
from __future__ import annotations
-import panel as pn
-import polars as pl
+from dashboard import DashboardPage, dashboard_page
+from dashboard.helpers.geography_helpers import (
+ ALL_GEOGRAPHY_TYPES_LABEL,
+ ALL_GEOGRAPHY_TYPES_VALUE,
+)
-from dashboard.components import bar_chart, control_row, control_row_spacer
-from dashboard.helpers.category_helpers import nonempty
-from dashboard.page_base import DashboardPage
-from dashboard.page_definitions import DashboardPageDefinition
+from ._vmt import * # Re-export the established VMT page helper surface.
+from ._vmt.composition import VmtPageCompositionMixin
+from ._vmt.features import (
+ CommercialVmtFeatureMixin,
+ ExternalVmtFeatureMixin,
+ SegmentedVmtFeatureMixin,
+ VmtOverviewFeatureMixin,
+)
+from ._vmt.selector_domains import VmtSelectorDomainsMixin
-VMT_VIEW_OPTIONS = [
- "Total Commercial VMT",
- "External VMT Only",
- "Internal VMT Only",
- "External minus Internal VMT",
-]
+@dashboard_page(
+ page_id="vmt",
+ title="VMT Validation",
+ group_id="validation",
+ order=54,
+ required_summary_ids=(
+ PERSONAL_AUTO_VMT_SUMMARY_ID,
+ NON_MOTORIZED_VMT_SUMMARY_ID,
+ "bicycle_vmt_by_facility_type",
+ ),
+ optional_summary_ids=(
+ "commercial_vehicle_validation_summary",
+ "commercial_vehicle_vmt_validation_summary",
+ "external_trip_validation_summary",
+ "external_vmt_validation_summary",
+ ),
+)
+class VMTValidationPage(
+ VmtPageCompositionMixin,
+ VmtOverviewFeatureMixin,
+ SegmentedVmtFeatureMixin,
+ CommercialVmtFeatureMixin,
+ ExternalVmtFeatureMixin,
+ VmtSelectorDomainsMixin,
+ DashboardPage,
+):
+ def selected_personal_vmt_geography_type_raw(self) -> str:
+ selected = str(self.personal_vmt_geography_type_sel.value)
+ raw_value = self.personal_vmt_geo_type_raw_by_label.get(selected, selected)
+ return ALL_GEOGRAPHY_TYPES_VALUE if raw_value is None else str(raw_value)
-def commercial_vmt_chart_data(
- data_list: list[tuple[str, pl.DataFrame]],
- vmt_view: str,
-) -> list[tuple[str, pl.DataFrame]]:
- """Return chart-ready commercial VMT rows for the selected comparison view."""
- value_col = {
- "Total Commercial VMT": "total_vmt",
- "External VMT Only": "external_vmt",
- "Internal VMT Only": "internal_vmt",
- "External minus Internal VMT": "vmt_difference",
- }[vmt_view]
- out = []
- for label, df in nonempty(data_list):
- chart_df = (
- df.with_columns((pl.col("external_vmt") - pl.col("internal_vmt")).alias("vmt"))
- if value_col == "vmt_difference"
- else df.with_columns(pl.col(value_col).alias("vmt"))
+ def selected_non_motorized_vmt_geography_type_raw(self) -> str:
+ selected = str(self.non_motorized_vmt_geography_type_sel.value)
+ raw_value = self.non_motorized_vmt_geo_type_raw_by_label.get(
+ selected,
+ selected,
)
- out.append((label, chart_df.select("commercial_vehicle_type", "vmt")))
- return out
+ return ALL_GEOGRAPHY_TYPES_VALUE if raw_value is None else str(raw_value)
+ def export_ignored_selectors(
+ self,
+ section_id: str,
+ selected_values: dict[str, str],
+ ) -> set[str]:
+ section_prefix = {
+ "personal_auto_vmt_body": "personal_auto_vmt",
+ "non_motorized_vmt_body": "non_motorized_vmt",
+ }.get(section_id)
+ if section_prefix is None:
+ return set()
-class VMTValidationPage(DashboardPage):
- def build_page(self) -> pn.viewable.Viewable:
- self.vmt_view_sel = self.selector(
- "commercial_vmt_view",
- widget=pn.widgets.Select(
- name="Commercial VMT View",
- options=VMT_VIEW_OPTIONS,
- value=VMT_VIEW_OPTIONS[0],
- ),
- label="Commercial VMT View",
- )
- self._body = self.section(
- "vmt_body",
- selectors=("commercial_vmt_view",),
- render=self.render_body,
- )
- return self.new_section(
- pn.pane.Markdown("## VMT Validation"),
- self._body,
- sizing_mode="stretch_width",
- )
-
- def render_commercial_chart(self) -> pn.viewable.Viewable:
- commercial_vmt = self.state.get_summary_table_set(
- "commercial_vmt_totals",
- self.weighting_key,
- )
- if commercial_vmt is None:
- return self.data_not_available_card(
- detail="Commercial VMT summaries are unavailable.",
- missing_items=["commercial_vmt_totals"],
- )
- vmt_view = self.vmt_view_sel.value
- commercial_vehicle_type_values = sorted(
- {
- str(value)
- for _, df in nonempty(commercial_vmt)
- for value in (
- df["commercial_vehicle_type"].cast(pl.Utf8).to_list()
- if "commercial_vehicle_type" in df.columns
- else []
- )
+ breakdown = selected_values.get(f"{section_prefix}_breakdown")
+ if breakdown == "Home Geography":
+ return {f"{section_prefix}_geography"}
+ if breakdown:
+ return {
+ f"{section_prefix}_geography_type",
+ f"{section_prefix}_geography",
}
- )
- commercial_vmt_data = self.get_filtered_view(
- "commercial_vmt",
- vmt_view,
- factory=lambda: commercial_vmt_chart_data(commercial_vmt, vmt_view),
- )
- return bar_chart(
- commercial_vmt_data,
- x_col="commercial_vehicle_type",
- y_col="vmt",
- title=f"External vs. Internal Commercial Vehicle VMT - {vmt_view}",
- xaxis_title="Commercial Vehicle Type",
- yaxis_title="Vehicle Miles Traveled",
- as_percent=self.as_percent,
- xaxis_categoryarray=commercial_vehicle_type_values,
- )
+ return set()
- def render_bicycle_chart(self) -> pn.viewable.Viewable:
- bicycle_vmt = self.state.get_summary_table_set(
- "bicycle_vmt_by_facility_type",
- self.weighting_key,
- )
- if bicycle_vmt is None:
- return self.data_not_available_card(
- detail="Bicycle VMT summaries are unavailable.",
- missing_items=["bicycle_vmt_by_facility_type"],
- )
- return bar_chart(
- nonempty(bicycle_vmt),
- x_col="facility_type",
- y_col="bicycle_vmt",
- title="Bicycle VMT by Facility Type",
- xaxis_title="Bicycle Facility Type",
- yaxis_title="Bicycle VMT",
- pct_col="pct",
- as_percent=self.as_percent,
- )
+ def export_canonical_selector_value(
+ self,
+ section_id: str,
+ selector_id: str,
+ value: str,
+ selected_values: dict[str, str],
+ ) -> str:
+ section_prefix = {
+ "personal_auto_vmt_body": "personal_auto_vmt",
+ "non_motorized_vmt_body": "non_motorized_vmt",
+ }.get(section_id)
+ if (
+ section_prefix is not None
+ and selector_id == f"{section_prefix}_geography_type"
+ and selected_values.get(f"{section_prefix}_breakdown") != "Home Geography"
+ ):
+ return ALL_GEOGRAPHY_TYPES_LABEL
+ return value
- def render_body(self):
- if not self.state.run_labels:
- return [self.no_runs_message()]
- return [
- pn.Row(
- pn.Column(
- control_row(self.vmt_view_sel),
- self.render_commercial_chart(),
- ),
- pn.Column(control_row_spacer(), self.render_bicycle_chart()),
- sizing_mode="stretch_width",
- )
- ]
+ def selected_personal_vmt_geography_raw(self) -> str:
+ selected = str(self.personal_vmt_geography_sel.value)
+ raw_value = self.personal_vmt_geo_raw_by_label.get(selected, selected)
+ return "All" if raw_value is None else str(raw_value)
+ def selected_non_motorized_vmt_geography_raw(self) -> str:
+ selected = str(self.non_motorized_vmt_geography_sel.value)
+ raw_value = self.non_motorized_vmt_geo_raw_by_label.get(selected, selected)
+ return "All" if raw_value is None else str(raw_value)
-PAGE = DashboardPageDefinition(
- page_id="vmt",
- title="VMT Validation",
- group_id="validation",
- order=54,
- page_cls=VMTValidationPage,
- required_summary_ids=(
- "commercial_vmt_totals",
- "bicycle_vmt_by_facility_type",
- ),
-)
+ def selected_personal_vmt_mode_raw(self) -> str:
+ selected = str(self.personal_vmt_mode_sel.value)
+ raw_value = self.personal_vmt_mode_raw_by_label.get(selected, selected)
+ return "All" if raw_value is None else str(raw_value)
-VMTValidationPage.definition = PAGE
+ def selected_non_motorized_vmt_mode_raw(self) -> str:
+ selected = str(self.non_motorized_vmt_mode_sel.value)
+ raw_value = self.non_motorized_vmt_mode_raw_by_label.get(selected, selected)
+ return "All" if raw_value is None else str(raw_value)
+
+ def selected_demo_commercial_vehicle_type_raw(self) -> str:
+ selected = str(self.demo_commercial_vehicle_type_sel.value)
+ raw_value = self.demo_commercial_vehicle_type_raw_by_label.get(
+ selected,
+ selected,
+ )
+ return "All" if raw_value is None else str(raw_value)
+
+ def selected_external_travel_trip_purpose_raw(self) -> str:
+ selected = str(self.external_travel_trip_purpose_sel.value)
+ raw_value = self.external_travel_trip_purpose_raw_by_label.get(
+ selected,
+ selected,
+ )
+ return "All" if raw_value is None else str(raw_value)
diff --git a/dashboard/rendering/__init__.py b/dashboard/rendering/__init__.py
new file mode 100644
index 0000000..ff25807
--- /dev/null
+++ b/dashboard/rendering/__init__.py
@@ -0,0 +1,29 @@
+"""Context-bound dashboard rendering services."""
+
+from dashboard.rendering.context import RenderContext
+from dashboard.rendering.plotter import FigureBuilder, Plotter
+from dashboard.rendering.layout import (
+ control_row,
+ control_row_spacer,
+ data_unavailable_card,
+ run_legend_entries,
+ run_legend_panes,
+ selector_row,
+)
+from dashboard.rendering.tables import (
+ column_titles,
+ data_table,
+ drop_index_columns,
+ format_numeric,
+ format_numeric_frame,
+ standardize_keys,
+ to_pandas,
+)
+
+__all__ = [
+ "FigureBuilder", "Plotter", "RenderContext", "column_titles",
+ "control_row", "control_row_spacer", "data_table", "data_unavailable_card",
+ "drop_index_columns", "format_numeric", "format_numeric_frame",
+ "run_legend_entries", "run_legend_panes", "selector_row", "standardize_keys",
+ "to_pandas",
+]
diff --git a/dashboard/rendering/context.py b/dashboard/rendering/context.py
new file mode 100644
index 0000000..d18b4b8
--- /dev/null
+++ b/dashboard/rendering/context.py
@@ -0,0 +1,55 @@
+"""Immutable rendering policy for one dashboard state."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+ValueMode = Literal["count", "share"]
+HoverMode = Literal["closest", "all"]
+
+DEFAULT_RUN_COLORS = (
+ "#4E79A7",
+ "#F28E2B",
+ "#59A14F",
+ "#E15759",
+ "#76B7B2",
+ "#EDC948",
+ "#B07AA1",
+ "#9C755F",
+)
+
+
+def _hover_mode(value: str | None) -> HoverMode:
+ return "all" if str(value or "").strip().lower() == "all" else "closest"
+
+
+@dataclass(frozen=True)
+class RenderContext:
+ """All presentation policy needed to render figures and run-aware UI."""
+
+ run_colors: tuple[str, ...] = DEFAULT_RUN_COLORS
+ run_labels: tuple[str, ...] = ()
+ value_mode: ValueMode = "count"
+ bar_hover_mode: HoverMode = "closest"
+ density_hover_mode: HoverMode = "closest"
+
+ @classmethod
+ def from_dashboard(cls, config, state) -> "RenderContext":
+ return cls(
+ run_colors=tuple(config.run_colors or DEFAULT_RUN_COLORS),
+ run_labels=tuple(str(label) for label in state.run_labels),
+ value_mode="share" if state.value_mode == "Percent" else "count",
+ bar_hover_mode=_hover_mode(config.bar_hover_mode),
+ density_hover_mode=_hover_mode(config.density_hover_mode),
+ )
+
+ def color(self, label: str, fallback_index: int = 0) -> str:
+ label = str(label)
+ index = (
+ self.run_labels.index(label)
+ if label in self.run_labels
+ else fallback_index
+ )
+ colors = self.run_colors or DEFAULT_RUN_COLORS
+ return colors[index % len(colors)]
diff --git a/dashboard/rendering/figures.py b/dashboard/rendering/figures.py
new file mode 100644
index 0000000..250b9f8
--- /dev/null
+++ b/dashboard/rendering/figures.py
@@ -0,0 +1,320 @@
+"""Plotly figure construction without Panel or process-global state."""
+
+from __future__ import annotations
+
+import math
+from typing import Literal
+
+import numpy as np
+import plotly.graph_objects as go
+import polars as pl
+
+from dashboard.data_access import RunTableData, RunTables
+from dashboard.rendering.context import RenderContext
+
+ChartTables = RunTables | RunTableData
+ChartValueMode = Literal["dashboard", "count", "share"]
+
+
+def _share_mode(context: RenderContext, value_mode: ChartValueMode) -> bool:
+ return context.value_mode == "share" if value_mode == "dashboard" else value_mode == "share"
+
+
+def _require_columns(data: ChartTables, chart: str, *columns: str) -> None:
+ for label, frame in data:
+ missing = [column for column in columns if column not in frame.columns]
+ if missing:
+ raise ValueError(
+ f"{chart} chart for run {label!r} is missing columns: {', '.join(missing)}"
+ )
+
+
+def _layout(
+ figure: go.Figure,
+ *,
+ title: str,
+ x_title: str,
+ y_title: str,
+ height: int,
+ barmode: str | None = None,
+) -> None:
+ kwargs = {
+ "title": dict(text=title, x=0.01, xanchor="left", y=0.98, yanchor="top"),
+ "height": height,
+ "xaxis_title": x_title,
+ "yaxis_title": y_title,
+ "legend": dict(orientation="h", yanchor="bottom", y=1.12, x=0),
+ "margin": dict(l=60, r=20, t=90, b=90),
+ "title_font": dict(size=16),
+ "font": dict(family="Inter, Segoe UI, Arial, sans-serif", size=12),
+ "hoverlabel": dict(font=dict(size=12)),
+ }
+ if barmode is not None:
+ kwargs["barmode"] = barmode
+ figure.update_layout(**kwargs)
+ figure.update_xaxes(automargin=True, tickangle=-20)
+ figure.update_yaxes(automargin=True)
+ figure.update_traces(marker_line_width=0.4)
+
+
+def _y_title(title: str, share: bool) -> str:
+ return f"Percent of {title} (%)" if share else title
+
+
+def _hover_value(value: float, y_title: str, share: bool) -> str:
+ if share:
+ return f"{value:,.2f}%"
+ if str(y_title).strip().lower() in {"trips", "tours", "stops"}:
+ return f"{value:,.0f}"
+ return f"{value:,.1f}"
+
+
+def _point_hover(
+ label: str,
+ x_value: object,
+ y_value: float,
+ *,
+ x: str,
+ y: str,
+ x_title: str,
+ y_title: str,
+ share: bool,
+) -> str:
+ return (
+ f"{label}
{x_title or x}: {x_value}"
+ f"
{_y_title(y_title or y, share)}: {_hover_value(y_value, y_title, share)}"
+ )
+
+
+def bar_figure(
+ context: RenderContext,
+ data: ChartTables,
+ *,
+ x: str,
+ y: str,
+ title: str = "",
+ x_title: str = "",
+ y_title: str = "Count",
+ barmode: str = "group",
+ share_y: str | None = None,
+ value_mode: ChartValueMode = "dashboard",
+ height: int = 400,
+ category_order: list[object] | None = None,
+ show_legend: bool | None = None,
+) -> go.Figure:
+ """Build a grouped/stacked categorical figure."""
+ _require_columns(data, "bar", x, y)
+ share = _share_mode(context, value_mode)
+ figure = go.Figure()
+ observed_order: list[object] = []
+ for index, (label, frame) in enumerate(data):
+ if frame.is_empty():
+ continue
+ if category_order is not None:
+ category_frame = pl.DataFrame({x: [str(value) for value in category_order]})
+ frame = category_frame.join(
+ frame.with_columns(pl.col(x).cast(pl.Utf8)), on=x, how="left"
+ )
+ fill = [pl.col(y).fill_null(0.0).cast(pl.Float64)]
+ if share_y and share_y in frame.columns:
+ fill.append(pl.col(share_y).fill_null(0.0).cast(pl.Float64))
+ frame = frame.with_columns(fill)
+ x_values = frame[x].to_list()
+ observed_order.extend(value for value in x_values if value not in observed_order)
+ source = share_y if share and share_y and share_y in frame.columns else y
+ values = np.asarray(frame[source].to_list(), dtype=float)
+ if share and source == y and values.sum() > 0:
+ values = values / values.sum() * 100.0
+ y_values = values.tolist()
+ hover = [
+ _point_hover(
+ str(label), x_value, y_value, x=x, y=y,
+ x_title=x_title, y_title=y_title, share=share,
+ )
+ for x_value, y_value in zip(x_values, y_values)
+ ]
+ figure.add_trace(
+ go.Bar(
+ name=str(label), x=x_values, y=y_values,
+ marker_color=context.color(str(label), index),
+ hovertemplate="%{customdata}", customdata=hover,
+ )
+ )
+ _layout(
+ figure, title=title, x_title=x_title,
+ y_title=_y_title(y_title, share), height=height, barmode=barmode,
+ )
+ if context.bar_hover_mode == "all":
+ figure.update_layout(hovermode="x unified")
+ if show_legend is not None:
+ figure.update_layout(showlegend=show_legend)
+ final_order = category_order or observed_order
+ if final_order:
+ figure.update_xaxes(type="category", categoryorder="array", categoryarray=final_order)
+ return figure
+
+
+def line_figure(
+ context: RenderContext,
+ data: ChartTables,
+ *,
+ x: str,
+ y: str,
+ title: str = "",
+ x_title: str = "",
+ y_title: str = "",
+ value_mode: ChartValueMode = "dashboard",
+ height: int = 350,
+) -> go.Figure:
+ _require_columns(data, "line", x, y)
+ share = _share_mode(context, value_mode)
+ figure = go.Figure()
+ for index, (label, frame) in enumerate(data):
+ values = np.asarray(frame[y].to_list(), dtype=float)
+ if share and values.sum() > 0:
+ values = values / values.sum() * 100.0
+ figure.add_trace(go.Scatter(
+ name=str(label), x=frame[x].to_list(), y=values.tolist(), mode="lines",
+ line=dict(color=context.color(str(label), index), width=2),
+ ))
+ _layout(figure, title=title, x_title=x_title, y_title=_y_title(y_title, share), height=height)
+ return figure
+
+
+def _clock_ticks(values: list[object]) -> tuple[list[str], list[str]] | None:
+ parsed = []
+ for value in dict.fromkeys(str(value) for value in values):
+ parts = value.split(":")
+ if len(parts) != 2 or not all(part.isdigit() for part in parts):
+ return None
+ hour, minute = map(int, parts)
+ if hour > 23 or minute not in {0, 30}:
+ return None
+ parsed.append((value, hour, minute))
+ tick_values = [value for value, _, minute in parsed if minute == 0]
+ return (tick_values, [f"{hour}:00" for _, hour, minute in parsed if minute == 0]) if tick_values else None
+
+
+def density_figure(
+ context: RenderContext,
+ data: ChartTables,
+ *,
+ x: str,
+ y: str,
+ title: str = "",
+ x_title: str = "",
+ y_title: str = "Frequency",
+ value_mode: ChartValueMode = "dashboard",
+ height: int = 350,
+ x_range: tuple[float, float] | None = None,
+ category_order: list[object] | None = None,
+ tick_values: list[object] | None = None,
+ tick_text: list[str] | None = None,
+ hover_x_title: str | None = None,
+) -> go.Figure:
+ _require_columns(data, "density", x, y)
+ share = _share_mode(context, value_mode)
+ figure = go.Figure()
+ observed_x: list[object] = []
+ for index, (label, frame) in enumerate(data):
+ x_values = frame[x].to_list()
+ observed_x.extend(x_values)
+ values = np.asarray(frame[y].to_list(), dtype=float)
+ if share and values.sum() > 0:
+ values = values / values.sum() * 100.0
+ y_values = values.tolist()
+ color = context.color(str(label), index)
+ hover = [
+ _point_hover(str(label), xv, yv, x=x, y=y,
+ x_title=hover_x_title or x_title, y_title=y_title, share=share)
+ for xv, yv in zip(x_values, y_values)
+ ]
+ figure.add_trace(go.Scatter(
+ name=str(label), x=x_values, y=y_values, mode="lines",
+ line=dict(color=color, width=2), fill="tozeroy",
+ hovertemplate="%{customdata}", customdata=hover,
+ ))
+ _layout(figure, title=title, x_title=x_title, y_title=_y_title(y_title, share), height=height)
+ if context.density_hover_mode == "all":
+ figure.update_layout(hovermode="x unified")
+ if x_range is not None:
+ figure.update_xaxes(range=[float(x_range[0]), float(x_range[1])])
+ if category_order is not None:
+ figure.update_xaxes(type="category", categoryorder="array", categoryarray=category_order)
+ if tick_values is not None:
+ kwargs = {"tickmode": "array", "tickvals": tick_values}
+ if tick_text is not None:
+ kwargs["ticktext"] = tick_text
+ figure.update_xaxes(**kwargs)
+ elif ticks := _clock_ticks(observed_x):
+ figure.update_xaxes(tickmode="array", tickvals=ticks[0], ticktext=ticks[1])
+ return figure
+
+
+def _finite(values: list[object]) -> list[float]:
+ output = []
+ for value in values:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ continue
+ if math.isfinite(number):
+ output.append(number)
+ return output
+
+
+def scatter_figure(
+ context: RenderContext,
+ data: ChartTables,
+ *,
+ x: str,
+ y: str,
+ title: str = "",
+ x_title: str = "",
+ y_title: str = "",
+ height: int = 400,
+ drop_zero_y: bool = False,
+ fit_overlays: ChartTables | None = None,
+ fit_annotation: str = "annotation",
+ one_to_one: bool = False,
+) -> go.Figure:
+ _require_columns(data, "scatter", x, y)
+ figure = go.Figure()
+ label_indices = {str(label): index for index, (label, _) in enumerate(data)}
+ axis_values: list[float] = []
+ for index, (label, frame) in enumerate(data):
+ if drop_zero_y:
+ frame = frame.filter(pl.col(y).fill_null(0) != 0)
+ if frame.is_empty():
+ continue
+ x_values, y_values = frame[x].to_list(), frame[y].to_list()
+ if one_to_one:
+ axis_values.extend(_finite([*x_values, *y_values]))
+ figure.add_trace(go.Scatter(
+ name=str(label), x=x_values, y=y_values, mode="markers",
+ marker=dict(color=context.color(str(label), index), size=8, line=dict(width=0.4)),
+ ))
+ for index, (label, frame) in enumerate(fit_overlays or []):
+ if frame.is_empty() or x not in frame.columns or y not in frame.columns:
+ continue
+ color = context.color(str(label), label_indices.get(str(label), index))
+ figure.add_trace(go.Scatter(
+ name=f"{label} fit", x=frame[x].to_list(), y=frame[y].to_list(),
+ mode="lines", line=dict(color=color, width=2),
+ ))
+ if fit_annotation in frame.columns and str(frame[fit_annotation][0] or "").strip():
+ figure.add_annotation(
+ text=str(frame[fit_annotation][0]), xref="paper", yref="paper",
+ x=0.02, y=max(0.05, 0.98 - 0.12 * index), showarrow=False,
+ font=dict(color=color, size=12), bgcolor="rgba(255,255,255,0.75)",
+ bordercolor=color, borderwidth=1,
+ )
+ if one_to_one:
+ maximum = max([value for value in axis_values if value >= 0], default=1.0) or 1.0
+ figure.add_trace(go.Scatter(
+ name="1:1 line", x=[0.0, maximum], y=[0.0, maximum], mode="lines",
+ line=dict(color="#BDBDBD", width=1.5, dash="dash"),
+ hoverinfo="skip", showlegend=False,
+ ))
+ _layout(figure, title=title, x_title=x_title, y_title=y_title, height=height)
+ return figure
diff --git a/dashboard/rendering/layout.py b/dashboard/rendering/layout.py
new file mode 100644
index 0000000..4980c62
--- /dev/null
+++ b/dashboard/rendering/layout.py
@@ -0,0 +1,114 @@
+"""Run-aware cards, legends, and small Panel layout helpers."""
+
+from __future__ import annotations
+
+import html
+
+import panel as pn
+
+from dashboard.rendering.context import RenderContext
+
+
+def run_legend_entries(
+ context: RenderContext,
+ run_labels: list[str] | tuple[str, ...] | None = None,
+) -> list[dict[str, str]]:
+ labels = tuple(str(label) for label in (run_labels or context.run_labels))
+ return [
+ {"label": label, "color": context.color(label, index)}
+ for index, label in enumerate(labels)
+ ]
+
+
+def _legend_html(label: str, color: str) -> str:
+ safe_label = html.escape(label)
+ safe_color = html.escape(color, quote=True)
+ return (
+ f''
+ f'{safe_label}
'
+ )
+
+
+def run_legend_panes(context: RenderContext) -> list[pn.pane.HTML]:
+ return [
+ pn.pane.HTML(_legend_html(entry["label"], entry["color"]))
+ for entry in run_legend_entries(context)
+ ]
+
+
+def kpi_box(
+ context: RenderContext,
+ label: str,
+ values: list[tuple[str, float]],
+ format_fn=None,
+ icon: str = "",
+) -> pn.viewable.Viewable:
+ if format_fn is None:
+ format_fn = lambda value: f"{value:,.0f}"
+ maximum = max([value for _, value in values], default=0) or 1
+ items = []
+ for index, (run_label, value) in enumerate(values):
+ color = context.color(run_label, index)
+ width = int((float(value) / maximum) * 100)
+ items.append(pn.pane.HTML(
+ f''
+ f'
{run_label}
'
+ f'
{format_fn(value)}
'
+ f'
'
+ ))
+ return pn.Card(
+ *items,
+ title=f"{icon} {label}" if icon else label,
+ sizing_mode="stretch_width",
+ min_width=260,
+ styles={"border-radius": "10px"},
+ )
+
+
+def data_unavailable_card(
+ title: str,
+ detail: str,
+ missing_items: list[str] | tuple[str, ...] | None = None,
+) -> pn.Card:
+ lines = [detail]
+ if missing_items:
+ lines.extend(("", "Required inputs:", *(f"- `{item}`" for item in missing_items)))
+ return pn.Card(
+ pn.pane.Markdown("\n".join(lines)),
+ title=title,
+ sizing_mode="stretch_width",
+ styles={"border-radius": "10px"},
+ )
+
+
+def control_row(*objects, height: int = 72) -> pn.Row:
+ return pn.Row(
+ *objects,
+ sizing_mode="stretch_width",
+ min_height=height,
+ margin=(0, 0, 8, 0),
+ styles={
+ "justify-content": "flex-end",
+ "align-items": "flex-start",
+ "flex-wrap": "wrap",
+ "row-gap": "8px",
+ "column-gap": "12px",
+ },
+ )
+
+
+def selector_row(*objects, height: int = 72) -> pn.Row:
+ return control_row(*objects, height=height)
+
+
+def control_row_spacer(height: int = 56) -> pn.pane.HTML:
+ return pn.pane.HTML(
+ f"",
+ sizing_mode="stretch_width",
+ margin=(0, 0, 8, 0),
+ )
diff --git a/dashboard/rendering/plotter.py b/dashboard/rendering/plotter.py
new file mode 100644
index 0000000..6cc9b84
--- /dev/null
+++ b/dashboard/rendering/plotter.py
@@ -0,0 +1,70 @@
+"""Panel adapters over the figure-first plotting API."""
+
+from __future__ import annotations
+
+import panel as pn
+
+from dashboard.rendering.context import RenderContext
+import dashboard.rendering.figures as figures
+from dashboard.rendering.layout import kpi_box
+
+
+class FigureBuilder:
+ """Build testable Plotly figures using one immutable render context."""
+
+ def __init__(self, context: RenderContext) -> None:
+ self.context = context
+
+ def bar(self, data, **kwargs):
+ return figures.bar_figure(self.context, data, **kwargs)
+
+ def line(self, data, **kwargs):
+ return figures.line_figure(self.context, data, **kwargs)
+
+ def density(self, data, **kwargs):
+ return figures.density_figure(self.context, data, **kwargs)
+
+ def scatter(self, data, **kwargs):
+ return figures.scatter_figure(self.context, data, **kwargs)
+
+
+class Plotter:
+ """Concise page plotting API with an explicit figure escape hatch."""
+
+ def __init__(self, context: RenderContext) -> None:
+ self.context = context
+ self.figure = FigureBuilder(context)
+
+ @staticmethod
+ def panel(figure, *, aspect_ratio: float | None = None) -> pn.pane.Plotly:
+ if aspect_ratio is not None:
+ return pn.pane.Plotly(
+ figure,
+ sizing_mode="scale_width",
+ aspect_ratio=aspect_ratio,
+ )
+ return pn.pane.Plotly(figure, sizing_mode="stretch_width")
+
+ def bar(self, data, **kwargs) -> pn.pane.Plotly:
+ return self.panel(self.figure.bar(data, **kwargs))
+
+ def line(self, data, **kwargs) -> pn.pane.Plotly:
+ return self.panel(self.figure.line(data, **kwargs))
+
+ def density(self, data, **kwargs) -> pn.pane.Plotly:
+ return self.panel(self.figure.density(data, **kwargs))
+
+ def scatter(
+ self,
+ data,
+ *,
+ panel_aspect_ratio: float | None = None,
+ **kwargs,
+ ) -> pn.pane.Plotly:
+ return self.panel(
+ self.figure.scatter(data, **kwargs),
+ aspect_ratio=panel_aspect_ratio,
+ )
+
+ def kpi(self, label: str, values, **kwargs):
+ return kpi_box(self.context, label, values, **kwargs)
diff --git a/dashboard/rendering/tables.py b/dashboard/rendering/tables.py
new file mode 100644
index 0000000..362ec46
--- /dev/null
+++ b/dashboard/rendering/tables.py
@@ -0,0 +1,166 @@
+"""Panel table rendering and display-only numeric formatting."""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import panel as pn
+import polars as pl
+
+from dashboard.data_access import RunTableData, RunTables
+
+TableData = RunTables | RunTableData
+
+
+def to_pandas(frame: pl.DataFrame):
+ categorical = [name for name in frame.columns if frame[name].dtype == pl.Categorical]
+ if categorical:
+ frame = frame.with_columns(pl.col(name).cast(pl.Utf8) for name in categorical)
+ return frame.to_pandas()
+
+
+def format_numeric(value, *, precision: int | None = 2):
+ if value is None:
+ return None
+ if isinstance(value, (bool, np.bool_)):
+ return value
+ if isinstance(value, (int, np.integer)):
+ return str(int(value))
+ if isinstance(value, (float, np.floating)):
+ number = float(value)
+ if not math.isfinite(number):
+ return None
+ if number.is_integer():
+ return str(int(number))
+ if precision is None:
+ return str(number)
+ if precision <= 0:
+ return str(int(round(number)))
+ decimals = precision - 1 - math.floor(math.log10(abs(number)))
+ rounded = round(number, decimals)
+ if float(rounded).is_integer():
+ return str(int(rounded))
+ return f"{rounded:.{max(decimals, 0)}f}".rstrip("0").rstrip(".")
+ return value
+
+
+def format_numeric_frame(
+ frame: pl.DataFrame,
+ *,
+ numeric_precision: int | None = 2,
+ numeric_precision_by_column: dict[str, int] | None = None,
+) -> pl.DataFrame:
+ if numeric_precision is None and not numeric_precision_by_column:
+ return frame
+ expressions = []
+ for column, dtype in frame.schema.items():
+ if not getattr(dtype, "is_numeric", lambda: False)():
+ continue
+ precision = (
+ numeric_precision_by_column[column]
+ if numeric_precision_by_column and column in numeric_precision_by_column
+ else numeric_precision
+ )
+ expressions.append(
+ pl.col(column).map_elements(
+ lambda value, digits=precision: format_numeric(value, precision=digits),
+ return_dtype=pl.Utf8,
+ ).alias(column)
+ )
+ return frame.with_columns(expressions) if expressions else frame
+
+
+def drop_index_columns(frame: pl.DataFrame) -> pl.DataFrame:
+ columns = [
+ column for column in frame.columns
+ if column == "index" or column.startswith("__index_level_")
+ ]
+ return frame.drop(columns) if columns else frame
+
+
+_WORD_OVERRIDES = {
+ "aadt": "AADT", "am": "AM", "av": "AV", "avg": "Average",
+ "brt": "BRT", "cvm": "CVM", "da": "DA", "dest": "Destination",
+ "hh": "Household", "hov": "HOV", "hov2": "HOV2", "hov3": "HOV3",
+ "id": "ID", "ids": "IDs", "lrt": "LRT", "maz": "MAZ", "md": "MD",
+ "mgra": "MGRA", "n": "Number", "nonmandatory": "Non-Mandatory",
+ "num": "Number", "orig": "Origin", "pm": "PM", "pmt": "PMT",
+ "pct": "Percent", "rmse": "RMSE", "sov": "SOV", "tap": "TAP",
+ "taz": "TAZ", "tnc": "TNC", "tod": "Time of Day", "vmt": "VMT",
+ "vol": "Volume",
+}
+_NAME_OVERRIDES = {
+ "% Diff": "% Diff", "FACTYPE": "Facility Type",
+ "From_Node": "From Node", "To_Node": "To Node",
+}
+
+
+def humanize_column(column: object) -> str:
+ name = str(column)
+ if name in _NAME_OVERRIDES:
+ return _NAME_OVERRIDES[name]
+ if " " in name and "_" not in name:
+ return name
+ words = []
+ for token in (token for token in name.replace("-", "_").split("_") if token):
+ lower = token.lower()
+ if lower in _WORD_OVERRIDES:
+ words.append(_WORD_OVERRIDES[lower])
+ elif lower in {"and", "of"}:
+ words.append(lower)
+ elif token.isupper() and len(token) > 1:
+ words.append(token)
+ else:
+ words.append(token[:1].upper() + token[1:].lower())
+ return " ".join(words).replace("Non Mandatory", "Non-Mandatory") or name
+
+
+def column_titles(columns: list[object] | tuple[object, ...]) -> dict[str, str]:
+ titles, counts = {}, {}
+ for column in columns:
+ title = humanize_column(column)
+ counts[title] = counts.get(title, 0) + 1
+ titles[str(column)] = f"{title} ({counts[title]})" if counts[title] > 1 else title
+ return titles
+
+
+def data_table(
+ data: TableData,
+ title: str = "",
+ height: int = 300,
+ numeric_precision: int | None = 2,
+ numeric_precision_by_column: dict[str, int] | None = None,
+ column_sorters: dict[str, str] | None = None,
+) -> pn.viewable.Viewable:
+ tabs = pn.Tabs()
+ for label, frame in data:
+ if frame.is_empty():
+ continue
+ display = format_numeric_frame(
+ drop_index_columns(frame),
+ numeric_precision=numeric_precision,
+ numeric_precision_by_column=numeric_precision_by_column,
+ )
+ configuration = {}
+ if column_sorters:
+ configuration["columns"] = [
+ {"field": str(column), "sorter": sorter}
+ for column, sorter in column_sorters.items()
+ if str(column) in display.columns
+ ]
+ tabs.append((label, pn.widgets.Tabulator(
+ to_pandas(display), height=height, sizing_mode="stretch_width",
+ theme="simple", titles=column_titles(display.columns),
+ show_index=False, configuration=configuration,
+ )))
+ return pn.Column(pn.pane.Markdown(f"### {title}"), tabs) if title else tabs
+
+
+def standardize_keys(data: TableData, key: str, value: str) -> list[tuple[str, pl.DataFrame]]:
+ keys = {item for _, frame in data for item in frame[key].to_list()}
+ output = []
+ for label, frame in data:
+ base = pl.DataFrame({key: list(keys)})
+ output.append((label, base.join(frame.select(key, value), on=key, how="left").fill_null(0).sort(key)))
+ return output
diff --git a/dashboard/state.py b/dashboard/state.py
index cdea890..9ac9e53 100644
--- a/dashboard/state.py
+++ b/dashboard/state.py
@@ -15,14 +15,23 @@
)
from processor.models import RunData
from processor.prepare.availability import table_availability, table_diagnostics
-from processor.summarize.cache import SummaryRun, normalize_weighting_modes
+from processor.summarize.cache_types import SummaryRun
+from runtime.weighting import (
+ WeightingModeDefinition,
+ weighting_mode_definitions,
+)
+
+_DEFAULT_WEIGHTING_DEFINITIONS = weighting_mode_definitions(None)
+_DEFAULT_WEIGHTING_LABELS = [
+ definition.label for definition in _DEFAULT_WEIGHTING_DEFINITIONS
+]
class DashboardState(param.Parameterized):
"""Single source of truth for live Panel dashboard state."""
weight_mode = param.ObjectSelector(
- default="Weighted", objects=["Weighted", "Unweighted"]
+ default=_DEFAULT_WEIGHTING_LABELS[0], objects=_DEFAULT_WEIGHTING_LABELS
)
value_mode = param.ObjectSelector(default="Percent", objects=["Percent", "Count"])
active_tab = param.Integer(default=0, bounds=(0, None))
@@ -31,6 +40,8 @@ def __init__(
self,
summary_runs: list[SummaryRun] | None = None,
weighting_modes: list[str] | None = None,
+ weighting_definitions: tuple[WeightingModeDefinition, ...] | None = None,
+ config: Any | None = None,
prepared_run_provider: DashboardPreparedRunProvider | None = None,
dashboard_segmentation_type: str | None = None,
default_segmentation_visibility: str = "full_and_segments",
@@ -43,8 +54,29 @@ def __init__(
if prepared_run_provider is not None
else DashboardPreparedRunProvider.not_requested()
)
- self._weighting_modes = normalize_weighting_modes(weighting_modes)
- weight_options = [mode.title() for mode in self._weighting_modes]
+ self._weighting_definitions = (
+ tuple(weighting_definitions)
+ if weighting_definitions is not None
+ else weighting_mode_definitions(weighting_modes)
+ )
+ self._weighting_modes = [
+ definition.mode_id for definition in self._weighting_definitions
+ ]
+ if weighting_modes is not None and self._weighting_modes != [
+ str(mode).strip().lower() for mode in weighting_modes
+ ]:
+ raise ValueError(
+ "Dashboard weighting definitions do not match the configured mode order."
+ )
+ self._weighting_mode_by_label = {
+ definition.label: definition.mode_id
+ for definition in self._weighting_definitions
+ }
+ self._prepared_run_provider.configure_weighting_modes(
+ self._weighting_definitions,
+ config=config,
+ )
+ weight_options = list(self._weighting_mode_by_label)
self.param.weight_mode.objects = weight_options
self.weight_mode = weight_options[0]
self._page_state: dict[str, dict[str, Any]] = {}
@@ -117,7 +149,7 @@ def cache_stats(self) -> dict[str, dict[str, int]]:
return self._cache_stats
def weighting_key(self) -> str:
- return str(self.weight_mode).strip().lower()
+ return self._weighting_mode_by_label[str(self.weight_mode)]
def value_key(self) -> str:
return "percent" if self.value_mode == "Percent" else "count"
@@ -153,12 +185,12 @@ def _visible_summary_runs(self) -> list[DashboardSummarySeries]:
def get_prepared_runs_if_loaded(
self,
- weighted: bool | None = None,
+ weighting_mode: str | None = None,
) -> list[tuple[str, RunData]] | None:
"""Return prepared runs only when the dashboard explicitly has them loaded."""
- if weighted is None:
- weighted = self.weight_mode == "Weighted"
- return self._prepared_run_provider.get_runs_if_loaded(weighted=weighted)
+ return self._prepared_run_provider.get_runs_if_loaded(
+ weighting_mode=weighting_mode or self.weighting_key()
+ )
def get_summary_table_set(
self,
@@ -330,13 +362,15 @@ def inspect_prepared_table(
self,
table_name: str,
*,
- weighted: bool | None = None,
+ weighting_mode: str | None = None,
required_columns: tuple[str, ...] = (),
) -> DashboardDataSelection:
"""Return usable and excluded runs for one prepared table."""
usable_runs: list[tuple[str, RunData]] = []
excluded_runs: list[VisualizationRunAvailability] = []
- prepared_runs = self.get_prepared_runs_if_loaded(weighted=weighted)
+ prepared_runs = self.get_prepared_runs_if_loaded(
+ weighting_mode=weighting_mode
+ )
if prepared_runs is None:
detail = (
"prepared run data was not requested for this dashboard session"
diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md
deleted file mode 100644
index 32fc178..0000000
--- a/docs/SCHEMA.md
+++ /dev/null
@@ -1,1071 +0,0 @@
-# Prepared Cache Estimation-Output Schemas
-
-This reference now mirrors the parquet schemas in `artifacts/prepared_cache/estimation-output/`.
-The file remains here for convenience, but the table definitions below are based on `estimation-output`, not `filtered`.
-
-## Primer: Adding a New Summary Function
-
-The summary layer in this repo is built around small Polars functions that take prepared runtime tables and return one summary table:
-
-```python
-def my_summary(rd: RunData, config: Config) -> pl.DataFrame:
- ...
-```
-
-The prepared tables documented below are the main contract those functions should use. In practice:
-
-- `rd.hh` maps to `households.parquet`
-- `rd.per` maps to `persons.parquet`
-- `rd.tours` maps to `tours.parquet`
-- `rd.trips` maps to `trips.parquet`
-- `rd.joint_participants` maps to `joint_tour_participants.parquet`
-- `rd.land_use` maps to `land_use.parquet`
-
-### Where New Summaries Live
-
-- Put the builder in one of the modules under `processor/summarize/summaries/`.
-- Follow the existing topical split:
- - `demographics.py` for household/person summaries
- - `long_term.py` for ownership, telecommute, and geography-based long-term summaries
- - `daily_travel.py` for person-day and tour-frequency summaries
- - `joint_travel.py` for joint-tour and participant summaries
- - `tour.py` for tour-level distributions
- - `trip.py` for trip/stop distributions
-
-Examples worth copying:
-
-- `processor/summarize/summaries/daily_travel.py`
-- `processor/summarize/summaries/trip.py`
-- `processor/summarize/summaries/tour.py`
-- `processor/summarize/summaries/long_term.py`
-
-### Builder Pattern Used In This Repo
-
-Most builders follow the same pattern:
-
-1. Define a stable output schema up front.
-2. Check for the required prepared columns and return an empty typed table if they are missing.
-3. Use prepared columns from `RunData` rather than raw ActivitySim inputs.
-4. Aggregate with `finalweight` unless the logic explicitly needs something else.
-5. Cast output columns to stable types before returning.
-6. Sort the result for deterministic output.
-7. Add total rows like `all_person_types`, `all_tour_purposes`, or `all_geographies` when the dashboard expects them.
-
-Starter template:
-
-```python
-import polars as pl
-
-from processor.models import RunData
-from runtime.config import Config
-
-
-def my_summary(rd: RunData, config: Config) -> pl.DataFrame:
- result_schema = {
- "category": pl.Utf8,
- "value": pl.Float64,
- }
-
- if "some_column" not in rd.trips.columns:
- return pl.DataFrame(schema=result_schema)
-
- return (
- rd.trips
- .filter(pl.col("some_column").is_not_null())
- .group_by("some_column")
- .agg(value=pl.col("finalweight").sum())
- .rename({"some_column": "category"})
- .with_columns(
- pl.col("category").cast(pl.Utf8),
- pl.col("value").cast(pl.Float64),
- )
- .select("category", "value")
- .sort("category")
- )
-```
-
-### Important Conventions
-
-- Prefer prepared aliases and normalized columns from `RunData`. `processor/models.py` explicitly calls out fields like `household_id`, `person_id`, `tour_id`, `tour_purpose`, `trip_purpose`, `tour_mode`, `trip_mode`, `tour_category`, `depart_hour`, `stops`, `out_dir_dist`, `SKIMDIST`, `HGEO`, and `WGEO` as part of the prepared summary contract.
-- Be defensive about missing columns. Several existing functions return an empty DataFrame with the right schema when a needed column is absent.
-- When choosing a purpose column, some existing summaries probe multiple candidates and prefer a non-numeric string field, for example `tour_purpose`, `tour_type`, or `purpose`.
-- Use `config` helpers when labels or ordering matter. Examples include `config.person_type_label(...)` and `config.ordered_modes(...)`.
-- Geography-aware summaries usually emit per-geography rows plus an `all_geographies` row when `config.geography_enabled` is true.
-- Dense outputs are often better than sparse outputs for charts. For example, distance-bin and time-bin summaries fill in missing bins with zeros.
-
-### Registering A New Summary
-
-After you add the function, register it in `processor/summarize/summary_specs.py` by adding a `SummarySpec` entry to `SUMMARY_SPECS`:
-
-```python
-SummarySpec(
- "my_summary_id",
- "my_summary_filename",
- my_module.my_summary,
-),
-```
-
-Notes:
-
-- `summary_id` is the stable internal identifier used by the cache and dashboard layers.
-- `filename` becomes the CSV name written to the summary cache.
-- The builder must return a single `pl.DataFrame`.
-- If you need multiple outputs from one shared computation, follow the `long_term.tlfd(...)` pattern and add small wrapper builders in `summary_specs.py`.
-
-### When To Update `processor/summarize/schema.py`
-
-If the dashboard expects a canonical column layout for the new summary, add an entry to `SUMMARY_OUTPUT_COLUMNS` in `processor/summarize/schema.py`.
-
-That file is not required for every summary, but it is used for dashboard-facing output contracts, especially where column names must stay fixed.
-
-### Practical Checklist
-
-- Add the builder function under `processor/summarize/summaries/`.
-- Use prepared `RunData` tables and columns from the schema reference below.
-- Return an empty typed DataFrame if required inputs are missing.
-- Aggregate with weights, usually `finalweight`.
-- Cast and order output columns explicitly.
-- Register the summary in `processor/summarize/summary_specs.py`.
-- Add a canonical output schema in `processor/summarize/schema.py` if the dashboard depends on fixed columns.
-- Run the relevant summary build or tests to verify the output shape.
-
-## households.parquet
-
-- Rows: 43,637
-- Columns: 203
-
-| Column | Type | Nullable |
-|---|---|---|
-| password | large_string | yes |
-| rm_household_id | int64 | yes |
-| ms_household_id | int64 | yes |
-| participation_group | int64 | yes |
-| incentive | int64 | yes |
-| incentive_amount | int64 | yes |
-| first_travel_date | large_string | yes |
-| last_travel_date | large_string | yes |
-| browser | int64 | yes |
-| tester | int64 | yes |
-| num_days_complete | int64 | yes |
-| is_complete | int64 | yes |
-| disposition | int64 | yes |
-| signup_complete_time | large_string | yes |
-| signup_platform | large_string | yes |
-| diary_platform | large_string | yes |
-| signup_rmove | int64 | yes |
-| signup_call_center | int64 | yes |
-| diary_call_center | int64 | yes |
-| num_days_complete_weekday | int64 | yes |
-| num_complete_mon | int64 | yes |
-| num_complete_tue | int64 | yes |
-| num_complete_wed | int64 | yes |
-| num_complete_thu | int64 | yes |
-| num_complete_fri | int64 | yes |
-| num_days_complete_weekend | int64 | yes |
-| num_complete_sat | int64 | yes |
-| num_complete_sun | int64 | yes |
-| num_trips | int64 | yes |
-| num_people_survey | int64 | yes |
-| num_people | int64 | yes |
-| raw_person_count | int64 | yes |
-| num_surveyable | int64 | yes |
-| num_activated | int64 | yes |
-| num_participants | int64 | yes |
-| num_adults | int64 | yes |
-| num_kids | int64 | yes |
-| num_workers | int64 | yes |
-| num_students | int64 | yes |
-| num_vehicles | int64 | yes |
-| income_detailed | int64 | yes |
-| income_followup | int64 | yes |
-| income_broad | int64 | yes |
-| residence_type | int64 | yes |
-| residence_rent_own | int64 | yes |
-| home_in_region | int64 | yes |
-| home_state | int64 | yes |
-| home_county | int64 | yes |
-| home_bg_2010 | int64 | yes |
-| home_bg_2020 | int64 | yes |
-| home_puma_2012 | int64 | yes |
-| home_puma_2022 | int64 | yes |
-| home_lon | double | yes |
-| home_lat | double | yes |
-| sample_home_bg | double | yes |
-| sample_home_lon | double | yes |
-| sample_home_lat | double | yes |
-| hh_weight | double | yes |
-| bicycle_cargo_1 | int64 | yes |
-| bicycle_cargo_2 | int64 | yes |
-| bicycle_cargo_3 | int64 | yes |
-| bicycle_cargo_4 | int64 | yes |
-| bicycle_type_1 | int64 | yes |
-| bicycle_type_2 | int64 | yes |
-| bicycle_type_3 | int64 | yes |
-| bicycle_type_4 | int64 | yes |
-| bicycle_type_997 | int64 | yes |
-| num_bicycle_adult | int64 | yes |
-| num_bicycle_child | int64 | yes |
-| ordot_hh | int64 | yes |
-| past_participation | int64 | yes |
-| feedback_web | large_string | yes |
-| is_complete_adjusted_a | int64 | yes |
-| is_complete_adjusted_b | int64 | yes |
-| sample_segment | int64 | yes |
-| home_mpo | large_string | yes |
-| TAZ | int64 | yes |
-| home_zone_id | int64 | yes |
-| external_home_taz | bool | yes |
-| external_home_zone_id | bool | yes |
-| survey_household_id | int64 | yes |
-| survey_hh_weight | int64 | yes |
-| household_id.1 | int64 | yes |
-| hhsize | int64 | yes |
-| children | int64 | yes |
-| auto_ownership | int64 | yes |
-| income | int64 | yes |
-| synthetic_income | bool | yes |
-| HHT | int64 | yes |
-| group_quarters | bool | yes |
-| external_MAZ | bool | yes |
-| school_escorting_outbound | int64 | yes |
-| school_escorting_inbound | int64 | yes |
-| school_escorting_outbound_cond | int64 | yes |
-| joint_tour_frequency_composition | int64 | yes |
-| has_joint_tour | int64 | yes |
-| sample_rate | int64 | yes |
-| income_segment | int64 | yes |
-| num_drivers | int64 | yes |
-| num_children | int64 | yes |
-| num_young_children | int64 | yes |
-| num_children_6_to_12 | int64 | yes |
-| num_children_5_to_15 | int64 | yes |
-| num_children_16_to_17 | int64 | yes |
-| num_gradeschool | int64 | yes |
-| num_highschool | int64 | yes |
-| num_college_age | int64 | yes |
-| num_young_adults | int64 | yes |
-| num_non_workers | int64 | yes |
-| num_predrive_child | int64 | yes |
-| num_nonworker_adults | int64 | yes |
-| num_fullTime_workers | int64 | yes |
-| num_partTime_workers | int64 | yes |
-| num_retired_adults | int64 | yes |
-| home_is_urban | bool | yes |
-| home_is_rural | bool | yes |
-| num_hh_in_zone | int64 | yes |
-| ebike_owner | bool | yes |
-| av_ownership | bool | yes |
-| workplace_location_accessibility | double | yes |
-| shopping_accessibility | double | yes |
-| othdiscr_accessibility | double | yes |
-| numAVowned | int64 | yes |
-| num_travel_active | int64 | yes |
-| num_travel_active_adults | int64 | yes |
-| num_travel_active_preschoolers | int64 | yes |
-| num_travel_active_children | int64 | yes |
-| num_travel_active_non_preschoolers | int64 | yes |
-| participates_in_jtf_model | bool | yes |
-| EXTERNAL | int64 | yes |
-| EMP_AFS | int64 | yes |
-| EMP_CON | int64 | yes |
-| EMP_GOV | int64 | yes |
-| EMP_HCS | int64 | yes |
-| EMP_IFRPBS | int64 | yes |
-| EMP_NRM | int64 | yes |
-| EMP_OSV | int64 | yes |
-| EMP_RET | int64 | yes |
-| EMP_AER | int64 | yes |
-| EMP_MFG | int64 | yes |
-| EMP_WT | int64 | yes |
-| EMP_EDU | int64 | yes |
-| EMP_TWU | int64 | yes |
-| EMP_TOTAL | int64 | yes |
-| ENROLLGRADEKto8 | int64 | yes |
-| ENROLLGRADE9to12 | int64 | yes |
-| DIST_Kto8 | large_string | yes |
-| DIST_9to12 | large_string | yes |
-| COLLEGEENROLL | int64 | yes |
-| PRKCST_HR | double | yes |
-| PRKCST_DAY | double | yes |
-| PRKCST_MNTH | double | yes |
-| PRKSPACES | large_string | yes |
-| INTHMI | int64 | yes |
-| PARKATTRACT | int64 | yes |
-| TERMINALTIME | large_string | yes |
-| ESCOOACCTIME | int64 | yes |
-| EBIKEACCTIME | large_string | yes |
-| PNR_SPACES | int64 | yes |
-| DISTRICT9 | int64 | yes |
-| TOTHHS | int64 | yes |
-| TOTPOP | int64 | yes |
-| ACRES | double | yes |
-| walk_dist_local_bus | double | yes |
-| walk_dist_premium_transit | double | yes |
-| icnt | int64 | yes |
-| empden | double | yes |
-| retempden | double | yes |
-| duden | double | yes |
-| popden | double | yes |
-| popempdenpermi | double | yes |
-| totint | int64 | yes |
-| None | bool | yes |
-| household_density | double | yes |
-| population_density | double | yes |
-| employment_density | double | yes |
-| density_index | double | yes |
-| ACTIVE_ACRES | double | yes |
-| pseudomsa | int64 | yes |
-| micro_dist_local_bus | int64 | yes |
-| microtransit | int64 | yes |
-| nev | int64 | yes |
-| preschool_target | int64 | yes |
-| is_parking_zone | bool | yes |
-| auPkRetail | double | yes |
-| auPkTotal | double | yes |
-| auOpRetail | double | yes |
-| auOpTotal | double | yes |
-| trPkRetail | double | yes |
-| trPkTotal | double | yes |
-| trPkHH | double | yes |
-| trOpRetail | double | yes |
-| trOpTotal | double | yes |
-| nmRetail | double | yes |
-| nmTotal | double | yes |
-| num_hh_joint_tours | int64 | yes |
-| household_id | int64 | yes |
-| finalweight | double | yes |
-| HHVEH | int32 | yes |
-| HHSIZE | int32 | yes |
-| WORKERS | int32 | yes |
-| ADULTS | int32 | yes |
-| home_taz | int64 | yes |
-
-## joint_tour_participants.parquet
-
-- Rows: 16,576
-- Columns: 5
-
-| Column | Type | Nullable |
-|---|---|---|
-| tour_id | int64 | yes |
-| household_id | int64 | yes |
-| person_id | int64 | yes |
-| participant_num | int64 | yes |
-| participant_id | int64 | yes |
-
-## land_use.parquet
-
-- Rows: 22,333
-- Columns: 45
-
-| Column | Type | Nullable |
-|---|---|---|
-| MAZ | int64 | yes |
-| TAZ | int64 | yes |
-| EXTERNAL | int64 | yes |
-| EMP_AFS | int64 | yes |
-| EMP_CON | int64 | yes |
-| EMP_GOV | int64 | yes |
-| EMP_HCS | int64 | yes |
-| EMP_IFRPBS | int64 | yes |
-| EMP_NRM | int64 | yes |
-| EMP_OSV | int64 | yes |
-| EMP_RET | int64 | yes |
-| EMP_AER | int64 | yes |
-| EMP_MFG | int64 | yes |
-| EMP_WT | int64 | yes |
-| EMP_EDU | int64 | yes |
-| EMP_TWU | int64 | yes |
-| EMP_TOTAL | int64 | yes |
-| ENROLLGRADEKto8 | int64 | yes |
-| ENROLLGRADE9to12 | int64 | yes |
-| DIST_Kto8 | large_string | yes |
-| DIST_9to12 | large_string | yes |
-| COLLEGEENROLL | int64 | yes |
-| PRKCST_HR | double | yes |
-| PRKCST_DAY | double | yes |
-| PRKCST_MNTH | double | yes |
-| PRKSPACES | large_string | yes |
-| INTHMI | int64 | yes |
-| PARKATTRACT | int64 | yes |
-| TERMINALTIME | large_string | yes |
-| ESCOOACCTIME | large_string | yes |
-| EBIKEACCTIME | large_string | yes |
-| PNR_SPACES | int64 | yes |
-| DISTRICT9 | int64 | yes |
-| TOTHHS | int64 | yes |
-| TOTPOP | int64 | yes |
-| ACRES | double | yes |
-| walk_dist_local_bus | double | yes |
-| walk_dist_premium_transit | double | yes |
-| icnt | int64 | yes |
-| empden | double | yes |
-| retempden | double | yes |
-| duden | double | yes |
-| popden | double | yes |
-| popempdenpermi | double | yes |
-| totint | int64 | yes |
-
-## persons.parquet
-
-- Rows: 86,280
-- Columns: 327
-
-| Column | Type | Nullable |
-|---|---|---|
-| person_num | int64 | yes |
-| rm_person_id | int64 | yes |
-| ms_person_id | int64 | yes |
-| hh_id | int64 | yes |
-| password | large_string | yes |
-| rm_household_id | int64 | yes |
-| surveyable | int64 | yes |
-| is_participant | int64 | yes |
-| is_proxy | int64 | yes |
-| has_proxy | int64 | yes |
-| has_phone | int64 | yes |
-| phone_type | int64 | yes |
-| hh_is_complete | int64 | yes |
-| is_complete | int64 | yes |
-| num_days | int64 | yes |
-| num_days_complete | int64 | yes |
-| num_trips | int64 | yes |
-| rmove_activated_time | large_string | yes |
-| is_active_participant | int64 | yes |
-| num_devices | int64 | yes |
-| hh_surveyable | int64 | yes |
-| relationship | int64 | yes |
-| hh_ages | int64 | yes |
-| age_app | int64 | yes |
-| gender | int64 | yes |
-| race_other | large_string | yes |
-| ethnicity_other | large_string | yes |
-| hh_employed | int64 | yes |
-| employment | int64 | yes |
-| work_freq | int64 | yes |
-| work_mode | int64 | yes |
-| telework_freq | int64 | yes |
-| industry | int64 | yes |
-| industry_other | large_string | yes |
-| job_type | int64 | yes |
-| num_jobs | int64 | yes |
-| commute_freq | int64 | yes |
-| work_lon | double | yes |
-| work_lat | double | yes |
-| work_in_region | int64 | yes |
-| work_state | int64 | yes |
-| work_county | int64 | yes |
-| work_bg_2010 | double | yes |
-| work_bg_2020 | double | yes |
-| work_puma_2012 | int64 | yes |
-| work_puma_2022 | int64 | yes |
-| education | int64 | yes |
-| hh_students | int64 | yes |
-| student | int64 | yes |
-| school_mode | int64 | yes |
-| school_type | int64 | yes |
-| school_freq | int64 | yes |
-| remote_class_freq | int64 | yes |
-| school_in_region | int64 | yes |
-| school_state | int64 | yes |
-| school_county | int64 | yes |
-| school_puma_2012 | int64 | yes |
-| school_puma_2022 | int64 | yes |
-| school_bg_2010 | double | yes |
-| school_bg_2020 | double | yes |
-| school_lon | double | yes |
-| school_lat | double | yes |
-| second_home | int64 | yes |
-| can_drive | int64 | yes |
-| vehicle | int64 | yes |
-| transit_freq | int64 | yes |
-| tnc_freq | int64 | yes |
-| bike_freq | int64 | yes |
-| vanpool_freq | int64 | yes |
-| bikeshare_freq | int64 | yes |
-| scootshare_freq | int64 | yes |
-| walk_freq | int64 | yes |
-| transit_pass | int64 | yes |
-| disability | int64 | yes |
-| participate | int64 | yes |
-| person_weight | double | yes |
-| age_fv | int64 | yes |
-| bike_attitude | int64 | yes |
-| bike_comfort_bike_lanes | int64 | yes |
-| bike_comfort_four_lanes | int64 | yes |
-| bike_comfort_markings | int64 | yes |
-| bike_comfort_path | int64 | yes |
-| bike_comfort_street | int64 | yes |
-| bike_comfort_wide_bike | int64 | yes |
-| bike_purp_1 | int64 | yes |
-| bike_purp_2 | int64 | yes |
-| bike_purp_3 | int64 | yes |
-| bike_purp_4 | int64 | yes |
-| bike_purp_5 | int64 | yes |
-| bike_purp_6 | int64 | yes |
-| bike_purp_7 | int64 | yes |
-| bike_purp_997 | int64 | yes |
-| bike_purp_other | large_string | yes |
-| commute_subsidy_1 | int64 | yes |
-| commute_subsidy_10 | int64 | yes |
-| commute_subsidy_11 | int64 | yes |
-| commute_subsidy_12 | int64 | yes |
-| commute_subsidy_13 | int64 | yes |
-| commute_subsidy_2 | int64 | yes |
-| commute_subsidy_3 | int64 | yes |
-| commute_subsidy_4 | int64 | yes |
-| commute_subsidy_5 | int64 | yes |
-| commute_subsidy_6 | int64 | yes |
-| commute_subsidy_7 | int64 | yes |
-| commute_subsidy_8 | int64 | yes |
-| commute_subsidy_9 | int64 | yes |
-| commute_subsidy_996 | int64 | yes |
-| commute_subsidy_998 | int64 | yes |
-| commute_subsidy_use_1 | int64 | yes |
-| commute_subsidy_use_10 | int64 | yes |
-| commute_subsidy_use_11 | int64 | yes |
-| commute_subsidy_use_12 | int64 | yes |
-| commute_subsidy_use_13 | int64 | yes |
-| commute_subsidy_use_2 | int64 | yes |
-| commute_subsidy_use_3 | int64 | yes |
-| commute_subsidy_use_4 | int64 | yes |
-| commute_subsidy_use_5 | int64 | yes |
-| commute_subsidy_use_6 | int64 | yes |
-| commute_subsidy_use_7 | int64 | yes |
-| commute_subsidy_use_8 | int64 | yes |
-| commute_subsidy_use_9 | int64 | yes |
-| commute_subsidy_use_996 | int64 | yes |
-| ethnicity_1 | int64 | yes |
-| ethnicity_2 | int64 | yes |
-| ethnicity_3 | int64 | yes |
-| ethnicity_4 | int64 | yes |
-| ethnicity_997 | int64 | yes |
-| ethnicity_999 | int64 | yes |
-| ev_charge | int64 | yes |
-| ev_charge_duration | int64 | yes |
-| ev_charge_time | int64 | yes |
-| ev_purchase | int64 | yes |
-| exercise_freq | int64 | yes |
-| exercise_freq_followup | int64 | yes |
-| home_park | int64 | yes |
-| home_park_pay | int64 | yes |
-| home_vehicle_park_other | large_string | yes |
-| micromobility_devices_1 | int64 | yes |
-| micromobility_devices_2 | int64 | yes |
-| micromobility_devices_3 | int64 | yes |
-| micromobility_devices_4 | int64 | yes |
-| micromobility_devices_996 | int64 | yes |
-| micromobility_devices_997 | int64 | yes |
-| office_available | int64 | yes |
-| online_socialize_1 | int64 | yes |
-| online_socialize_2 | int64 | yes |
-| online_socialize_3 | int64 | yes |
-| online_socialize_996 | int64 | yes |
-| online_socialize_997 | int64 | yes |
-| phone_contact | large_string | yes |
-| race_1 | int64 | yes |
-| race_2 | int64 | yes |
-| race_3 | int64 | yes |
-| race_4 | int64 | yes |
-| race_5 | int64 | yes |
-| race_997 | int64 | yes |
-| race_999 | int64 | yes |
-| remote_work_ability | int64 | yes |
-| remote_work_broadband_1 | int64 | yes |
-| remote_work_broadband_2 | int64 | yes |
-| remote_work_broadband_3 | int64 | yes |
-| remote_work_broadband_4 | int64 | yes |
-| remote_work_broadband_5 | int64 | yes |
-| remote_work_broadband_996 | int64 | yes |
-| remote_work_broadband_997 | int64 | yes |
-| remote_work_broadband_other | large_string | yes |
-| school_mode_all_1 | int64 | yes |
-| school_mode_all_100 | int64 | yes |
-| school_mode_all_101 | int64 | yes |
-| school_mode_all_102 | int64 | yes |
-| school_mode_all_103 | int64 | yes |
-| school_mode_all_104 | int64 | yes |
-| school_mode_all_105 | int64 | yes |
-| school_mode_all_106 | int64 | yes |
-| school_mode_all_107 | int64 | yes |
-| school_mode_all_24 | int64 | yes |
-| school_mode_all_other_comment | large_string | yes |
-| school_mode_primary | int64 | yes |
-| share_2 | int64 | yes |
-| share_5 | int64 | yes |
-| share_6 | int64 | yes |
-| share_7 | int64 | yes |
-| share_996 | int64 | yes |
-| share_work_1 | int64 | yes |
-| share_work_2 | int64 | yes |
-| share_work_3 | int64 | yes |
-| share_work_5 | int64 | yes |
-| share_work_996 | int64 | yes |
-| share_work_997 | int64 | yes |
-| tnc_work_hours | int64 | yes |
-| transit_increase_1 | int64 | yes |
-| transit_increase_10 | int64 | yes |
-| transit_increase_11 | int64 | yes |
-| transit_increase_2 | int64 | yes |
-| transit_increase_3 | int64 | yes |
-| transit_increase_4 | int64 | yes |
-| transit_increase_5 | int64 | yes |
-| transit_increase_6 | int64 | yes |
-| transit_increase_7 | int64 | yes |
-| transit_increase_8 | int64 | yes |
-| transit_increase_9 | int64 | yes |
-| transit_increase_996 | int64 | yes |
-| work_vehicle_park | int64 | yes |
-| work_vehicle_park_pay | int64 | yes |
-| student_housing | int64 | yes |
-| ecommerce | int64 | yes |
-| OCCP | int64 | yes |
-| occupation_other | large_string | yes |
-| occupation_business | int64 | yes |
-| occupation_business_other | large_string | yes |
-| occupation_hosp | int64 | yes |
-| occupation_hosp_other | large_string | yes |
-| person_pct_trips_flagged | double | yes |
-| num_no_flag_days | int64 | yes |
-| age | int64 | yes |
-| person_type | int64 | yes |
-| work_taz | int64 | yes |
-| work_maz | int64 | yes |
-| external_work_taz | bool | yes |
-| external_work_maz | bool | yes |
-| school_taz | int64 | yes |
-| school_maz | int64 | yes |
-| external_school_taz | bool | yes |
-| external_school_maz | bool | yes |
-| survey_person_id | int64 | yes |
-| survey_household_id | int64 | yes |
-| survey_person_weight | int64 | yes |
-| household_id | int64 | yes |
-| SEX | int64 | yes |
-| PNUM | int64 | yes |
-| pstudent | int64 | yes |
-| is_student | bool | yes |
-| major_uni | bool | yes |
-| school_zone_id | int64 | yes |
-| is_commercial_driver_no_workplace | bool | yes |
-| is_delivery_driver | bool | yes |
-| transit_pass_subsidy | int64 | yes |
-| free_parking_at_work | bool | yes |
-| transit_pass_ownership | bool | yes |
-| telecommute_frequency | large_string | yes |
-| industry_coded | large_string | yes |
-| SCHG | int64 | yes |
-| ESR | int64 | yes |
-| is_worker | large_string | yes |
-| occupation_category | large_string | yes |
-| work_from_home | large_string | yes |
-| workplace_zone_id | int64 | yes |
-| external_worker_identification | bool | yes |
-| is_internal_worker | bool | yes |
-| external_workplace_zone_id | int64 | yes |
-| has_license | bool | yes |
-| WKHP | int64 | yes |
-| WKW | int64 | yes |
-| ptype | int64 | yes |
-| bike_comfort | large_string | yes |
-| cdap_activity | large_string | yes |
-| mandatory_tour_frequency | large_string | yes |
-| _escort | int64 | yes |
-| _shopping | int64 | yes |
-| _othmaint | int64 | yes |
-| _eatout | int64 | yes |
-| _social | int64 | yes |
-| _othdiscr | int64 | yes |
-| non_mandatory_tour_frequency | int64 | yes |
-| age_16_to_19 | bool | yes |
-| age_16_p | bool | yes |
-| adult | bool | yes |
-| male | bool | yes |
-| female | bool | yes |
-| pemploy | int64 | yes |
-| is_university | bool | yes |
-| school_segment | int64 | yes |
-| is_external_worker | bool | yes |
-| home_zone_id | int64 | yes |
-| time_factor_work | double | yes |
-| time_factor_nonwork | double | yes |
-| naics_code | int64 | yes |
-| occupation | large_string | yes |
-| is_income_less25K | bool | yes |
-| is_income_25K_to_60K | bool | yes |
-| is_income_60K_to_120K | bool | yes |
-| is_income_greater60K | bool | yes |
-| is_income_greater120K | bool | yes |
-| is_non_worker_in_HH | bool | yes |
-| is_all_adults_full_time_workers | bool | yes |
-| is_pre_drive_child_in_HH | bool | yes |
-| is_out_of_home_worker | bool | yes |
-| external_workplace_location_logsum | large_string | yes |
-| external_workplace_modechoice_logsum | large_string | yes |
-| school_location_logsum | double | yes |
-| school_modechoice_logsum | double | yes |
-| distance_to_school | double | yes |
-| roundtrip_auto_time_to_school | double | yes |
-| workplace_location_logsum | double | yes |
-| workplace_modechoice_logsum | double | yes |
-| distance_to_work | double | yes |
-| work_zone_area_type | int64 | yes |
-| auto_time_home_to_work | double | yes |
-| roundtrip_auto_time_to_work | double | yes |
-| exp_daily_work | double | yes |
-| travel_active | bool | yes |
-| work_and_school_and_worker | bool | yes |
-| work_and_school_and_student | bool | yes |
-| num_mand | int64 | yes |
-| num_work_tours | int64 | yes |
-| has_pre_school_child_with_mandatory | bool | yes |
-| has_driving_age_child_with_mandatory | bool | yes |
-| num_joint_tours | int64 | yes |
-| num_non_mand | int64 | yes |
-| num_escort_tours | int64 | yes |
-| num_eatout_tours | int64 | yes |
-| num_shop_tours | int64 | yes |
-| num_maint_tours | int64 | yes |
-| num_discr_tours | int64 | yes |
-| num_social_tours | int64 | yes |
-| num_non_escort_tours | int64 | yes |
-| num_shop_maint_tours | int64 | yes |
-| num_shop_maint_escort_tours | int64 | yes |
-| num_add_shop_maint_tours | int64 | yes |
-| num_soc_discr_tours | int64 | yes |
-| num_add_soc_discr_tours | int64 | yes |
-| person_id | int64 | yes |
-| finalweight | double | yes |
-| home_taz | int64 | yes |
-| work_taz_right | int64 | yes |
-| school_taz_right | int64 | yes |
-| imf_choice | int32 | yes |
-
-## tours.parquet
-
-- Rows: 72,311
-- Columns: 51
-
-| Column | Type | Nullable |
-|---|---|---|
-| person_id | int64 | yes |
-| tour_type | large_string | yes |
-| tour_type_count | int64 | yes |
-| tour_type_num | int64 | yes |
-| tour_num | int64 | yes |
-| tour_count | int64 | yes |
-| tour_category | large_string | yes |
-| number_of_participants | int64 | yes |
-| destination | int64 | yes |
-| origin | int64 | yes |
-| household_id | int64 | yes |
-| start | int64 | yes |
-| end | int64 | yes |
-| duration | int64 | yes |
-| school_esc_outbound | large_string | yes |
-| school_esc_inbound | large_string | yes |
-| num_escortees | int64 | yes |
-| tdd | int64 | yes |
-| tour_id_temp | int64 | yes |
-| composition | large_string | yes |
-| is_external_tour | bool | yes |
-| is_internal_tour | bool | yes |
-| destination_logsum | double | yes |
-| vehicle_occup_1 | large_string | yes |
-| vehicle_occup_2 | large_string | yes |
-| vehicle_occup_3.5 | large_string | yes |
-| pnr_zone_id | int64 | yes |
-| tour_mode | large_string | yes |
-| mode_choice_logsum | double | yes |
-| selected_vehicle | large_string | yes |
-| atwork_subtour_frequency | large_string | yes |
-| parent_tour_id | double | yes |
-| stop_frequency | large_string | yes |
-| primary_purpose | large_string | yes |
-| tour_id | int64 | yes |
-| tour_purpose | large_string | yes |
-| start_hour | int32 | yes |
-| end_hour | int32 | yes |
-| tourdur | int32 | yes |
-| finalweight | double | yes |
-| HHVEH | int64 | yes |
-| WORKERS | int64 | yes |
-| ADULTS | int64 | yes |
-| AUTOSUFF | int32 | yes |
-| num_ob_stops | int32 | yes |
-| num_ib_stops | int32 | yes |
-| num_tot_stops | int32 | yes |
-| OTAZ | int32 | yes |
-| DTAZ | int32 | yes |
-| SKIMDIST | double | yes |
-| NUMBER_HH | int32 | yes |
-
-## trips.parquet
-
-- Rows: 207,207
-- Columns: 262
-
-| Column | Type | Nullable |
-|---|---|---|
-| person_id | int64 | yes |
-| household_id | int64 | yes |
-| primary_purpose | large_string | yes |
-| trip_num | int32 | yes |
-| outbound | bool | yes |
-| trip_count | int64 | yes |
-| destination | int64 | yes |
-| origin | int64 | yes |
-| tour_id | int64 | yes |
-| escort_participants | large_string | yes |
-| school_escort_direction | large_string | yes |
-| purpose | large_string | yes |
-| destination_logsum | double | yes |
-| depart | int64 | yes |
-| trip_mode | large_string | yes |
-| mode_choice_logsum | double | yes |
-| vot_da | double | yes |
-| vot_s2 | double | yes |
-| vot_s3 | double | yes |
-| ebike_owner | bool | yes |
-| parkingCost | double | yes |
-| auto_op_cost | double | yes |
-| autoCPMFactor | double | yes |
-| autoParkingCostFactor | double | yes |
-| autoTermTimeFactor | double | yes |
-| costFactorS2 | double | yes |
-| costFactorS3 | double | yes |
-| transitSubsidyPassDiscount | int64 | yes |
-| origTaxiWaitTime | double | yes |
-| origSingleTNCWaitTime | double | yes |
-| destSingleTNCWaitTime | double | yes |
-| origSharedTNCWaitTime | double | yes |
-| da_dist_skims | double | yes |
-| s2_time_skims | double | yes |
-| s2_dist_skims | double | yes |
-| s2_cost_skims | int64 | yes |
-| s3_time_skims | double | yes |
-| s3_dist_skims | double | yes |
-| s3_cost_skims | int64 | yes |
-| ebike_time | double | yes |
-| escooter_time | double | yes |
-| microtransit_orig | int64 | yes |
-| microtransit_dest | int64 | yes |
-| microtransit_operating | bool | yes |
-| microtransit_available | bool | yes |
-| microtransit_time | double | yes |
-| nev_orig | int64 | yes |
-| nev_dest | int64 | yes |
-| nev_operating | bool | yes |
-| nev_available | bool | yes |
-| nev_time | double | yes |
-| microtransit_local_access_available_out | bool | yes |
-| nev_local_access_available_out | bool | yes |
-| microtransit_local_egress_available_out | bool | yes |
-| nev_local_egress_available_out | bool | yes |
-| microtransit_local_access_available_in | bool | yes |
-| nev_local_access_available_in | bool | yes |
-| microtransit_local_egress_available_in | bool | yes |
-| nev_local_egress_available_in | bool | yes |
-| microtransit_local_access_time_out | double | yes |
-| nev_local_access_time_out | double | yes |
-| microtransit_local_egress_time_out | int64 | yes |
-| nev_local_egress_time_out | int64 | yes |
-| microtransit_local_access_time_in | int64 | yes |
-| nev_local_access_time_in | int64 | yes |
-| microtransit_local_egress_time_in | double | yes |
-| nev_local_egress_time_in | double | yes |
-| parking_zone | int64 | yes |
-| trip_period | large_string | yes |
-| tour_participants | int64 | yes |
-| is_ea | bool | yes |
-| is_am | bool | yes |
-| is_md | bool | yes |
-| is_pm | bool | yes |
-| is_ev | bool | yes |
-| vot1 | int64 | yes |
-| vot2 | int64 | yes |
-| vot3 | int64 | yes |
-| inbound | int32 | yes |
-| DRIVEALONE_EA_LOW | int64 | yes |
-| SHARED2_EA_LOW | int64 | yes |
-| SHARED3_EA_LOW | int64 | yes |
-| DRIVEALONE_EA_MED | int64 | yes |
-| SHARED2_EA_MED | int64 | yes |
-| SHARED3_EA_MED | int64 | yes |
-| DRIVEALONE_EA_HIGH | int64 | yes |
-| SHARED2_EA_HIGH | int64 | yes |
-| SHARED3_EA_HIGH | int64 | yes |
-| WALK_LOC_EA | int64 | yes |
-| WALK_PRM_EA | int64 | yes |
-| WALK_MIX_EA | int64 | yes |
-| PNR_LOCOUT_EA | int64 | yes |
-| PNR_PRMOUT_EA | int64 | yes |
-| PNR_MIXOUT_EA | int64 | yes |
-| KNR_LOCOUT_EA | int64 | yes |
-| KNR_PRMOUT_EA | int64 | yes |
-| KNR_MIXOUT_EA | int64 | yes |
-| TNC_LOCOUT_EA | int64 | yes |
-| TNC_PRMOUT_EA | int64 | yes |
-| TNC_MIXOUT_EA | int64 | yes |
-| PNR_LOCIN_EA | int64 | yes |
-| PNR_PRMIN_EA | int64 | yes |
-| PNR_MIXIN_EA | int64 | yes |
-| KNR_LOCIN_EA | int64 | yes |
-| KNR_PRMIN_EA | int64 | yes |
-| KNR_MIXIN_EA | int64 | yes |
-| TNC_LOCIN_EA | int64 | yes |
-| TNC_PRMIN_EA | int64 | yes |
-| TNC_MIXIN_EA | int64 | yes |
-| BIKE_EA | int64 | yes |
-| WALK_EA | int64 | yes |
-| DRIVEALONE_AM_LOW | int64 | yes |
-| SHARED2_AM_LOW | int64 | yes |
-| SHARED3_AM_LOW | int64 | yes |
-| DRIVEALONE_AM_MED | int64 | yes |
-| SHARED2_AM_MED | int64 | yes |
-| SHARED3_AM_MED | int64 | yes |
-| DRIVEALONE_AM_HIGH | int64 | yes |
-| SHARED2_AM_HIGH | int64 | yes |
-| SHARED3_AM_HIGH | int64 | yes |
-| WALK_LOC_AM | int64 | yes |
-| WALK_PRM_AM | int64 | yes |
-| WALK_MIX_AM | int64 | yes |
-| PNR_LOCOUT_AM | int64 | yes |
-| PNR_PRMOUT_AM | int64 | yes |
-| PNR_MIXOUT_AM | int64 | yes |
-| KNR_LOCOUT_AM | int64 | yes |
-| KNR_PRMOUT_AM | int64 | yes |
-| KNR_MIXOUT_AM | int64 | yes |
-| TNC_LOCOUT_AM | int64 | yes |
-| TNC_PRMOUT_AM | int64 | yes |
-| TNC_MIXOUT_AM | int64 | yes |
-| PNR_LOCIN_AM | int64 | yes |
-| PNR_PRMIN_AM | int64 | yes |
-| PNR_MIXIN_AM | int64 | yes |
-| KNR_LOCIN_AM | int64 | yes |
-| KNR_PRMIN_AM | int64 | yes |
-| KNR_MIXIN_AM | int64 | yes |
-| TNC_LOCIN_AM | int64 | yes |
-| TNC_PRMIN_AM | int64 | yes |
-| TNC_MIXIN_AM | int64 | yes |
-| BIKE_AM | int64 | yes |
-| WALK_AM | int64 | yes |
-| DRIVEALONE_MD_LOW | int64 | yes |
-| SHARED2_MD_LOW | int64 | yes |
-| SHARED3_MD_LOW | int64 | yes |
-| DRIVEALONE_MD_MED | int64 | yes |
-| SHARED2_MD_MED | int64 | yes |
-| SHARED3_MD_MED | int64 | yes |
-| DRIVEALONE_MD_HIGH | int64 | yes |
-| SHARED2_MD_HIGH | int64 | yes |
-| SHARED3_MD_HIGH | int64 | yes |
-| WALK_LOC_MD | int64 | yes |
-| WALK_PRM_MD | int64 | yes |
-| WALK_MIX_MD | int64 | yes |
-| PNR_LOCOUT_MD | int64 | yes |
-| PNR_PRMOUT_MD | int64 | yes |
-| PNR_MIXOUT_MD | int64 | yes |
-| KNR_LOCOUT_MD | int64 | yes |
-| KNR_PRMOUT_MD | int64 | yes |
-| KNR_MIXOUT_MD | int64 | yes |
-| TNC_LOCOUT_MD | int64 | yes |
-| TNC_PRMOUT_MD | int64 | yes |
-| TNC_MIXOUT_MD | int64 | yes |
-| PNR_LOCIN_MD | int64 | yes |
-| PNR_PRMIN_MD | int64 | yes |
-| PNR_MIXIN_MD | int64 | yes |
-| KNR_LOCIN_MD | int64 | yes |
-| KNR_PRMIN_MD | int64 | yes |
-| KNR_MIXIN_MD | int64 | yes |
-| TNC_LOCIN_MD | int64 | yes |
-| TNC_PRMIN_MD | int64 | yes |
-| TNC_MIXIN_MD | int64 | yes |
-| BIKE_MD | int64 | yes |
-| WALK_MD | int64 | yes |
-| DRIVEALONE_PM_LOW | int64 | yes |
-| SHARED2_PM_LOW | int64 | yes |
-| SHARED3_PM_LOW | int64 | yes |
-| DRIVEALONE_PM_MED | int64 | yes |
-| SHARED2_PM_MED | int64 | yes |
-| SHARED3_PM_MED | int64 | yes |
-| DRIVEALONE_PM_HIGH | int64 | yes |
-| SHARED2_PM_HIGH | int64 | yes |
-| SHARED3_PM_HIGH | int64 | yes |
-| WALK_LOC_PM | int64 | yes |
-| WALK_PRM_PM | int64 | yes |
-| WALK_MIX_PM | int64 | yes |
-| PNR_LOCOUT_PM | int64 | yes |
-| PNR_PRMOUT_PM | int64 | yes |
-| PNR_MIXOUT_PM | int64 | yes |
-| KNR_LOCOUT_PM | int64 | yes |
-| KNR_PRMOUT_PM | int64 | yes |
-| KNR_MIXOUT_PM | int64 | yes |
-| TNC_LOCOUT_PM | int64 | yes |
-| TNC_PRMOUT_PM | int64 | yes |
-| TNC_MIXOUT_PM | int64 | yes |
-| PNR_LOCIN_PM | int64 | yes |
-| PNR_PRMIN_PM | int64 | yes |
-| PNR_MIXIN_PM | int64 | yes |
-| KNR_LOCIN_PM | int64 | yes |
-| KNR_PRMIN_PM | int64 | yes |
-| KNR_MIXIN_PM | int64 | yes |
-| TNC_LOCIN_PM | int64 | yes |
-| TNC_PRMIN_PM | int64 | yes |
-| TNC_MIXIN_PM | int64 | yes |
-| BIKE_PM | int64 | yes |
-| WALK_PM | int64 | yes |
-| DRIVEALONE_EV_LOW | int64 | yes |
-| SHARED2_EV_LOW | int64 | yes |
-| SHARED3_EV_LOW | int64 | yes |
-| DRIVEALONE_EV_MED | int64 | yes |
-| SHARED2_EV_MED | int64 | yes |
-| SHARED3_EV_MED | int64 | yes |
-| DRIVEALONE_EV_HIGH | int64 | yes |
-| SHARED2_EV_HIGH | int64 | yes |
-| SHARED3_EV_HIGH | int64 | yes |
-| WALK_LOC_EV | int64 | yes |
-| WALK_PRM_EV | int64 | yes |
-| WALK_MIX_EV | int64 | yes |
-| PNR_LOCOUT_EV | int64 | yes |
-| PNR_PRMOUT_EV | int64 | yes |
-| PNR_MIXOUT_EV | int64 | yes |
-| KNR_LOCOUT_EV | int64 | yes |
-| KNR_PRMOUT_EV | int64 | yes |
-| KNR_MIXOUT_EV | int64 | yes |
-| TNC_LOCOUT_EV | int64 | yes |
-| TNC_PRMOUT_EV | int64 | yes |
-| TNC_MIXOUT_EV | int64 | yes |
-| PNR_LOCIN_EV | int64 | yes |
-| PNR_PRMIN_EV | int64 | yes |
-| PNR_MIXIN_EV | int64 | yes |
-| KNR_LOCIN_EV | int64 | yes |
-| KNR_PRMIN_EV | int64 | yes |
-| KNR_MIXIN_EV | int64 | yes |
-| TNC_LOCIN_EV | int64 | yes |
-| TNC_PRMIN_EV | int64 | yes |
-| TNC_MIXIN_EV | int64 | yes |
-| BIKE_EV | int64 | yes |
-| WALK_EV | int64 | yes |
-| sample_rate | int64 | yes |
-| origin_parking_zone | int64 | yes |
-| otaz | int64 | yes |
-| dtaz | int64 | yes |
-| trip_id | int64 | yes |
-| trip_purpose | large_string | yes |
-| depart_hour | int32 | yes |
-| tour_purpose | large_string | yes |
-| finalweight | double | yes |
-| AUTOSUFF | int32 | yes |
-| num_participants | int32 | yes |
-| tour_mode | large_string | yes |
-| tour_category | large_string | yes |
-| HHVEH | int64 | yes |
-| WORKERS | int64 | yes |
-| OTAZ | int32 | yes |
-| DTAZ | int32 | yes |
-| od_dist | double | yes |
-| max_trip_num | int64 | yes |
-| stops | int32 | yes |
-| tour_OTAZ | int64 | yes |
-| tour_DTAZ | int64 | yes |
-| out_dir_dist | double | yes |
diff --git a/docs/adding-dashboard-pages.md b/docs/adding-dashboard-pages.md
deleted file mode 100644
index 9ed6081..0000000
--- a/docs/adding-dashboard-pages.md
+++ /dev/null
@@ -1,362 +0,0 @@
-# Adding Dashboard Pages
-
-This guide covers the new public dashboard page authoring API.
-
-Use it when the summary table you need already exists and you want to add or refactor a page under `dashboard/pages/`.
-
-If the summary does not exist yet, start with [adding-summaries.md](adding-summaries.md).
-
-## Mental Model
-
-A dashboard page now has one source of truth for page-local interactivity:
-
-1. register selectors once
-2. register sections once
-3. render section content from pure-ish render functions
-
-The framework takes care of:
-
-- widget watchers
-- stable section containers
-- rerendering only the affected sections when a selector changes
-- rerendering all sections when the global dashboard state changes
-- deriving export selector and export region metadata from those same registrations
-
-## Registration Objects
-
-Each page module still exports a module-level `PAGE = DashboardPageDefinition(...)`.
-
-`DashboardPageDefinition` is intentionally narrow:
-
-- `page_id`
-- `title`
-- `page_cls`
-- `order`
-- `group_id`
-- `default_enabled`
-- `prepared_data_mode`
-- `required_summary_ids`
-- `required_prepared_tables`
-
-Grouped navigation is declared in a sibling `GROUP = DashboardGroupDefinition(...)` inside the package `__init__.py`.
-
-`DashboardGroupDefinition` now uses `default_page_id`, not `default_child_id`.
-
-There is no `child_id`.
-
-## Page Lifecycle
-
-Page authors subclass `DashboardPage`.
-
-The public lifecycle hooks are:
-
-- `build_page(self) -> pn.viewable.Viewable`
-- `sync_controls(self) -> None`
-- `on_global_state_changed(self) -> None`
-
-`build_page()` is required. It should:
-
-- create widgets
-- register selectors
-- register sections
-- return one stable root view
-- avoid heavy data reshaping or cross-run filtering work
-
-`sync_controls()` is optional. It runs before every refresh pass and is the right place to:
-
-- populate selector options from current data availability
-- reset invalid widget values to safe defaults
-- keep selector value/bootstrap logic out of `render_*()` methods
-
-`on_global_state_changed()` is optional. Use it for page-local cache invalidation when weighting mode, value mode, or available runs change.
-
-## Target Structure
-
-When adding or refactoring a page, aim for this shape:
-
-1. `build_page()`
- - declare widgets
- - register selectors
- - register sections
- - return the stable root layout
-
-2. `sync_controls()`
- - compute selector options from current dashboard state
- - keep selector defaults valid
-
-3. `render_*()` section methods
- - one logical section per render method
- - narrow control flow
- - prefer pure helper functions for chart-ready reshaping
-
-4. shared helpers
- - use `dashboard/helpers/` for logic that appears in multiple pages
- - keep page-local helpers only for truly page-specific business rules
-
-As a rule of thumb:
-
-- `build_page()` should read like layout assembly
-- `sync_controls()` should read like selector synchronization
-- `render_*()` should read like "load data, handle missing state, render views"
-
-## Reference Pages
-
-Use the current pages below as implementation references when possible:
-
-- `dashboard/pages/overview.py`
- - simplest summary-only page
- - good reference for straightforward section rendering
-- `dashboard/pages/raw_trip_demo.py`
- - smallest prepared-data page
- - good reference for `resolve_prepared_visualization(...)`
-- `dashboard/pages/trip_summaries/trip_mode.py`
- - good reference for one-selector, one-section chart pages
-- `dashboard/pages/long_term_choices/mandatory_location_choice.py`
- - good reference for multi-section geography-driven pages
- - also the main complexity target when refactoring shared geography helpers
-- `dashboard/pages/skim_summaries/trip_skims.py`
- - strongest current example of selector sync plus independently refreshed sections
-- `dashboard/pages/skim_summaries/tour_skims.py`
- - strongest current example of sectioned live distributions with non-exportable controls
-
-## Public Helpers
-
-The core authoring helpers are:
-
-```python
-self.selector(
- selector_id,
- widget=...,
- label="...",
- exportable=True,
-)
-
-self.section(
- section_id,
- selectors=("selector_a", "selector_b"),
- export=True,
- render=self.render_section_name,
-)
-
-self.section_view("section_id")
-self.mark_section_stale("section_id")
-```
-
-The most commonly used data helpers remain available on `DashboardPage`:
-
-- `resolve_summary_visualization(...)`
-- `resolve_prepared_visualization(...)`
-- `require_summary(...)`
-- `require_summaries(...)`
-- `optional_summary(...)`
-- `unavailable_visualization(...)`
-- `data_not_available_card(...)`
-- `get_filtered_view(...)`
-- `clear_filtered_view_cache(...)`
-- `as_percent`
-- `weighting_key`
-
-## Shared Helper Map
-
-When page logic starts repeating, prefer one of the existing helper modules before
-adding page-local utility functions:
-
-- `dashboard/helpers/category_helpers.py`
- - selector option building
- - config-ordered category values
- - config-driven label columns
- - category completion for charts and tables
-- `dashboard/helpers/geography_helpers.py`
- - geography column normalization
- - geography level and geography id selector domains
- - geography-level and geography-id filtering
- - all-geographies and all-within-level handling
-- `dashboard/helpers/person_type_helpers.py`
- - person-type selector domains
- - person-type filtering
- - total-person-type rollups and weights
-- `dashboard/helpers/time_distance_helpers.py`
- - time-bin labels and durations
- - distance-bin sorting
-- `dashboard/helpers/comparison_helpers.py`
- - percent-error formatting
- - base-run percent-difference tables
- - weighted average lookups for comparison tables
-- `dashboard/pages/skim_summaries/_shared.py`
- - skim-page-specific shared logic that should stay local to skim pages
-
-If a transform is clearly reusable across unrelated pages, move it into
-`dashboard/helpers/`. If it only makes sense for one page family, keep it close
-to that family, as with the skim shared module or a page-local support module.
-
-## Minimal Example
-
-```python
-from __future__ import annotations
-
-import panel as pn
-
-from dashboard.page_base import DashboardPage, SectionContent
-from dashboard.page_definitions import DashboardPageDefinition
-
-
-class MyNewPage(DashboardPage):
- def build_page(self) -> pn.viewable.Viewable:
- self.purpose_sel = self.selector(
- "purpose",
- widget=pn.widgets.Select(name="Purpose", options=["Total"], value="Total"),
- label="Purpose",
- )
-
- summary_section = self.section(
- "summary",
- selectors=("purpose",),
- render=self.render_summary,
- )
-
- return pn.Column(
- pn.pane.Markdown("## My New Page"),
- pn.Row(pn.pane.Markdown("**Purpose:**"), self.purpose_sel),
- summary_section,
- sizing_mode="stretch_width",
- )
-
- def sync_controls(self) -> None:
- options = self._purpose_options()
- self.purpose_sel.options = options
- if self.purpose_sel.value not in options:
- self.purpose_sel.value = options[0]
-
- def render_summary(self) -> SectionContent:
- summaries = self.require_summaries(*self.required_summary_ids)
- if summaries is None:
- return [
- self.data_not_available_card(
- detail="This page depends on precomputed summaries.",
- missing_items=list(self.required_summary_ids),
- )
- ]
- return [pn.pane.Markdown(f"Current purpose: {self.purpose_sel.value}")]
-
-
-PAGE = DashboardPageDefinition(
- page_id="my_new_page",
- title="My New Page",
- order=120,
- page_cls=MyNewPage,
- required_summary_ids=("my_summary_table",),
-)
-
-MyNewPage.definition = PAGE
-```
-
-## Authoring Rules
-
-Do:
-
-- create widgets in `build_page()`
-- register every page-local interactive control with `selector(...)`
-- register every refreshable content area with `section(...)`
-- return content from section render functions
-- keep expensive reshaping work behind `get_filtered_view(...)`
-- prefer `require_summaries(...)`, `optional_summary(...)`, `resolve_summary_visualization(...)`, and `resolve_prepared_visualization(...)` over repeated ad hoc state lookups in render code
-- add a concise module docstring that explains what the page shows
-- add short docstrings to helper functions when they encode a business rule that is not obvious from the function name
-
-Do not:
-
-- call `_watch_widget(...)` on newly authored pages
-- assign `section.objects = ...` directly from page code
-- declare page-local selector metadata in `PAGE`
-- declare export regions in `PAGE`
-- use `child_id`
-- put large data-transformation blocks inline in `build_page()`
-- let `render_*()` methods become catch-all implementations for the entire page
-
-## Refresh Semantics
-
-The runtime now refreshes at section granularity.
-
-Selector change:
-
-- only sections that depend on that selector rerender
-
-Global state change:
-
-- all sections rerender
-
-Sections declared with `selectors=()` only rerender on global refresh unless you explicitly call `mark_section_stale(...)`.
-
-## Grouped Pages
-
-Grouped pages are identified only by leaf `page_id`.
-
-Live config uses leaf page ids inside a group:
-
-```yaml
-dashboard:
- live:
- pages:
- - overview
- - tours:
- - tour_summary
- - tour_mode
-```
-
-The group package defines:
-
-```python
-GROUP = DashboardGroupDefinition(
- group_id="tours",
- title="Tours",
- order=30,
- default_page_id="tour_summary",
-)
-```
-
-## Export
-
-Export metadata is derived from runtime selector and section registration.
-
-That means:
-
-- registered selectors become export selector metadata
-- registered exportable sections become export regions
-- section selector dependencies define which selector combinations need pre-rendered variants
-
-Grouped export config uses leaf page ids:
-
-```yaml
-dashboard:
- export:
- pages:
- trip_summaries:
- children:
- trip_mode:
- tour_purpose: all
-```
-
-## Checklist
-
-1. Add or update the page module under `dashboard/pages/`.
-2. Add or update the module-level `PAGE`.
-3. If the page belongs to a group, set `group_id` on `PAGE` and keep the package `GROUP` aligned with `default_page_id`.
-4. Implement `build_page()`.
-5. Register selectors and sections.
-6. Keep selector option logic in `sync_controls()`.
-7. Keep section renderers short and move reusable transforms into shared helpers.
-8. Declare the summary/prepared-data contract in `PAGE`.
-9. Add or update tests covering selector refresh and missing-data behavior.
-10. If the page should export interactively, add an export-focused test slice too.
-
-## Short Recipe
-
-For most new pages, the fastest safe workflow is:
-
-1. Start from a nearby reference page with similar selectors and data shape.
-2. Keep `build_page()` limited to widget creation, selector registration, section registration, and layout.
-3. Move selector domain logic into `sync_controls()`.
-4. Keep each `render_*()` method focused on one section.
-5. Extract chart-ready reshaping into a small helper or shared helper module.
-6. Use `get_filtered_view(...)` around any repeated cross-run filtering or aggregation.
-7. Add one live refresh test and, if the page exports, one export-oriented test slice.
diff --git a/docs/adding-summaries.md b/docs/adding-summaries.md
deleted file mode 100644
index a6e93ca..0000000
--- a/docs/adding-summaries.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# Adding Summary Tables
-
-This guide is for contributors adding a new summary table to the `processor/summarize/` package and, optionally, exposing it in the dashboard.
-
-The short version is:
-
-1. Add or update a builder function in `processor/summarize/summaries/`.
-2. Register it in `processor/summarize/summary_specs.py`.
-3. Declare the builder contract once so summarize can derive empty fallback schema and dashboard-facing columns from the builder itself.
-4. Wire it into a dashboard page through `required_summary_ids` if a page needs it.
-5. Add tests.
-
-## Mental Model
-
-A summary builder is a pure data step:
-
-- input: prepared `RunData` plus normalized `Config`
-- output: one Polars `DataFrame`
-
-The builder should not know whether the dashboard is in live mode or export mode. It should also not special-case weighted vs unweighted logic; the cache layer handles that by swapping `finalweight`.
-
-Each registered builder should also own its summary contract through `processor/summarize/contracts.py`. That contract is the single source of truth for:
-
-- the typed empty fallback frame
-- safe preflight prerequisites used by resilient summarize execution
-- exported output-column metadata derived by `processor/summarize/schema.py`
-
-## Step 1: Choose the Right Module
-
-Add the summary to the most relevant existing topic module when possible:
-
-- `processor/summarize/summaries/demographics.py`
-- `processor/summarize/summaries/daily_travel.py`
-- `processor/summarize/summaries/joint_travel.py`
-- `processor/summarize/summaries/long_term.py`
-- `processor/summarize/summaries/tour.py`
-- `processor/summarize/summaries/trip.py`
-- `processor/summarize/summaries/validation.py`
-- `processor/summarize/summaries/legacy.py`
-
-Create a new module only when the summary is a distinct topic area rather than just another table in an existing topic.
-
-## Step 2: Write the Builder Function
-
-New summary builders should follow this signature and declare a contract:
-
-```python
-@summary_contract(
- schema={
- "trip_mode": pl.Utf8,
- "distance_bin": pl.Int64,
- "freq": pl.Float64,
- },
- required_columns={"trips": ("trip_mode", "distance", "finalweight")},
-)
-def my_summary(rd: RunData, config: Config) -> pl.DataFrame:
- ...
-```
-
-Expectations:
-
-- Read from the prepared runtime tables on `RunData`.
-- Aggregate `pl.col("finalweight").sum()` instead of counting rows directly.
-- Return a plain `pl.DataFrame`.
-- Let the contract define the typed empty fallback schema once.
-- If the builder has more nuanced missing-data logic than the contract can express, return `empty_summary_frame(my_summary)` or an equivalent typed empty frame with the same schema.
-- Keep domain-specific reshaping in the summary layer only if it is part of the table contract. Page-specific chart shaping belongs in the dashboard page.
-
-Example skeleton:
-
-```python
-@summary_contract(
- schema={
- "trip_mode": pl.Utf8,
- "distance_bin": pl.Int64,
- "freq": pl.Float64,
- },
- required_columns={"trips": ("trip_mode", "distance", "finalweight")},
-)
-def trip_distance_by_mode(rd: RunData, config: Config) -> pl.DataFrame:
- if "distance" not in rd.trips.columns:
- return empty_summary_frame(trip_distance_by_mode)
-
- return (
- rd.trips
- .filter(pl.col("trip_mode").is_not_null())
- .with_columns((pl.col("distance") / 5).floor().cast(pl.Int64).mul(5).alias("distance_bin"))
- .group_by(["trip_mode", "distance_bin"])
- .agg(pl.col("finalweight").sum().alias("freq"))
- .sort(["trip_mode", "distance_bin"])
- )
-```
-
-## Step 3: Register the Summary in `processor/summarize/summary_specs.py`
-
-Registration still happens in the `SUMMARY_SPECS` tuple:
-
-```python
-SummarySpec("trip_distance_by_mode", "tripDistanceByMode", trips.trip_distance_by_mode)
-```
-
-`SummarySpec` stays intentionally small:
-
-- `summary_id`: stable id used by dashboard pages and tests
-- `filename`: CSV filename stem written under each weighting mode directory
-- `builder`: function that returns the summary table
-
-The cache module derives these related structures from `SUMMARY_SPECS`:
-
-- `SUMMARY_SPEC_BY_ID`
-- `SUMMARY_FILENAME_BY_ID`
-- `DEFAULT_SUMMARY_IDS`
-
-If the summary is not in `SUMMARY_SPECS`, it does not exist to the rest of the application.
-
-## Step 4: Use Derived Output Schema Metadata When Needed
-
-If the new table is a reusable dashboard-facing contract, make sure its builder contract schema is correct. `processor/summarize/schema.py` now derives canonical output columns from the registered builders instead of maintaining a separate hand-written column map.
-
-Do this when:
-
-- dashboard pages depend on a stable shape
-- the summary has fallback or empty-frame behavior that should keep the same columns
-- you want `tests/test_runtime_canonical_columns.py` to enforce the contract
-
-Skip it when the table is private, transitional, or not yet used as a stable dashboard input.
-
-## Step 5: Wire It Into a Dashboard Page
-
-If a page should consume the summary:
-
-1. Add the summary id to the page's `PAGE.required_summary_ids`.
-2. Use `require_summary(...)` or `require_summaries(...)` in a section render function.
-3. Keep page-specific filtering and chart shaping in the page module.
-
-Example:
-
-```python
-PAGE = DashboardPageDefinition(
- page_id="trip_distance",
- title="Trip Distance",
- page_cls=TripDistancePage,
- required_summary_ids=("trip_distance_by_mode",),
-)
-```
-
-This is what makes the summary available through `DashboardState` and keeps live mode, export mode, and validation aligned.
-
-## End-to-End Example
-
-Use this order when adding a new summary that will appear in the dashboard:
-
-1. Add `trip_distance_by_mode()` to `processor/summarize/summaries/trip.py`.
-2. Register it in `processor/summarize/summary_specs.py` with a stable `summary_id`.
-3. Add or update the builder contract schema if the page will treat it as a stable reusable table.
-4. Add a new page or update an existing page in `dashboard/pages/`.
-5. Declare the page dependency in `PAGE.required_summary_ids`.
-6. Add export selector metadata only if the page has page-local controls that must work in HTML export.
-7. Add tests covering the summary output shape and the page wiring.
-
-## Testing Checklist
-
-### Summary-focused tests
-
-Prefer adding or extending:
-
-- `tests/test_runtime_canonical_columns.py` for canonical prepared-column usage and output shape
-- `tests/test_summary_cache.py` for cache-layer registration or manifest behavior
-
-Test at least:
-
-- weighted and unweighted paths behave through `finalweight`
-- missing-column fallback returns the expected empty schema
-- unavailable and failed summaries are recorded with explicit manifest state
-- output columns remain stable
-
-### Dashboard-facing tests
-
-If the summary is used by a page, add or extend:
-
-- `tests/test_dashboard_live.py`
-- `tests/test_export_html.py`
-
-Test at least:
-
-- the page validates and refreshes using the new summary id
-- export works if the page participates in HTML export
-- page selectors still serialize correctly if the new summary changes available options
-
-## Common Mistakes
-
-- counting rows instead of summing `finalweight`
-- reading raw ActivitySim column names directly when `prepare_data()` already provides canonical aliases
-- registering the builder locally but forgetting to add it to `SUMMARY_SPECS`
-- duplicating output schema in both the builder and `processor/summarize/schema.py`
-- returning different columns from the empty-data path and the populated-data path
-- putting chart-specific reshaping into the summary table when it belongs in the page
-
-## Good Files to Read Before Editing
-
-- `processor/summarize/cache.py` for registration and weighting behavior
-- `processor/summarize/contracts.py` for builder contracts and typed empty fallback helpers
-- `processor/models.py` for the prepared `RunData` contract
-- `processor/prepare/enrichment/pipeline.py` for the `prepare_data()` entrypoint
-- `processor/prepare/enrichment/canonicalize.py` and `processor/prepare/enrichment/columns.py` for canonical column preparation helpers
-- `processor/summarize/schema.py` for dashboard-facing output contracts
-- `tests/test_runtime_canonical_columns.py` for the expected testing style
-- [adding-dashboard-pages.md](adding-dashboard-pages.md) if the summary will be displayed in the UI
diff --git a/docs/adr/ADR-custom-export-runtime.md b/docs/adr/ADR-custom-export-runtime.md
deleted file mode 100644
index bfad6e5..0000000
--- a/docs/adr/ADR-custom-export-runtime.md
+++ /dev/null
@@ -1,96 +0,0 @@
-# ADR: Custom Offline Export Runtime
-
-- Status: Accepted
-- Date: 2026-04-23
-
-## Context
-
-`activitysim_visualizer` uses Panel for the live dashboard, but it also supports a standalone offline HTML export used for review and sharing.
-
-The project had to choose between two broad export strategies:
-
-1. Keep a generic all-Panel export path.
-2. Build a custom client-side export runtime that serializes only the dashboard state we want to ship offline.
-
-## Decision
-
-The project keeps the custom client-side export runtime.
-
-The current implementation lives under `dashboard/export/` and produces:
-
-- one self-contained HTML file
-- one embedded JSON payload describing dashboard/page state
-- one embedded browser runtime that validates and renders that payload
-
-## Why Panel Is Still the Live Runtime
-
-Panel remains a good fit for the live dashboard because it gives the project:
-
-- fast composition of plots, tables, and controls
-- server-backed interactivity for exploratory work
-- straightforward integration with Python-side summary tables and cached state
-- a productive page-controller model for contributors
-
-The decision here is not "Panel was a mistake." The decision is specifically about offline export behavior.
-
-## Why We Did Not Keep the Original All-Panel Export Path
-
-The generic all-Panel export path was not the right long-term fit for this project’s offline needs.
-
-Main reasons:
-
-- The exported artifact was larger than necessary because it carried more live-app machinery than the offline use case needed.
-- The project wanted tighter control over what state combinations were materialized offline.
-- The offline use case only needs a bounded set of precomputed views, not a fully general live dashboard runtime.
-- Debugging and reasoning about offline behavior is easier when the payload and renderer contract are explicit.
-
-## Why the Custom Export Runtime Exists
-
-The custom export path exists to optimize for the actual offline review workflow:
-
-- one HTML file that can be handed around easily
-- no server requirement
-- bounded, explicit state growth driven by export config
-- control over which selectors are interactive offline
-- smaller artifacts than shipping the full live runtime
-- clearer failure modes when export payload/runtime contracts drift
-
-## Accepted Tradeoffs
-
-This decision intentionally accepts some tradeoffs.
-
-Costs:
-
-- there is a Python-to-JavaScript schema contract to maintain
-- new supported node kinds require work in both serializer and runtime
-- export cannot automatically support every Panel component the live app might use
-- contributors need docs and tests to keep the subsystem safe
-
-Benefits:
-
-- offline exports stay self-contained
-- artifact size is more controllable
-- page/selector support is explicit instead of accidental
-- runtime failures can be made visible and diagnosable
-- the export path is easier to harden with focused tests
-
-## Guardrails We Rely On
-
-To keep this decision healthy over time, the project relies on:
-
-- typed payload definitions in `dashboard/export/types.py`
-- explicit schema versioning
-- a runtime compatibility check in `dashboard/export/assets/export_runtime.js`
-- page-owned selector and section registration via the public `DashboardPage` API
-- export contract tests for payload shape, serializer coverage, smoke behavior, warnings, and artifact size
-- contributor docs that explain how to extend the subsystem safely
-
-## Consequences for Future Work
-
-Future contributors should assume:
-
-- the custom export runtime is the supported direction
-- replacing it with generic live-app export would need a new ADR
-- page and selector export support should be added through the shared page registry and docs, not through ad hoc export-only configuration
-
-If the team later decides the maintenance cost is too high, that should be revisited explicitly with fresh evidence about artifact size, complexity, and offline user needs.
diff --git a/docs/export_html_contributor_guide.md b/docs/export_html_contributor_guide.md
deleted file mode 100644
index 30114cc..0000000
--- a/docs/export_html_contributor_guide.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# Export HTML Contributor Guide
-
-This guide explains how the standalone HTML export works after the sectioned page API refactor.
-
-Use it when you are:
-
-- adding export support for a page
-- changing selector behavior in export
-- changing the Python-to-JavaScript payload/runtime contract
-
-## Mental Model
-
-The export path does not save the live Panel app directly.
-
-Instead it:
-
-1. resolves the enabled leaf pages
-2. instantiates the same `DashboardPage` objects used by live mode
-3. reads registered selectors and sections from each page instance
-4. pre-renders the needed selector variants per section
-5. serializes the resulting page shell and region snapshots into JSON
-6. renders that JSON with `dashboard/export/assets/export_runtime.js`
-
-The important architectural change is that export metadata is no longer supposed to come from separate page-owned `PAGE.selectors` or `PAGE.export_regions` declarations for newly authored pages. It should come from runtime `selector(...)` and `section(...)` registration.
-
-## Source of Truth
-
-For new-style pages, export support is derived from:
-
-- `PAGE = DashboardPageDefinition(...)`
-- `DashboardPage.build_page()`
-- `self.selector(...)`
-- `self.section(...)`
-
-`PAGE` still owns:
-
-- page identity
-- ordering
-- group membership
-- summary/prepared-data requirements
-
-The page instance owns:
-
-- selector widgets
-- section dependencies
-- exportable section boundaries
-
-## What Makes a Page Export-Friendly
-
-The serializer handles these common Panel types well:
-
-- `pn.Column`
-- `pn.Row`
-- `pn.Card`
-- `pn.Tabs`
-- `pn.pane.Markdown`
-- `pn.pane.HTML`
-- `pn.pane.Plotly`
-- `pn.widgets.Tabulator`
-- `pn.widgets.Select`
-- `pn.widgets.RadioButtonGroup`
-- `pn.Spacer`
-
-A good interactive export page usually has:
-
-- a small bounded selector option set
-- stable widget instances created in `build_page()`
-- sections whose content can be fully pre-rendered
-
-## Export Metadata Derivation
-
-Registered selectors become export selector metadata.
-
-Registered sections become export regions when `export=True`.
-
-Section selector dependencies do double duty:
-
-- they drive live section-aware refresh
-- they define the selector dimensions that need offline variants
-
-`export=False` on a section keeps it static in the exported page shell.
-
-`exportable=False` on a selector keeps the control visible in live mode while disabling offline interactivity for that selector.
-
-## Page Authoring Expectations
-
-For export-aware pages:
-
-- create widgets in `build_page()`
-- register selectors with stable ids
-- register sections with stable ids
-- keep section render functions deterministic for a given widget state
-- reconcile widget options and defaults in `sync_controls()`
-
-Do not add new export-only registries or duplicate selector metadata in `PAGE`.
-
-## Config Shape
-
-Standalone page selectors are configured under:
-
-```yaml
-dashboard:
- export:
- pages:
- trip_mode:
- tour_purpose: all
-```
-
-Grouped page selectors are configured under:
-
-```yaml
-dashboard:
- export:
- pages:
- trip_summaries:
- children:
- trip_mode:
- tour_purpose: all
-```
-
-Grouped pages are identified by leaf `page_id`, not `child_id`.
-
-## Runtime Validation
-
-Export config validation checks:
-
-- unknown page ids
-- unknown group ids
-- unknown selector ids
-- unknown part ids
-
-For migrated pages, selector ids and part ids should resolve from the runtime registration graph first, with legacy `PAGE.selectors` and legacy export-part metadata only serving as compatibility fallback for untouched pages.
-
-## When You Change the Contract
-
-If you change the payload or runtime behavior:
-
-1. update `dashboard/export/types.py`
-2. update `dashboard/export/payload.py`
-3. update `dashboard/export/serializer.py` if node serialization changed
-4. update `dashboard/export/assets/export_runtime.js`
-5. update `docs/export_html_schema.md`
-6. update the export payload and smoke tests
-
-The generated runtime asset is now built from `dashboard/export/js_runtime/` by
-`dashboard/export/build_export_runtime.py`. Make source changes in `js_runtime/`, then
-rebuild the committed asset.
-
-## Recommended Tests
-
-At minimum, touch the relevant subset of:
-
-- `tests/test_export_payload.py`
-- `tests/test_export_html.py`
-- `tests/test_export_html_smoke.py`
-- `tests/test_export_serializer.py`
-- `tests/test_export_warnings.py`
-
-Good export-focused assertions now include:
-
-- selector metadata derives from `selector(...)`
-- region metadata derives from `section(...)`
-- grouped descriptors use `default_page_id`
-- grouped config resolves children by leaf `page_id`
-- unrelated sections do not produce unnecessary selector variants
diff --git a/docs/plotting-summary-tables.md b/docs/plotting-summary-tables.md
deleted file mode 100644
index afcfbfd..0000000
--- a/docs/plotting-summary-tables.md
+++ /dev/null
@@ -1,404 +0,0 @@
-# Plotting Summary Tables
-
-This primer explains how to turn precomputed summary tables into the kinds of plots the dashboard already uses.
-
-It is aimed at the common case where:
-
-- the summary table already exists
-- you want to add or adjust a chart on a dashboard page
-- you want to follow the same patterns used in `dashboard/components.py` and `dashboard/pages/`
-
-If you need to create the summary itself first, start with [adding-summaries.md](adding-summaries.md).
-
-## Mental Model
-
-In this codebase, plotting usually happens in two layers:
-
-1. A page module fetches one or more summary tables and reshapes them into chart-ready data.
-2. A shared helper in `dashboard/components.py` turns that prepared data into a `pn.pane.Plotly`.
-
-The shared helpers expect data in this form:
-
-```python
-list[tuple[str, pl.DataFrame]]
-```
-
-Each tuple is:
-
-- `run_label`: the visible label for one model run
-- `pl.DataFrame`: the data for that run after any filtering, grouping, sorting, or renaming needed for plotting
-
-That means pages should usually do business logic first, and plotting second.
-
-## The Shared Plot Helpers
-
-The main helpers live in `dashboard/components.py`:
-
-- `bar_chart(...)`: grouped or stacked categorical comparisons across runs
-- `line_chart(...)`: overlaid line profiles
-- `density_chart(...)`: filled line charts used for distributions such as TLFD or time-of-day profiles
-
-All three helpers:
-
-- accept `list[tuple[str, pl.DataFrame]]`
-- use consistent run colors
-- apply shared layout and legend styling
-- honor `as_percent=self.as_percent` so the same chart can flip between counts and percentages
-
-## The Core Workflow
-
-Most pages follow the same sequence:
-
-1. Fetch summaries with `require_summary(...)` or `require_summaries(...)`.
-2. Build chart-ready tables in small helper functions.
-3. Cache selector-dependent reshaping with `get_filtered_view(...)` when useful.
-4. Pass the prepared data into `bar_chart(...)` or `density_chart(...)`.
-
-The dashboard examples in `dashboard/pages/overview.py`, `dashboard/pages/long_term_choices/mandatory_location_choice.py`, `dashboard/pages/tour_summaries/tour_mode.py`, and `dashboard/pages/tours/tod.py` all follow this pattern.
-
-## Plot Type 1: Categorical Bar Charts
-
-Use `bar_chart(...)` when you want to compare categories across runs.
-
-Typical inputs:
-
-- person type distributions
-- household size distributions
-- auto ownership
-- mode shares
-- stop frequency
-
-Minimal example:
-
-```python
-chart = bar_chart(
- data_list,
- x_col="tour_mode",
- y_col="tour_count",
- title="Tour Mode",
- xaxis_title="Mode",
- yaxis_title="Tours",
- as_percent=self.as_percent,
-)
-```
-
-### When the summary table is already chart-ready
-
-Sometimes the summary already has one row per category and one value column you can plot directly. In that case, the page only needs light cleanup.
-
-Example pattern from `dashboard/pages/overview.py`:
-
-```python
-def person_type_chart_data(
- pertype_list: list[tuple[str, pl.DataFrame]],
-) -> list[tuple[str, pl.DataFrame]]:
- return [
- (label, df.with_columns(pl.col("person_type_label").cast(pl.Utf8)))
- for label, df in pertype_list
- ]
-```
-
-This is a good fit when you only need to:
-
-- cast labels to strings
-- select a subset of columns
-- preserve the existing category rows
-
-### When you need to filter by a selector
-
-If a page has a selector such as purpose or geography, filter the table before plotting.
-
-Example pattern from `dashboard/pages/tour_summaries/tour_mode.py`:
-
-```python
-df.filter(pl.col("tour_purpose").cast(pl.Utf8) == purpose).select(
- ["tour_mode", column]
-).sort("tour_mode")
-```
-
-This is the standard approach when the summary is long-form and one selector value corresponds to one chart.
-
-### When the "Total" option is not stored directly
-
-Some pages define a synthetic "Total" by filtering out pre-aggregated rows and summing the detailed rows.
-
-Example pattern from `dashboard/pages/tour_summaries/tour_mode.py`:
-
-```python
-purpose_col = pl.col("tour_purpose").cast(pl.Utf8)
-(
- df.filter(~purpose_col.is_in(["all_tour_purposes", "Total"]))
- .group_by("tour_mode")
- .agg(pl.col(column).sum().alias(column))
- .sort("tour_mode")
-)
-```
-
-Use this pattern when:
-
-- the table stores both detailed rows and rolled-up labels
-- you want a "Total" chart built from the detailed rows only
-
-### Optional percent values in hover
-
-`bar_chart(...)` supports `pct_col=...`.
-
-Example:
-
-```python
-bar_chart(
- auto_own_list,
- x_col="household_vehicle_count",
- y_col="household_count",
- pct_col="pct",
- as_percent=self.as_percent,
-)
-```
-
-This is useful when the summary already contains both:
-
-- an absolute measure such as `household_count`
-- a percentage column such as `pct`
-
-The chart still plots `y_col`, but the hover text also shows `pct_col`.
-
-## Plot Type 2: Distribution and Profile Charts
-
-Use `density_chart(...)` for ordered x-axes where the shape of the distribution matters more than discrete bars.
-
-Typical inputs:
-
-- trip length frequency distributions
-- distance distributions
-- departure and arrival profiles
-- duration distributions
-
-Minimal example:
-
-```python
-density_chart(
- data_list,
- x_col="distance_bin",
- y_col="person_count",
- title="Work TLFD",
- xaxis_title="Distance (miles)",
- yaxis_title="Persons",
- as_percent=self.as_percent,
-)
-```
-
-### Sort the x-axis deliberately
-
-Unlike simple bar charts, distribution plots usually need a meaningful order. The page should sort before passing data to the chart helper.
-
-Example pattern from `dashboard/pages/long_term_choices/mandatory_location_choice.py`:
-
-```python
-df.filter(pl.col("geography") == geography).select(
- pl.col("distance_bin"),
- pl.col("person_count"),
-).sort("distance_bin")
-```
-
-If you do not sort, Plotly will use the row order it receives.
-
-### Rename value columns to a common plotting column
-
-When several charts use different measure columns from the same summary, it is often easier to rename the selected measure to one shared plotting name.
-
-Example pattern from `dashboard/pages/tours/tod.py`:
-
-```python
-df.filter(pl.col("tour_purpose").cast(pl.Utf8) == purpose).select(
- ["time_bin", val_col]
-).rename({val_col: "freq"})
-```
-
-This makes the chart call simpler because every downstream chart can use `y_col="freq"`.
-
-### Convert coded bins into display labels
-
-Many summary tables use coded bins that should not be shown directly.
-
-Example pattern from `dashboard/pages/tours/tod.py`:
-
-```python
-selected.with_columns(
- pl.col("time_bin")
- .map_elements(lambda tb: _time_label(int(tb), maxbin), return_dtype=pl.Utf8)
- .alias("clock_time")
-)
-```
-
-Other examples include:
-
-- converting integer household-size bins to strings
-- converting auto ownership counts to display labels
-- converting duration bins into hours
-
-The key idea is that the page owns display-friendly reshaping, while `density_chart(...)` stays generic.
-
-## Plot Type 3: Multi-Chart Views From One Summary
-
-Sometimes one summary table powers several related charts. In those cases, prepare all chart datasets in one helper and return them together.
-
-Example pattern from `dashboard/pages/tours/tod.py`:
-
-```python
-def chart_data(tod_list, purpose):
- dep_data = [...]
- arr_data = [...]
- dur_data = [...]
- return dep_data, arr_data, dur_data
-```
-
-This pattern works well when:
-
-- the same selector drives several plots
-- several plots share the same filtering logic
-- you want to cache the reshaping once with `get_filtered_view(...)`
-
-Example:
-
-```python
-dep_data, arr_data, dur_data = self.get_filtered_view(
- "tour_tod",
- raw_purpose,
- factory=lambda: chart_data(tod_list, raw_purpose),
-)
-```
-
-Use this whenever selector-driven reshaping would otherwise be repeated several times across section render functions.
-
-## Common Reshaping Patterns
-
-These are the most common ways summary tables become chart-ready.
-
-### 1. Cast category columns to strings
-
-Useful for discrete labels such as household size or person type.
-
-```python
-df.with_columns(pl.col("household_size").cast(pl.Utf8))
-```
-
-### 2. Select only the columns the chart needs
-
-Useful when the summary carries extra metadata.
-
-```python
-df.select(
- pl.col("geography"),
- pl.col("work_from_home_worker_count"),
-)
-```
-
-### 3. Filter to one selector value
-
-Useful for purpose, geography, tour mode, or person type views.
-
-```python
-df.filter(pl.col("geography") == geography)
-```
-
-### 4. Group and aggregate
-
-Useful when a "Total" option should be built dynamically.
-
-```python
-df.group_by("tour_mode").agg(pl.col(column).sum().alias(column))
-```
-
-### 5. Sort into display order
-
-Useful for distributions, ordered categories, and numeric bins.
-
-```python
-df.sort("distance_bin")
-```
-
-### 6. Rename the plotted value column
-
-Useful when several summaries expose different measure names.
-
-```python
-df.rename({val_col: "freq"})
-```
-
-## Choosing the Right Plot
-
-A quick rule of thumb:
-
-- Use `bar_chart(...)` for discrete categories like mode, purpose, household size, or auto ownership.
-- Use `density_chart(...)` for ordered bins like distance, time, or duration.
-- Use `line_chart(...)` when you want the same ordered comparison without filled areas.
-
-If the x-axis has a natural progression, a line-style chart is usually the better fit. If the x-axis is a set of labeled buckets with no continuous feel, a bar chart is usually clearer.
-
-## Practical Guidelines
-
-Keep the following habits when adding a chart:
-
-- Do chart-specific reshaping in page helper functions, not inside `dashboard/components.py`.
-- Keep shared helpers generic and presentation-focused.
-- Filter out `None` or empty data frames when a summary may be absent for some runs.
-- Sort ordered x-axes before plotting.
-- Prefer small helpers with names like `chart_data(...)`, `purpose_options(...)`, or `*_chart_data(...)`.
-- Use `get_filtered_view(...)` when a selector-dependent reshape is reused or expensive.
-- Pass `as_percent=self.as_percent` so the chart respects the page-wide count/percent toggle.
-
-## A Good Starting Template
-
-This is a solid pattern for a new chart on a page:
-
-```python
-def my_chart_data(
- summary_list: list[tuple[str, pl.DataFrame]],
- selected_value: str | None,
-) -> list[tuple[str, pl.DataFrame]]:
- out = []
- for label, df in summary_list:
- if df is None or len(df) == 0:
- continue
- chart_df = (
- df.filter(pl.col("segment").cast(pl.Utf8) == selected_value)
- .select(["category", "count"])
- .sort("category")
- )
- out.append((label, chart_df))
- return out
-
-
-data = self.get_filtered_view(
- "my_chart",
- selected_value,
- factory=lambda: my_chart_data(summary_list, selected_value),
-)
-
-chart = bar_chart(
- data,
- x_col="category",
- y_col="count",
- title="My Summary Chart",
- xaxis_title="Category",
- yaxis_title="Count",
- as_percent=self.as_percent,
-)
-```
-
-That template matches the structure already used across the dashboard.
-
-## Examples to Revisit in the Repo
-
-These files are especially useful references:
-
-- `dashboard/components.py`: shared chart APIs and layout behavior
-- `dashboard/pages/overview.py`: simple chart-ready summary tables
-- `dashboard/pages/long_term_choices/mandatory_location_choice.py`: geography filtering and TLFD-style plots
-- `dashboard/pages/tour_summaries/tour_mode.py`: selector-driven filtering and dynamic totals
-- `dashboard/pages/tours/tod.py`: profile plots, relabeling bins, and multi-chart caching
-
-If you are deciding where a transformation belongs, a good rule is:
-
-- page modules own meaning
-- shared chart helpers own appearance
diff --git a/docs/summary-workflow.md b/docs/summary-workflow.md
deleted file mode 100644
index e597e62..0000000
--- a/docs/summary-workflow.md
+++ /dev/null
@@ -1,242 +0,0 @@
-# Summary and Dashboard Workflow
-
-This project now has five logical pipeline steps in config:
-
-1. `prepare`
-2. `skimjoin`
-3. `summarize`
-4. `segment`
-5. `dashboard`
-
-At runtime those currently collapse into three executable workflow boundaries:
-`prepare`, `summarize`, and `dashboard`. `skimjoin` runs inside prepare, and
-`segment` runs inside summarize.
-
-Those steps can be selected explicitly from CLI flags or supplied as defaults in
-`pipeline.steps` within the YAML config.
-
-Understanding which workflow you are in makes the rest of the codebase much easier to follow.
-
-## Effective Plan Resolution
-
-`run.py` resolves execution in this order:
-
-1. Load config and normalize any legacy keys to the canonical config schema.
-2. Start from `pipeline.steps` when CLI step flags are absent.
-3. Apply CLI overrides such as `--prepare-only`, `--summarize`, `--dashboard`,
- `--from-csvs`, and `--no-dashboard`.
-4. Collapse logical steps into runtime workflow boundaries.
-5. Resolve dashboard mode from CLI first, then `pipeline.dashboard_mode`.
-
-`pipeline.overwrite: true` changes the default cache preference for selected
-workflow steps, while explicit refresh flags still force cache deletion and
-rebuild behavior.
-
-Dashboard mode supports:
-
-- `none`: do not run dashboard
-- `live`: build/serve the live Panel app
-- `export`: write standalone HTML
-- `host`: reserved for future hosted deployment behavior; current runtime falls back to live mode
-
-## Step 1: Prepare
-
-Typical command:
-
-```bash
-python run.py --prepare-only
-```
-
-High-level path:
-
-1. `run.py` parses CLI flags.
-2. `runtime.workflows.resolve_run_entries()` chooses run inputs from CLI or config.
-3. `runtime.workflows.run_prepare_workflow()` tries prepared-cache reuse first.
-4. If a run defines `prepared_table_map`, the workflow loads those canonical prepared tables directly and skips raw prepare.
- This path is intended for already-prepared, already-skimjoined tables that may have been filtered or post-processed outside this repo.
-5. Otherwise, on a miss, raw runs are read by `processor.prepare.reader` and normalized by `processor.prepare.enrichment.pipeline`.
-6. After any prepared run is loaded, the workflow optionally validates cross-table key relationships according to `prepare.validation.relationship_checks` (`warn` by default).
-7. `processor.prepare.cache.write_prepared_run_cache()` writes one prepared cache directory per raw-derived run.
-
-Integrated skimjoin, when enabled through `pipeline.steps`, legacy
-`skimjoin.enabled`, or run/global skimjoin config, runs against those prepared
-tables. That now includes a prepared tour column named
-`first_inbound_trip_depart`, derived from the first inbound trip on each tour so
-inbound tour PERIOD lookups do not reuse the outbound tour start time.
-
-Prepared cache layout:
-
-```text
-/
- /
- manifest.json
- households.parquet|csv
- persons.parquet|csv
- tours.parquet|csv
- trips.parquet|csv
- joint_tour_participants.parquet|csv
- land_use.parquet|csv
-```
-
-The standard prepare workflow writes parquet by default, but `prepare.output.file_format`
-may switch prepared-cache output to CSV. Custom `prepared_table_map` inputs may also
-mix `.parquet` and `.csv` files within the same run because they are loaded directly,
-not rewritten into prepared cache in v1. They also do not rerun integrated skimjoin;
-if you need skim outputs in that path, they should already exist in the supplied
-prepared tables.
-
-Skimjoin source-column selection is explicit in the skimjoin config:
-
-- `activitysim.trip_mode_column` and `activitysim.trip_id_column` control trip lookup inputs
-- `activitysim.tour_mode_column` and `activitysim.tour_id_column` control tour lookup inputs
-- `dimensions..source_columns.trip_source_column` controls trip placeholder resolution
-- `dimensions..source_columns.outbound_tour_source_column` controls outbound tour placeholder resolution
-- `dimensions..source_columns.inbound_tour_source_column` controls inbound tour placeholder resolution
-
-For the shared zone columns used by skimjoin defaults and common overrides:
-
-- prepare always materializes `OTAZ` and `DTAZ` on trips and tours
-- if `zones.use_maz: true`, prepare also materializes `o_maz` and `d_maz`
-- inbound tours reuse those same prepared columns, while skimjoin swaps lookup direction logically in the inbound tour context
-
-## Step 2: Summarize
-
-Typical command:
-
-```bash
-python run.py --summarize
-```
-
-High-level path:
-
-1. `run.py` resolves the summarize step.
-2. `runtime.workflows.run_summary_workflow()` tries summary-cache reuse first.
-3. On a summary-cache miss, summarize reuses in-memory prepared runs, then loads custom `prepared_table_map` inputs when configured, then tries prepared cache, then rebuilds prepare from raw inputs only if needed.
-4. `processor.summarize.cache.build_mode_summaries_with_metadata()` builds weighted and optionally unweighted tables.
-5. `processor.summarize.cache.write_summary_run_cache()` writes one cache directory per run unless `--skip-summary-cache-write` is set.
-6. If segmentation is enabled, summary generation also builds segment-specific
- analysis units within this same workflow boundary.
-
-Cache layout:
-
-```text
-/
- /
- manifest.json
- weighted/
- .csv
- unweighted/
- .csv
-```
-
-`manifest.json` is important. Prepared manifests record the schema version, prepare-config digest, run fingerprint, table file mapping, and per-table state/diagnostic metadata. Summary manifests record the schema version, summary ids, weighting modes, summary file mapping, run fingerprint, summary-config digest, and per-summary state/diagnostic metadata used to validate whether a cache is still safe to reuse.
-
-The summarize step also records the prepared-manifest identity it was built from, so summary caches are explicitly layered on top of prepared inputs rather than raw-run assumptions.
-That prepared-input identity now covers both standard prepared caches and user-supplied `prepared_table_map` inputs, so summary reuse is invalidated when external prepared files change.
-
-Summary building is now best-effort per summary. Each registered builder can declare a typed output contract and safe prerequisite metadata. The cache layer uses that contract to:
-
-- skip impossible summaries before calling the builder
-- write typed empty fallback tables for `empty`, `unavailable`, or `failed` summaries
-- continue building the rest of the summary set for the run
-- record explicit summary states in the manifest so empty outputs do not hide why they were empty
-
-## Step 3: Dashboard
-
-Typical cache-backed command:
-
-```bash
-python run.py --from-csvs
-```
-
-High-level path:
-
-1. `run.py` loads config and resolves the effective plan.
-2. `runtime.workflows.load_summary_runs_from_cache()` reads each cache directory and validates its manifest.
-3. `dashboard.page_registry` resolves the enabled live pages from config.
-4. `dashboard.app.build_dashboard()` builds the shared `DashboardState`, the sidebar controls, and the page controllers.
-5. Each page pulls one summary table per run from `DashboardState`.
-
-Important behavior:
-
-- Summary-backed pages do not rebuild summary tables.
-- Dashboard-only runs can now load summary caches either from explicit
- `--from-csvs` directories or from the configured runs when the summarize step
- is omitted.
-- If an enabled page requires disaggregate tables, `run.py` loads prepared runs for that page set from memory, custom `prepared_table_map` inputs, prepared cache, or the prepare workflow.
-- Most pages should stay summary-backed and declare their requirements through `PAGE.required_summary_ids`.
-- Prepared-data pages must also declare `PAGE.required_prepared_tables`, which lets the workflow prune unused prepared tables before dashboard startup/export.
-
-When you want one invocation to do both processor work and dashboard startup, run
-the steps explicitly together:
-
-```bash
-python run.py --summarize --dashboard
-```
-
-`python run.py` can also follow `pipeline.steps` from config when no CLI step
-flags are supplied.
-
-## Standalone HTML Export
-
-Typical command:
-
-```bash
-python run.py --from-csvs --export-html output.html
-```
-
-Config-driven export is also supported with `pipeline.dashboard_mode: export`,
-with the output path coming from `dashboard.export.output_path` when present.
-Relative export output paths resolve from `root`.
-
-High-level path:
-
-1. `runtime.workflows.load_summary_runs_from_cache()` loads the same summary inputs used by live mode.
-2. `dashboard.page_registry` resolves the export page set.
-3. `dashboard.export.html.build_export_html_document()` creates a client-side payload that contains:
- - dashboard-level state combinations
- - serialized page content
- - selector metadata for export-enabled page widgets
-4. The export runtime swaps between pre-rendered states in the browser without requiring a Python server.
-
-Important behavior:
-
-- Export never computes missing summaries.
-- Export only supports page-local widget behavior that has been registered through `DashboardPage.selector(...)`.
-- A live page can render correctly and still be only partially exportable if its selectors or sections are not registered with the public `DashboardPage` authoring API.
-
-## Weighted vs Unweighted
-
-The summary cache layer supports two weighting modes:
-
-- `weighted`: uses the prepared `finalweight` column as-is
-- `unweighted`: uses `strip_weights()` to reset `finalweight` to `1.0` before building summaries
-
-Summary builders should always aggregate `finalweight` rather than switching behavior internally based on mode. That keeps weighting behavior centralized in `processor/summarize/cache.py`.
-
-## Where New Contributors Usually Need to Look
-
-| Question | Start here |
-|---|---|
-| How are runs loaded and normalized? | `processor/prepare/reader.py`, `processor/prepare/enrichment/pipeline.py` |
-| Why was a prepared or summary cache reused or rejected? | `runtime/workflows/`, `processor/prepare/cache.py`, `processor/summarize/cache.py` |
-| Which summary ids exist? | `processor/summarize/summary_specs.py` |
-| Which output columns are considered canonical? | `processor/summarize/schema.py`, derived from builder contracts |
-| How does a page get discovered? | `dashboard/page_registry.py` |
-| How does export know about page-local selectors? | `dashboard/page_base.py`, `dashboard/export/payload.py`, `dashboard/export/serializer.py` |
-
-For prepare internals, the enrichment package is now split by responsibility:
-
-- `processor/prepare/enrichment/columns.py` for source-column resolution helpers
-- `processor/prepare/enrichment/canonicalize.py` for raw-to-canonical field materialization
-- `processor/prepare/enrichment/weights.py` for `finalweight` assignment
-- `processor/prepare/enrichment/zones.py` for MAZ/TAZ, geography, and skim helpers
-- `processor/prepare/enrichment/pipeline.py` for the public `prepare_data()` orchestration entrypoint
-
-## Related Guides
-
-- [architecture.md](architecture.md)
-- [adding-summaries.md](adding-summaries.md)
-- [adding-dashboard-pages.md](adding-dashboard-pages.md)
-- [export_html_schema.md](export_html_schema.md)
-- [export_html_contributor_guide.md](export_html_contributor_guide.md)
diff --git a/example_skimjoin_config.yaml b/example_skimjoin_config.yaml
index 5d12fb3..0871d2d 100644
--- a/example_skimjoin_config.yaml
+++ b/example_skimjoin_config.yaml
@@ -21,6 +21,8 @@ defaults:
origin: OTAZ
destination: DTAZ
output_prefix: skim_
+ missing_matrix_policy: error # error | warn | set_null
+ missing_od_policy: error # error | warn | set_null
sentinel_values:
- 9999
- 999999
@@ -124,3 +126,13 @@ modes:
matrix: "WTW_TIV__{PERIOD}"
origin: OTAZ
destination: pnr_taz
+
+# Optional trip-to-tour aggregation for outputs that should be accumulated from
+# joined trips instead of (or in addition to) direct tour lookups.
+# tour_aggregation:
+# method: aggregate_trips
+# aggregations:
+# skim_auto_time: sum
+# skim_auto_distance: sum
+# directional_outputs:
+# skim_auto_time: true
diff --git a/processor/__init__.py b/processor/__init__.py
index b05da85..1135e73 100644
--- a/processor/__init__.py
+++ b/processor/__init__.py
@@ -1,5 +1,5 @@
"""Processor package for prepared ActivitySim data and derived outputs."""
-from processor.models import ProcessorWorkflowResult, RunData
+from processor.models import RunData
-__all__ = ["ProcessorWorkflowResult", "RunData"]
+__all__ = ["RunData"]
diff --git a/processor/cache_identity.py b/processor/cache_identity.py
index c10e375..a633086 100644
--- a/processor/cache_identity.py
+++ b/processor/cache_identity.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections import Counter
+from pathlib import Path
import re
@@ -53,4 +54,15 @@ def build_run_fingerprint(
}
-__all__ = ["build_run_fingerprint", "build_run_keys", "slugify"]
+def file_identity(path: str | Path) -> dict[str, object]:
+ """Return a portable identity for an external input file."""
+ resolved = Path(path).resolve()
+ stat = resolved.stat()
+ return {
+ "path": str(resolved),
+ "size": int(stat.st_size),
+ "mtime_ns": int(stat.st_mtime_ns),
+ }
+
+
+__all__ = ["build_run_fingerprint", "build_run_keys", "file_identity", "slugify"]
diff --git a/processor/models.py b/processor/models.py
index 64947e7..b034ce5 100644
--- a/processor/models.py
+++ b/processor/models.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, Collection, Literal, Optional
+from typing import Any, Callable, Collection, Literal, Optional
import numpy as np
import polars as pl
@@ -87,6 +87,8 @@ class RunData:
trip_weight_col: Optional[str] = None
day: pl.DataFrame = field(default_factory=pl.DataFrame)
vehicles: pl.DataFrame = field(default_factory=pl.DataFrame)
+ trip_hypothetical_skims: pl.DataFrame = field(default_factory=pl.DataFrame)
+ tour_hypothetical_skims: pl.DataFrame = field(default_factory=pl.DataFrame)
table_availability_metadata: TableAvailabilityMetadata = field(
default_factory=TableAvailabilityMetadata
)
@@ -119,12 +121,72 @@ def __post_init__(self) -> None:
self.skimjoin_reports = dict(self.skimjoin_artifacts.reports)
+def map_run_data_tables(
+ run: RunData,
+ transform: Callable[[str, pl.DataFrame], pl.DataFrame],
+ *,
+ clear_weight_columns: bool = False,
+) -> RunData:
+ """Copy a run while applying one transform to every DataFrame table."""
+
+ def mapped(table_name: str, frame: pl.DataFrame) -> pl.DataFrame:
+ result = transform(table_name, frame)
+ if not isinstance(result, pl.DataFrame):
+ raise TypeError(
+ f"RunData table transform for {table_name!r} returned "
+ f"{type(result).__name__}; expected polars.DataFrame."
+ )
+ return result
+
+ return RunData(
+ label=run.label,
+ run_dir=run.run_dir,
+ skim_file=run.skim_file,
+ hh=mapped("hh", run.hh),
+ per=mapped("per", run.per),
+ day=mapped("day", run.day),
+ tours=mapped("tours", run.tours),
+ trips=mapped("trips", run.trips),
+ vehicles=mapped("vehicles", run.vehicles),
+ trip_hypothetical_skims=mapped(
+ "trip_hypothetical_skims", run.trip_hypothetical_skims
+ ),
+ tour_hypothetical_skims=mapped(
+ "tour_hypothetical_skims", run.tour_hypothetical_skims
+ ),
+ joint_participants=mapped("joint_participants", run.joint_participants),
+ land_use=mapped("land_use", run.land_use),
+ skim_matrix=run.skim_matrix,
+ skim_zone_map=run.skim_zone_map,
+ hh_weight_col=None if clear_weight_columns else run.hh_weight_col,
+ person_weight_col=None if clear_weight_columns else run.person_weight_col,
+ trip_weight_col=None if clear_weight_columns else run.trip_weight_col,
+ table_availability_metadata=TableAvailabilityMetadata(
+ states=dict(run.table_availability_metadata.states),
+ diagnostics=dict(run.table_availability_metadata.diagnostics),
+ ),
+ prepare_diagnostics=dict(run.prepare_diagnostics),
+ skimjoin_artifacts=SkimjoinArtifacts(
+ manifest=dict(run.skimjoin_artifacts.manifest),
+ reports=dict(run.skimjoin_artifacts.reports),
+ ),
+ skimjoin_manifest=dict(run.skimjoin_manifest),
+ skimjoin_reports=dict(run.skimjoin_reports),
+ )
+
+
def prune_prepared_run(
prepared_run: RunData,
required_tables: Collection[PreparedTableName],
) -> RunData:
"""Return a copy of ``prepared_run`` that keeps only the requested tables."""
keep = set(required_tables)
+ trip_sidecar = prepared_run.trip_hypothetical_skims
+ if "trips" not in keep:
+ trip_sidecar = pl.DataFrame()
+ tour_sidecar = prepared_run.tour_hypothetical_skims
+ if "tours" not in keep:
+ tour_sidecar = pl.DataFrame()
return RunData(
label=prepared_run.label,
run_dir=prepared_run.run_dir,
@@ -135,6 +197,8 @@ def prune_prepared_run(
tours=prepared_run.tours if "tours" in keep else pl.DataFrame(),
trips=prepared_run.trips if "trips" in keep else pl.DataFrame(),
vehicles=prepared_run.vehicles if "vehicles" in keep else pl.DataFrame(),
+ trip_hypothetical_skims=trip_sidecar,
+ tour_hypothetical_skims=tour_sidecar,
joint_participants=(
prepared_run.joint_participants
if "joint_participants" in keep
@@ -169,19 +233,3 @@ def prune_prepared_runs(
(label, prune_prepared_run(prepared_run, required_tables))
for label, prepared_run in prepared_runs
]
-
-
-@dataclass
-class ProcessorWorkflowResult:
- """Prepared and summary workflow outputs for one processor invocation.
-
- This is the shared in-memory handoff between prepare, summarize, and
- dashboard/export workflows. ``prepared_runs`` is the authoritative
- prepared-table contract carried across those steps.
- """
-
- summary_runs: list[Any] = field(default_factory=list)
- prepared_runs: list[tuple[str, RunData]] = field(default_factory=list)
- prepared_runs_by_key: dict[str, tuple[str, RunData]] = field(default_factory=dict)
- run_keys: list[str] = field(default_factory=list)
- run_fingerprints_by_key: dict[str, dict[str, object]] = field(default_factory=dict)
diff --git a/processor/prepare/cache.py b/processor/prepare/cache.py
index e711c00..f8c743a 100644
--- a/processor/prepare/cache.py
+++ b/processor/prepare/cache.py
@@ -19,6 +19,7 @@
from processor.cache_identity import (
build_run_fingerprint,
build_run_keys,
+ file_identity,
slugify,
)
from processor.models import RunData
@@ -31,8 +32,8 @@
from processor.prepare.writer import write_all
from runtime.config import Config
-SCHEMA_VERSION = 8
-SUPPORTED_SCHEMA_VERSIONS = {2, 3, 4, 5, 6, 7, 8}
+SCHEMA_VERSION = 9
+SUPPORTED_SCHEMA_VERSIONS = {2, 3, 4, 5, 6, 7, 8, 9}
SUPPORTED_FILE_FORMATS = ("parquet", "csv")
PREPARED_TABLE_ATTRS: tuple[tuple[str, str, str], ...] = (
("hh", "households", "households"),
@@ -48,6 +49,10 @@
table_id: (attr_name, stem)
for attr_name, table_id, stem in PREPARED_TABLE_ATTRS
}
+SIDECAR_TABLE_ATTRS: tuple[tuple[str, str], ...] = (
+ ("trip_hypothetical_skims", "trip_hypothetical_skims"),
+ ("tour_hypothetical_skims", "tour_hypothetical_skims"),
+)
class PreparedCacheError(RuntimeError):
@@ -87,7 +92,7 @@ def build_prepared_manifest_identity(
normalized_table_map = dict(sorted((prepared_table_map or {}).items()))
identity["prepared_table_map"] = normalized_table_map
identity["prepared_table_fingerprints"] = {
- table_id: _file_identity(path)
+ table_id: file_identity(path)
for table_id, path in normalized_table_map.items()
}
else:
@@ -95,19 +100,15 @@ def build_prepared_manifest_identity(
return identity
-def _file_identity(path: str | Path) -> dict[str, object]:
- resolved = Path(path).resolve()
- stat = resolved.stat()
+def _table_file_map(file_format: str) -> dict[str, str]:
return {
- "path": str(resolved),
- "size": int(stat.st_size),
- "mtime_ns": int(stat.st_mtime_ns),
+ table_id: f"{stem}.{file_format}" for _, table_id, stem in PREPARED_TABLE_ATTRS
}
-def _table_file_map(file_format: str) -> dict[str, str]:
+def _sidecar_file_map(file_format: str) -> dict[str, str]:
return {
- table_id: f"{stem}.{file_format}" for _, table_id, stem in PREPARED_TABLE_ATTRS
+ attr_name: f"{stem}.{file_format}" for attr_name, stem in SIDECAR_TABLE_ATTRS
}
@@ -118,6 +119,22 @@ def _manifest_table_map(manifest: dict[str, object]) -> dict[str, str]:
}
+def _skimjoin_resolved_network_los_file(
+ rd: RunData | None = None,
+ run_fingerprint: dict[str, object] | None = None,
+) -> str | None:
+ if rd is not None:
+ value = rd.skimjoin_manifest.get("skimjoin_resolved_network_los_file")
+ if value:
+ return str(value)
+ skimjoin = (run_fingerprint or {}).get("skimjoin")
+ if isinstance(skimjoin, dict):
+ value = skimjoin.get("resolved_network_los_file")
+ if value:
+ return str(value)
+ return None
+
+
def _prepared_tables_dir(cache_dir: Path, manifest: dict[str, object] | None = None) -> Path:
if manifest is not None:
table_root = str(manifest.get("table_root", "")).strip()
@@ -131,6 +148,16 @@ def _prepared_tables_dir(cache_dir: Path, manifest: dict[str, object] | None = N
return cache_dir
+def _sidecar_tables_dir(cache_dir: Path, manifest: dict[str, object] | None = None) -> Path:
+ if manifest is not None:
+ sidecar_root = str(manifest.get("sidecar_root", "")).strip()
+ if sidecar_root:
+ if cache_dir.name == sidecar_root:
+ return cache_dir
+ return cache_dir / sidecar_root
+ return _prepared_tables_dir(cache_dir, manifest)
+
+
def _read_table_file(path: Path) -> pl.DataFrame:
suffix = path.suffix.lower()
if suffix == ".parquet":
@@ -180,6 +207,35 @@ def _write_skimjoin_outputs(cache_dir: Path, rd: RunData, config: Config) -> Non
write_table(skimjoin_dir / filename, table)
+def _write_sidecar_tables(
+ cache_dir: Path,
+ rd: RunData,
+ *,
+ file_format: str,
+) -> dict[str, str]:
+ sidecar_frames = {
+ attr_name: getattr(rd, attr_name)
+ for attr_name, _ in SIDECAR_TABLE_ATTRS
+ if isinstance(getattr(rd, attr_name), pl.DataFrame)
+ and not getattr(rd, attr_name).is_empty()
+ }
+ if not sidecar_frames:
+ return {}
+
+ sidecar_dir = cache_dir / "prepared_tables"
+ sidecar_dir.mkdir(parents=True, exist_ok=True)
+ filenames = _sidecar_file_map(file_format)
+ for attr_name, frame in sidecar_frames.items():
+ path = sidecar_dir / filenames[attr_name]
+ if file_format == "parquet":
+ frame.write_parquet(path)
+ elif file_format == "csv":
+ frame.write_csv(path)
+ else:
+ raise ValueError(f"Unsupported sidecar file format {file_format!r}.")
+ return {attr_name: filenames[attr_name] for attr_name in sidecar_frames}
+
+
def write_prepared_run_cache(
rd: RunData,
config: Config,
@@ -221,6 +277,7 @@ def write_prepared_run_cache(
tables_to_write[stem] = table
write_all(tables_to_write, cache_dir, file_format=file_format)
+ sidecar_files = _write_sidecar_tables(cache_dir.parent, rd, file_format=file_format)
_write_skimjoin_outputs(cache_dir, rd, config)
manifest = {
@@ -234,7 +291,9 @@ def write_prepared_run_cache(
"prepare_config_digest": config.prepare_config_digest,
"table_format": file_format,
"table_root": "prepared_tables",
+ "sidecar_root": "prepared_tables",
"table_files": _table_file_map(file_format),
+ "sidecar_files": sidecar_files,
"table_states": {
table_id: table_states.get(
table_id,
@@ -280,6 +339,10 @@ def write_prepared_run_cache(
"prepare_diagnostics": dict(rd.prepare_diagnostics),
"skimjoin_enabled": bool(rd.skimjoin_manifest.get("skimjoin_enabled", False)),
"skimjoin_config_digest": rd.skimjoin_manifest.get("skimjoin_config_digest"),
+ "skimjoin_resolved_network_los_file": _skimjoin_resolved_network_los_file(
+ rd,
+ run_fingerprint,
+ ),
"skimjoin_status": rd.skimjoin_manifest.get("skimjoin_status"),
"skimjoin_applied_outputs": list(
rd.skimjoin_manifest.get("skimjoin_applied_outputs", [])
@@ -299,6 +362,11 @@ def write_prepared_run_cache(
"skimjoin_failure_detail": rd.skimjoin_manifest.get(
"skimjoin_failure_detail"
),
+ "skimjoin_hypothetical_sidecars_enabled": bool(
+ config.skimjoin.create_hypothetical_skim_tables
+ ),
+ "skimjoin_trip_hypothetical_rows": int(rd.trip_hypothetical_skims.height),
+ "skimjoin_tour_hypothetical_rows": int(rd.tour_hypothetical_skims.height),
}
write_manifest(cache_dir, manifest)
return PreparedRunCacheEntry(
@@ -399,6 +467,22 @@ def load_prepared_run_cache(
} and is_empty_sentinel_frame(table):
table = pl.DataFrame()
loaded_tables[attr_name] = table
+ sidecar_tables: dict[str, pl.DataFrame] = {
+ attr_name: pl.DataFrame() for attr_name, _ in SIDECAR_TABLE_ATTRS
+ }
+ sidecar_files = {
+ str(attr_name): str(filename)
+ for attr_name, filename in dict(manifest.get("sidecar_files", {})).items()
+ }
+ sidecar_dir = _sidecar_tables_dir(cache_dir, manifest)
+ for attr_name, stem in SIDECAR_TABLE_ATTRS:
+ filename = sidecar_files.get(attr_name)
+ if not filename:
+ continue
+ path = sidecar_dir / filename
+ if not path.exists():
+ raise PreparedCacheError(f"Missing prepared sidecar file: {path}")
+ sidecar_tables[attr_name] = _read_table_file(path)
return attach_table_availability(
RunData(
@@ -411,6 +495,8 @@ def load_prepared_run_cache(
tours=loaded_tables["tours"],
trips=loaded_tables["trips"],
vehicles=loaded_tables["vehicles"],
+ trip_hypothetical_skims=sidecar_tables["trip_hypothetical_skims"],
+ tour_hypothetical_skims=sidecar_tables["tour_hypothetical_skims"],
joint_participants=loaded_tables["joint_participants"],
land_use=loaded_tables["land_use"],
skim_matrix=None,
@@ -422,6 +508,12 @@ def load_prepared_run_cache(
skimjoin_manifest={
"skimjoin_enabled": bool(manifest.get("skimjoin_enabled", False)),
"skimjoin_config_digest": manifest.get("skimjoin_config_digest"),
+ "skimjoin_resolved_network_los_file": manifest.get(
+ "skimjoin_resolved_network_los_file"
+ )
+ or _skimjoin_resolved_network_los_file(
+ run_fingerprint=dict(manifest.get("run_fingerprint", {}))
+ ),
"skimjoin_status": manifest.get("skimjoin_status"),
"skimjoin_applied_outputs": list(
manifest.get("skimjoin_applied_outputs", [])
@@ -438,6 +530,15 @@ def load_prepared_run_cache(
"skimjoin_fallback_outputs": list(
manifest.get("skimjoin_fallback_outputs", [])
),
+ "skimjoin_hypothetical_sidecars_enabled": bool(
+ manifest.get("skimjoin_hypothetical_sidecars_enabled", False)
+ ),
+ "skimjoin_trip_hypothetical_rows": int(
+ manifest.get("skimjoin_trip_hypothetical_rows", 0)
+ ),
+ "skimjoin_tour_hypothetical_rows": int(
+ manifest.get("skimjoin_tour_hypothetical_rows", 0)
+ ),
"skimjoin_failure_detail": manifest.get("skimjoin_failure_detail"),
},
),
diff --git a/processor/prepare/enrichment/bootstrap.py b/processor/prepare/enrichment/bootstrap.py
deleted file mode 100644
index e36892a..0000000
--- a/processor/prepare/enrichment/bootstrap.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Prepare pipeline state initialization helpers."""
-
-from __future__ import annotations
-
-from processor.models import RunData
-from processor.prepare.availability import table_availability, table_unavailable_reasons
-from processor.prepare.enrichment.types import _PrepareState
-
-
-def _init_prepare_state(rd: RunData) -> _PrepareState:
- """Create mutable prepare state from the raw run container."""
- return _PrepareState(
- label=rd.label,
- run_dir=rd.run_dir,
- skim_file=rd.skim_file,
- hh=rd.hh,
- per=rd.per,
- day=rd.day,
- tours=rd.tours,
- trips=rd.trips,
- vehicles=rd.vehicles,
- joint_participants=rd.joint_participants,
- land_use=rd.land_use,
- skim=rd.skim_matrix,
- skim_map=rd.skim_zone_map,
- hh_weight_col=rd.hh_weight_col,
- person_weight_col=rd.person_weight_col,
- trip_weight_col=rd.trip_weight_col,
- table_states=table_availability(rd),
- table_reasons=table_unavailable_reasons(rd),
- prepare_diagnostics=dict(rd.prepare_diagnostics),
- )
diff --git a/processor/prepare/enrichment/canonicalize.py b/processor/prepare/enrichment/canonicalize.py
index 42df468..6dba8a9 100644
--- a/processor/prepare/enrichment/canonicalize.py
+++ b/processor/prepare/enrichment/canonicalize.py
@@ -169,6 +169,7 @@ def _canonicalize_tours(tours: pl.DataFrame, config: Config) -> pl.DataFrame:
tours,
"tour_purpose",
config.col_tour_purpose,
+ fallbacks=("tour_purpose", "primary_purpose", "tour_type", "purpose"),
require_non_numeric=True,
)
tours = _materialize_column(
diff --git a/processor/prepare/enrichment/stages.py b/processor/prepare/enrichment/domains.py
similarity index 55%
rename from processor/prepare/enrichment/stages.py
rename to processor/prepare/enrichment/domains.py
index 69823f3..c1997da 100644
--- a/processor/prepare/enrichment/stages.py
+++ b/processor/prepare/enrichment/domains.py
@@ -1,4 +1,4 @@
-"""Named stage groups for prepare enrichment orchestration."""
+"""Domain-oriented prepare operations over one explicit mutable run state."""
from __future__ import annotations
@@ -13,9 +13,13 @@
from processor.prepare.enrichment.households_persons import (
_enrich_households_and_persons,
)
+from processor.prepare.enrichment.non_motorized_distance import (
+ _derive_non_motorized_distance,
+)
from processor.prepare.enrichment.student_enrollment import (
_derive_student_enrollment,
)
+from processor.prepare.enrichment.time_periods import _derive_time_periods
from processor.prepare.enrichment.tours import _enrich_tours
from processor.prepare.enrichment.trips import _enrich_trips
from processor.prepare.enrichment.types import _PrepareState
@@ -28,34 +32,40 @@
)
-def _run_prepare_core_stages(state: _PrepareState, config: Config) -> _PrepareState:
- """Run identifier cleanup and shared weighting steps."""
+def normalize_source_domain(state: _PrepareState, config: Config) -> _PrepareState:
+ """Canonicalize source columns and establish weights and escort semantics."""
state = _canonicalize_identifiers_and_core_columns(state, config)
state = _normalize_escort_fields(state, config)
return _apply_weights(state, config)
-def _run_prepare_person_and_tour_stages(
- state: _PrepareState, config: Config
+def enrich_people_and_places_domain(
+ state: _PrepareState,
+ config: Config,
) -> _PrepareState:
- """Run household, person, and tour enrichment stages sharing zone context."""
- zone_context = _build_zone_context(state, config)
- state = _enrich_households_and_persons(state, config, zone_context)
+ """Enrich household/person tables and their shared geography context."""
+ zones = _build_zone_context(state, config)
+ state = _enrich_households_and_persons(state, config, zones)
state = _derive_student_enrollment(state, config)
state = _prepare_day(state)
state.land_use = _add_land_use_aggregated_geographies(
state.land_use,
config=config,
- zone_context=zone_context,
+ zone_context=zones,
)
- state = _enrich_tours(state, config, zone_context)
- state = _enrich_trips(state, config, zone_context)
+ state = _enrich_tours(state, config, zones)
+ return _enrich_trips(state, config, zones)
+
+
+def enrich_mobility_domain(state: _PrepareState, config: Config) -> _PrepareState:
+ """Add vehicle, distance, time-period, and value-of-time outputs."""
state = _prepare_vehicles(state)
- return state
+ state = _derive_non_motorized_distance(state, config)
+ state = _derive_time_periods(state, config)
+ return _normalize_vot_bins(state, config)
-def _run_prepare_output_stages(state: _PrepareState, config: Config) -> _PrepareState:
- """Normalize output-only columns and cast final prepared table schemas."""
- state = _normalize_vot_bins(state, config)
+def finalize_output_domain(state: _PrepareState) -> _PrepareState:
+ """Validate diagnostics and cast the canonical prepared schemas."""
_log_prepare_diagnostics(state)
return _cast_prepared_tables(state)
diff --git a/processor/prepare/enrichment/finalize.py b/processor/prepare/enrichment/finalize.py
index ec74fcb..bf9893d 100644
--- a/processor/prepare/enrichment/finalize.py
+++ b/processor/prepare/enrichment/finalize.py
@@ -4,8 +4,6 @@
import polars as pl
-from processor.models import RunData
-from processor.prepare.availability import attach_table_availability
from processor.prepare.enrichment.columns import _cast_if_present
from processor.prepare.enrichment.types import _PrepareState
@@ -94,7 +92,10 @@ def _cast_tours(tours: pl.DataFrame) -> pl.DataFrame:
"school_esc_inbound": pl.Utf8,
"start_hour": pl.Int32,
"end_hour": pl.Int32,
+ "start_period": pl.Utf8,
+ "end_period": pl.Utf8,
"first_inbound_trip_depart": pl.Int32,
+ "first_inbound_trip_period": pl.Utf8,
"tourdur": pl.Int32,
"o_maz": pl.Int64,
"d_maz": pl.Int64,
@@ -131,6 +132,7 @@ def _cast_trips(trips: pl.DataFrame) -> pl.DataFrame:
"summary_tour_purpose": pl.Utf8,
"tour_category": pl.Utf8,
"depart_hour": pl.Int32,
+ "trip_period": pl.Utf8,
"o_maz": pl.Int64,
"d_maz": pl.Int64,
"pnr_zone_id": pl.Int64,
@@ -142,6 +144,7 @@ def _cast_trips(trips: pl.DataFrame) -> pl.DataFrame:
"income_segment": pl.Int64,
"vot_bin": pl.Utf8,
"od_dist": pl.Float64,
+ "prepared_non_motorized_distance": pl.Float64,
"out_dir_dist": pl.Float64,
"stops": pl.Int32,
"inbound": pl.Int32,
@@ -215,29 +218,3 @@ def _cast_prepared_tables(state: _PrepareState) -> _PrepareState:
state.vehicles = _cast_vehicles(state.vehicles)
state.land_use = _cast_land_use(state.land_use)
return state
-
-
-def _finalize_prepared_run(state: _PrepareState) -> RunData:
- return attach_table_availability(
- RunData(
- label=state.label,
- run_dir=state.run_dir,
- skim_file=state.skim_file,
- hh=state.hh,
- per=state.per,
- day=state.day,
- tours=state.tours,
- trips=state.trips,
- vehicles=state.vehicles,
- joint_participants=state.joint_participants,
- land_use=state.land_use,
- skim_matrix=state.skim,
- skim_zone_map=state.skim_map,
- hh_weight_col=state.hh_weight_col,
- person_weight_col=state.person_weight_col,
- trip_weight_col=state.trip_weight_col,
- prepare_diagnostics=dict(state.prepare_diagnostics),
- ),
- table_states=state.table_states,
- table_reasons=state.table_reasons,
- )
diff --git a/processor/prepare/enrichment/households_persons.py b/processor/prepare/enrichment/households_persons.py
index 17b3c9a..cc41db0 100644
--- a/processor/prepare/enrichment/households_persons.py
+++ b/processor/prepare/enrichment/households_persons.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
from processor.prepare.enrichment.autosuff import derive_household_autosuff_counts
@@ -20,6 +20,7 @@
from runtime.config import Config
LOGGER = get_logger("processor.prepare")
+NATIVE_HOME_GEOGRAPHY_COLUMNS = ("home_county", "home_mpo")
def _add_configured_geographies(
@@ -44,6 +45,41 @@ def _add_configured_geographies(
)
return result
+
+def _copy_native_home_geographies_to_persons(state: _PrepareState) -> None:
+ """Copy household-native home geography columns onto persons when available."""
+ if not _has_columns(state.hh, "household_id") or not _has_columns(
+ state.per, "household_id"
+ ):
+ return
+ geography_columns = [
+ column for column in NATIVE_HOME_GEOGRAPHY_COLUMNS if column in state.hh.columns
+ ]
+ if not geography_columns:
+ return
+
+ temp_columns = {column: f"_hh_{column}" for column in geography_columns}
+ join_frame = state.hh.select(
+ [
+ "household_id",
+ *[
+ pl.col(column).alias(temp_column)
+ for column, temp_column in temp_columns.items()
+ ],
+ ]
+ )
+ state.per = state.per.join(join_frame, on="household_id", how="left")
+ exprs: list[pl.Expr] = []
+ for column, temp_column in temp_columns.items():
+ if column in state.per.columns:
+ exprs.append(
+ pl.coalesce([pl.col(column), pl.col(temp_column)]).alias(column)
+ )
+ else:
+ exprs.append(pl.col(temp_column).alias(column))
+ state.per = state.per.with_columns(exprs).drop(list(temp_columns.values()))
+
+
def _derive_num_joint_tours(state: _PrepareState) -> None:
if (
"num_joint_tours" in state.per.columns
@@ -129,6 +165,7 @@ def _enrich_persons(
on="household_id",
how="left",
)
+ _copy_native_home_geographies_to_persons(state)
state.per = _to_taz(
state.per,
diff --git a/processor/prepare/enrichment/non_motorized_distance.py b/processor/prepare/enrichment/non_motorized_distance.py
new file mode 100644
index 0000000..b0ce87a
--- /dev/null
+++ b/processor/prepare/enrichment/non_motorized_distance.py
@@ -0,0 +1,197 @@
+"""Prepared non-motorized trip distance lookup."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from runtime.logging import get_logger
+import numpy as np
+import polars as pl
+
+from processor.prepare.enrichment.types import _PrepareState
+from processor.prepare.enrichment.zones import (
+ _nullable_float_numpy,
+ _record_prepare_metric,
+ _skim_lookup,
+ _skim_series,
+)
+from runtime.config import Config
+
+LOGGER = get_logger("processor.prepare")
+OUTPUT_COLUMN = "prepared_non_motorized_distance"
+DIAGNOSTIC_ID = f"trips.{OUTPUT_COLUMN}"
+NON_MOTORIZED_MODES = {"WALK", "BIKE", "EBIKE"}
+
+
+def _load_matrix(path: str, matrix_name: str) -> tuple[np.ndarray, dict[int, int] | None]:
+ import openmatrix as omx
+
+ handle = omx.open_file(path)
+ try:
+ matrix = np.array(handle[matrix_name])
+ zone_map: dict[int, int] | None = None
+ mappings = handle.list_mappings()
+ if mappings:
+ raw_map = handle.mapping(mappings[0])
+ normalized: dict[int, int] = {}
+ for key, value in raw_map.items():
+ normalized_key = (
+ key.decode("utf-8")
+ if isinstance(key, (bytes, bytearray))
+ else key
+ )
+ try:
+ normalized[int(normalized_key)] = int(value)
+ except Exception:
+ continue
+ zone_map = normalized or None
+ return matrix, zone_map
+ finally:
+ handle.close()
+
+
+def _eligible_non_motorized_expr() -> pl.Expr:
+ return (
+ pl.col("trip_mode")
+ .cast(pl.Utf8)
+ .str.to_uppercase()
+ .is_in(sorted(NON_MOTORIZED_MODES))
+ )
+
+
+def _record_diagnostics(
+ state: _PrepareState,
+ *,
+ source_type: str,
+ source_file: str,
+ matrix_or_column: str | None,
+ total: int,
+ unresolved: int,
+) -> None:
+ eligible_unresolved = 0
+ if "trip_mode" in state.trips.columns and OUTPUT_COLUMN in state.trips.columns:
+ eligible_unresolved = state.trips.filter(
+ _eligible_non_motorized_expr() & pl.col(OUTPUT_COLUMN).is_null()
+ ).height
+ _record_prepare_metric(
+ state,
+ DIAGNOSTIC_ID,
+ total=total,
+ unresolved=unresolved,
+ details={
+ "source_type": source_type,
+ "source_file": source_file,
+ "matrix": matrix_or_column,
+ "value_column": matrix_or_column if source_type == "csv" else None,
+ "eligible_non_motorized_unresolved": int(eligible_unresolved),
+ },
+ )
+
+
+def _derive_from_csv(state: _PrepareState, config: Config) -> _PrepareState:
+ settings = config.prepare_non_motorized_distance_skim
+ required = {"o_maz", "d_maz"}
+ if not required.issubset(set(state.trips.columns)):
+ LOGGER.info(
+ "[prepare_data] Non-motorized CSV distance skipped for '%s'; trips need o_maz and d_maz.",
+ state.label,
+ )
+ return state
+
+ lookup = (
+ pl.read_csv(settings.file)
+ .with_row_index("_nm_lookup_row_id")
+ .select(
+ pl.col("_nm_lookup_row_id"),
+ pl.col("OMAZ").cast(pl.Int64).alias("_nm_o_maz"),
+ pl.col("DMAZ").cast(pl.Int64).alias("_nm_d_maz"),
+ pl.col(settings.value_column).cast(pl.Float64).alias(OUTPUT_COLUMN),
+ )
+ .group_by(["_nm_o_maz", "_nm_d_maz"], maintain_order=True)
+ .agg(pl.col(OUTPUT_COLUMN).sort_by("_nm_lookup_row_id").last())
+ )
+ trips = state.trips.drop(OUTPUT_COLUMN, strict=False).with_row_index("_nm_row_id")
+ joined = (
+ trips.join(
+ lookup,
+ left_on=["o_maz", "d_maz"],
+ right_on=["_nm_o_maz", "_nm_d_maz"],
+ how="left",
+ )
+ .sort("_nm_row_id")
+ .drop("_nm_row_id")
+ )
+ state.trips = joined
+ total = state.trips.filter(
+ pl.col("o_maz").is_not_null() & pl.col("d_maz").is_not_null()
+ ).height
+ unresolved = state.trips.filter(
+ pl.col("o_maz").is_not_null()
+ & pl.col("d_maz").is_not_null()
+ & pl.col(OUTPUT_COLUMN).is_null()
+ ).height
+ _record_diagnostics(
+ state,
+ source_type="csv",
+ source_file=settings.file,
+ matrix_or_column=settings.value_column,
+ total=total,
+ unresolved=unresolved,
+ )
+ return state
+
+
+def _derive_from_omx(state: _PrepareState, config: Config) -> _PrepareState:
+ settings = config.prepare_non_motorized_distance_skim
+ required = {"OTAZ", "DTAZ"}
+ if not required.issubset(set(state.trips.columns)):
+ LOGGER.info(
+ "[prepare_data] Non-motorized OMX distance skipped for '%s'; trips need OTAZ and DTAZ.",
+ state.label,
+ )
+ return state
+
+ matrix, zone_map = _load_matrix(settings.file, settings.matrix)
+ origins = _nullable_float_numpy(state.trips["OTAZ"])
+ destinations = _nullable_float_numpy(state.trips["DTAZ"])
+ distances = _skim_lookup(matrix, origins, destinations, zone_map)
+ state.trips = state.trips.drop(OUTPUT_COLUMN, strict=False).with_columns(
+ _skim_series(OUTPUT_COLUMN, distances)
+ )
+ total = state.trips.filter(
+ pl.col("OTAZ").is_not_null() & pl.col("DTAZ").is_not_null()
+ ).height
+ unresolved = state.trips.filter(
+ pl.col("OTAZ").is_not_null()
+ & pl.col("DTAZ").is_not_null()
+ & pl.col(OUTPUT_COLUMN).is_null()
+ ).height
+ _record_diagnostics(
+ state,
+ source_type="omx",
+ source_file=settings.file,
+ matrix_or_column=settings.matrix,
+ total=total,
+ unresolved=unresolved,
+ )
+ return state
+
+
+def _derive_non_motorized_distance(state: _PrepareState, config: Config) -> _PrepareState:
+ settings = config.prepare_non_motorized_distance_skim
+ if not settings.enabled:
+ return state
+ if state.trips.is_empty():
+ return state
+
+ source_file = Path(settings.file)
+ LOGGER.info(
+ "[prepare_data] Computing non-motorized distances for '%s' from %s",
+ state.label,
+ source_file,
+ )
+ if settings.source_type == "csv":
+ return _derive_from_csv(state, config)
+ if settings.source_type == "omx":
+ return _derive_from_omx(state, config)
+ return state
diff --git a/processor/prepare/enrichment/pipeline.py b/processor/prepare/enrichment/pipeline.py
index d7d1a7e..12293b9 100644
--- a/processor/prepare/enrichment/pipeline.py
+++ b/processor/prepare/enrichment/pipeline.py
@@ -2,16 +2,16 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from processor.models import RunData
-from processor.prepare.enrichment.bootstrap import _init_prepare_state
-from processor.prepare.enrichment.finalize import _finalize_prepared_run
-from processor.prepare.enrichment.stages import (
- _run_prepare_core_stages,
- _run_prepare_output_stages,
- _run_prepare_person_and_tour_stages,
+from processor.prepare.enrichment.domains import (
+ enrich_mobility_domain,
+ enrich_people_and_places_domain,
+ finalize_output_domain,
+ normalize_source_domain,
)
+from processor.prepare.enrichment.types import _PrepareState
from runtime.config import Config
LOGGER = get_logger("processor.prepare")
@@ -21,13 +21,14 @@ def prepare_data(rd: RunData, config: Config) -> RunData:
"""Enrich ``RunData`` with derived columns needed by summaries and dashboard pages."""
LOGGER.info("[prepare_data] Starting: %s", rd.label)
- state = _init_prepare_state(rd)
- state = _run_prepare_core_stages(state, config)
- state = _run_prepare_person_and_tour_stages(state, config)
- state = _run_prepare_output_stages(state, config)
+ state = _PrepareState.from_run(rd)
+ state = normalize_source_domain(state, config)
+ state = enrich_people_and_places_domain(state, config)
+ state = enrich_mobility_domain(state, config)
+ state = finalize_output_domain(state)
LOGGER.info("[prepare_data] Complete: %s", state.label)
- return _finalize_prepared_run(state)
+ return state.to_run()
__all__ = ["prepare_data"]
diff --git a/processor/prepare/enrichment/time_periods.py b/processor/prepare/enrichment/time_periods.py
new file mode 100644
index 0000000..5120366
--- /dev/null
+++ b/processor/prepare/enrichment/time_periods.py
@@ -0,0 +1,173 @@
+"""Prepared trip and tour time-period label derivation."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from processor.prepare.enrichment.types import _PrepareState
+from processor.prepare.enrichment.zones import _record_prepare_metric
+from processor.time_periods import load_network_los_period_mapping
+from runtime.config import Config
+
+
+def _mapping_frame(mapping: dict[str, str], output_column: str) -> pl.DataFrame:
+ return pl.DataFrame(
+ {
+ "__period_key": list(mapping.keys()),
+ output_column: list(mapping.values()),
+ },
+ schema={"__period_key": pl.Utf8, output_column: pl.Utf8},
+ )
+
+
+def _derive_period_column(
+ df: pl.DataFrame,
+ *,
+ source_column: str,
+ output_column: str,
+ mapping: dict[str, str],
+) -> pl.DataFrame:
+ if source_column not in df.columns:
+ return df
+ if df.is_empty():
+ return df.with_columns(pl.lit(None, dtype=pl.Utf8).alias(output_column))
+ period_lookup = _mapping_frame(mapping, output_column)
+ return (
+ df.drop(output_column) if output_column in df.columns else df
+ ).with_columns(
+ pl.col(source_column)
+ .cast(pl.Int64, strict=False)
+ .cast(pl.Utf8)
+ .alias("__period_key")
+ ).join(period_lookup, on="__period_key", how="left").drop("__period_key")
+
+
+def _record_period_metric(
+ state: _PrepareState,
+ *,
+ metric_id: str,
+ df: pl.DataFrame,
+ source_column: str,
+ output_column: str,
+) -> None:
+ if source_column not in df.columns or output_column not in df.columns:
+ _record_prepare_metric(
+ state,
+ metric_id,
+ total=0,
+ unresolved=0,
+ details={
+ "source_column": source_column,
+ "output_column": output_column,
+ "status": "source_column_missing",
+ },
+ )
+ return
+ total = df.height
+ unresolved = df.filter(pl.col(output_column).is_null()).height
+ _record_prepare_metric(
+ state,
+ metric_id,
+ total=total,
+ unresolved=unresolved,
+ details={
+ "source_column": source_column,
+ "output_column": output_column,
+ "status": "derived",
+ },
+ )
+
+
+def _attach_first_inbound_trip_period(state: _PrepareState) -> None:
+ required = {"tour_id", "outbound", "trip_period"}
+ if "tour_id" not in state.tours.columns or not required.issubset(state.trips.columns):
+ return
+
+ sort_columns = ["tour_id"]
+ if "trip_num" in state.trips.columns:
+ sort_columns.append("trip_num")
+
+ first_inbound = (
+ state.trips.filter(
+ pl.col("outbound").cast(pl.Utf8).str.to_lowercase().is_in(["false", "0"])
+ )
+ .sort(sort_columns)
+ .group_by("tour_id", maintain_order=True)
+ .agg(pl.col("trip_period").first().alias("first_inbound_trip_period"))
+ )
+
+ if "first_inbound_trip_period" in state.tours.columns:
+ state.tours = state.tours.drop("first_inbound_trip_period")
+ state.tours = state.tours.join(first_inbound, on="tour_id", how="left")
+ total = state.tours.height
+ unresolved = state.tours.filter(pl.col("first_inbound_trip_period").is_null()).height
+ _record_prepare_metric(
+ state,
+ "time_periods.tours.first_inbound_trip_period",
+ total=total,
+ unresolved=unresolved,
+ details={
+ "source_column": "trips.trip_period",
+ "output_column": "first_inbound_trip_period",
+ "status": "derived",
+ },
+ )
+
+
+def _derive_time_periods(state: _PrepareState, config: Config) -> _PrepareState:
+ settings = config.prepare_time_periods
+ if not settings.enabled or settings.network_los_file is None:
+ return state
+
+ mapping = load_network_los_period_mapping(settings.network_los_file)
+
+ trip_source = settings.trip_period_number_column
+ if trip_source in state.trips.columns:
+ state.trips = _derive_period_column(
+ state.trips,
+ source_column=trip_source,
+ output_column="trip_period",
+ mapping=mapping,
+ )
+ _record_period_metric(
+ state,
+ metric_id="time_periods.trips.trip_period",
+ df=state.trips,
+ source_column=trip_source,
+ output_column="trip_period",
+ )
+
+ start_source = settings.tour_start_period_number_column
+ if start_source in state.tours.columns:
+ state.tours = _derive_period_column(
+ state.tours,
+ source_column=start_source,
+ output_column="start_period",
+ mapping=mapping,
+ )
+ _record_period_metric(
+ state,
+ metric_id="time_periods.tours.start_period",
+ df=state.tours,
+ source_column=start_source,
+ output_column="start_period",
+ )
+
+ end_source = settings.tour_end_period_number_column
+ if end_source in state.tours.columns:
+ state.tours = _derive_period_column(
+ state.tours,
+ source_column=end_source,
+ output_column="end_period",
+ mapping=mapping,
+ )
+ _record_period_metric(
+ state,
+ metric_id="time_periods.tours.end_period",
+ df=state.tours,
+ source_column=end_source,
+ output_column="end_period",
+ )
+
+ _attach_first_inbound_trip_period(state)
+ return state
diff --git a/processor/prepare/enrichment/tours.py b/processor/prepare/enrichment/tours.py
index 0b2bf55..c6ea30c 100644
--- a/processor/prepare/enrichment/tours.py
+++ b/processor/prepare/enrichment/tours.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
from processor.tour_purpose import with_summary_tour_purpose
diff --git a/processor/prepare/enrichment/trips.py b/processor/prepare/enrichment/trips.py
index 058eb24..f5806bd 100644
--- a/processor/prepare/enrichment/trips.py
+++ b/processor/prepare/enrichment/trips.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import numpy as np
import polars as pl
diff --git a/processor/prepare/enrichment/types.py b/processor/prepare/enrichment/types.py
index 9a8a781..0012dec 100644
--- a/processor/prepare/enrichment/types.py
+++ b/processor/prepare/enrichment/types.py
@@ -7,6 +7,13 @@
import numpy as np
import polars as pl
+from processor.models import RunData
+from processor.prepare.availability import (
+ attach_table_availability,
+ table_availability,
+ table_unavailable_reasons,
+)
+
@dataclass
class _PrepareState:
@@ -30,6 +37,57 @@ class _PrepareState:
table_reasons: dict[str, str]
prepare_diagnostics: dict[str, object]
+ @classmethod
+ def from_run(cls, run: RunData) -> "_PrepareState":
+ """Open the pipeline's single mutable boundary around a raw run."""
+ return cls(
+ label=run.label,
+ run_dir=run.run_dir,
+ skim_file=run.skim_file,
+ hh=run.hh,
+ per=run.per,
+ day=run.day,
+ tours=run.tours,
+ trips=run.trips,
+ vehicles=run.vehicles,
+ joint_participants=run.joint_participants,
+ land_use=run.land_use,
+ skim=run.skim_matrix,
+ skim_map=run.skim_zone_map,
+ hh_weight_col=run.hh_weight_col,
+ person_weight_col=run.person_weight_col,
+ trip_weight_col=run.trip_weight_col,
+ table_states=table_availability(run),
+ table_reasons=table_unavailable_reasons(run),
+ prepare_diagnostics=dict(run.prepare_diagnostics),
+ )
+
+ def to_run(self) -> RunData:
+ """Close the mutation boundary and return the prepared artifact."""
+ return attach_table_availability(
+ RunData(
+ label=self.label,
+ run_dir=self.run_dir,
+ skim_file=self.skim_file,
+ hh=self.hh,
+ per=self.per,
+ day=self.day,
+ tours=self.tours,
+ trips=self.trips,
+ vehicles=self.vehicles,
+ joint_participants=self.joint_participants,
+ land_use=self.land_use,
+ skim_matrix=self.skim,
+ skim_zone_map=self.skim_map,
+ hh_weight_col=self.hh_weight_col,
+ person_weight_col=self.person_weight_col,
+ trip_weight_col=self.trip_weight_col,
+ prepare_diagnostics=dict(self.prepare_diagnostics),
+ ),
+ table_states=self.table_states,
+ table_reasons=self.table_reasons,
+ )
+
@dataclass(frozen=True)
class _ZoneContext:
diff --git a/processor/prepare/enrichment/vot_bins.py b/processor/prepare/enrichment/vot_bins.py
index 938e170..1788595 100644
--- a/processor/prepare/enrichment/vot_bins.py
+++ b/processor/prepare/enrichment/vot_bins.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
from processor.prepare.enrichment.types import _PrepareState
diff --git a/processor/prepare/enrichment/weights.py b/processor/prepare/enrichment/weights.py
index 74761db..4c437e3 100644
--- a/processor/prepare/enrichment/weights.py
+++ b/processor/prepare/enrichment/weights.py
@@ -4,7 +4,7 @@
from typing import Optional
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
from processor.prepare.enrichment.columns import _has_columns
diff --git a/processor/prepare/enrichment/zones.py b/processor/prepare/enrichment/zones.py
index aba1225..2b15707 100644
--- a/processor/prepare/enrichment/zones.py
+++ b/processor/prepare/enrichment/zones.py
@@ -4,7 +4,7 @@
from typing import Optional
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import numpy as np
import polars as pl
diff --git a/processor/prepare/reader.py b/processor/prepare/reader.py
index 214e271..2418d0f 100644
--- a/processor/prepare/reader.py
+++ b/processor/prepare/reader.py
@@ -5,7 +5,7 @@
from pathlib import Path
from typing import Optional
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import numpy as np
import polars as pl
diff --git a/processor/prepare/writer.py b/processor/prepare/writer.py
index 7d2979c..a16b3fe 100644
--- a/processor/prepare/writer.py
+++ b/processor/prepare/writer.py
@@ -4,7 +4,7 @@
from pathlib import Path
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
LOGGER = get_logger("processor.prepare.writer")
diff --git a/processor/segmentation.py b/processor/segmentation.py
index 320cb8b..aa27be4 100644
--- a/processor/segmentation.py
+++ b/processor/segmentation.py
@@ -19,6 +19,17 @@
SegmentationDefinition,
)
+SEGMENTABLE_TABLES: tuple[str, ...] = (
+ "hh",
+ "per",
+ "day",
+ "tours",
+ "trips",
+ "vehicles",
+ "joint_participants",
+ "land_use",
+)
+
@dataclass(frozen=True)
class ResolvedSegmentationSource:
@@ -60,32 +71,32 @@ def resolve_segmentation_source(
return ResolvedSegmentationSource(
source_type=source.type,
anchor_table=source.source_table,
- anchor_key_column=_anchor_key_for_table(source.source_table),
+ anchor_key_column=_anchor_key_for_table(
+ source.source_table,
+ source_df,
+ ),
segment_value_column=source.column,
source_column=source.column,
)
matching_tables = [
table_name
- for table_name in ("hh", "per", "tours", "trips", "land_use")
+ for table_name in SEGMENTABLE_TABLES
if source.column in _table_for(prepared_run, table_name).columns
]
if not matching_tables:
raise ValueError(
- f"Configured segmentation column {source.column!r} was not found in hh, per, tours, trips, or land_use."
+ f"Configured segmentation column {source.column!r} was not found in any segmentable prepared table."
)
if len(matching_tables) > 1:
raise ValueError(
f"Configured segmentation column {source.column!r} appears in multiple prepared tables: {', '.join(matching_tables)}. Set segmentation.source.source_table."
)
table_name = matching_tables[0]
- anchor_key = _anchor_key_for_table(table_name)
- if table_name == "land_use":
- land_use_df = _table_for(prepared_run, "land_use")
- if "MAZ" in land_use_df.columns:
- anchor_key = "MAZ"
- elif "TAZ" in land_use_df.columns:
- anchor_key = "TAZ"
+ anchor_key = _anchor_key_for_table(
+ table_name,
+ _table_for(prepared_run, table_name),
+ )
return ResolvedSegmentationSource(
source_type=source.type,
anchor_table=table_name,
@@ -112,13 +123,25 @@ def resolve_segmentation_source(
)
-def _anchor_key_for_table(table_name: str) -> str | None:
+def _anchor_key_for_table(
+ table_name: str,
+ table: pl.DataFrame | None = None,
+) -> str | None:
mapping = {
"hh": "household_id",
"per": "person_id",
+ "day": "day_id",
"tours": "tour_id",
"trips": "trip_id",
+ "vehicles": "vehicle_id",
}
+ if table_name == "day" and table is not None and "day_id" not in table.columns:
+ return None
+ if table_name == "land_use" and table is not None:
+ if "MAZ" in table.columns:
+ return "MAZ"
+ if "TAZ" in table.columns:
+ return "TAZ"
return mapping.get(table_name)
@@ -284,6 +307,169 @@ def _slice_run_data_from_source_subset(
joint_participants=joint,
)
+ if source_table == "vehicles":
+ if "household_id" not in matched_source_df.columns:
+ raise ValueError(
+ "vehicles segmentation requires a household_id column."
+ )
+ vehicles = _matched_source_rows(
+ prepared_run.vehicles,
+ matched_source_df,
+ preferred_key="vehicle_id",
+ )
+ household_ids = matched_source_df.select("household_id").drop_nulls().unique()
+ hh = prepared_run.hh.join(household_ids, on="household_id", how="inner")
+ per = prepared_run.per.join(household_ids, on="household_id", how="inner")
+ tours = prepared_run.tours.join(household_ids, on="household_id", how="inner")
+ trips = prepared_run.trips.join(household_ids, on="household_id", how="inner")
+ tour_ids = tours.select("tour_id") if "tour_id" in tours.columns else pl.DataFrame()
+ joint = (
+ prepared_run.joint_participants.join(tour_ids, on="tour_id", how="inner")
+ if not tour_ids.is_empty() and "tour_id" in prepared_run.joint_participants.columns
+ else prepared_run.joint_participants.head(0)
+ )
+ return _copy_run_data(
+ prepared_run,
+ hh=hh,
+ per=per,
+ day=prepared_run.day.join(household_ids, on="household_id", how="inner")
+ if "household_id" in prepared_run.day.columns
+ else prepared_run.day.head(0),
+ tours=tours,
+ trips=trips,
+ vehicles=vehicles,
+ joint_participants=joint,
+ )
+
+ if source_table == "day":
+ if (
+ "person_id" not in matched_source_df.columns
+ and "household_id" not in matched_source_df.columns
+ ):
+ raise ValueError(
+ "day segmentation requires a person_id or household_id column."
+ )
+ day = _matched_source_rows(
+ prepared_run.day,
+ matched_source_df,
+ preferred_key="day_id",
+ )
+ person_ids = (
+ matched_source_df.select("person_id").drop_nulls().unique()
+ if "person_id" in matched_source_df.columns
+ else pl.DataFrame()
+ )
+ household_ids = (
+ matched_source_df.select("household_id").drop_nulls().unique()
+ if "household_id" in matched_source_df.columns
+ else pl.DataFrame()
+ )
+ per = (
+ prepared_run.per.join(person_ids, on="person_id", how="inner")
+ if "person_id" in matched_source_df.columns
+ else prepared_run.per.join(household_ids, on="household_id", how="inner")
+ )
+ household_ids = _unique_column_frames(
+ "household_id",
+ [
+ household_ids,
+ (
+ per.select("household_id")
+ if "household_id" in per.columns
+ else pl.DataFrame()
+ ),
+ ],
+ )
+ hh = prepared_run.hh.join(household_ids, on="household_id", how="inner")
+ if not person_ids.is_empty():
+ tours = prepared_run.tours.join(person_ids, on="person_id", how="inner")
+ trips = prepared_run.trips.join(person_ids, on="person_id", how="inner")
+ else:
+ tours = prepared_run.tours.join(household_ids, on="household_id", how="inner")
+ trips = prepared_run.trips.join(household_ids, on="household_id", how="inner")
+ tour_ids = tours.select("tour_id") if "tour_id" in tours.columns else pl.DataFrame()
+ joint = (
+ prepared_run.joint_participants.join(tour_ids, on="tour_id", how="inner")
+ if not tour_ids.is_empty() and "tour_id" in prepared_run.joint_participants.columns
+ else prepared_run.joint_participants.head(0)
+ )
+ return _copy_run_data(
+ prepared_run,
+ hh=hh,
+ per=per,
+ day=day,
+ tours=tours,
+ trips=trips,
+ vehicles=prepared_run.vehicles.join(household_ids, on="household_id", how="inner")
+ if "household_id" in prepared_run.vehicles.columns
+ else prepared_run.vehicles.head(0),
+ joint_participants=joint,
+ )
+
+ if source_table == "joint_participants":
+ missing = [
+ column
+ for column in ("tour_id", "person_id")
+ if column not in matched_source_df.columns
+ ]
+ if missing:
+ raise ValueError(
+ "joint_tour_participants segmentation requires tour_id and person_id columns."
+ )
+ joint = _matched_source_rows(
+ prepared_run.joint_participants,
+ matched_source_df,
+ preferred_key=None,
+ )
+ tour_ids = joint.select("tour_id").drop_nulls().unique()
+ participant_person_ids = joint.select("person_id").drop_nulls().unique()
+ tours = prepared_run.tours.join(tour_ids, on="tour_id", how="inner")
+ tour_owner_person_ids = (
+ tours.select("person_id").drop_nulls().unique()
+ if "person_id" in tours.columns
+ else pl.DataFrame()
+ )
+ person_ids = _unique_column_frames(
+ "person_id",
+ [participant_person_ids, tour_owner_person_ids],
+ )
+ per = prepared_run.per.join(person_ids, on="person_id", how="inner")
+ household_ids = _unique_column_frames(
+ "household_id",
+ [
+ (
+ per.select("household_id")
+ if "household_id" in per.columns
+ else pl.DataFrame()
+ ),
+ (
+ tours.select("household_id")
+ if "household_id" in tours.columns
+ else pl.DataFrame()
+ ),
+ ],
+ )
+ hh = prepared_run.hh.join(household_ids, on="household_id", how="inner")
+ trips = prepared_run.trips.join(tour_ids, on="tour_id", how="inner")
+ return _copy_run_data(
+ prepared_run,
+ hh=hh,
+ per=per,
+ day=prepared_run.day.join(person_ids, on="person_id", how="inner")
+ if "person_id" in prepared_run.day.columns
+ else (
+ prepared_run.day.join(household_ids, on="household_id", how="inner")
+ if "household_id" in prepared_run.day.columns
+ else prepared_run.day.head(0)
+ ),
+ tours=tours,
+ trips=trips,
+ vehicles=prepared_run.vehicles.join(household_ids, on="household_id", how="inner")
+ if "household_id" in prepared_run.vehicles.columns
+ else prepared_run.vehicles.head(0),
+ joint_participants=joint,
+ )
+
if source_table == "land_use":
zone_key = next(
(
@@ -333,6 +519,14 @@ def _slice_run_data_from_source_subset(
vehicles=prepared_run.vehicles.join(household_ids, on="household_id", how="inner")
if "household_id" in prepared_run.vehicles.columns
else prepared_run.vehicles.head(0),
+ trip_hypothetical_skims=_filter_trip_sidecar(
+ prepared_run.trip_hypothetical_skims,
+ trips,
+ ),
+ tour_hypothetical_skims=_filter_tour_sidecar(
+ prepared_run.tour_hypothetical_skims,
+ tours,
+ ),
joint_participants=joint,
land_use=land_use,
skim_matrix=prepared_run.skim_matrix,
@@ -349,6 +543,35 @@ def _slice_run_data_from_source_subset(
raise ValueError(f"Unsupported segmentation source table {source_table!r}.")
+def _matched_source_rows(
+ source_table: pl.DataFrame,
+ matched_source_df: pl.DataFrame,
+ *,
+ preferred_key: str | None,
+) -> pl.DataFrame:
+ if (
+ preferred_key is not None
+ and preferred_key in source_table.columns
+ and preferred_key in matched_source_df.columns
+ ):
+ keys = matched_source_df.select(preferred_key).drop_nulls().unique()
+ return source_table.join(keys, on=preferred_key, how="inner")
+ return matched_source_df.select(
+ [column for column in source_table.columns if column in matched_source_df.columns]
+ )
+
+
+def _unique_column_frames(column_name: str, frames: list[pl.DataFrame]) -> pl.DataFrame:
+ selected = [
+ frame.select(column_name)
+ for frame in frames
+ if column_name in frame.columns
+ ]
+ if not selected:
+ return pl.DataFrame({column_name: []})
+ return pl.concat(selected).drop_nulls().unique()
+
+
def _copy_run_data(
prepared_run: RunData,
*,
@@ -370,6 +593,14 @@ def _copy_run_data(
tours=tours,
trips=trips,
vehicles=vehicles,
+ trip_hypothetical_skims=_filter_trip_sidecar(
+ prepared_run.trip_hypothetical_skims,
+ trips,
+ ),
+ tour_hypothetical_skims=_filter_tour_sidecar(
+ prepared_run.tour_hypothetical_skims,
+ tours,
+ ),
joint_participants=joint_participants,
land_use=prepared_run.land_use,
skim_matrix=prepared_run.skim_matrix,
@@ -384,6 +615,24 @@ def _copy_run_data(
)
+def _filter_trip_sidecar(
+ sidecar: pl.DataFrame,
+ trips: pl.DataFrame,
+) -> pl.DataFrame:
+ if sidecar.is_empty() or "trip_id" not in sidecar.columns or "trip_id" not in trips.columns:
+ return sidecar
+ return sidecar.join(trips.select("trip_id").unique(), on="trip_id", how="inner")
+
+
+def _filter_tour_sidecar(
+ sidecar: pl.DataFrame,
+ tours: pl.DataFrame,
+) -> pl.DataFrame:
+ if sidecar.is_empty() or "tour_id" not in sidecar.columns or "tour_id" not in tours.columns:
+ return sidecar
+ return sidecar.join(tours.select("tour_id").unique(), on="tour_id", how="inner")
+
+
def build_analysis_units_for_run(
*,
run_key: str,
diff --git a/processor/skimjoin/annotate/tours.py b/processor/skimjoin/annotate/tours.py
index 30b741f..f29106f 100644
--- a/processor/skimjoin/annotate/tours.py
+++ b/processor/skimjoin/annotate/tours.py
@@ -12,22 +12,26 @@ def annotate_tours(
tours: pl.DataFrame,
normalized: NormalizedConfig,
inventory: pl.DataFrame,
+ trips: pl.DataFrame | None = None,
skim_store: SkimStore | None = None,
include_fallback_report: bool = False,
-) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame] | tuple[
- pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame
-]:
+) -> (
+ tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]
+ | tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame, pl.DataFrame]
+):
tours_with_ids = tours.with_row_index("_row_id")
contexts = pl.concat(
[
_directional_tour_context(
tours_with_ids,
normalized,
+ trips=trips,
outbound=True,
),
_directional_tour_context(
tours_with_ids,
normalized,
+ trips=trips,
outbound=False,
),
],
@@ -49,36 +53,126 @@ def annotate_tours(
def _directional_tour_context(
tours: pl.DataFrame,
normalized: NormalizedConfig,
+ trips: pl.DataFrame | None = None,
*,
outbound: bool,
) -> pl.DataFrame:
activitysim = normalized.activitysim
- expressions: list[pl.Expr] = [
+ od_columns = _tour_od_columns(tours, normalized)
+ context_origin = (
+ pl.col(od_columns["origin"]) if outbound else pl.col(od_columns["destination"])
+ )
+ context_destination = (
+ pl.col(od_columns["destination"]) if outbound else pl.col(od_columns["origin"])
+ )
+ maz_origin = pl.col(od_columns["o_maz"]) if outbound else pl.col(od_columns["d_maz"])
+ maz_destination = (
+ pl.col(od_columns["d_maz"]) if outbound else pl.col(od_columns["o_maz"])
+ )
+ depart_expr = _tour_departure_expr(tours)
+ return tours.with_columns(
pl.col("_row_id").cast(pl.Int64),
- pl.col(activitysim.tour_id_column).cast(pl.Int64, strict=False).alias("trip_id"),
+ pl.col(activitysim.tour_id_column)
+ .cast(pl.Int64, strict=False)
+ .alias("trip_id"),
pl.lit(outbound).alias(activitysim.outbound_column),
pl.lit("outbound" if outbound else "inbound").alias(TOUR_DIRECTION_COLUMN),
- ]
- if not outbound:
- expressions.extend(_inbound_endpoint_swap_expressions(tours))
- return tours.with_columns(expressions)
-
-
-def _inbound_endpoint_swap_expressions(tours: pl.DataFrame) -> list[pl.Expr]:
- expressions: list[pl.Expr] = []
- for origin_column, destination_column in (
- ("origin", "destination"),
- ("OTAZ", "DTAZ"),
- ("o_maz", "d_maz"),
- ):
- if origin_column in tours.columns and destination_column in tours.columns:
- expressions.extend(
- [
- pl.col(destination_column).alias(origin_column),
- pl.col(origin_column).alias(destination_column),
- ]
- )
- return expressions
+ depart_expr.alias("depart"),
+ context_origin.cast(pl.Float64).alias("OTAZ"),
+ context_destination.cast(pl.Float64).alias("DTAZ"),
+ maz_origin.cast(pl.Float64).alias("o_maz"),
+ maz_destination.cast(pl.Float64).alias("d_maz"),
+ )
+
+
+def _tour_od_columns(
+ tours: pl.DataFrame,
+ normalized: NormalizedConfig,
+) -> dict[str, str]:
+ defaults = normalized.defaults
+ return {
+ "origin": _first_present_column(
+ tours,
+ defaults.origin,
+ "OTAZ",
+ "origin",
+ ),
+ "destination": _first_present_column(
+ tours,
+ defaults.destination,
+ "DTAZ",
+ "destination",
+ ),
+ "o_maz": _first_present_column(
+ tours,
+ "o_maz",
+ defaults.origin,
+ "OTAZ",
+ "origin",
+ ),
+ "d_maz": _first_present_column(
+ tours,
+ "d_maz",
+ defaults.destination,
+ "DTAZ",
+ "destination",
+ ),
+ }
+
+
+def _first_present_column(
+ tours: pl.DataFrame,
+ *candidates: str,
+) -> str:
+ for column in candidates:
+ if column in tours.columns:
+ return column
+ raise ValueError(
+ "Tour skimjoin context requires one of the configured origin/destination "
+ f"columns, but none were present. Tried: {', '.join(repr(column) for column in candidates)}"
+ )
+
+
+def _tour_departure_expr(tours: pl.DataFrame) -> pl.Expr:
+ if "depart" in tours.columns:
+ return pl.col("depart")
+ if "start" in tours.columns:
+ return pl.col("start")
+ if "start_hour" in tours.columns:
+ return pl.col("start_hour")
+ return pl.lit(None, dtype=pl.Int64)
+
+
+def _first_inbound_departures(
+ trips: pl.DataFrame,
+ normalized: NormalizedConfig,
+) -> dict[int, int | float | None] | None:
+ activitysim = normalized.activitysim
+ required = {activitysim.tour_id_column, activitysim.outbound_column, "depart"}
+ if not required.issubset(trips.columns):
+ return None
+ trip_num_present = "trip_num" in trips.columns
+ inbound = trips.filter(
+ pl.col(activitysim.outbound_column).is_not_null()
+ & ~pl.col(activitysim.outbound_column).cast(pl.Boolean, strict=False)
+ & pl.col("depart").is_not_null()
+ )
+ if inbound.is_empty():
+ return {}
+ sort_columns = [activitysim.tour_id_column]
+ if trip_num_present:
+ sort_columns.append("trip_num")
+ sort_columns.append("depart")
+ first_inbound = (
+ inbound.sort(sort_columns)
+ .group_by(activitysim.tour_id_column)
+ .agg(pl.col("depart").first().alias("first_inbound_depart"))
+ )
+ return {
+ int(row[activitysim.tour_id_column]): row["first_inbound_depart"]
+ for row in first_inbound.to_dicts()
+ if row.get(activitysim.tour_id_column) is not None
+ }
def aggregate_tours_from_trips(
diff --git a/processor/skimjoin/config/network_los.py b/processor/skimjoin/config/network_los.py
index d61f36b..5842e2b 100644
--- a/processor/skimjoin/config/network_los.py
+++ b/processor/skimjoin/config/network_los.py
@@ -1,36 +1,7 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import yaml
+"""Compatibility import for shared ActivitySim time-period helpers."""
+from __future__ import annotations
-def load_network_los_period_mapping(path: str | Path) -> dict[str, str]:
- data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
- skim_time_periods = data.get("skim_time_periods")
- if not isinstance(skim_time_periods, dict):
- raise ValueError("network_los_file is missing skim_time_periods.")
-
- periods = skim_time_periods.get("periods")
- labels = skim_time_periods.get("labels")
- if not isinstance(periods, list) or not isinstance(labels, list):
- raise ValueError("skim_time_periods must define list-valued periods and labels.")
- if len(periods) < 2:
- raise ValueError("skim_time_periods.periods must contain at least two breakpoints.")
- if len(labels) != len(periods) - 1:
- raise ValueError("skim_time_periods.labels must have exactly len(periods) - 1 entries.")
+from processor.time_periods import load_network_los_period_mapping
- mapping: dict[str, str] = {}
- for idx, label in enumerate(labels):
- start = periods[idx]
- end = periods[idx + 1]
- try:
- start_int = int(start)
- end_int = int(end)
- except (TypeError, ValueError) as exc:
- raise ValueError("skim_time_periods.periods must contain integers.") from exc
- # ActivitySim trip-side period buckets are treated as 1..48 rather than 0..47.
- # The network_los breakpoints remain zero-based, so shift each covered bucket by +1.
- for period_number in range(start_int, end_int):
- mapping[str(period_number + 1)] = str(label)
- return mapping
+__all__ = ["load_network_los_period_mapping"]
diff --git a/processor/skimjoin/config/normalize.py b/processor/skimjoin/config/normalize.py
index 9365641..39ed012 100644
--- a/processor/skimjoin/config/normalize.py
+++ b/processor/skimjoin/config/normalize.py
@@ -3,7 +3,7 @@
import re
from typing import Any
-from processor.skimjoin.config.network_los import load_network_los_period_mapping
+from processor.time_periods import load_network_los_period_mapping
from processor.skimjoin.config.schema import (
ActivitySimConfig,
DimensionConfig,
diff --git a/processor/skimjoin/hypothetical_sidecars.py b/processor/skimjoin/hypothetical_sidecars.py
new file mode 100644
index 0000000..02489d7
--- /dev/null
+++ b/processor/skimjoin/hypothetical_sidecars.py
@@ -0,0 +1,240 @@
+"""Helpers for optional hypothetical skim sidecar generation."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from processor.skimjoin.annotate.tours import annotate_tours
+from processor.skimjoin.annotate.trips import annotate_trips
+from processor.skimjoin.config.schema import NormalizedConfig
+from processor.skimjoin.skimstore.base import SkimStore
+
+TRIP_HYPOTHETICAL_SIDECAR_SCHEMA = {
+ "trip_id": pl.Int64,
+ "observed_mode": pl.Utf8,
+ "hypothetical_mode": pl.Utf8,
+ "component": pl.Utf8,
+ "value": pl.Float64,
+ "finalweight": pl.Float64,
+}
+
+TOUR_HYPOTHETICAL_SIDECAR_SCHEMA = {
+ "tour_id": pl.Int64,
+ "observed_mode": pl.Utf8,
+ "hypothetical_mode": pl.Utf8,
+ "direction": pl.Utf8,
+ "component": pl.Utf8,
+ "value": pl.Float64,
+ "finalweight": pl.Float64,
+}
+
+
+def build_hypothetical_sidecars(
+ *,
+ trips: pl.DataFrame,
+ tours: pl.DataFrame,
+ normalized: NormalizedConfig,
+ inventory: pl.DataFrame,
+ skim_store: SkimStore | None = None,
+) -> tuple[pl.DataFrame, pl.DataFrame]:
+ """Build long-form hypothetical skim sidecars for trips and tours."""
+ trip_sidecar = _build_trip_hypothetical_sidecar(
+ trips=trips,
+ normalized=normalized,
+ inventory=inventory,
+ skim_store=skim_store,
+ )
+ tour_sidecar = _build_tour_hypothetical_sidecar(
+ tours=tours,
+ normalized=normalized,
+ inventory=inventory,
+ skim_store=skim_store,
+ )
+ return trip_sidecar, tour_sidecar
+
+
+def _build_trip_hypothetical_sidecar(
+ *,
+ trips: pl.DataFrame,
+ normalized: NormalizedConfig,
+ inventory: pl.DataFrame,
+ skim_store: SkimStore | None,
+) -> pl.DataFrame:
+ mode_column = normalized.activitysim.trip_mode_column
+ trip_id_column = normalized.activitysim.trip_id_column
+ if (
+ trips.is_empty()
+ or mode_column not in trips.columns
+ or trip_id_column not in trips.columns
+ or "finalweight" not in trips.columns
+ ):
+ return pl.DataFrame(schema=TRIP_HYPOTHETICAL_SIDECAR_SCHEMA)
+
+ frames: list[pl.DataFrame] = []
+ for mode in _lookup_modes(normalized.trip_lookups):
+ outputs = _outputs_for_mode(normalized.trip_lookups, mode)
+ if not outputs:
+ continue
+ hypothetical_input = trips.with_columns(
+ pl.col(mode_column).cast(pl.Utf8).alias("__observed_mode"),
+ pl.lit(mode).alias(mode_column),
+ )
+ annotated, _, _ = annotate_trips(
+ hypothetical_input,
+ normalized,
+ inventory,
+ skim_store=skim_store,
+ include_fallback_report=False,
+ )
+ frames.append(
+ _melt_trip_outputs(
+ annotated,
+ outputs=outputs,
+ trip_id_column=trip_id_column,
+ hypothetical_mode=mode,
+ )
+ )
+ return _concat_frames(frames, TRIP_HYPOTHETICAL_SIDECAR_SCHEMA)
+
+
+def _build_tour_hypothetical_sidecar(
+ *,
+ tours: pl.DataFrame,
+ normalized: NormalizedConfig,
+ inventory: pl.DataFrame,
+ skim_store: SkimStore | None,
+) -> pl.DataFrame:
+ mode_column = normalized.activitysim.tour_mode_column
+ tour_id_column = normalized.activitysim.tour_id_column
+ if (
+ tours.is_empty()
+ or mode_column not in tours.columns
+ or tour_id_column not in tours.columns
+ or "finalweight" not in tours.columns
+ ):
+ return pl.DataFrame(schema=TOUR_HYPOTHETICAL_SIDECAR_SCHEMA)
+
+ frames: list[pl.DataFrame] = []
+ for mode in _lookup_modes(normalized.tour_lookups):
+ outputs = _outputs_for_mode(normalized.tour_lookups, mode)
+ if not outputs:
+ continue
+ hypothetical_input = tours.with_columns(
+ pl.col(mode_column).cast(pl.Utf8).alias("__observed_mode"),
+ pl.lit(mode).alias(mode_column),
+ )
+ annotated, _, _ = annotate_tours(
+ hypothetical_input,
+ normalized,
+ inventory,
+ skim_store=skim_store,
+ include_fallback_report=False,
+ )
+ frames.append(
+ _melt_tour_outputs(
+ annotated,
+ outputs=outputs,
+ tour_id_column=tour_id_column,
+ hypothetical_mode=mode,
+ )
+ )
+ return _concat_frames(frames, TOUR_HYPOTHETICAL_SIDECAR_SCHEMA)
+
+
+def _lookup_modes(rules) -> list[str]:
+ return sorted({str(rule.mode) for rule in rules})
+
+
+def _outputs_for_mode(rules, mode: str) -> list[str]:
+ return sorted({str(rule.output) for rule in rules if str(rule.mode) == str(mode)})
+
+
+def _melt_trip_outputs(
+ annotated: pl.DataFrame,
+ *,
+ outputs: list[str],
+ trip_id_column: str,
+ hypothetical_mode: str,
+) -> pl.DataFrame:
+ available_outputs = [column for column in outputs if column in annotated.columns]
+ if not available_outputs:
+ return pl.DataFrame(schema=TRIP_HYPOTHETICAL_SIDECAR_SCHEMA)
+ return (
+ annotated.select(
+ pl.col(trip_id_column).cast(pl.Int64, strict=False).alias("trip_id"),
+ pl.col("__observed_mode").cast(pl.Utf8).alias("observed_mode"),
+ pl.col("finalweight").cast(pl.Float64),
+ *[pl.col(column).cast(pl.Float64, strict=False).alias(column) for column in available_outputs],
+ )
+ .melt(
+ id_vars=["trip_id", "observed_mode", "finalweight"],
+ value_vars=available_outputs,
+ variable_name="component",
+ value_name="value",
+ )
+ .with_columns(pl.lit(hypothetical_mode).alias("hypothetical_mode"))
+ .select(
+ "trip_id",
+ "observed_mode",
+ "hypothetical_mode",
+ "component",
+ "value",
+ "finalweight",
+ )
+ .cast(TRIP_HYPOTHETICAL_SIDECAR_SCHEMA, strict=False)
+ )
+
+
+def _melt_tour_outputs(
+ annotated: pl.DataFrame,
+ *,
+ outputs: list[str],
+ tour_id_column: str,
+ hypothetical_mode: str,
+) -> pl.DataFrame:
+ available_outputs = [column for column in outputs if column in annotated.columns]
+ if not available_outputs:
+ return pl.DataFrame(schema=TOUR_HYPOTHETICAL_SIDECAR_SCHEMA)
+ return (
+ annotated.select(
+ pl.col(tour_id_column).cast(pl.Int64, strict=False).alias("tour_id"),
+ pl.col("__observed_mode").cast(pl.Utf8).alias("observed_mode"),
+ pl.col("finalweight").cast(pl.Float64),
+ *[pl.col(column).cast(pl.Float64, strict=False).alias(column) for column in available_outputs],
+ )
+ .melt(
+ id_vars=["tour_id", "observed_mode", "finalweight"],
+ value_vars=available_outputs,
+ variable_name="component",
+ value_name="value",
+ )
+ .with_columns(
+ pl.lit(hypothetical_mode).alias("hypothetical_mode"),
+ pl.when(pl.col("component").str.ends_with("_outbound"))
+ .then(pl.lit("outbound"))
+ .when(pl.col("component").str.ends_with("_inbound"))
+ .then(pl.lit("inbound"))
+ .otherwise(None)
+ .alias("direction"),
+ )
+ .select(
+ "tour_id",
+ "observed_mode",
+ "hypothetical_mode",
+ "direction",
+ "component",
+ "value",
+ "finalweight",
+ )
+ .cast(TOUR_HYPOTHETICAL_SIDECAR_SCHEMA, strict=False)
+ )
+
+
+def _concat_frames(
+ frames: list[pl.DataFrame],
+ schema: dict[str, pl.DataType],
+) -> pl.DataFrame:
+ populated = [frame for frame in frames if not frame.is_empty()]
+ if not populated:
+ return pl.DataFrame(schema=schema)
+ return pl.concat(populated, how="vertical_relaxed").cast(schema, strict=False)
diff --git a/processor/skimjoin/pipeline.py b/processor/skimjoin/pipeline.py
index a3b1b40..2a87453 100644
--- a/processor/skimjoin/pipeline.py
+++ b/processor/skimjoin/pipeline.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
import polars as pl
from processor.models import RunData, SkimjoinArtifacts
@@ -27,7 +27,7 @@
def apply_skimjoin(rd: RunData, config: Config) -> RunData:
"""Apply optional skim enrichment to prepared trips and tours."""
- if not config.skimjoin.enabled:
+ if not config.skimjoin_step_enabled():
return _package_disabled_skimjoin(rd)
LOGGER.info("[skimjoin] Starting skim enrichment for '%s'", rd.label)
@@ -40,15 +40,21 @@ def apply_skimjoin(rd: RunData, config: Config) -> RunData:
result = _run_integrated_skimjoin(
rd=rd,
normalized=normalized,
+ create_hypothetical_skim_tables=config.skimjoin.create_hypothetical_skim_tables,
annotate_trips_fn=annotate_trips,
annotate_tours_fn=annotate_tours,
)
except Exception as exc:
+ if config.skimjoin.failure_policy == "error":
+ raise
+ LOGGER.exception("[skimjoin] Enrichment failed for %r", rd.label)
return _package_failed_skimjoin(rd, config, exc)
return _package_applied_skimjoin(rd, config, result)
def _package_disabled_skimjoin(rd: RunData) -> RunData:
+ rd.trip_hypothetical_skims = pl.DataFrame()
+ rd.tour_hypothetical_skims = pl.DataFrame()
manifest = _skimjoin_manifest(
enabled=False,
status="disabled",
@@ -65,11 +71,14 @@ def _package_disabled_skimjoin(rd: RunData) -> RunData:
def _package_failed_skimjoin(rd: RunData, config: Config, exc: Exception) -> RunData:
+ rd.trip_hypothetical_skims = pl.DataFrame()
+ rd.tour_hypothetical_skims = pl.DataFrame()
failure_detail = f"{type(exc).__name__}: {exc}"
manifest = _skimjoin_manifest(
enabled=True,
status="failed",
config_digest=config.skimjoin.config_digest,
+ resolved_network_los_file=config.skimjoin.resolved_network_los_file,
fallback_count=0,
fallback_outputs=[],
failure_detail=failure_detail,
@@ -119,15 +128,21 @@ def _package_applied_skimjoin(rd: RunData, config: Config, result: object) -> Ru
rd.trips = result.annotated_trips
rd.tours = result.enriched_tours
+ rd.trip_hypothetical_skims = result.trip_hypothetical_skims
+ rd.tour_hypothetical_skims = result.tour_hypothetical_skims
manifest = _skimjoin_manifest(
enabled=True,
status=status,
config_digest=config.skimjoin.config_digest,
+ resolved_network_los_file=config.skimjoin.resolved_network_los_file,
applied_outputs=applied_outputs,
skipped_rules=skipped_rules.to_dicts(),
warning_count=int(result.missing_lookup_report.height),
fallback_count=int(result.fallback_lookup_report.height),
fallback_outputs=fallback_outputs,
+ hypothetical_sidecars_enabled=config.skimjoin.create_hypothetical_skim_tables,
+ trip_hypothetical_rows=int(result.trip_hypothetical_skims.height),
+ tour_hypothetical_rows=int(result.tour_hypothetical_skims.height),
)
reports = {
"skim_lookup_summary": result.lookup_summary,
diff --git a/processor/skimjoin/runtime_execution.py b/processor/skimjoin/runtime_execution.py
index edd2643..c5110b0 100644
--- a/processor/skimjoin/runtime_execution.py
+++ b/processor/skimjoin/runtime_execution.py
@@ -10,6 +10,7 @@
from processor.models import RunData
from processor.skimjoin.annotate.tours import annotate_tours
from processor.skimjoin.annotate.trips import annotate_trips
+from processor.skimjoin.hypothetical_sidecars import build_hypothetical_sidecars
from processor.skimjoin.inventory import inventory_skim_files
from processor.skimjoin.runtime_types import _RuntimeSkimjoinResult
from processor.skimjoin.skimstore.omx import OmxSkimStore
@@ -59,15 +60,17 @@ def _run_integrated_skimjoin(
*,
rd: RunData,
normalized: object,
+ create_hypothetical_skim_tables: bool = False,
annotate_trips_fn: Callable[..., tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]] = annotate_trips,
annotate_tours_fn: Callable[..., tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame]] = annotate_tours,
) -> _RuntimeSkimjoinResult:
inventory = _resolved_runtime_inventory(normalized)
+ skim_store = OmxSkimStore()
trip_outputs = annotate_trips_fn(
rd.trips,
normalized,
inventory,
- skim_store=OmxSkimStore(),
+ skim_store=skim_store,
include_fallback_report=True,
)
annotated_trips, lookup_summary, missing_lookup_report, fallback_lookup_report = trip_outputs
@@ -75,13 +78,25 @@ def _run_integrated_skimjoin(
rd.tours,
normalized,
inventory,
- skim_store=OmxSkimStore(),
+ skim_store=skim_store,
include_fallback_report=True,
)
enriched_tours, tour_lookup_summary, tour_missing_lookup_report, tour_fallback_lookup_report = tour_outputs
+ trip_hypothetical_skims = pl.DataFrame()
+ tour_hypothetical_skims = pl.DataFrame()
+ if create_hypothetical_skim_tables:
+ trip_hypothetical_skims, tour_hypothetical_skims = build_hypothetical_sidecars(
+ trips=rd.trips,
+ tours=rd.tours,
+ normalized=normalized,
+ inventory=inventory,
+ skim_store=skim_store,
+ )
return _RuntimeSkimjoinResult(
annotated_trips=annotated_trips,
enriched_tours=enriched_tours,
+ trip_hypothetical_skims=trip_hypothetical_skims,
+ tour_hypothetical_skims=tour_hypothetical_skims,
lookup_summary=pl.concat(
[lookup_summary, tour_lookup_summary],
how="vertical_relaxed",
diff --git a/processor/skimjoin/runtime_reports.py b/processor/skimjoin/runtime_reports.py
index da3b95d..0db2b0c 100644
--- a/processor/skimjoin/runtime_reports.py
+++ b/processor/skimjoin/runtime_reports.py
@@ -16,22 +16,30 @@ def _skimjoin_manifest(
enabled: bool,
status: str,
config_digest: str | None,
+ resolved_network_los_file: str | None = None,
applied_outputs: list[str] | None = None,
skipped_rules: list[dict[str, object]] | None = None,
warning_count: int = 0,
fallback_count: int = 0,
fallback_outputs: list[str] | None = None,
+ hypothetical_sidecars_enabled: bool = False,
+ trip_hypothetical_rows: int = 0,
+ tour_hypothetical_rows: int = 0,
failure_detail: str | None = None,
) -> dict[str, object]:
return {
"skimjoin_enabled": enabled,
"skimjoin_status": status,
"skimjoin_config_digest": config_digest,
+ "skimjoin_resolved_network_los_file": resolved_network_los_file,
"skimjoin_applied_outputs": list(applied_outputs or []),
"skimjoin_skipped_rules": list(skipped_rules or []),
"skimjoin_warning_count": int(warning_count),
"skimjoin_fallback_count": int(fallback_count),
"skimjoin_fallback_outputs": list(fallback_outputs or []),
+ "skimjoin_hypothetical_sidecars_enabled": bool(hypothetical_sidecars_enabled),
+ "skimjoin_trip_hypothetical_rows": int(trip_hypothetical_rows),
+ "skimjoin_tour_hypothetical_rows": int(tour_hypothetical_rows),
"skimjoin_failure_detail": failure_detail,
}
diff --git a/processor/skimjoin/runtime_types.py b/processor/skimjoin/runtime_types.py
index 78d8c18..6178346 100644
--- a/processor/skimjoin/runtime_types.py
+++ b/processor/skimjoin/runtime_types.py
@@ -13,6 +13,8 @@ class _RuntimeSkimjoinResult:
annotated_trips: pl.DataFrame
enriched_tours: pl.DataFrame
+ trip_hypothetical_skims: pl.DataFrame
+ tour_hypothetical_skims: pl.DataFrame
lookup_summary: pl.DataFrame
missing_lookup_report: pl.DataFrame
fallback_lookup_report: pl.DataFrame
diff --git a/processor/summarize/__init__.py b/processor/summarize/__init__.py
index e94edcb..532f41a 100644
--- a/processor/summarize/__init__.py
+++ b/processor/summarize/__init__.py
@@ -1 +1,5 @@
-"""ActivitySim summarization modules."""
+"""Public API for declaring ActivitySim summaries."""
+
+from processor.summarize.contracts import summary
+
+__all__ = ["summary"]
diff --git a/processor/summarize/builder.py b/processor/summarize/builder.py
new file mode 100644
index 0000000..e4af017
--- /dev/null
+++ b/processor/summarize/builder.py
@@ -0,0 +1,199 @@
+"""Build registered summary tables without cache or filesystem concerns."""
+
+from __future__ import annotations
+
+import hashlib
+import inspect
+import json
+
+import polars as pl
+
+from runtime.logging import get_logger
+from processor.models import RunData
+from runtime.weighting import WEIGHTING_MODES, normalize_weighting_modes
+from processor.summarize.contracts import missing_summary_inputs
+from processor.summarize.catalog import (
+ DEFAULT_SUMMARY_IDS,
+ SUMMARY_BY_ID,
+)
+from runtime.config import Config
+
+LOGGER = get_logger("processor.summarize.builder")
+
+
+def summary_builder_identity(summary_id: str) -> dict[str, object]:
+ spec = SUMMARY_BY_ID[summary_id]
+ try:
+ source = inspect.getsource(spec.builder)
+ except (OSError, TypeError):
+ source = f"{spec.builder.__module__}.{spec.builder.__qualname__}"
+ return {
+ "summary_id": summary_id,
+ "filename": spec.filename,
+ "builder_module": spec.builder.__module__,
+ "builder_qualname": spec.builder.__qualname__,
+ "builder_source_digest": hashlib.sha256(source.encode("utf-8")).hexdigest(),
+ }
+
+
+def summary_digest(summary_id: str, config: Config) -> str:
+ payload = {
+ "summary_config_digest": config.summary_config_digest,
+ "summary": summary_builder_identity(summary_id),
+ }
+ return hashlib.sha256(
+ json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
+ ).hexdigest()
+
+
+def summary_digests(
+ config: Config,
+ summary_ids: list[str] | None = None,
+) -> dict[str, str]:
+ return {
+ summary_id: summary_digest(summary_id, config)
+ for summary_id in (
+ summary_ids if summary_ids is not None else DEFAULT_SUMMARY_IDS
+ )
+ }
+
+
+def _summary_spec(summary_id: str):
+ spec = SUMMARY_BY_ID.get(summary_id)
+ if spec is None:
+ raise KeyError(f"Unknown summary id: {summary_id}")
+ return spec
+
+
+def _summary_ids(summary_ids: list[str] | None) -> list[str]:
+ return list(summary_ids) if summary_ids is not None else list(DEFAULT_SUMMARY_IDS)
+
+
+def _empty_summary(summary_id: str) -> pl.DataFrame:
+ return _summary_spec(summary_id).empty()
+
+
+def _build_one(
+ summary_id: str,
+ run: RunData,
+ config: Config,
+ *,
+ raise_on_error: bool = False,
+) -> tuple[pl.DataFrame, dict[str, object]]:
+ spec = _summary_spec(summary_id)
+ missing_inputs = missing_summary_inputs(spec.builder, run)
+ if missing_inputs:
+ detail = "; ".join(
+ f"{table_name} ({reason})"
+ for table_name, reason in sorted(missing_inputs.items())
+ )
+ LOGGER.warning(
+ "Skipping summary %r for run %r because required prepared inputs are unavailable: %s",
+ summary_id,
+ run.label,
+ detail,
+ )
+ return _empty_summary(summary_id), {"state": "unavailable", "detail": detail}
+ try:
+ table = spec.builder(run, config)
+ except Exception as exc:
+ if raise_on_error:
+ raise
+ LOGGER.exception("Summary %r failed for run %r", summary_id, run.label)
+ return _empty_summary(summary_id), {"state": "failed", "detail": str(exc)}
+ return table, {"state": "empty" if table.is_empty() else "available"}
+
+
+def build_summaries(
+ run: RunData,
+ config: Config,
+ summary_ids: list[str] | None = None,
+) -> dict[str, pl.DataFrame]:
+ """Build requested tables, allowing builder exceptions to propagate."""
+ return {
+ summary_id: _summary_spec(summary_id).builder(run, config)
+ for summary_id in _summary_ids(summary_ids)
+ }
+
+
+def build_summaries_with_metadata(
+ run: RunData,
+ config: Config,
+ summary_ids: list[str] | None = None,
+ *,
+ raise_on_error: bool = False,
+) -> tuple[dict[str, pl.DataFrame], dict[str, dict[str, object]]]:
+ """Build requested tables while recording unavailable and failed inputs."""
+ tables: dict[str, pl.DataFrame] = {}
+ metadata: dict[str, dict[str, object]] = {}
+ for summary_id in _summary_ids(summary_ids):
+ table, table_metadata = _build_one(
+ summary_id,
+ run,
+ config,
+ raise_on_error=raise_on_error,
+ )
+ tables[summary_id] = table
+ metadata[summary_id] = table_metadata
+ return tables, metadata
+
+
+def _runs_by_weighting_mode(
+ run: RunData,
+ config: Config,
+ weighting_modes: list[str] | None,
+) -> dict[str, RunData]:
+ modes = normalize_weighting_modes(
+ weighting_modes or config.weighting_modes,
+ additional_definitions=config.weighting_mode_definitions,
+ )
+ configured_definitions = {
+ definition.mode_id: definition
+ for definition in config.weighting_mode_definitions
+ }
+ runs: dict[str, RunData] = {}
+ for mode in modes:
+ definition = configured_definitions.get(mode)
+ if definition is None:
+ definition = WEIGHTING_MODES.get(mode)
+ runs[mode] = definition.apply(run, config)
+ return runs
+
+
+def build_mode_summaries(
+ run: RunData,
+ config: Config,
+ weighting_modes: list[str] | None = None,
+ summary_ids: list[str] | None = None,
+) -> dict[str, dict[str, pl.DataFrame]]:
+ return {
+ mode: build_summaries(mode_run, config, summary_ids)
+ for mode, mode_run in _runs_by_weighting_mode(
+ run, config, weighting_modes
+ ).items()
+ }
+
+
+def build_mode_summaries_with_metadata(
+ run: RunData,
+ config: Config,
+ weighting_modes: list[str] | None = None,
+ summary_ids: list[str] | None = None,
+ *,
+ raise_on_error: bool = False,
+) -> tuple[
+ dict[str, dict[str, pl.DataFrame]],
+ dict[str, dict[str, dict[str, object]]],
+]:
+ tables_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
+ metadata_by_mode: dict[str, dict[str, dict[str, object]]] = {}
+ for mode, mode_run in _runs_by_weighting_mode(run, config, weighting_modes).items():
+ tables, metadata = build_summaries_with_metadata(
+ mode_run,
+ config,
+ summary_ids,
+ raise_on_error=raise_on_error,
+ )
+ tables_by_mode[mode] = tables
+ metadata_by_mode[mode] = metadata
+ return tables_by_mode, metadata_by_mode
diff --git a/processor/summarize/cache.py b/processor/summarize/cache.py
index 4dae3e0..51cafa3 100644
--- a/processor/summarize/cache.py
+++ b/processor/summarize/cache.py
@@ -1,22 +1,11 @@
-"""Stable public summary cache API re-exporting smaller single-purpose modules."""
+"""Summary cache serialization and loading."""
from __future__ import annotations
-import inspect
-import hashlib
-import json
from pathlib import Path
-import polars as pl
-
from processor.cache_identity import build_run_fingerprint, build_run_keys, slugify
-from processor.models import RunData
-from processor.summarize.cache_execution import (
- build_mode_summaries as _build_mode_summaries,
- build_mode_summaries_with_metadata as _build_mode_summaries_with_metadata,
- build_summaries as _build_summaries,
- build_summaries_with_metadata as _build_summaries_with_metadata,
-)
+from processor.summarize import builder as summary_builder
from processor.summarize.cache_storage import (
SCHEMA_VERSION,
discover_cache_dirs,
@@ -29,131 +18,15 @@
write_summary_run_cache as _write_summary_run_cache,
)
from processor.summarize.cache_types import (
- SUPPORTED_WEIGHTING_MODES,
- SummaryCacheError,
SummaryRun,
- create_summary_run,
- normalize_weighting_modes,
- strip_weights,
)
-from processor.summarize.summary_specs import (
- DEFAULT_SUMMARY_IDS,
+from processor.summarize.catalog import (
SUMMARY_FILENAME_BY_ID,
- SUMMARY_SPEC_BY_ID,
- SummarySpec,
+ SUMMARY_BY_ID,
)
from runtime.config import Config
-def requested_summary_ids(config: Config) -> list[str]:
- """Return the registered default summary ids."""
- return list(DEFAULT_SUMMARY_IDS)
-
-
-def summary_builder_identity(summary_id: str) -> dict[str, object]:
- spec = SUMMARY_SPEC_BY_ID[summary_id]
- try:
- source = inspect.getsource(spec.builder)
- except (OSError, TypeError):
- source = f"{spec.builder.__module__}.{spec.builder.__qualname__}"
- return {
- "summary_id": summary_id,
- "filename": spec.filename,
- "builder_module": spec.builder.__module__,
- "builder_qualname": spec.builder.__qualname__,
- "builder_source_digest": hashlib.sha256(
- source.encode("utf-8")
- ).hexdigest(),
- }
-
-
-def summary_digest(summary_id: str, config: Config) -> str:
- payload = {
- "summary_config_digest": config.summary_config_digest,
- "summary": summary_builder_identity(summary_id),
- }
- return hashlib.sha256(
- json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
- ).hexdigest()
-
-
-def summary_digests(
- config: Config,
- summary_ids: list[str] | None = None,
-) -> dict[str, str]:
- return {
- summary_id: summary_digest(summary_id, config)
- for summary_id in (summary_ids or requested_summary_ids(config))
- }
-
-
-def build_summaries(
- rd: RunData,
- config: Config,
- summary_ids: list[str] | None = None,
-) -> dict[str, pl.DataFrame]:
- """Build the requested summary tables for one prepared run."""
- return _build_summaries(
- rd,
- config,
- summary_ids=summary_ids,
- default_summary_ids=DEFAULT_SUMMARY_IDS,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
- )
-
-
-def build_summaries_with_metadata(
- rd: RunData,
- config: Config,
- summary_ids: list[str] | None = None,
-) -> tuple[dict[str, pl.DataFrame], dict[str, dict[str, object]]]:
- """Build summaries plus per-summary execution metadata."""
- return _build_summaries_with_metadata(
- rd,
- config,
- summary_ids=summary_ids,
- default_summary_ids=DEFAULT_SUMMARY_IDS,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
- )
-
-
-def build_mode_summaries(
- rd: RunData,
- config: Config,
- weighting_modes: list[str] | None = None,
- summary_ids: list[str] | None = None,
-) -> dict[str, dict[str, pl.DataFrame]]:
- """Build the requested summaries for every enabled weighting mode."""
- return _build_mode_summaries(
- rd,
- config,
- weighting_modes=weighting_modes,
- summary_ids=summary_ids,
- default_summary_ids=DEFAULT_SUMMARY_IDS,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
- )
-
-
-def build_mode_summaries_with_metadata(
- rd: RunData,
- config: Config,
- weighting_modes: list[str] | None = None,
- summary_ids: list[str] | None = None,
-) -> tuple[
- dict[str, dict[str, pl.DataFrame]],
- dict[str, dict[str, dict[str, object]]],
-]:
- """Build requested summaries plus per-mode execution metadata."""
- return _build_mode_summaries_with_metadata(
- rd,
- config,
- weighting_modes=weighting_modes,
- summary_ids=summary_ids,
- default_summary_ids=DEFAULT_SUMMARY_IDS,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
- )
-
-
def summary_file_map(summary_ids: list[str]) -> dict[str, str]:
"""Return persisted filenames for the requested summary ids."""
return _summary_file_map(
@@ -177,7 +50,10 @@ def write_summary_run_cache(
output_root=output_root,
run_fingerprint=run_fingerprint,
prepared_manifest_identity=prepared_manifest_identity,
- summary_digests=summary_digests(config, list(summary_run.summaries_by_mode.values())[0].keys()),
+ summary_digests=summary_builder.summary_digests(
+ config,
+ list(summary_run.summaries_by_mode.values())[0].keys(),
+ ),
summary_filename_by_id=SUMMARY_FILENAME_BY_ID,
)
@@ -205,7 +81,7 @@ def load_summary_run_cache(
expected_prepared_manifest_identity=expected_prepared_manifest_identity,
expected_label=expected_label,
expected_run_key=expected_run_key,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
+ summary_spec_by_id=SUMMARY_BY_ID,
)
@@ -225,7 +101,7 @@ def write_summary_run_bundle(
output_root=output_root,
run_fingerprint=run_fingerprint,
prepared_manifest_identity=prepared_manifest_identity,
- summary_digests=summary_digests(config, requested_ids),
+ summary_digests=summary_builder.summary_digests(config, requested_ids),
summary_filename_by_id=SUMMARY_FILENAME_BY_ID,
)
@@ -252,7 +128,9 @@ def inspect_summary_run_bundle(
expected_prepared_manifest_identity=expected_prepared_manifest_identity,
expected_label=expected_label,
expected_run_key=expected_run_key,
- expected_summary_digests=summary_digests(config, expected_summary_ids),
+ expected_summary_digests=summary_builder.summary_digests(
+ config, expected_summary_ids
+ ),
)
@@ -279,37 +157,20 @@ def load_summary_run_bundle(
expected_prepared_manifest_identity=expected_prepared_manifest_identity,
expected_label=expected_label,
expected_run_key=expected_run_key,
- summary_spec_by_id=SUMMARY_SPEC_BY_ID,
+ summary_spec_by_id=SUMMARY_BY_ID,
)
__all__ = [
- "DEFAULT_SUMMARY_IDS",
"SCHEMA_VERSION",
- "SUMMARY_FILENAME_BY_ID",
- "SUMMARY_SPEC_BY_ID",
- "SUPPORTED_WEIGHTING_MODES",
- "SummaryCacheError",
"SummaryRun",
- "SummarySpec",
- "build_mode_summaries",
- "build_mode_summaries_with_metadata",
"build_run_fingerprint",
"build_run_keys",
- "build_summaries",
- "build_summaries_with_metadata",
- "create_summary_run",
"discover_cache_dirs",
"inspect_summary_run_bundle",
"load_summary_run_cache",
"load_summary_run_bundle",
- "normalize_weighting_modes",
- "requested_summary_ids",
- "summary_builder_identity",
- "summary_digest",
- "summary_digests",
"slugify",
- "strip_weights",
"summary_file_map",
"summary_root",
"write_summary_run_cache",
diff --git a/processor/summarize/cache_execution.py b/processor/summarize/cache_execution.py
deleted file mode 100644
index e5461f5..0000000
--- a/processor/summarize/cache_execution.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""Summary execution helpers separate from cache persistence."""
-
-from __future__ import annotations
-
-import polars as pl
-
-from activitysim_viz_logging import get_logger
-from processor.models import RunData
-from processor.summarize.cache_types import normalize_weighting_modes, strip_weights
-from processor.summarize.contracts import empty_summary_frame, missing_summary_inputs
-from runtime.config import Config
-
-LOGGER = get_logger("processor.summarize.cache")
-
-
-def _summary_spec(
- summary_id: str,
- *,
- summary_spec_by_id: dict[str, object],
-):
- spec = summary_spec_by_id.get(summary_id)
- if spec is None:
- raise KeyError(f"Unknown summary id: {summary_id}")
- return spec
-
-
-def _resolved_summary_ids(
- *,
- config: Config,
- summary_ids: list[str] | None,
- default_summary_ids: list[str],
-) -> list[str]:
- return summary_ids or list(default_summary_ids)
-
-
-def _empty_summary_result(
- summary_id: str,
- *,
- summary_spec_by_id: dict[str, object],
-) -> pl.DataFrame:
- return empty_summary_frame(_summary_spec(summary_id, summary_spec_by_id=summary_spec_by_id).builder)
-
-
-def _detail_from_missing_inputs(missing_inputs: dict[str, str]) -> str:
- return "; ".join(
- f"{table_name} ({reason})"
- for table_name, reason in sorted(missing_inputs.items())
- )
-
-
-def _summary_state_for_table(table: pl.DataFrame) -> str:
- return "empty" if table.is_empty() else "available"
-
-
-def _run_data_by_weighting_mode(
- rd: RunData,
- weighting_modes: list[str],
-) -> dict[str, RunData]:
- mode_runs: dict[str, RunData] = {"weighted": rd}
- if "unweighted" in weighting_modes:
- mode_runs["unweighted"] = strip_weights(rd)
- return mode_runs
-
-
-def _build_one_summary_with_metadata(
- summary_id: str,
- *,
- rd: RunData,
- config: Config,
- summary_spec_by_id: dict[str, object],
-) -> tuple[pl.DataFrame, dict[str, object]]:
- spec = _summary_spec(summary_id, summary_spec_by_id=summary_spec_by_id)
- missing_inputs = missing_summary_inputs(spec.builder, rd)
- if missing_inputs:
- detail = _detail_from_missing_inputs(missing_inputs)
- LOGGER.warning(
- "Skipping summary %r for run %r because required prepared inputs are unavailable: %s",
- summary_id,
- rd.label,
- detail,
- )
- return _empty_summary_result(
- summary_id,
- summary_spec_by_id=summary_spec_by_id,
- ), {"state": "unavailable", "detail": detail}
-
- try:
- table = spec.builder(rd, config)
- except Exception as exc:
- LOGGER.warning(
- "Summary %r failed for run %r: %s",
- summary_id,
- rd.label,
- exc,
- )
- return _empty_summary_result(
- summary_id,
- summary_spec_by_id=summary_spec_by_id,
- ), {"state": "failed", "detail": str(exc)}
-
- return table, {"state": _summary_state_for_table(table)}
-
-
-def build_summaries(
- rd: RunData,
- config: Config,
- *,
- summary_ids: list[str] | None,
- default_summary_ids: list[str],
- summary_spec_by_id: dict[str, object],
-) -> dict[str, pl.DataFrame]:
- """Build the requested summary tables for one prepared run."""
- resolved_summary_ids = _resolved_summary_ids(
- config=config,
- summary_ids=summary_ids,
- default_summary_ids=default_summary_ids,
- )
- tables: dict[str, pl.DataFrame] = {}
- for summary_id in resolved_summary_ids:
- spec = _summary_spec(summary_id, summary_spec_by_id=summary_spec_by_id)
- tables[summary_id] = spec.builder(rd, config)
- return tables
-
-
-def build_summaries_with_metadata(
- rd: RunData,
- config: Config,
- *,
- summary_ids: list[str] | None,
- default_summary_ids: list[str],
- summary_spec_by_id: dict[str, object],
-) -> tuple[dict[str, pl.DataFrame], dict[str, dict[str, object]]]:
- """Build summaries plus per-summary execution metadata."""
- resolved_summary_ids = _resolved_summary_ids(
- config=config,
- summary_ids=summary_ids,
- default_summary_ids=default_summary_ids,
- )
- tables: dict[str, pl.DataFrame] = {}
- metadata: dict[str, dict[str, object]] = {}
- for summary_id in resolved_summary_ids:
- table, summary_metadata = _build_one_summary_with_metadata(
- summary_id,
- rd=rd,
- config=config,
- summary_spec_by_id=summary_spec_by_id,
- )
- tables[summary_id] = table
- metadata[summary_id] = summary_metadata
- return tables, metadata
-
-
-def build_mode_summaries(
- rd: RunData,
- config: Config,
- *,
- weighting_modes: list[str] | None,
- summary_ids: list[str] | None,
- default_summary_ids: list[str],
- summary_spec_by_id: dict[str, object],
-) -> dict[str, dict[str, pl.DataFrame]]:
- """Build the requested summaries for every enabled weighting mode."""
- resolved_weighting_modes = normalize_weighting_modes(
- weighting_modes or config.weighting_modes
- )
- mode_runs = _run_data_by_weighting_mode(rd, resolved_weighting_modes)
- summaries_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
- for mode in resolved_weighting_modes:
- mode_rd = mode_runs["weighted"] if mode == "weighted" else mode_runs[mode]
- summaries_by_mode[mode] = build_summaries(
- mode_rd,
- config,
- summary_ids=summary_ids,
- default_summary_ids=default_summary_ids,
- summary_spec_by_id=summary_spec_by_id,
- )
- return summaries_by_mode
-
-
-def build_mode_summaries_with_metadata(
- rd: RunData,
- config: Config,
- *,
- weighting_modes: list[str] | None,
- summary_ids: list[str] | None,
- default_summary_ids: list[str],
- summary_spec_by_id: dict[str, object],
-) -> tuple[
- dict[str, dict[str, pl.DataFrame]],
- dict[str, dict[str, dict[str, object]]],
-]:
- """Build requested summaries plus per-mode execution metadata."""
- resolved_weighting_modes = normalize_weighting_modes(
- weighting_modes or config.weighting_modes
- )
- mode_runs = _run_data_by_weighting_mode(rd, resolved_weighting_modes)
- summaries_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
- metadata_by_mode: dict[str, dict[str, dict[str, object]]] = {}
- for mode in resolved_weighting_modes:
- mode_rd = mode_runs["weighted"] if mode == "weighted" else mode_runs[mode]
- mode_tables, mode_metadata = build_summaries_with_metadata(
- mode_rd,
- config,
- summary_ids=summary_ids,
- default_summary_ids=default_summary_ids,
- summary_spec_by_id=summary_spec_by_id,
- )
- summaries_by_mode[mode] = mode_tables
- metadata_by_mode[mode] = mode_metadata
- return summaries_by_mode, metadata_by_mode
-
diff --git a/processor/summarize/cache_storage.py b/processor/summarize/cache_storage.py
index 9981401..38fe0f3 100644
--- a/processor/summarize/cache_storage.py
+++ b/processor/summarize/cache_storage.py
@@ -20,8 +20,7 @@
SummaryRun,
normalize_weighting_modes,
)
-from processor.summarize.contracts import empty_summary_frame
-from processor.summarize.writer import write_all
+from processor.summarize.csv_export import write_summary_csvs
from runtime.config import Config
SCHEMA_VERSION = 15
@@ -77,9 +76,9 @@ def _build_mode_cache_payload(
for summary_id, table in mode_tables.items():
filename = Path(
- summary_file_map([summary_id], summary_filename_by_id=summary_filename_by_id)[
- summary_id
- ]
+ summary_file_map(
+ [summary_id], summary_filename_by_id=summary_filename_by_id
+ )[summary_id]
).stem
state, detail = _summary_storage_state(
table,
@@ -224,7 +223,7 @@ def write_summary_run_cache(
summary_id: (summary_digests or {}).get(summary_id, "")
for summary_id in summary_ids
}
- write_all(mode_payload["file_tables"], run_dir / mode)
+ write_summary_csvs(mode_payload["file_tables"], run_dir / mode)
manifest = _summary_manifest(
summary_run=summary_run,
@@ -292,7 +291,9 @@ def write_summary_run_bundle(
if summary_run.is_full_segment and mode not in empty_summaries:
empty_summaries[mode] = list(mode_payload["empty_summaries"])
summary_states[mode] = dict(mode_payload["summary_states"])
- unavailable_summaries[mode] = list(mode_payload["unavailable_summaries"])
+ unavailable_summaries[mode] = list(
+ mode_payload["unavailable_summaries"]
+ )
failed_summaries[mode] = list(mode_payload["failed_summaries"])
summary_diagnostics[mode] = dict(mode_payload["summary_diagnostics"])
manifest_summary_digests[mode] = {
@@ -315,7 +316,7 @@ def write_summary_run_bundle(
/ summary_run.segmentation_type
/ summary_run.segment_id
)
- write_all(mode_payload["file_tables"], mode_dir)
+ write_summary_csvs(mode_payload["file_tables"], mode_dir)
if summary_run.is_full_segment:
continue
entry = segmentation_type_entries.setdefault(
@@ -360,8 +361,7 @@ def write_summary_run_bundle(
)
manifest["segmentation_enabled"] = len(summary_runs) > 1
manifest["segmentation_types"] = [
- segmentation_type_entries[key]
- for key in sorted(segmentation_type_entries)
+ segmentation_type_entries[key] for key in sorted(segmentation_type_entries)
]
write_manifest(run_dir, manifest)
for summary_run in summary_runs:
@@ -395,13 +395,11 @@ def _validate_manifest_identity(
raise SummaryCacheError(
f"Cache summary config digest mismatch in {cache_dir}; summaries were built from a different summary configuration."
)
- if (
- expected_summary_config_digest is not None
- and manifest.get("summary_config_digest") is None
- and manifest.get("config_digest") is not None
- ):
+ if expected_summary_config_digest is not None and manifest.get(
+ "summary_config_digest"
+ ) is None:
raise SummaryCacheError(
- f"Cache {cache_dir} uses a legacy full-config digest. Rebuild summaries once to migrate to presentation-safe caches."
+ f"Cache {cache_dir} has no summary config digest. Rebuild its summaries."
)
if (
expected_run_fingerprint is not None
@@ -431,12 +429,16 @@ def _validated_mode_and_summary_ids(
expected_summary_ids: list[str] | None,
) -> tuple[list[str], list[str]]:
resolved_expected_modes = normalize_weighting_modes(
- expected_modes or config.weighting_modes
+ expected_modes or config.weighting_modes,
+ additional_definitions=config.weighting_mode_definitions,
)
manifest_modes = normalize_weighting_modes(
- [str(mode) for mode in manifest.get("weighting_modes", [])]
+ [str(mode) for mode in manifest.get("weighting_modes", [])],
+ additional_definitions=config.weighting_mode_definitions,
)
- missing_modes = [mode for mode in resolved_expected_modes if mode not in manifest_modes]
+ missing_modes = [
+ mode for mode in resolved_expected_modes if mode not in manifest_modes
+ ]
if missing_modes:
raise SummaryCacheError(
f"Cache {cache_dir} is missing weighting modes: {missing_modes}"
@@ -512,7 +514,7 @@ def _empty_summary_result(
spec = summary_spec_by_id.get(summary_id)
if spec is None:
return pl.DataFrame()
- return empty_summary_frame(spec.builder)
+ return spec.empty()
def _loaded_summary_table(
@@ -524,11 +526,20 @@ def _loaded_summary_table(
manifest_summary_states: dict[str, dict[str, str]],
summary_spec_by_id: dict[str, object],
) -> tuple[pl.DataFrame, str]:
- table = pl.read_csv(path, infer_schema_length=10000)
+ is_declared_empty = summary_id in empty_summaries.get(mode, [])
+ spec = summary_spec_by_id.get(summary_id)
+ if is_declared_empty or spec is None:
+ table = pl.read_csv(path, infer_schema_length=10000)
+ else:
+ table = pl.read_csv(
+ path,
+ infer_schema_length=10000,
+ schema_overrides=dict(spec.contract.schema),
+ )
state = manifest_summary_states.get(mode, {}).get(summary_id)
if state is None:
- state = "empty" if summary_id in empty_summaries.get(mode, []) else "available"
- if summary_id in empty_summaries.get(mode, []) and is_empty_sentinel_frame(table):
+ state = "empty" if is_declared_empty else "available"
+ if is_declared_empty and is_empty_sentinel_frame(table):
table = _empty_summary_result(
summary_id,
summary_spec_by_id=summary_spec_by_id,
@@ -596,9 +607,7 @@ def _segment_mode_dirs(
group_dict = dict(group)
for raw_segment in list(group_dict.get("segments", [])):
segment = dict(raw_segment)
- segment_key = (
- f"{group_dict.get('segmentation_type', 'full')}::{segment.get('segment_id', 'full')}"
- )
+ segment_key = f"{group_dict.get('segmentation_type', 'full')}::{segment.get('segment_id', 'full')}"
summary_roots = {
str(mode): str(path)
for mode, path in dict(segment.get("summary_roots", {})).items()
@@ -891,7 +900,9 @@ def load_summary_run_bundle(
str(summary_id): str(detail)
for summary_id, detail in dict(mode_details).items()
}
- for mode, mode_details in dict(segment.get("summary_diagnostics", {})).items()
+ for mode, mode_details in dict(
+ segment.get("summary_diagnostics", {})
+ ).items()
}
empty_summaries = {
mode: [
diff --git a/processor/summarize/cache_types.py b/processor/summarize/cache_types.py
index ac3e745..ea56e85 100644
--- a/processor/summarize/cache_types.py
+++ b/processor/summarize/cache_types.py
@@ -6,9 +6,8 @@
import polars as pl
-from processor.models import RunData, SkimjoinArtifacts, TableAvailabilityMetadata
-
-SUPPORTED_WEIGHTING_MODES = ("weighted", "unweighted")
+from processor.models import RunData
+from runtime.weighting import WEIGHTING_MODES, normalize_weighting_modes
class SummaryCacheError(RuntimeError):
@@ -41,60 +40,9 @@ class SummaryRun:
manifest: dict[str, object] | None = None
-def normalize_weighting_modes(modes: list[str] | None) -> list[str]:
- """Validate, normalize, and deduplicate weighting mode names."""
- if not modes:
- modes = list(SUPPORTED_WEIGHTING_MODES)
- normalized: list[str] = []
- seen: set[str] = set()
- for raw_mode in modes:
- mode = str(raw_mode).strip().lower()
- if mode not in SUPPORTED_WEIGHTING_MODES:
- raise ValueError(
- f"Unsupported weighting mode {raw_mode!r}. Supported modes: {SUPPORTED_WEIGHTING_MODES}"
- )
- if mode not in seen:
- normalized.append(mode)
- seen.add(mode)
- return normalized
-
-
def strip_weights(rd: RunData) -> RunData:
"""Return a copy of ``RunData`` with all ``finalweight`` values reset to 1.0."""
-
- def _reset(df: pl.DataFrame) -> pl.DataFrame:
- if "finalweight" in df.columns:
- return df.with_columns(pl.lit(1.0).alias("finalweight"))
- return df
-
- return RunData(
- label=rd.label,
- run_dir=rd.run_dir,
- skim_file=rd.skim_file,
- hh=_reset(rd.hh),
- per=_reset(rd.per),
- day=_reset(rd.day),
- tours=_reset(rd.tours),
- trips=_reset(rd.trips),
- vehicles=_reset(rd.vehicles),
- joint_participants=rd.joint_participants,
- land_use=rd.land_use,
- skim_matrix=rd.skim_matrix,
- skim_zone_map=rd.skim_zone_map,
- hh_weight_col=None,
- person_weight_col=None,
- trip_weight_col=None,
- table_availability_metadata=TableAvailabilityMetadata(
- states=dict(rd.table_availability_metadata.states),
- diagnostics=dict(rd.table_availability_metadata.diagnostics),
- ),
- skimjoin_artifacts=SkimjoinArtifacts(
- manifest=dict(rd.skimjoin_artifacts.manifest),
- reports=dict(rd.skimjoin_artifacts.reports),
- ),
- skimjoin_manifest=dict(rd.skimjoin_manifest),
- skimjoin_reports=dict(rd.skimjoin_reports),
- )
+ return WEIGHTING_MODES.get("unweighted").apply(rd, None)
def create_summary_run(
@@ -142,7 +90,6 @@ def create_summary_run(
__all__ = [
- "SUPPORTED_WEIGHTING_MODES",
"SummaryCacheError",
"SummaryRun",
"create_summary_run",
diff --git a/processor/summarize/catalog.py b/processor/summarize/catalog.py
new file mode 100644
index 0000000..d794327
--- /dev/null
+++ b/processor/summarize/catalog.py
@@ -0,0 +1,101 @@
+"""Deterministic catalog assembled from explicitly imported owning modules."""
+
+from __future__ import annotations
+
+from types import ModuleType
+
+from processor.summarize.contracts import SummaryDefinition, get_summary_definition
+from processor.summarize.summaries import (
+ daily_travel_activity,
+ daily_travel_escort_counts,
+ daily_travel_escort_distributions,
+ demographics,
+ joint_travel,
+ long_term_distance,
+ long_term_geography,
+ long_term_person,
+ long_term_vehicle,
+ skimjoin,
+ tour,
+ tour_geography,
+ tour_profiles,
+ tour_vehicles,
+ trip,
+ trip_distributions,
+ validation,
+ validation_scaffolds,
+)
+
+SUMMARY_MODULES: tuple[ModuleType, ...] = (
+ demographics,
+ long_term_person,
+ long_term_vehicle,
+ long_term_geography,
+ long_term_distance,
+ daily_travel_activity,
+ daily_travel_escort_counts,
+ daily_travel_escort_distributions,
+ joint_travel,
+ tour,
+ tour_vehicles,
+ tour_profiles,
+ tour_geography,
+ trip,
+ trip_distributions,
+ skimjoin,
+ validation,
+ validation_scaffolds,
+)
+
+
+def build_summary_catalog(
+ modules: tuple[ModuleType, ...] = SUMMARY_MODULES,
+) -> tuple[SummaryDefinition, ...]:
+ """Collect declarations once and reject duplicate ids immediately."""
+ definitions: list[SummaryDefinition] = []
+ seen_builders: set[int] = set()
+ by_id: dict[str, SummaryDefinition] = {}
+ for module in modules:
+ for value in vars(module).values():
+ definition = get_summary_definition(value) if callable(value) else None
+ if (
+ definition is None
+ or definition.builder.__module__ != module.__name__
+ or id(definition.builder) in seen_builders
+ ):
+ continue
+ seen_builders.add(id(definition.builder))
+ previous = by_id.get(definition.summary_id)
+ if previous is not None:
+ raise ValueError(
+ f"Duplicate summary id {definition.summary_id!r}: "
+ f"{previous.builder.__module__}.{previous.builder.__name__} and "
+ f"{definition.builder.__module__}.{definition.builder.__name__}."
+ )
+ by_id[definition.summary_id] = definition
+ definitions.append(definition)
+ return tuple(definitions)
+
+
+SUMMARY_DEFINITIONS = build_summary_catalog()
+SUMMARY_BY_ID = {
+ definition.summary_id: definition for definition in SUMMARY_DEFINITIONS
+}
+SUMMARY_FILENAME_BY_ID = {
+ definition.summary_id: f"{definition.filename}.csv"
+ for definition in SUMMARY_DEFINITIONS
+}
+DEFAULT_SUMMARY_IDS = [
+ definition.summary_id
+ for definition in SUMMARY_DEFINITIONS
+ if definition.build_by_default
+]
+
+__all__ = [
+ "DEFAULT_SUMMARY_IDS",
+ "SUMMARY_BY_ID",
+ "SUMMARY_DEFINITIONS",
+ "SUMMARY_FILENAME_BY_ID",
+ "SUMMARY_MODULES",
+ "build_summary_catalog",
+]
diff --git a/processor/summarize/contracts.py b/processor/summarize/contracts.py
index 5e92f2e..1f5d287 100644
--- a/processor/summarize/contracts.py
+++ b/processor/summarize/contracts.py
@@ -1,8 +1,9 @@
-"""Builder contract helpers for resilient summary execution."""
+"""Single-declaration contracts for summary builders."""
from __future__ import annotations
from dataclasses import dataclass, field
+from functools import wraps
from typing import Callable, Mapping
import polars as pl
@@ -12,9 +13,13 @@
SummarySchema = Mapping[str, pl.DataType]
+class SummaryResultError(ValueError):
+ """Raised when a builder violates its declared output contract."""
+
+
@dataclass(frozen=True)
class SummaryContract:
- """Static contract metadata attached to one summary builder."""
+ """Input and output shape for one summary builder."""
schema: SummarySchema
required_tables: tuple[str, ...] = ()
@@ -22,17 +27,30 @@ class SummaryContract:
@property
def columns(self) -> tuple[str, ...]:
- return tuple(self.schema.keys())
+ return tuple(self.schema)
+
+
+@dataclass(frozen=True)
+class SummaryDefinition:
+ """Complete registry metadata declared beside one builder."""
+ summary_id: str
+ filename: str
+ builder: Callable
+ contract: SummaryContract
+ build_by_default: bool = True
-def summary_contract(
+ def empty(self) -> pl.DataFrame:
+ return pl.DataFrame(schema=dict(self.contract.schema))
+
+
+def _contract(
*,
schema: SummarySchema,
- required_tables: tuple[str, ...] = (),
- required_columns: dict[str, tuple[str, ...]] | None = None,
-) -> Callable[[Callable], Callable]:
- """Attach output-shape and prerequisite metadata to a builder."""
- contract = SummaryContract(
+ required_tables: tuple[str, ...],
+ required_columns: dict[str, tuple[str, ...]] | None,
+) -> SummaryContract:
+ return SummaryContract(
schema=dict(schema),
required_tables=tuple(required_tables),
required_columns={
@@ -41,44 +59,137 @@ def summary_contract(
},
)
+
+def validate_summary_result(
+ definition: SummaryDefinition,
+ result: object,
+) -> pl.DataFrame:
+ """Validate a successful builder result without silently reshaping it."""
+ summary_id = definition.summary_id
+ if not isinstance(result, pl.DataFrame):
+ raise SummaryResultError(
+ f"Summary {summary_id!r} returned {type(result).__name__}; expected polars.DataFrame."
+ )
+
+ expected_columns = list(definition.contract.schema)
+ actual_columns = result.columns
+ missing = [column for column in expected_columns if column not in actual_columns]
+ unexpected = [column for column in actual_columns if column not in expected_columns]
+ if missing or unexpected:
+ details = []
+ if missing:
+ details.append("missing columns: " + ", ".join(missing))
+ if unexpected:
+ details.append("unexpected columns: " + ", ".join(unexpected))
+ raise SummaryResultError(
+ f"Summary {summary_id!r} returned an invalid schema ({'; '.join(details)})."
+ )
+ if actual_columns != expected_columns:
+ raise SummaryResultError(
+ f"Summary {summary_id!r} returned columns in the wrong order; "
+ f"expected {expected_columns!r}, got {actual_columns!r}."
+ )
+
+ dtype_errors = [
+ f"{column}: expected {expected}, got {result.schema[column]}"
+ for column, expected in definition.contract.schema.items()
+ if result.schema[column] != expected
+ ]
+ if dtype_errors:
+ raise SummaryResultError(
+ f"Summary {summary_id!r} returned invalid dtypes ("
+ + "; ".join(dtype_errors)
+ + ")."
+ )
+ return result
+
+
+def summary(
+ *,
+ id: str | None = None,
+ schema: SummarySchema,
+ filename: str | None = None,
+ build_by_default: bool = True,
+ required_tables: tuple[str, ...] = (),
+ required_columns: dict[str, tuple[str, ...]] | None = None,
+) -> Callable[[Callable], Callable]:
+ """Declare identity, prerequisites, and result schema in one place."""
+ contract = _contract(
+ schema=schema,
+ required_tables=required_tables,
+ required_columns=required_columns,
+ )
+
def decorator(builder: Callable) -> Callable:
- setattr(builder, "_summary_contract", contract)
+ summary_id = str(id or builder.__name__)
+
+ @wraps(builder)
+ def checked(*args, **kwargs):
+ run = args[0] if args else kwargs.get("rd") or kwargs.get("run")
+ if isinstance(run, RunData) and missing_summary_inputs(checked, run):
+ return checked.empty()
+ return validate_summary_result(
+ checked.summary_definition,
+ builder(*args, **kwargs),
+ )
+
+ definition = SummaryDefinition(
+ summary_id=summary_id,
+ filename=str(filename or summary_id),
+ builder=checked,
+ contract=contract,
+ build_by_default=bool(build_by_default),
+ )
+ checked.summary_definition = definition
+ checked.empty = definition.empty
+ return checked
+
+ return decorator
+
+
+def output_schema(*, schema: SummarySchema) -> Callable[[Callable], Callable]:
+ """Attach a typed-empty schema to a non-registry helper."""
+ contract = _contract(schema=schema, required_tables=(), required_columns=None)
+
+ def decorator(builder: Callable) -> Callable:
+ builder._output_contract = contract
+ builder.empty = lambda: pl.DataFrame(schema=dict(contract.schema))
return builder
return decorator
+def get_summary_definition(builder: Callable) -> SummaryDefinition | None:
+ definition = getattr(builder, "summary_definition", None)
+ return definition if isinstance(definition, SummaryDefinition) else None
+
+
def get_summary_contract(builder: Callable) -> SummaryContract | None:
- """Return the contract attached to ``builder`` when present."""
- contract = getattr(builder, "_summary_contract", None)
+ definition = get_summary_definition(builder)
+ if definition is not None:
+ return definition.contract
+ contract = getattr(builder, "_output_contract", None)
return contract if isinstance(contract, SummaryContract) else None
-def empty_summary_frame(builder: Callable) -> pl.DataFrame:
- """Return the typed empty fallback frame for ``builder``."""
+def empty_summary_frame(builder: Callable | SummaryDefinition) -> pl.DataFrame:
+ """Return a typed empty frame for framework and diagnostic code."""
+ if isinstance(builder, SummaryDefinition):
+ return builder.empty()
+ definition = get_summary_definition(builder)
+ if definition is not None:
+ return definition.empty()
contract = get_summary_contract(builder)
- if contract is None:
- return pl.DataFrame()
- return pl.DataFrame(schema=dict(contract.schema))
+ return pl.DataFrame(schema=dict(contract.schema)) if contract else pl.DataFrame()
def summary_output_columns(builder: Callable) -> tuple[str, ...]:
- """Return the ordered output columns declared by ``builder``."""
contract = get_summary_contract(builder)
- if contract is None:
- return ()
- return contract.columns
-
+ return contract.columns if contract else ()
-def missing_summary_inputs(
- builder: Callable,
- rd: RunData,
-) -> dict[str, str]:
- """Return missing-input diagnostics for ``builder``.
- Contracts only express safe, mechanical preflight checks.
- Builders may still apply more nuanced domain-specific validation.
- """
+def missing_summary_inputs(builder: Callable, rd: RunData) -> dict[str, str]:
+ """Return mechanical prerequisite failures for one builder and run."""
contract = get_summary_contract(builder)
if contract is None:
return {}
@@ -89,9 +200,7 @@ def missing_summary_inputs(
if rd.skim_matrix is None:
missing[table_name] = "required skim data is unavailable"
continue
-
- table = getattr(rd, table_name, None)
- if table is None:
+ if getattr(rd, table_name, None) is None:
missing[table_name] = "required table is unavailable"
for table_name, required_columns in contract.required_columns.items():
@@ -99,28 +208,28 @@ def missing_summary_inputs(
if rd.skim_matrix is None:
missing[table_name] = "required skim data is unavailable"
continue
-
table = getattr(rd, table_name, None)
if table is None:
missing[table_name] = "required table is unavailable"
continue
-
- missing_columns = [
- column for column in required_columns if column not in table.columns
- ]
- if missing_columns:
+ absent = [column for column in required_columns if column not in table.columns]
+ if absent:
missing[table_name] = "missing required columns: " + ", ".join(
- sorted(missing_columns)
+ sorted(absent)
)
-
return missing
__all__ = [
"SummaryContract",
+ "SummaryDefinition",
+ "SummaryResultError",
"empty_summary_frame",
"get_summary_contract",
+ "get_summary_definition",
"missing_summary_inputs",
- "summary_contract",
+ "output_schema",
+ "summary",
"summary_output_columns",
+ "validate_summary_result",
]
diff --git a/processor/summarize/csv_export.py b/processor/summarize/csv_export.py
new file mode 100644
index 0000000..561776b
--- /dev/null
+++ b/processor/summarize/csv_export.py
@@ -0,0 +1,42 @@
+"""Supported CSV export boundary for summary calibration tables."""
+
+from collections.abc import Mapping
+from pathlib import Path
+
+from runtime.logging import get_logger
+import polars as pl
+
+LOGGER = get_logger("processor.summarize.csv_export")
+
+
+def write_summary_csvs(
+ summaries: Mapping[str, pl.DataFrame], output_dir: str | Path
+) -> None:
+ """
+ Write named summary DataFrames to the supported calibration CSV layout.
+
+ Args:
+ summaries: mapping from a plain filename stem to a DataFrame
+ output_dir: directory to write files to
+
+ Raises:
+ TypeError: if a summary value is not a Polars DataFrame
+ ValueError: if a filename stem is empty, includes a directory, or has a suffix
+ """
+ output_dir = Path(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ for name, df in summaries.items():
+ if not isinstance(name, str) or not name.strip():
+ raise ValueError("Summary CSV names must be non-empty strings.")
+ if Path(name).name != name or Path(name).suffix:
+ raise ValueError(
+ f"Summary CSV name {name!r} must be a plain filename without a suffix."
+ )
+ if not isinstance(df, pl.DataFrame):
+ raise TypeError(
+ f"Summary CSV {name!r} must be a Polars DataFrame, got {type(df).__name__}."
+ )
+ path = output_dir / f"{name}.csv"
+ df.write_csv(path)
+ LOGGER.info("Written: %s", path)
diff --git a/processor/summarize/external.py b/processor/summarize/external.py
new file mode 100644
index 0000000..9478a62
--- /dev/null
+++ b/processor/summarize/external.py
@@ -0,0 +1,201 @@
+"""Load user-supplied dashboard-ready summary tables."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import polars as pl
+
+from processor.cache_identity import file_identity
+from processor.summarize.cache_types import SummaryRun, create_summary_run
+from processor.summarize.validation_derived import apply_validation_derived_summaries
+from processor.summarize.catalog import SUMMARY_BY_ID
+from runtime.config import Config
+
+
+def summary_table_map_identity(
+ summary_table_map: dict[str, str] | None,
+) -> dict[str, object] | None:
+ """Return stable identity metadata for user-supplied summary files."""
+ if not summary_table_map:
+ return None
+ normalized = dict(sorted(summary_table_map.items()))
+ missing_paths = [
+ f"{summary_id}: {path}"
+ for summary_id, path in normalized.items()
+ if not Path(path).exists()
+ ]
+ if missing_paths:
+ raise ValueError(
+ "summary_table_map references missing files: " + "; ".join(missing_paths)
+ )
+ return {
+ "summary_table_map": normalized,
+ "summary_table_fingerprints": {
+ summary_id: file_identity(path) for summary_id, path in normalized.items()
+ },
+ }
+
+
+def validate_summary_table_map_ids(
+ summary_table_map: dict[str, str] | None,
+ *,
+ field_name: str = "summary_table_map",
+) -> None:
+ """Reject user-supplied mappings for unknown summary ids."""
+ unknown_ids = [
+ summary_id
+ for summary_id in sorted(summary_table_map or {})
+ if summary_id not in SUMMARY_BY_ID
+ ]
+ if unknown_ids:
+ raise ValueError(
+ f"{field_name} contains unsupported summary ids: "
+ + ", ".join(repr(summary_id) for summary_id in unknown_ids)
+ )
+
+
+def _read_summary_table(path: str | Path) -> pl.DataFrame:
+ resolved = Path(path)
+ suffix = resolved.suffix.lower()
+ if suffix == ".csv":
+ return pl.read_csv(resolved, infer_schema_length=10000)
+ if suffix == ".parquet":
+ return pl.read_parquet(resolved)
+ raise ValueError(f"Summary table path must end with '.csv' or '.parquet': {path}")
+
+
+def _canonical_summary_table(summary_id: str, table: pl.DataFrame) -> pl.DataFrame:
+ contract = SUMMARY_BY_ID[summary_id].contract
+ missing_columns = [
+ column for column in contract.columns if column not in table.columns
+ ]
+ if missing_columns:
+ raise ValueError(
+ f"summary_table_map[{summary_id!r}] is missing required columns: "
+ + ", ".join(sorted(missing_columns))
+ )
+ unexpected_columns = [
+ column for column in table.columns if column not in contract.columns
+ ]
+ if unexpected_columns:
+ raise ValueError(
+ f"summary_table_map[{summary_id!r}] has unexpected columns: "
+ + ", ".join(sorted(unexpected_columns))
+ )
+ try:
+ return table.cast(dict(contract.schema)).select(contract.columns)
+ except (pl.exceptions.InvalidOperationError, pl.exceptions.ComputeError) as exc:
+ raise ValueError(
+ f"summary_table_map[{summary_id!r}] cannot be cast to its declared schema: {exc}"
+ ) from exc
+
+
+def load_summary_table_map(
+ *,
+ summary_table_map: dict[str, str],
+ label: str,
+ run_key: str,
+ config: Config,
+ source_run_dir: str | None = None,
+) -> SummaryRun:
+ """Load one run's mapped summary files into a full-segment ``SummaryRun``."""
+ validate_summary_table_map_ids(summary_table_map)
+ rejected_modes = [
+ definition.mode_id
+ for definition in config.weighting_mode_definitions
+ if definition.external_summary_policy == "reject"
+ ]
+ if rejected_modes:
+ raise ValueError(
+ "summary_table_map cannot be copied into weighting modes that reject "
+ "mode-independent outside summaries: "
+ + ", ".join(repr(mode) for mode in rejected_modes)
+ )
+ tables: dict[str, pl.DataFrame] = {}
+ metadata: dict[str, dict[str, object]] = {}
+ for summary_id, path in sorted(summary_table_map.items()):
+ table_path = Path(path)
+ if not table_path.exists():
+ raise ValueError(
+ f"summary_table_map[{summary_id!r}] does not exist: {path}"
+ )
+ table = _read_summary_table(table_path)
+ table = _canonical_summary_table(summary_id, table)
+ tables[summary_id] = table
+ metadata[summary_id] = {
+ "state": "empty" if table.is_empty() else "available",
+ "source": "summary_table_map",
+ "source_file": str(table_path.resolve()),
+ }
+
+ summaries_by_mode = {mode: dict(tables) for mode in config.weighting_modes}
+ metadata_by_mode = {mode: dict(metadata) for mode in config.weighting_modes}
+ return create_summary_run(
+ label=label,
+ run_key=run_key,
+ summaries_by_mode=summaries_by_mode,
+ summary_metadata_by_mode=metadata_by_mode,
+ source_run_dir=source_run_dir,
+ )
+
+
+def merge_summary_table_map_run(
+ base_runs: list[SummaryRun],
+ external_run: SummaryRun | None,
+) -> list[SummaryRun]:
+ """Overlay user-supplied run-level summaries onto generated/cache summaries."""
+ if external_run is None:
+ return apply_validation_derived_summaries(base_runs)
+ if not base_runs:
+ return apply_validation_derived_summaries([external_run])
+
+ merged: list[SummaryRun] = []
+ overlaid = False
+ for base_run in base_runs:
+ if base_run.run_key != external_run.run_key:
+ merged.append(base_run)
+ continue
+ summaries_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
+ metadata_by_mode: dict[str, dict[str, dict[str, object]]] = {}
+ for mode, base_tables in base_run.summaries_by_mode.items():
+ external_tables = external_run.summaries_by_mode.get(mode, {})
+ summaries_by_mode[mode] = {**base_tables, **external_tables}
+ metadata_by_mode[mode] = {
+ **base_run.summary_metadata_by_mode.get(mode, {}),
+ **external_run.summary_metadata_by_mode.get(mode, {}),
+ }
+ merged.append(
+ create_summary_run(
+ label=base_run.label,
+ run_key=base_run.run_key,
+ summaries_by_mode=summaries_by_mode,
+ summary_metadata_by_mode=metadata_by_mode,
+ segmentation_type=base_run.segmentation_type,
+ segment_id=base_run.segment_id,
+ segment_label=base_run.segment_label,
+ is_full_segment=base_run.is_full_segment,
+ segment_source_type=base_run.segment_source_type,
+ segment_column=base_run.segment_column,
+ segment_values=base_run.segment_values,
+ segment_source_table=base_run.segment_source_table,
+ segment_source_key_column=base_run.segment_source_key_column,
+ segment_csv_file=base_run.segment_csv_file,
+ segment_csv_key_column=base_run.segment_csv_key_column,
+ segment_csv_value_column=base_run.segment_csv_value_column,
+ source_run_dir=base_run.source_run_dir,
+ manifest=base_run.manifest,
+ )
+ )
+ overlaid = True
+ if not overlaid:
+ merged.append(external_run)
+ return apply_validation_derived_summaries(merged)
+
+
+__all__ = [
+ "load_summary_table_map",
+ "merge_summary_table_map_run",
+ "summary_table_map_identity",
+ "validate_summary_table_map_ids",
+]
diff --git a/processor/summarize/schema.py b/processor/summarize/schema.py
index c8c79e5..5be7da6 100644
--- a/processor/summarize/schema.py
+++ b/processor/summarize/schema.py
@@ -2,16 +2,14 @@
from __future__ import annotations
-from processor.summarize.contracts import get_summary_contract
-from processor.summarize.summary_specs import SUMMARY_SPECS
+from processor.summarize.catalog import SUMMARY_DEFINITIONS
def _columns_for(summary_id: str) -> tuple[str, ...]:
- for spec in SUMMARY_SPECS:
+ for spec in SUMMARY_DEFINITIONS:
if spec.summary_id != summary_id:
continue
- contract = get_summary_contract(spec.builder)
- return tuple(contract.schema.keys()) if contract is not None else ()
+ return tuple(spec.contract.schema)
return ()
@@ -25,8 +23,8 @@ def _columns_for(summary_id: str) -> tuple[str, ...]:
"trip_mode_by_tour_purpose_and_tour_mode",
"tour_mode_by_tour_purpose_and_auto_sufficiency",
"tour_time_of_day_by_tour_purpose",
- "destination_distance",
- "destination_average_distance",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
)
}
diff --git a/processor/summarize/summaries/daily_travel.py b/processor/summarize/summaries/daily_travel.py
deleted file mode 100644
index bb2763f..0000000
--- a/processor/summarize/summaries/daily_travel.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Daily travel summaries grouped by activity and escort themes."""
-
-from processor.summarize.summaries.daily_travel_activity import (
- dap_summary,
- indiv_nm_summary,
- mandatory_tour_freq,
- tour_rate_per_person,
- trip_rate_per_person,
-)
-from processor.summarize.summaries.daily_travel_escort import (
- adult_escort_event_stop_distribution,
- adult_escort_trip_stop_frequency,
- adult_escorted_tour_distance_distribution_by_direction,
- adult_escorted_tour_purposes_by_direction,
- adult_escorted_tours_by_person_type_and_direction,
- adult_escorted_trip_distance_distribution_by_direction,
- escorted_tours_to_from_school,
- households_with_school_escorting_by_student_count_and_direction,
- schoolkids_per_escorted_tour_by_student_count_and_direction,
- student_households_by_student_count,
- student_school_escort_status_by_direction,
- total_escorted_tours,
-)
-
-__all__ = [
- "adult_escort_event_stop_distribution",
- "adult_escort_trip_stop_frequency",
- "adult_escorted_tour_distance_distribution_by_direction",
- "adult_escorted_tour_purposes_by_direction",
- "adult_escorted_tours_by_person_type_and_direction",
- "adult_escorted_trip_distance_distribution_by_direction",
- "dap_summary",
- "escorted_tours_to_from_school",
- "households_with_school_escorting_by_student_count_and_direction",
- "indiv_nm_summary",
- "mandatory_tour_freq",
- "schoolkids_per_escorted_tour_by_student_count_and_direction",
- "student_households_by_student_count",
- "student_school_escort_status_by_direction",
- "total_escorted_tours",
- "tour_rate_per_person",
- "trip_rate_per_person",
-]
diff --git a/processor/summarize/summaries/daily_travel_activity.py b/processor/summarize/summaries/daily_travel_activity.py
index fd1c7cb..722be56 100644
--- a/processor/summarize/summaries/daily_travel_activity.py
+++ b/processor/summarize/summaries/daily_travel_activity.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import (
_all_person_types_rollup,
_summary_purpose_column,
@@ -13,7 +13,8 @@
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="daily_activity_pattern_by_person_type",
schema={
"person_type": pl.Utf8,
"daily_activity_pattern": pl.Utf8,
@@ -24,7 +25,7 @@
def dap_summary(rd: RunData, config: Config) -> pl.DataFrame:
"""DAP by person type. Columns: person_type, daily_activity_pattern, person_count"""
if "person_type" not in rd.per.columns or "cdap_activity" not in rd.per.columns:
- return empty_summary_frame(dap_summary)
+ return dap_summary.empty()
df = (
rd.per.filter(pl.col("cdap_activity").is_not_null())
@@ -49,7 +50,8 @@ def dap_summary(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="mandatory_tour_frequency_by_person_type",
schema={
"person_type": pl.Utf8,
"mandatory_tour_frequency": pl.Int32,
@@ -60,7 +62,7 @@ def dap_summary(rd: RunData, config: Config) -> pl.DataFrame:
def mandatory_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
"""Returns DataFrame: person_type, mandatory_tour_frequency, person_count."""
if "person_type" not in rd.per.columns or "imf_choice" not in rd.per.columns:
- return empty_summary_frame(mandatory_tour_freq)
+ return mandatory_tour_freq.empty()
df = (
rd.per.filter(pl.col("imf_choice") > 0)
@@ -82,7 +84,8 @@ def mandatory_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="nonmandatory_tour_frequency_by_person_type",
schema={
"person_type": pl.Utf8,
"nonmandatory_tour_frequency": pl.Utf8,
@@ -98,11 +101,11 @@ def indiv_nm_summary(rd: RunData, config: Config) -> pl.DataFrame:
"""Returns DataFrame: person_type, nonmandatory_tour_frequency, person_count."""
per = rd.per
if "person_type" not in per.columns:
- return empty_summary_frame(indiv_nm_summary)
+ return indiv_nm_summary.empty()
if "tour_category" in rd.tours.columns:
inm_counts = (
- rd.tours.filter(pl.col("tour_category") == "non-mandatory")
+ rd.tours.filter(pl.col("tour_category") == "non_mandatory")
.group_by("person_id")
.agg(pl.len().alias("inmTours"))
)
@@ -156,7 +159,8 @@ def indiv_nm_summary(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="tour_rates_by_person_type_and_tour_purpose",
schema={
"person_type": pl.Utf8,
"tour_purpose": pl.Utf8,
@@ -174,11 +178,11 @@ def tour_rate_per_person(rd: RunData, config: Config) -> pl.DataFrame:
if not person_required.issubset(set(rd.per.columns)) or not tour_required.issubset(
set(rd.tours.columns)
):
- return empty_summary_frame(tour_rate_per_person)
+ return tour_rate_per_person.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(tour_rate_per_person)
+ return tour_rate_per_person.empty()
weighted_person_days = (
rd.per.filter(
@@ -258,7 +262,8 @@ def tour_rate_per_person(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="trip_rates_by_person_type_and_trip_purpose",
schema={
"person_type": pl.Utf8,
"trip_purpose": pl.Utf8,
@@ -276,7 +281,7 @@ def trip_rate_per_person(rd: RunData, config: Config) -> pl.DataFrame:
if not person_required.issubset(set(rd.per.columns)) or not trip_required.issubset(
set(rd.trips.columns)
):
- return empty_summary_frame(trip_rate_per_person)
+ return trip_rate_per_person.empty()
person_totals = (
rd.per.filter(pl.col("person_type").is_not_null())
diff --git a/processor/summarize/summaries/daily_travel_escort.py b/processor/summarize/summaries/daily_travel_escort.py
deleted file mode 100644
index 1217f7c..0000000
--- a/processor/summarize/summaries/daily_travel_escort.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Escort daily travel summary facade."""
-
-from processor.summarize.summaries.daily_travel_escort_counts import (
- adult_escorted_tour_purposes_by_direction,
- adult_escorted_tours_by_person_type_and_direction,
- escorted_tours_to_from_school,
- households_with_school_escorting_by_student_count_and_direction,
- schoolkids_per_escorted_tour_by_student_count_and_direction,
- student_households_by_student_count,
- student_school_escort_status_by_direction,
- total_escorted_tours,
-)
-from processor.summarize.summaries.daily_travel_escort_distributions import (
- adult_escort_event_stop_distribution,
- adult_escort_trip_stop_frequency,
- adult_escorted_tour_distance_distribution_by_direction,
- adult_escorted_trip_distance_distribution_by_direction,
-)
-
-__all__ = [
- "adult_escort_event_stop_distribution",
- "adult_escort_trip_stop_frequency",
- "adult_escorted_tour_distance_distribution_by_direction",
- "adult_escorted_tour_purposes_by_direction",
- "adult_escorted_tours_by_person_type_and_direction",
- "adult_escorted_trip_distance_distribution_by_direction",
- "escorted_tours_to_from_school",
- "households_with_school_escorting_by_student_count_and_direction",
- "schoolkids_per_escorted_tour_by_student_count_and_direction",
- "student_households_by_student_count",
- "student_school_escort_status_by_direction",
- "total_escorted_tours",
-]
diff --git a/processor/summarize/summaries/daily_travel_escort_counts.py b/processor/summarize/summaries/daily_travel_escort_counts.py
index 17f84de..b8d9f0b 100644
--- a/processor/summarize/summaries/daily_travel_escort_counts.py
+++ b/processor/summarize/summaries/daily_travel_escort_counts.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.daily_travel_escort_shared import (
_adult_escorted_tours_with_household,
_adult_side_escorted_tours,
@@ -20,7 +20,8 @@
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="escorted_tour_totals",
schema={"tour_count": pl.Float64},
required_columns={
"tours": ("school_esc_outbound", "school_esc_inbound", "finalweight")
@@ -40,7 +41,8 @@ def total_escorted_tours(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="school_escorted_tours_by_escort_type_and_direction",
schema={"escort_type": pl.Utf8, "direction": pl.Utf8, "tour_count": pl.Float64},
required_columns={
"tours": (
@@ -59,11 +61,11 @@ def escorted_tours_to_from_school(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(escorted_tours_to_from_school)
+ return escorted_tours_to_from_school.empty()
school_tours = _adult_side_escorted_tours(rd)
if school_tours.is_empty():
- return empty_summary_frame(escorted_tours_to_from_school)
+ return escorted_tours_to_from_school.empty()
outbound = (
school_tours.filter(_escort_label_present("school_esc_outbound"))
@@ -110,7 +112,8 @@ def escorted_tours_to_from_school(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="adult_escorted_tour_purposes_by_direction",
schema={"tour_purpose": pl.Utf8, "direction": pl.Utf8, "tour_count": pl.Float64},
required_columns={
"tours": (
@@ -126,19 +129,20 @@ def adult_escorted_tour_purposes_by_direction(
) -> pl.DataFrame:
required = {"school_esc_outbound", "school_esc_inbound", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(adult_escorted_tour_purposes_by_direction)
+ return adult_escorted_tour_purposes_by_direction.empty()
escorted = _adult_side_escorted_tours(rd)
if escorted.is_empty():
- return empty_summary_frame(adult_escorted_tour_purposes_by_direction)
+ return adult_escorted_tour_purposes_by_direction.empty()
purpose_col = _summary_purpose_column(escorted)
if not purpose_col:
- return empty_summary_frame(adult_escorted_tour_purposes_by_direction)
+ return adult_escorted_tour_purposes_by_direction.empty()
outbound = (
escorted.filter(
- _escort_label_present("school_esc_outbound") & pl.col(purpose_col).is_not_null()
+ _escort_label_present("school_esc_outbound")
+ & pl.col(purpose_col).is_not_null()
)
.group_by(purpose_col)
.agg(tour_count=pl.col("finalweight").sum())
@@ -147,7 +151,8 @@ def adult_escorted_tour_purposes_by_direction(
)
inbound = (
escorted.filter(
- _escort_label_present("school_esc_inbound") & pl.col(purpose_col).is_not_null()
+ _escort_label_present("school_esc_inbound")
+ & pl.col(purpose_col).is_not_null()
)
.group_by(purpose_col)
.agg(tour_count=pl.col("finalweight").sum())
@@ -197,7 +202,8 @@ def adult_escorted_tour_purposes_by_direction(
)
-@summary_contract(
+@summary(
+ id="adult_escorted_tours_by_person_type_and_direction",
schema={"person_type": pl.Utf8, "direction": pl.Utf8, "tour_count": pl.Float64},
required_columns={
"tours": ("school_esc_outbound", "school_esc_inbound", "finalweight")
@@ -208,17 +214,17 @@ def adult_escorted_tours_by_person_type_and_direction(
) -> pl.DataFrame:
required = {"school_esc_outbound", "school_esc_inbound", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(adult_escorted_tours_by_person_type_and_direction)
+ return adult_escorted_tours_by_person_type_and_direction.empty()
escorted = _adult_side_escorted_tours(rd)
if escorted.is_empty() or "person_type" not in escorted.columns:
- return empty_summary_frame(adult_escorted_tours_by_person_type_and_direction)
+ return adult_escorted_tours_by_person_type_and_direction.empty()
escorted = escorted.filter(pl.col("person_type").is_not_null()).with_columns(
pl.col("person_type").cast(pl.Utf8)
)
if escorted.is_empty():
- return empty_summary_frame(adult_escorted_tours_by_person_type_and_direction)
+ return adult_escorted_tours_by_person_type_and_direction.empty()
outbound = (
escorted.filter(_escort_label_present("school_esc_outbound"))
@@ -251,7 +257,8 @@ def adult_escorted_tours_by_person_type_and_direction(
)
-@summary_contract(
+@summary(
+ id="student_school_escort_status_by_direction",
schema={"direction": pl.Utf8, "escort_type": pl.Utf8, "tour_count": pl.Float64},
required_columns={
"tours": (
@@ -267,11 +274,11 @@ def student_school_escort_status_by_direction(
) -> pl.DataFrame:
required = {"school_esc_outbound", "school_esc_inbound", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(student_school_escort_status_by_direction)
+ return student_school_escort_status_by_direction.empty()
student_school_tours = _student_school_tours(rd)
if student_school_tours.is_empty():
- return empty_summary_frame(student_school_escort_status_by_direction)
+ return student_school_escort_status_by_direction.empty()
base = student_school_tours.with_columns(
pl.col("finalweight").cast(pl.Float64),
@@ -318,7 +325,8 @@ def student_school_escort_status_by_direction(
)
-@summary_contract(
+@summary(
+ id="student_households_by_student_count",
schema={"student_count": pl.Int64, "household_count": pl.Float64},
required_columns={
"hh": ("household_id", "finalweight"),
@@ -328,7 +336,7 @@ def student_school_escort_status_by_direction(
def student_households_by_student_count(rd: RunData, config: Config) -> pl.DataFrame:
households = _student_households(rd)
if households.is_empty():
- return empty_summary_frame(student_households_by_student_count)
+ return student_households_by_student_count.empty()
return (
households.group_by("student_count")
@@ -341,8 +349,13 @@ def student_households_by_student_count(rd: RunData, config: Config) -> pl.DataF
)
-@summary_contract(
- schema={"student_count": pl.Int64, "direction": pl.Utf8, "household_count": pl.Float64},
+@summary(
+ id="households_with_school_escorting_by_student_count_and_direction",
+ schema={
+ "student_count": pl.Int64,
+ "direction": pl.Utf8,
+ "household_count": pl.Float64,
+ },
required_columns={
"hh": ("household_id", "finalweight"),
"per": ("household_id", "person_type"),
@@ -359,9 +372,7 @@ def households_with_school_escorting_by_student_count_and_direction(
) -> pl.DataFrame:
households = _student_households(rd)
if households.is_empty():
- return empty_summary_frame(
- households_with_school_escorting_by_student_count_and_direction
- )
+ return households_with_school_escorting_by_student_count_and_direction.empty()
student_tours = _student_school_tours_with_household(rd)
direction_frames: list[pl.DataFrame] = []
@@ -427,7 +438,8 @@ def households_with_school_escorting_by_student_count_and_direction(
)
-@summary_contract(
+@summary(
+ id="schoolkids_per_escorted_tour_by_student_count_and_direction",
schema={
"student_count": pl.Int64,
"direction": pl.Utf8,
@@ -437,7 +449,12 @@ def households_with_school_escorting_by_student_count_and_direction(
required_columns={
"hh": ("household_id", "finalweight"),
"per": ("household_id", "person_type"),
- "tours": ("school_esc_outbound", "school_esc_inbound", "num_escortees", "finalweight"),
+ "tours": (
+ "school_esc_outbound",
+ "school_esc_inbound",
+ "num_escortees",
+ "finalweight",
+ ),
},
)
def schoolkids_per_escorted_tour_by_student_count_and_direction(
@@ -445,23 +462,14 @@ def schoolkids_per_escorted_tour_by_student_count_and_direction(
) -> pl.DataFrame:
households = _student_households(rd)
if households.is_empty():
- return empty_summary_frame(
- schoolkids_per_escorted_tour_by_student_count_and_direction
- )
+ return schoolkids_per_escorted_tour_by_student_count_and_direction.empty()
escorted_tours = _adult_escorted_tours_with_household(rd)
if escorted_tours.is_empty():
- return empty_summary_frame(
- schoolkids_per_escorted_tour_by_student_count_and_direction
- )
- if "num_escortees" in escorted_tours.columns:
- num_escortees_col = "num_escortees"
- elif "num_escorted" in escorted_tours.columns:
- num_escortees_col = "num_escorted"
- else:
- return empty_summary_frame(
- schoolkids_per_escorted_tour_by_student_count_and_direction
- )
+ return schoolkids_per_escorted_tour_by_student_count_and_direction.empty()
+ if "num_escortees" not in escorted_tours.columns:
+ return schoolkids_per_escorted_tour_by_student_count_and_direction.empty()
+ num_escortees_col = "num_escortees"
base = (
escorted_tours.filter(pl.col(num_escortees_col).is_not_null())
@@ -477,9 +485,7 @@ def schoolkids_per_escorted_tour_by_student_count_and_direction(
)
)
if base.is_empty():
- return empty_summary_frame(
- schoolkids_per_escorted_tour_by_student_count_and_direction
- )
+ return schoolkids_per_escorted_tour_by_student_count_and_direction.empty()
def _aggregate_direction(direction: str, direction_filter: pl.Expr) -> pl.DataFrame:
filtered = base.filter(direction_filter)
@@ -515,8 +521,12 @@ def _aggregate_direction(direction: str, direction_filter: pl.Expr) -> pl.DataFr
return (
pl.concat(
[
- _aggregate_direction("outbound", _escort_label_present("school_esc_outbound")),
- _aggregate_direction("inbound", _escort_label_present("school_esc_inbound")),
+ _aggregate_direction(
+ "outbound", _escort_label_present("school_esc_outbound")
+ ),
+ _aggregate_direction(
+ "inbound", _escort_label_present("school_esc_inbound")
+ ),
_aggregate_direction(
"both",
_escort_label_present("school_esc_outbound")
diff --git a/processor/summarize/summaries/daily_travel_escort_distributions.py b/processor/summarize/summaries/daily_travel_escort_distributions.py
index 2478a02..abfb1b1 100644
--- a/processor/summarize/summaries/daily_travel_escort_distributions.py
+++ b/processor/summarize/summaries/daily_travel_escort_distributions.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.daily_travel_escort_shared import (
_adult_side_escorted_tours,
_adult_side_explicit_escorted_tours,
@@ -21,10 +21,16 @@
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="adult_escorted_tour_distance_distribution_by_direction",
schema={"distance_bin": pl.Utf8, "direction": pl.Utf8, "tour_count": pl.Float64},
required_columns={
- "tours": ("SKIMDIST", "school_esc_outbound", "school_esc_inbound", "finalweight")
+ "tours": (
+ "SKIMDIST",
+ "school_esc_outbound",
+ "school_esc_inbound",
+ "finalweight",
+ )
},
)
def adult_escorted_tour_distance_distribution_by_direction(
@@ -32,23 +38,17 @@ def adult_escorted_tour_distance_distribution_by_direction(
) -> pl.DataFrame:
required = {"SKIMDIST", "school_esc_outbound", "school_esc_inbound", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(
- adult_escorted_tour_distance_distribution_by_direction
- )
+ return adult_escorted_tour_distance_distribution_by_direction.empty()
escorted = _adult_side_escorted_tours(rd)
if escorted.is_empty():
- return empty_summary_frame(
- adult_escorted_tour_distance_distribution_by_direction
- )
+ return adult_escorted_tour_distance_distribution_by_direction.empty()
base = escorted.filter(pl.col("SKIMDIST").is_not_null()).with_columns(
_rounded_distance_bin_expr("SKIMDIST")
)
if base.is_empty():
- return empty_summary_frame(
- adult_escorted_tour_distance_distribution_by_direction
- )
+ return adult_escorted_tour_distance_distribution_by_direction.empty()
result = pl.concat(
[
@@ -67,10 +67,13 @@ def adult_escorted_tour_distance_distribution_by_direction(
],
how="vertical",
)
- return _sorted_distance_bins(result, direction_col="direction", value_col="tour_count")
+ return _sorted_distance_bins(
+ result, direction_col="direction", value_col="tour_count"
+ )
-@summary_contract(
+@summary(
+ id="adult_escorted_trip_distance_distribution_by_direction",
schema={"distance_bin": pl.Utf8, "direction": pl.Utf8, "trip_count": pl.Float64},
required_columns={
"tours": ("tour_id", "school_esc_outbound", "school_esc_inbound"),
@@ -81,21 +84,15 @@ def adult_escorted_trip_distance_distribution_by_direction(
rd: RunData, config: Config
) -> pl.DataFrame:
if "tour_id" not in rd.tours.columns or "tour_id" not in rd.trips.columns:
- return empty_summary_frame(
- adult_escorted_trip_distance_distribution_by_direction
- )
+ return adult_escorted_trip_distance_distribution_by_direction.empty()
direction_expr = _trip_direction_expr(rd.trips)
if direction_expr is None:
- return empty_summary_frame(
- adult_escorted_trip_distance_distribution_by_direction
- )
+ return adult_escorted_trip_distance_distribution_by_direction.empty()
escorted_tours = _adult_side_explicit_escorted_tours(rd)
if escorted_tours.is_empty():
- return empty_summary_frame(
- adult_escorted_trip_distance_distribution_by_direction
- )
+ return adult_escorted_trip_distance_distribution_by_direction.empty()
escorted_trip_ids = escorted_tours.select(
"tour_id", "school_esc_outbound", "school_esc_inbound"
@@ -106,18 +103,14 @@ def adult_escorted_trip_distance_distribution_by_direction(
or "od_dist" not in trips.columns
or "finalweight" not in trips.columns
):
- return empty_summary_frame(
- adult_escorted_trip_distance_distribution_by_direction
- )
+ return adult_escorted_trip_distance_distribution_by_direction.empty()
base = trips.filter(pl.col("od_dist").is_not_null()).with_columns(
direction_expr,
_rounded_distance_bin_expr("od_dist"),
)
if base.is_empty():
- return empty_summary_frame(
- adult_escorted_trip_distance_distribution_by_direction
- )
+ return adult_escorted_trip_distance_distribution_by_direction.empty()
result = pl.concat(
[
@@ -145,10 +138,13 @@ def adult_escorted_trip_distance_distribution_by_direction(
],
how="vertical",
)
- return _sorted_distance_bins(result, direction_col="direction", value_col="trip_count")
+ return _sorted_distance_bins(
+ result, direction_col="direction", value_col="trip_count"
+ )
-@summary_contract(
+@summary(
+ id="adult_escort_event_stop_distribution",
schema={"segment": pl.Utf8, "stop_count": pl.Int32, "tour_count": pl.Float64},
required_columns={
"tours": ("tour_id", "school_esc_outbound", "school_esc_inbound"),
@@ -163,11 +159,11 @@ def adult_escorted_trip_distance_distribution_by_direction(
)
def adult_escort_event_stop_distribution(rd: RunData, config: Config) -> pl.DataFrame:
if "tour_id" not in rd.tours.columns or "tour_id" not in rd.trips.columns:
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
escorted = _adult_side_explicit_escorted_tours(rd)
if escorted.is_empty():
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
required_trip_cols = {
"tour_id",
@@ -177,14 +173,14 @@ def adult_escort_event_stop_distribution(rd: RunData, config: Config) -> pl.Data
"finalweight",
}
if not required_trip_cols.issubset(set(rd.trips.columns)):
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
escorted_trip_ids = escorted.select(
"tour_id", "school_esc_outbound", "school_esc_inbound"
).unique()
trips = rd.trips.join(escorted_trip_ids, on="tour_id", how="inner")
if trips.is_empty():
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
events = (
trips.filter(pl.col("escort_event_role").is_not_null())
@@ -207,7 +203,7 @@ def adult_escort_event_stop_distribution(rd: RunData, config: Config) -> pl.Data
)
)
if events.is_empty():
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
empty_segment_schema = {
"segment": pl.Utf8,
@@ -245,7 +241,7 @@ def _segment_counts(segment: str, stop_col: str, role: str) -> pl.DataFrame:
how="vertical",
)
if result.is_empty():
- return empty_summary_frame(adult_escort_event_stop_distribution)
+ return adult_escort_event_stop_distribution.empty()
return (
result.with_columns(
@@ -258,7 +254,8 @@ def _segment_counts(segment: str, stop_col: str, role: str) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="adult_escort_trip_stop_frequency",
schema={
"tour_purpose": pl.Utf8,
"outbound_stop_count": pl.Int32,
@@ -288,24 +285,33 @@ def adult_escort_trip_stop_frequency(rd: RunData, config: Config) -> pl.DataFram
"finalweight",
}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(adult_escort_trip_stop_frequency)
+ return adult_escort_trip_stop_frequency.empty()
escorted = _adult_side_escorted_tours(rd)
if escorted.is_empty():
- return empty_summary_frame(adult_escort_trip_stop_frequency)
+ return adult_escort_trip_stop_frequency.empty()
purpose_col = _summary_purpose_column(escorted)
if not purpose_col:
- return empty_summary_frame(adult_escort_trip_stop_frequency)
+ return adult_escort_trip_stop_frequency.empty()
return (
escorted.filter(pl.col(purpose_col).is_not_null())
.with_columns(
[
pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose"),
- pl.col("num_ob_stops").clip(0, 3).cast(pl.Int32).alias("outbound_stop_count"),
- pl.col("num_ib_stops").clip(0, 3).cast(pl.Int32).alias("inbound_stop_count"),
- pl.col("num_tot_stops").clip(0, 6).cast(pl.Int32).alias("total_stop_count"),
+ pl.col("num_ob_stops")
+ .clip(0, 3)
+ .cast(pl.Int32)
+ .alias("outbound_stop_count"),
+ pl.col("num_ib_stops")
+ .clip(0, 3)
+ .cast(pl.Int32)
+ .alias("inbound_stop_count"),
+ pl.col("num_tot_stops")
+ .clip(0, 6)
+ .cast(pl.Int32)
+ .alias("total_stop_count"),
]
)
.group_by(
diff --git a/processor/summarize/summaries/daily_travel_escort_shared.py b/processor/summarize/summaries/daily_travel_escort_shared.py
index 97f777f..ff71630 100644
--- a/processor/summarize/summaries/daily_travel_escort_shared.py
+++ b/processor/summarize/summaries/daily_travel_escort_shared.py
@@ -79,10 +79,7 @@ def _explicit_escort_label_present(column: str) -> pl.Expr:
def _escort_label_present(column: str) -> pl.Expr:
- return (
- pl.col(column).is_not_null()
- & ~_escort_type_matches(column, "not_escorted")
- )
+ return pl.col(column).is_not_null() & ~_escort_type_matches(column, "not_escorted")
def _both_escort_labels_present() -> pl.Expr:
diff --git a/processor/summarize/summaries/demographics.py b/processor/summarize/summaries/demographics.py
index cfe3d7a..efd7b9b 100644
--- a/processor/summarize/summaries/demographics.py
+++ b/processor/summarize/summaries/demographics.py
@@ -3,10 +3,11 @@
import polars as pl
from runtime.config import Config
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
-@summary_contract(
+@summary(
+ id="household_size_distribution",
schema={
"household_size": pl.Int64,
"household_count": pl.Float64,
@@ -19,11 +20,13 @@ def hh_size(rd: RunData, config: Config | None = None) -> pl.DataFrame:
rd.hh.group_by("HHSIZE")
.agg(household_count=pl.col("finalweight").sum())
.rename({"HHSIZE": "household_size"})
+ .with_columns(pl.col("household_size").cast(pl.Int64))
.sort("household_size")
)
-@summary_contract(
+@summary(
+ id="person_type_distribution",
schema={
"person_type": pl.Utf8,
"person_type_label": pl.Utf8,
@@ -34,7 +37,7 @@ def hh_size(rd: RunData, config: Config | None = None) -> pl.DataFrame:
def person_type(rd: RunData, config: Config) -> pl.DataFrame:
"""Returns DataFrame: person_type, person_type_label, person_count."""
if "person_type" not in rd.per.columns:
- return empty_summary_frame(person_type)
+ return person_type.empty()
return (
rd.per.select(
[
@@ -52,11 +55,13 @@ def person_type(rd: RunData, config: Config) -> pl.DataFrame:
)
.alias("person_type_label")
)
+ .select("person_type", "person_type_label", "person_count")
.sort("person_type")
)
-@summary_contract(
+@summary(
+ id="population_totals",
schema={
"person_count": pl.Float64,
"household_count": pl.Float64,
diff --git a/processor/summarize/summaries/joint_travel.py b/processor/summarize/summaries/joint_travel.py
index ea21258..b7a47a6 100644
--- a/processor/summarize/summaries/joint_travel.py
+++ b/processor/summarize/summaries/joint_travel.py
@@ -4,10 +4,11 @@
from runtime.config import Config
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
-@summary_contract(
+@summary(
+ id="jtf_distribution",
schema={
"jtf_code": pl.Int32,
"jtf_label": pl.Utf8,
@@ -152,7 +153,8 @@ def joint_tour_freq(rd: RunData, config: Config | None = None) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="joint_tours_by_household_size",
schema={
"household_size": pl.Int32,
"household_count": pl.Float64,
@@ -172,7 +174,7 @@ def joint_tours_hhsize(rd: RunData, config: Config | None = None) -> pl.DataFram
or "household_id" not in rd.hh.columns
or "finalweight" not in rd.hh.columns
):
- return empty_summary_frame(joint_tours_hhsize)
+ return joint_tours_hhsize.empty()
joint_tour_hhs = (
rd.tours.filter(pl.col("tour_category") == "joint")
@@ -198,7 +200,8 @@ def joint_tours_hhsize(rd: RunData, config: Config | None = None) -> pl.DataFram
)
-@summary_contract(
+@summary(
+ id="joint_tour_party_size_distribution",
schema={
"party_size": pl.Int32,
"joint_tour_count": pl.Float64,
@@ -208,12 +211,12 @@ def joint_tours_hhsize(rd: RunData, config: Config | None = None) -> pl.DataFram
def joint_party_size(rd: RunData, config: Config | None = None) -> pl.DataFrame:
"""Joint tour party size distribution (capped at 5+). Columns: party_size (1-5), joint_tour_count."""
if "tour_category" not in rd.tours.columns or "NUMBER_HH" not in rd.tours.columns:
- return empty_summary_frame(joint_party_size)
+ return joint_party_size.empty()
joint_tours = rd.tours.filter(pl.col("tour_category") == "joint")
if joint_tours.is_empty():
- return empty_summary_frame(joint_party_size)
+ return joint_party_size.empty()
df = (
joint_tours.group_by("NUMBER_HH")
@@ -245,7 +248,8 @@ def joint_party_size(rd: RunData, config: Config | None = None) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="joint_tour_composition_distribution",
schema={
"tour_composition": pl.Utf8,
"joint_tour_count": pl.Float64,
@@ -255,7 +259,7 @@ def joint_party_size(rd: RunData, config: Config | None = None) -> pl.DataFrame:
def joint_composition(rd: RunData, config: Config | None = None) -> pl.DataFrame:
"""Joint tour composition. Columns: tour_composition, joint_tour_count."""
if "tour_category" not in rd.tours.columns:
- return empty_summary_frame(joint_composition)
+ return joint_composition.empty()
joint_tours = rd.tours.filter(pl.col("tour_category") == "joint")
# ActivitySim uses "composition"; fall back to "tour_composition" if somehow renamed
@@ -264,7 +268,7 @@ def joint_composition(rd: RunData, config: Config | None = None) -> pl.DataFrame
)
if comp_col not in joint_tours.columns:
- return empty_summary_frame(joint_composition)
+ return joint_composition.empty()
return (
joint_tours.group_by(comp_col)
@@ -274,7 +278,8 @@ def joint_composition(rd: RunData, config: Config | None = None) -> pl.DataFrame
)
-@summary_contract(
+@summary(
+ id="joint_tour_composition_by_party_size",
schema={
"tour_composition": pl.Utf8,
"party_size": pl.Int64,
@@ -297,7 +302,7 @@ def joint_composition_by_party_size(rd: RunData, config: Config) -> pl.DataFrame
"finalweight",
}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(joint_composition_by_party_size)
+ return joint_composition_by_party_size.empty()
return (
rd.tours.filter(
@@ -325,7 +330,8 @@ def joint_composition_by_party_size(rd: RunData, config: Config) -> pl.DataFrame
)
-@summary_contract(
+@summary(
+ id="person_jtp_by_household_size",
schema={
"household_size": pl.Int64,
"joint_tour_person_count": pl.Float64,
@@ -343,7 +349,7 @@ def joint_participation_person_by_hhsize(rd: RunData, config: Config) -> pl.Data
if not person_required.issubset(set(rd.per.columns)) or not hh_required.issubset(
set(rd.hh.columns)
):
- return empty_summary_frame(joint_participation_person_by_hhsize)
+ return joint_participation_person_by_hhsize.empty()
persons_with_hhsize = rd.per.join(
rd.hh.select("household_id", "hhsize"),
@@ -352,7 +358,7 @@ def joint_participation_person_by_hhsize(rd: RunData, config: Config) -> pl.Data
).filter(pl.col("hhsize").is_not_null())
if persons_with_hhsize.is_empty():
- return empty_summary_frame(joint_participation_person_by_hhsize)
+ return joint_participation_person_by_hhsize.empty()
total_people = persons_with_hhsize.group_by("hhsize").agg(
total_person_weight=pl.col("finalweight").sum()
@@ -384,7 +390,8 @@ def joint_participation_person_by_hhsize(rd: RunData, config: Config) -> pl.Data
)
-@summary_contract(
+@summary(
+ id="household_jtp_by_household_size_and_jtf",
schema={
"jtf": pl.Utf8,
"household_size": pl.Utf8,
@@ -401,7 +408,7 @@ def jtf_by_hhsize(rd: RunData, config: Config | None = None) -> pl.DataFrame:
hh = rd.hh
if "tour_category" not in rd.tours.columns or "HHSIZE" not in hh.columns:
- return empty_summary_frame(jtf_by_hhsize)
+ return jtf_by_hhsize.empty()
joint_tours = rd.tours.filter(pl.col("tour_category") == "joint")
diff --git a/processor/summarize/summaries/legacy.py b/processor/summarize/summaries/legacy.py
deleted file mode 100644
index 99eb7a6..0000000
--- a/processor/summarize/summaries/legacy.py
+++ /dev/null
@@ -1,593 +0,0 @@
-import polars as pl
-from runtime.config import Config
-from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
-
-
-def _tour_purpose_column(tours: pl.DataFrame) -> str | None:
- """Return the canonical prepared tour-purpose column when available."""
- if "tour_purpose" not in tours.columns:
- return None
- if tours["tour_purpose"].dtype.is_numeric():
- return None
- return "tour_purpose"
-
-
-@summary_contract(
- schema={},
- required_columns={"per": ("HGEO", "WGEO", "finalweight")},
-)
-def geo_flows(rd: RunData, config: Config) -> pl.DataFrame:
- """Home-to-work geography flow matrix.
-
- Returns wide DataFrame: row=HGEO, col=WGEO value, plus Total row/col.
- Returns empty DataFrame if geography is not enabled.
- """
- if (
- not config.geography_enabled
- or "HGEO" not in rd.per.columns
- or "WGEO" not in rd.per.columns
- ):
- return pl.DataFrame()
-
- workers = (
- rd.per.filter(
- pl.col("is_worker").cast(pl.Utf8).str.to_lowercase().is_in(["true", "1"])
- )
- if "is_worker" in rd.per.columns
- else rd.per
- )
-
- pivot = (
- workers.filter(pl.col("HGEO").is_not_null() & pl.col("WGEO").is_not_null())
- .group_by(["HGEO", "WGEO"])
- .agg(pl.col("finalweight").sum().alias("n"))
- .pivot(on="WGEO", index="HGEO", values="n", aggregate_function="sum")
- )
-
- if len(pivot) == 0:
- return pl.DataFrame()
-
- geo_cols = [c for c in pivot.columns if c != "HGEO"]
- pivot = pivot.fill_null(0)
- pivot = pivot.with_columns(pl.sum_horizontal(geo_cols).alias("Total"))
-
- # Totals row
- total_vals: dict = {"HGEO": "Total"}
- for col in geo_cols + ["Total"]:
- if col in pivot.columns:
- total_vals[col] = pivot[col].sum()
- pivot = pl.concat([pivot, pl.DataFrame([total_vals])])
- return pivot
-
-
-def _combined_nm_tours(
- rd: RunData,
- config: Config,
- purpose: str | None = None,
-) -> pl.DataFrame:
- tours = rd.tours
- if "tour_category" not in tours.columns:
- return pl.DataFrame({"SKIMDIST": [], "finalweight": []})
- purpose_col = _tour_purpose_column(tours)
-
- indiv = tours.filter(pl.col("tour_category").is_in(["non-mandatory", "atwork"]))
- joint = tours.filter(pl.col("tour_category") == "joint")
- if len(joint) > 0:
- joint = joint.with_columns(
- (pl.col("finalweight") * pl.col("NUMBER_HH").fill_null(1)).alias(
- "finalweight"
- )
- )
-
- if purpose and purpose != "All NM":
- if purpose_col is None:
- return pl.DataFrame({"SKIMDIST": [], "finalweight": []})
- indiv = indiv.filter(pl.col(purpose_col).cast(pl.Utf8) == purpose)
- joint = joint.filter(pl.col(purpose_col).cast(pl.Utf8) == purpose)
-
- parts: list[pl.DataFrame] = []
- for df in (indiv, joint):
- if len(df) > 0 and "SKIMDIST" in df.columns and "finalweight" in df.columns:
- parts.append(df.select(["SKIMDIST", "finalweight"]))
-
- if not parts:
- return pl.DataFrame({"SKIMDIST": [], "finalweight": []})
- return pl.concat(parts)
-
-
-@summary_contract(
- schema={
- "purpose": pl.Utf8,
- "distbin": pl.Int32,
- "freq": pl.Float64,
- },
- required_columns={
- "tours": ("tour_category", "tour_purpose", "SKIMDIST", "finalweight")
- },
-)
-def distance_distribution(rd: RunData, config: Config) -> pl.DataFrame:
- """NM destination distance distribution by purpose.
-
- Columns: purpose, distbin, freq
- """
- tours = rd.tours
- purpose_col = _tour_purpose_column(tours)
- if "tour_category" in tours.columns and purpose_col is not None:
- purposes = sorted(
- tours.filter(
- pl.col("tour_category").is_in(["non-mandatory", "atwork", "joint"])
- )[purpose_col]
- .drop_nulls()
- .cast(pl.Utf8)
- .unique()
- .to_list()
- )
- else:
- return empty_summary_frame(distance_distribution)
-
- labels = ["All NM"] + purposes
- bins = list(range(41))
- rows: list[dict[str, object]] = []
- for purpose in labels:
- combined = _combined_nm_tours(rd, config, purpose)
- if len(combined) > 0 and "SKIMDIST" in combined.columns:
- combined = combined.with_columns(
- pl.col("SKIMDIST").cast(pl.Float64).fill_null(0.0).clip(0, 999.0)
- ).with_columns(
- pl.col("SKIMDIST").cast(pl.Int32).clip(0, 40).alias("distbin")
- )
- counts = combined.group_by("distbin").agg(
- pl.col("finalweight").sum().alias("freq")
- )
- freq_map = {
- int(row["distbin"]): float(row["freq"])
- for row in counts.iter_rows(named=True)
- }
- else:
- freq_map = {}
-
- for distbin in bins:
- rows.append(
- {
- "purpose": purpose,
- "distbin": distbin,
- "freq": freq_map.get(distbin, 0.0),
- }
- )
-
- return pl.DataFrame(
- rows,
- schema={
- "purpose": pl.Utf8,
- "distbin": pl.Int32,
- "freq": pl.Float64,
- },
- )
-
-
-@summary_contract(
- schema={
- "purpose": pl.Utf8,
- "avg_distance": pl.Float64,
- },
- required_columns={
- "tours": ("tour_category", "tour_purpose", "SKIMDIST", "finalweight")
- },
-)
-def average_distance(rd: RunData, config: Config) -> pl.DataFrame:
- """Average NM tour distance by purpose.
-
- Columns: purpose, avg_distance
- """
- tours = rd.tours
- purpose_col = _tour_purpose_column(tours)
- if "tour_category" in tours.columns and purpose_col is not None:
- purposes = sorted(
- tours.filter(
- pl.col("tour_category").is_in(["non-mandatory", "atwork", "joint"])
- )[purpose_col]
- .drop_nulls()
- .cast(pl.Utf8)
- .unique()
- .to_list()
- )
- else:
- return empty_summary_frame(average_distance)
-
- rows: list[dict[str, object]] = []
- for purpose in purposes:
- combined = _combined_nm_tours(rd, config, purpose)
- if len(combined) == 0:
- avg_distance = None
- else:
- valid = combined.filter(
- pl.col("SKIMDIST").is_not_null() & pl.col("finalweight").is_not_null()
- )
- if len(valid) == 0:
- avg_distance = None
- else:
- weights = valid["finalweight"].to_numpy()
- distances = valid["SKIMDIST"].to_numpy()
- total_weight = float(weights.sum())
- avg_distance = (
- float((distances * weights).sum() / total_weight)
- if total_weight > 0
- else None
- )
- rows.append({"purpose": purpose, "avg_distance": avg_distance})
-
- return pl.DataFrame(
- rows,
- schema={"purpose": pl.Utf8, "avg_distance": pl.Float64},
- )
-
-
-@summary_contract(
- schema={
- "ptype": pl.Utf8,
- "tour_purp": pl.Utf8,
- "tour_rate": pl.Float64,
- },
- required_columns={
- "per": ("finalweight",),
- "tours": ("tour_category", "finalweight"),
- },
-)
-def nm_tour_rates(rd: RunData, config: Config) -> pl.DataFrame:
- """NM tour rates per person by person type and purpose.
-
- Columns: ptype, tour_purp, tour_rate.
- """
- ptype_col = "person_type" if "person_type" in rd.per.columns else None
- purpose_col = _tour_purpose_column(rd.tours)
- if (
- "tour_category" not in rd.tours.columns
- or purpose_col is None
- or ptype_col is None
- or ptype_col not in rd.tours.columns
- ):
- return empty_summary_frame(nm_tour_rates)
-
- nm_tours = rd.tours.filter(pl.col("tour_category") == "non-mandatory")
- purposes = (
- nm_tours[purpose_col].drop_nulls().cast(pl.Utf8).unique().sort().to_list()
- )
-
- per_counts = rd.per.group_by(ptype_col).agg(
- pl.col("finalweight").sum().alias("n_per")
- )
- total_per = rd.per["finalweight"].sum()
- ptypes = rd.per[ptype_col].drop_nulls().unique().to_list()
-
- nm_grouped = nm_tours.group_by([ptype_col, purpose_col]).agg(
- pl.col("finalweight").sum().alias("n_tours")
- )
-
- result = []
- for ptype in ptypes:
- n_per_row = per_counts.filter(pl.col(ptype_col) == ptype)["n_per"]
- n_per = float(n_per_row[0]) if len(n_per_row) > 0 else 0
- for purp in purposes:
- n_row = nm_grouped.filter(
- (pl.col(ptype_col) == ptype)
- & (pl.col(purpose_col).cast(pl.Utf8) == purp)
- )["n_tours"]
- n = float(n_row[0]) if len(n_row) > 0 else 0
- result.append(
- {
- "ptype": str(ptype),
- "tour_purp": purp,
- "tour_rate": (n / n_per) if n_per > 0 else 0,
- }
- )
-
- for purp in purposes:
- n_row = nm_grouped.filter(pl.col(purpose_col).cast(pl.Utf8) == purp)["n_tours"]
- n = float(n_row.sum()) if len(n_row) > 0 else 0
- result.append(
- {
- "ptype": "All",
- "tour_purp": purp,
- "tour_rate": (
- (n / float(total_per)) if total_per and total_per > 0 else 0
- ),
- }
- )
-
- return pl.DataFrame(
- result,
- schema={"ptype": pl.Utf8, "tour_purp": pl.Utf8, "tour_rate": pl.Float64},
- )
-
-
-@summary_contract(
- schema={
- "population": pl.Float64,
- "households": pl.Float64,
- "employment": pl.Float64,
- "tours": pl.Float64,
- "trips": pl.Float64,
- "stops": pl.Float64,
- "pmt": pl.Float64,
- "vmt": pl.Float64,
- "vehicle_trips": pl.Float64,
- },
- required_columns={
- "per": ("finalweight",),
- "hh": ("finalweight",),
- "tours": ("finalweight",),
- "trips": ("finalweight", "stops", "od_dist", "num_participants"),
- },
-)
-def system_totals(rd: RunData, config: Config | None = None) -> pl.DataFrame:
- """
- System-wide KPIs. Returns single-row DataFrame with columns:
- population, households, employment, tours, trips, stops,
- pmt, vmt, vehicle_trips.
- """
- pop = rd.per["finalweight"].sum()
- hh = rd.hh["finalweight"].sum()
- emp_col = next(
- (
- c
- for c in [
- "EMPLOYMENT",
- "EMPLOY_TOT",
- "TOTEMP",
- "total_employment",
- "employment",
- ]
- if c in rd.land_use.columns
- ),
- None,
- )
- emp = rd.land_use[emp_col].sum() if emp_col else 0
-
- tours = rd.tours["finalweight"].sum()
- trips_total = rd.trips["finalweight"].sum()
- stops = rd.trips.filter(pl.col("stops") == 1)["finalweight"].sum()
-
- # PMT = sum of od_dist * finalweight for all trips
- pmt = rd.trips.with_columns(
- (pl.col("od_dist") * pl.col("finalweight")).alias("pmt_w")
- )["pmt_w"].sum()
-
- # Identify auto trips via trip_mode string:
- # Use config.mode_groups["Auto"] if defined, else check for common auto mode substrings.
- trips_df = rd.trips
- if "trip_mode" in trips_df.columns:
- auto_modes: list[str] | None = None
- if config is not None and config.mode_groups and "Auto" in config.mode_groups:
- auto_modes = config.mode_groups["Auto"]
-
- if auto_modes is not None:
- auto_filter = pl.col("trip_mode").cast(pl.Utf8).is_in(auto_modes)
- else:
- # Fallback: modes that look like auto (drive alone / shared ride)
- auto_filter = (
- pl.col("trip_mode")
- .cast(pl.Utf8)
- .str.to_uppercase()
- .str.contains("DRIVE|SHARED|SOV|HOV|AUTO")
- )
- auto_trips = trips_df.filter(auto_filter)
- else:
- auto_trips = trips_df.head(0)
-
- vmt = auto_trips.with_columns(
- (
- pl.col("od_dist")
- * pl.col("finalweight")
- / pl.col("num_participants").fill_null(1)
- ).alias("vmt_w")
- )["vmt_w"].sum()
-
- vehicle_trips = auto_trips["finalweight"].sum()
-
- return pl.DataFrame(
- [
- {
- "population": float(pop) if pop is not None else 0.0,
- "households": float(hh) if hh is not None else 0.0,
- "employment": float(emp) if emp is not None else 0.0,
- "tours": float(tours) if tours is not None else 0.0,
- "trips": float(trips_total) if trips_total is not None else 0.0,
- "stops": float(stops) if stops is not None else 0.0,
- "pmt": float(pmt) if pmt is not None else 0.0,
- "vmt": float(vmt) if vmt is not None else 0.0,
- "vehicle_trips": (
- float(vehicle_trips) if vehicle_trips is not None else 0.0
- ),
- }
- ],
- schema={
- "population": pl.Float64,
- "households": pl.Float64,
- "employment": pl.Float64,
- "tours": pl.Float64,
- "trips": pl.Float64,
- "stops": pl.Float64,
- "pmt": pl.Float64,
- "vmt": pl.Float64,
- "vehicle_trips": pl.Float64,
- },
- )
-
-
-def tour_mode_profile(rd: RunData, config: Config) -> pl.DataFrame:
- """Tour mode by auto sufficiency level (0, 1, 2) and total, by tour purpose/category.
-
- Returns DataFrame: tour_mode, purpose_group, freq_as0, freq_as1, freq_as2, freq_all.
- Purpose groups are derived from the config-resolved tour purpose column.
- """
- if "tour_mode" not in rd.tours.columns:
- return empty_summary_frame(grouped_tour_mode_profile)
-
- indiv = (
- rd.tours.filter(
- pl.col("tour_category").is_in(["mandatory", "non-mandatory", "atwork"])
- )
- if "tour_category" in rd.tours.columns
- else rd.tours
- )
- joint = (
- (
- rd.tours.filter(pl.col("tour_category") == "joint").with_columns(
- (pl.col("finalweight") * pl.col("NUMBER_HH")).alias("wgt")
- )
- )
- if "tour_category" in rd.tours.columns
- else rd.tours.head(0)
- )
-
- # Build purpose group filter pairs: (label, df, filter_expr)
- purpose_groups = []
- purpose_col = _tour_purpose_column(rd.tours)
- if purpose_col:
- purposes = (
- indiv[purpose_col].drop_nulls().cast(pl.Utf8).unique().sort().to_list()
- )
- for p in purposes:
- purpose_groups.append((p, indiv, pl.col(purpose_col).cast(pl.Utf8) == p))
- if len(joint) > 0:
- j_purposes = (
- joint[purpose_col].drop_nulls().cast(pl.Utf8).unique().sort().to_list()
- )
- for p in j_purposes:
- purpose_groups.append(
- (f"joint_{p}", joint, pl.col(purpose_col).cast(pl.Utf8) == p)
- )
- else:
- return empty_summary_frame(grouped_tour_mode_profile)
-
- all_modes = rd.tours["tour_mode"].drop_nulls().unique().to_list()
- all_modes = config.ordered_modes(all_modes)
-
- result_rows = []
- for purp_name, df, purp_filter in purpose_groups:
- wgt_col = "wgt" if "wgt" in df.columns else "finalweight"
- for as_val in range(3):
- as_filter = (
- (pl.col("AUTOSUFF") == as_val)
- if "AUTOSUFF" in df.columns
- else pl.lit(True)
- )
- sub = df.filter(purp_filter & as_filter)
- counts = sub.group_by("tour_mode").agg(pl.col(wgt_col).sum().alias("n"))
- for mode in all_modes:
- n_row = counts.filter(pl.col("tour_mode") == mode)["n"]
- n = float(n_row[0]) if len(n_row) > 0 else 0.0
- result_rows.append(
- {
- "tour_mode": mode,
- "purpose": purp_name,
- "autosuff": as_val,
- "freq": n,
- }
- )
-
- if not result_rows:
- return pl.DataFrame()
-
- df_result = pl.DataFrame(
- result_rows,
- schema={
- "tour_mode": pl.Utf8,
- "purpose": pl.Utf8,
- "autosuff": pl.Int32,
- "freq": pl.Float64,
- },
- )
- pivot = df_result.pivot(
- on="autosuff",
- index=["tour_mode", "purpose"],
- values="freq",
- aggregate_function="sum",
- ).fill_null(0)
-
- for as_val in range(3):
- col = str(as_val)
- if col in pivot.columns:
- pivot = pivot.rename({col: f"freq_as{as_val}"})
- for col in ["freq_as0", "freq_as1", "freq_as2"]:
- if col not in pivot.columns:
- pivot = pivot.with_columns(pl.lit(0.0).alias(col))
-
- pivot = pivot.with_columns(
- (pl.col("freq_as0") + pl.col("freq_as1") + pl.col("freq_as2")).alias("freq_all")
- )
-
- cols = ["tour_mode", "purpose", "freq_as0", "freq_as1", "freq_as2", "freq_all"]
- pivot = pivot.select(cols)
- total = (
- pivot.group_by("tour_mode")
- .agg(
- [
- pl.col("freq_as0").sum(),
- pl.col("freq_as1").sum(),
- pl.col("freq_as2").sum(),
- pl.col("freq_all").sum(),
- ]
- )
- .with_columns(pl.lit("Total").alias("purpose"))
- .select(cols)
- )
-
- return pl.concat([pivot, total], how="vertical")
-
-
-@summary_contract(
- schema={
- "mode_group": pl.Utf8,
- "purpose": pl.Utf8,
- "freq_as0": pl.Float64,
- "freq_as1": pl.Float64,
- "freq_as2": pl.Float64,
- "freq_all": pl.Float64,
- },
- required_columns={"tours": ("tour_mode",)},
-)
-def grouped_tour_mode_profile(rd: RunData, config: Config) -> pl.DataFrame:
- """Tour mode grouped by config.mode_groups, by auto sufficiency and purpose.
-
- Returns DataFrame: mode_group, purpose, freq_as0, freq_as1, freq_as2, freq_all.
- Returns empty DataFrame if mode_groups not configured.
- """
- if not config.mode_groups:
- return empty_summary_frame(grouped_tour_mode_profile)
-
- detail = tour_mode_profile(rd, config)
- if len(detail) == 0:
- return empty_summary_frame(grouped_tour_mode_profile)
-
- mode_to_group = {}
- for grp, modes in config.mode_groups.items():
- for m in modes:
- mode_to_group[m] = grp
-
- group_map = pl.DataFrame(
- {
- "tour_mode": list(mode_to_group.keys()),
- "mode_group": list(mode_to_group.values()),
- },
- schema={
- "tour_mode": pl.Utf8,
- "mode_group": pl.Utf8,
- },
- )
-
- result = (
- detail.join(group_map, on="tour_mode", how="left")
- .filter(pl.col("mode_group").is_not_null())
- .group_by(["mode_group", "purpose"])
- .agg(
- [
- pl.col("freq_as0").sum(),
- pl.col("freq_as1").sum(),
- pl.col("freq_as2").sum(),
- pl.col("freq_all").sum(),
- ]
- )
- )
- return result
diff --git a/processor/summarize/summaries/long_term.py b/processor/summarize/summaries/long_term.py
deleted file mode 100644
index cb2f70c..0000000
--- a/processor/summarize/summaries/long_term.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""Long-term summary facade grouped by domain."""
-
-from processor.summarize.summaries.long_term_distance import (
- schl_tlfd,
- tlfd,
- univ_tlfd,
- work_tlfd,
-)
-from processor.summarize.summaries.long_term_geography import (
- commuting_flows,
- external_workplace_loc,
- free_parking,
- internal_vs_external,
- park_and_ride_location_residual_histogram,
- park_and_ride_location_residuals,
- school_shadow_pricing_residual_histogram,
- school_shadow_pricing_residuals,
- school_loc_vs_land_use_enrollment,
- wfh,
- workplace_shadow_pricing_residual_histogram,
- workplace_shadow_pricing_residuals,
- workplace_vs_land_use_employment,
-)
-from processor.summarize.summaries.long_term_person import (
- bicycle_comfort_level,
- license_holding_status,
- telecommute,
- transit_pass,
- transit_subsidy,
-)
-from processor.summarize.summaries.long_term_vehicle import (
- auto_ownership,
- av_ownership,
- vehicle_char_age,
- vehicle_char_body,
- vehicle_char_fuel,
-)
-
-__all__ = [
- "auto_ownership",
- "av_ownership",
- "bicycle_comfort_level",
- "commuting_flows",
- "external_workplace_loc",
- "free_parking",
- "internal_vs_external",
- "license_holding_status",
- "park_and_ride_location_residual_histogram",
- "park_and_ride_location_residuals",
- "school_shadow_pricing_residual_histogram",
- "school_shadow_pricing_residuals",
- "school_loc_vs_land_use_enrollment",
- "schl_tlfd",
- "telecommute",
- "tlfd",
- "transit_pass",
- "transit_subsidy",
- "univ_tlfd",
- "vehicle_char_age",
- "vehicle_char_body",
- "vehicle_char_fuel",
- "wfh",
- "work_tlfd",
- "workplace_shadow_pricing_residual_histogram",
- "workplace_shadow_pricing_residuals",
- "workplace_vs_land_use_employment",
-]
diff --git a/processor/summarize/summaries/long_term_distance.py b/processor/summarize/summaries/long_term_distance.py
index ab5f5e0..f9a1a48 100644
--- a/processor/summarize/summaries/long_term_distance.py
+++ b/processor/summarize/summaries/long_term_distance.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import output_schema, summary
from processor.summarize.summaries.long_term_shared import _student_filter_expr
from processor.summarize.summaries.summary_helpers import (
_configured_geography_columns,
@@ -14,7 +14,7 @@
from runtime.config import Config
-@summary_contract(
+@output_schema(
schema={
"distance_bin": pl.Int32,
"geography_type": pl.Utf8,
@@ -27,11 +27,11 @@ def _bin_dist(df: pl.DataFrame, dist_col: str) -> pl.DataFrame:
return df.with_columns(
pl.col(dist_col).fill_null(0.0).clip(0, 9999)
).with_columns(
- (pl.col(dist_col).cast(pl.Int32) + 1).clip(1, 51).alias("distance_bin")
+ pl.col(dist_col).cast(pl.Int32).clip(0, 51).alias("distance_bin")
)
def _make_tlfd(persons: pl.DataFrame, dist_col: str) -> pl.DataFrame:
- empty = empty_summary_frame(work_tlfd)
+ empty = work_tlfd.empty()
if dist_col not in persons.columns:
return empty
@@ -49,7 +49,7 @@ def _make_tlfd(persons: pl.DataFrame, dist_col: str) -> pl.DataFrame:
dist_col,
)
distance_bins = pl.DataFrame(
- {"distance_bin": list(range(1, 52))}, schema={"distance_bin": pl.Int32}
+ {"distance_bin": list(range(0, 52))}, schema={"distance_bin": pl.Int32}
)
outputs: list[pl.DataFrame] = []
@@ -82,7 +82,9 @@ def _make_tlfd(persons: pl.DataFrame, dist_col: str) -> pl.DataFrame:
pl.lit(geography_type).alias("geography_type"),
pl.col("person_count").fill_null(0.0).cast(pl.Float64),
)
- .select("distance_bin", "geography_type", "geography_id", "person_count")
+ .select(
+ "distance_bin", "geography_type", "geography_id", "person_count"
+ )
)
total = (
@@ -118,7 +120,12 @@ def _make_tlfd(persons: pl.DataFrame, dist_col: str) -> pl.DataFrame:
workers = (
rd.per.filter(
(pl.col("workplace_zone_id") > 0)
- & (pl.col("is_worker").cast(pl.Utf8).str.to_lowercase().is_in(["true", "1"]))
+ & (
+ pl.col("is_worker")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .is_in(["true", "1"])
+ )
)
if "is_worker" in rd.per.columns
else rd.per.head(0)
@@ -154,7 +161,8 @@ def _make_tlfd(persons: pl.DataFrame, dist_col: str) -> pl.DataFrame:
}
-@summary_contract(
+@summary(
+ id="work_location_distance_distribution_by_geography",
schema={
"distance_bin": pl.Int32,
"geography_type": pl.Utf8,
@@ -167,7 +175,8 @@ def work_tlfd(rd: RunData, config: Config) -> pl.DataFrame:
return tlfd(rd, config)["work"]
-@summary_contract(
+@summary(
+ id="university_location_distance_distribution_by_geography",
schema={
"distance_bin": pl.Int32,
"geography_type": pl.Utf8,
@@ -180,7 +189,8 @@ def univ_tlfd(rd: RunData, config: Config) -> pl.DataFrame:
return tlfd(rd, config)["univ"]
-@summary_contract(
+@summary(
+ id="school_location_distance_distribution_by_geography",
schema={
"distance_bin": pl.Int32,
"geography_type": pl.Utf8,
diff --git a/processor/summarize/summaries/long_term_geography.py b/processor/summarize/summaries/long_term_geography.py
index b867c2f..d5a14b1 100644
--- a/processor/summarize/summaries/long_term_geography.py
+++ b/processor/summarize/summaries/long_term_geography.py
@@ -2,18 +2,16 @@
from __future__ import annotations
-import math
-
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.long_term_shared import (
_student_filter_expr,
_worker_filter_expr,
)
from processor.summarize.summaries.summary_helpers import (
- _aggregate_counts_across_geographies,
+ aggregate_counts_across_geographies,
_configured_geography_columns,
_configured_geography_dimensions,
_configured_land_use_geography_dimensions,
@@ -84,7 +82,8 @@ def _all_geographies_external_worker_counts(df: pl.DataFrame) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="work_from_home_rate_by_geography",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -96,13 +95,13 @@ def _all_geographies_external_worker_counts(df: pl.DataFrame) -> pl.DataFrame:
def wfh(rd: RunData, config: Config) -> pl.DataFrame:
required = {"is_worker", "home_zone_id", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(wfh)
+ return wfh.empty()
workers = rd.per.filter(
_worker_filter_expr() & pl.col("home_zone_id").is_not_null()
)
if workers.is_empty():
- return empty_summary_frame(wfh)
+ return wfh.empty()
if "work_from_home" in workers.columns:
workers = workers.with_columns(
@@ -151,7 +150,9 @@ def _aggregate_wfh_counts(
)
outputs = [
- _aggregate_wfh_counts(base, geography_type=geography_type, geography_id_col=geography_col)
+ _aggregate_wfh_counts(
+ base, geography_type=geography_type, geography_id_col=geography_col
+ )
for geography_type, geography_col in _configured_geography_dimensions(
base,
config=config,
@@ -182,7 +183,8 @@ def _aggregate_wfh_counts(
)
-@summary_contract(
+@summary(
+ id="internal_external_worker_by_geography",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -196,7 +198,7 @@ def _aggregate_wfh_counts(
def internal_vs_external(rd: RunData, config: Config) -> pl.DataFrame:
required = {"is_worker", "is_external_worker", "home_zone_id", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(internal_vs_external)
+ return internal_vs_external.empty()
base = (
rd.per.filter(
@@ -219,7 +221,7 @@ def internal_vs_external(rd: RunData, config: Config) -> pl.DataFrame:
)
)
if base.is_empty():
- return empty_summary_frame(internal_vs_external)
+ return internal_vs_external.empty()
outputs = [
*[
@@ -254,7 +256,8 @@ def internal_vs_external(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="external_worker_workplace_locations",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -278,7 +281,7 @@ def external_workplace_loc(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(external_workplace_loc)
+ return external_workplace_loc.empty()
base = rd.per.filter(
(pl.col("is_external_worker") == True)
@@ -293,20 +296,19 @@ def external_workplace_loc(rd: RunData, config: Config) -> pl.DataFrame:
),
)
if base.is_empty():
- return empty_summary_frame(external_workplace_loc)
+ return external_workplace_loc.empty()
all_worker_count = float(
- rd.per.filter(_worker_filter_expr())
- .select(pl.col("finalweight").sum().cast(pl.Float64).alias("all_worker_count"))[
- "all_worker_count"
- ][0]
+ rd.per.filter(_worker_filter_expr()).select(
+ pl.col("finalweight").sum().cast(pl.Float64).alias("all_worker_count")
+ )["all_worker_count"][0]
or 0.0
)
return (
pl.concat(
[
- _aggregate_counts_across_geographies(
+ aggregate_counts_across_geographies(
base,
geography_dimensions=_configured_geography_dimensions(
base,
@@ -330,15 +332,16 @@ def external_workplace_loc(rd: RunData, config: Config) -> pl.DataFrame:
.sort(["geography_type", "geography_id"])
)
+
def _workplace_land_use_and_modeled_counts(
rd: RunData,
config: Config,
) -> tuple[pl.DataFrame, pl.DataFrame] | None:
land_use_required = {"MAZ", "employment_count"}
person_required = {"workplace_zone_id", "is_worker", "finalweight"}
- if not land_use_required.issubset(set(rd.land_use.columns)) or not person_required.issubset(
- set(rd.per.columns)
- ):
+ if not land_use_required.issubset(
+ set(rd.land_use.columns)
+ ) or not person_required.issubset(set(rd.per.columns)):
return None
land_use_base = rd.land_use.select(
@@ -358,7 +361,9 @@ def _workplace_land_use_and_modeled_counts(
"finalweight",
*_configured_geography_columns(rd.per, config=config, role_prefix="work"),
)
- land_use_dimensions = _configured_land_use_geography_dimensions(rd.land_use, config=config)
+ land_use_dimensions = _configured_land_use_geography_dimensions(
+ rd.land_use, config=config
+ )
worker_dimensions = dict(
_configured_geography_dimensions(
worker_base,
@@ -432,7 +437,8 @@ def _workplace_land_use_and_modeled_counts(
)
-@summary_contract(
+@summary(
+ id="workplace_location_employment_comparison",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -447,7 +453,7 @@ def _workplace_land_use_and_modeled_counts(
def workplace_vs_land_use_employment(rd: RunData, config: Config) -> pl.DataFrame:
aligned = _workplace_land_use_and_modeled_counts(rd, config)
if aligned is None:
- return empty_summary_frame(workplace_vs_land_use_employment)
+ return workplace_vs_land_use_employment.empty()
land_use_counts, worker_counts = aligned
return (
land_use_counts.join(
@@ -467,7 +473,8 @@ def workplace_vs_land_use_employment(rd: RunData, config: Config) -> pl.DataFram
)
-@summary_contract(
+@summary(
+ id="commuting_flows",
schema={
"origin_geography_type": pl.Utf8,
"origin_geography_id": pl.Utf8,
@@ -482,7 +489,7 @@ def workplace_vs_land_use_employment(rd: RunData, config: Config) -> pl.DataFram
def commuting_flows(rd: RunData, config: Config) -> pl.DataFrame:
required = {"home_zone_id", "workplace_zone_id", "is_worker", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(commuting_flows)
+ return commuting_flows.empty()
def aggregate_flows(
df: pl.DataFrame,
@@ -528,7 +535,7 @@ def aggregate_flows(
*_configured_geography_columns(rd.per, config=config, role_prefix="work"),
)
if base.is_empty():
- return empty_summary_frame(commuting_flows)
+ return commuting_flows.empty()
home_dimensions = _configured_geography_dimensions(
base,
@@ -548,7 +555,9 @@ def aggregate_flows(
)
outputs = [
aggregate_flows(
- base.filter(pl.col(origin_col).is_not_null() & pl.col(destination_col).is_not_null()),
+ base.filter(
+ pl.col(origin_col).is_not_null() & pl.col(destination_col).is_not_null()
+ ),
origin_type=origin_type,
origin_col=origin_col,
destination_type=origin_type,
@@ -599,9 +608,9 @@ def _school_land_use_and_modeled_counts(
) -> tuple[pl.DataFrame, pl.DataFrame] | None:
land_use_required = {"MAZ", "enrollment_count", "student_type"}
person_required = {"school_zone_id", "is_student", "finalweight", "student_type"}
- if not land_use_required.issubset(set(rd.land_use.columns)) or not person_required.issubset(
- set(rd.per.columns)
- ):
+ if not land_use_required.issubset(
+ set(rd.land_use.columns)
+ ) or not person_required.issubset(set(rd.per.columns)):
return None
land_use_base = rd.land_use.filter(
@@ -628,7 +637,9 @@ def _school_land_use_and_modeled_counts(
*_configured_geography_columns(rd.per, config=config, role_prefix="school"),
)
)
- land_use_dimensions = _configured_land_use_geography_dimensions(rd.land_use, config=config)
+ land_use_dimensions = _configured_land_use_geography_dimensions(
+ rd.land_use, config=config
+ )
student_dimensions = dict(
_configured_geography_dimensions(
student_base,
@@ -655,7 +666,9 @@ def _school_land_use_and_modeled_counts(
pl.col("student_type").cast(pl.Utf8),
pl.col("enrollment_count").cast(pl.Float64),
)
- .select("geography_type", "geography_id", "student_type", "enrollment_count")
+ .select(
+ "geography_type", "geography_id", "student_type", "enrollment_count"
+ )
)
student_outputs.append(
student_base.filter(pl.col(student_col).is_not_null())
@@ -706,7 +719,7 @@ def _park_and_ride_location_counts(
rd: RunData,
config: Config,
) -> pl.DataFrame | None:
- required_tour_cols = {"tour_mode", "pnr_zone_id", "finalweight"}
+ required_tour_cols = {"tour_mode", "finalweight"}
if not required_tour_cols.issubset(set(rd.tours.columns)):
return None
@@ -719,26 +732,32 @@ def _park_and_ride_location_counts(
join_key = None
land_use_base_col = None
- geography_base_col = None
geography_base_type = None
if "pnr_zone_id" in rd.tours.columns and "MAZ" in rd.land_use.columns:
join_key = "pnr_zone_id"
land_use_base_col = "MAZ"
- geography_base_col = "MAZ"
geography_base_type = "maz"
elif "pnr_taz" in rd.tours.columns and "TAZ" in rd.land_use.columns:
join_key = "pnr_taz"
land_use_base_col = "TAZ"
- geography_base_col = "TAZ"
geography_base_type = "taz"
- if (
- join_key is None
- or land_use_base_col is None
- or geography_base_col is None
- or geography_base_type is None
- ):
+ if join_key is None or land_use_base_col is None or geography_base_type is None:
return None
+ geography_dimensions: list[tuple[str, str]] = [
+ (geography_base_type, land_use_base_col)
+ ]
+ if land_use_base_col == "MAZ" and "TAZ" in rd.land_use.columns:
+ geography_dimensions.append(("taz", "TAZ"))
+ seen_geography_cols = {column for _, column in geography_dimensions}
+ for geography_type, geography_col in _configured_land_use_geography_dimensions(
+ rd.land_use,
+ config=config,
+ ):
+ if geography_col not in seen_geography_cols:
+ geography_dimensions.append((geography_type, geography_col))
+ seen_geography_cols.add(geography_col)
+
pnr_modes = {mode.lower() for mode in config.pnr_tour_modes}
modeled = (
rd.tours.with_columns(pl.col("tour_mode").cast(pl.Utf8).str.to_lowercase())
@@ -756,12 +775,12 @@ def _park_and_ride_location_counts(
land_use_cols = [land_use_base_col, capacity_col]
land_use_cols.extend(
- column
- for _, column in _configured_land_use_geography_dimensions(rd.land_use, config=config)
- if column not in land_use_cols
+ column for _, column in geography_dimensions if column not in land_use_cols
)
land_use_base = (
- rd.land_use.filter(pl.col(capacity_col).is_not_null() & pl.col(land_use_base_col).is_not_null())
+ rd.land_use.filter(
+ pl.col(capacity_col).is_not_null() & pl.col(land_use_base_col).is_not_null()
+ )
.select(*land_use_cols)
.group_by(land_use_base_col)
.agg(
@@ -784,7 +803,10 @@ def _park_and_ride_location_counts(
how="left",
coalesce=True,
)
- if used_lots.height != modeled.height or used_lots["pnr_lot_capacity"].null_count() > 0:
+ if (
+ used_lots.height != modeled.height
+ or used_lots["pnr_lot_capacity"].null_count() > 0
+ ):
return None
outputs = [
@@ -802,11 +824,8 @@ def _park_and_ride_location_counts(
)
.select("geography_type", "geography_id", "pnr_tour_count", "pnr_lot_capacity")
]
- for geography_type, geography_col in _configured_land_use_geography_dimensions(
- used_lots,
- config=config,
- ):
- if geography_col == geography_base_col or geography_col not in used_lots.columns:
+ for geography_type, geography_col in geography_dimensions:
+ if geography_col == land_use_base_col or geography_col not in used_lots.columns:
continue
outputs.append(
used_lots.filter(pl.col(geography_col).is_not_null())
@@ -822,7 +841,9 @@ def _park_and_ride_location_counts(
pl.col("pnr_tour_count").cast(pl.Float64),
pl.col("pnr_lot_capacity").cast(pl.Float64),
)
- .select("geography_type", "geography_id", "pnr_tour_count", "pnr_lot_capacity")
+ .select(
+ "geography_type", "geography_id", "pnr_tour_count", "pnr_lot_capacity"
+ )
)
outputs.append(
used_lots.select(
@@ -835,7 +856,8 @@ def _park_and_ride_location_counts(
return pl.concat(outputs, how="vertical").sort(["geography_type", "geography_id"])
-@summary_contract(
+@summary(
+ id="school_location_enrollment_comparison",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -851,7 +873,7 @@ def _park_and_ride_location_counts(
def school_loc_vs_land_use_enrollment(rd: RunData, config: Config) -> pl.DataFrame:
aligned = _school_land_use_and_modeled_counts(rd, config)
if aligned is None:
- return empty_summary_frame(school_loc_vs_land_use_enrollment)
+ return school_loc_vs_land_use_enrollment.empty()
land_use_counts, student_counts = aligned
return (
land_use_counts.join(
@@ -878,7 +900,8 @@ def school_loc_vs_land_use_enrollment(rd: RunData, config: Config) -> pl.DataFra
)
-@summary_contract(
+@summary(
+ id="workplace_shadow_pricing_residuals",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -896,24 +919,39 @@ def school_loc_vs_land_use_enrollment(rd: RunData, config: Config) -> pl.DataFra
def workplace_shadow_pricing_residuals(rd: RunData, config: Config) -> pl.DataFrame:
aligned = _workplace_land_use_and_modeled_counts(rd, config)
if aligned is None:
- return empty_summary_frame(workplace_shadow_pricing_residuals)
+ return workplace_shadow_pricing_residuals.empty()
land_use_counts, worker_counts = aligned
- return _finalize_residual_frame(
- land_use_counts.join(
- worker_counts,
- on=["geography_type", "geography_id"],
- how="full",
- coalesce=True,
+ return (
+ _finalize_residual_frame(
+ land_use_counts.join(
+ worker_counts,
+ on=["geography_type", "geography_id"],
+ how="full",
+ coalesce=True,
+ )
+ .with_columns(
+ pl.col("employment_count").fill_null(0.0).cast(pl.Float64),
+ pl.col("worker_count").fill_null(0.0).cast(pl.Float64),
+ )
+ .with_columns(
+ *_residual_metrics_columns("employment_count", "worker_count")
+ )
)
- .with_columns(
- pl.col("employment_count").fill_null(0.0).cast(pl.Float64),
- pl.col("worker_count").fill_null(0.0).cast(pl.Float64),
+ .select(
+ "geography_type",
+ "geography_id",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
)
- .with_columns(*_residual_metrics_columns("employment_count", "worker_count"))
- ).sort(["geography_type", "geography_id"])
+ .sort(["geography_type", "geography_id"])
+ )
-@summary_contract(
+@summary(
+ id="school_shadow_pricing_residuals",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -932,7 +970,7 @@ def workplace_shadow_pricing_residuals(rd: RunData, config: Config) -> pl.DataFr
def school_shadow_pricing_residuals(rd: RunData, config: Config) -> pl.DataFrame:
aligned = _school_land_use_and_modeled_counts(rd, config)
if aligned is None:
- return empty_summary_frame(school_shadow_pricing_residuals)
+ return school_shadow_pricing_residuals.empty()
land_use_counts, student_counts = aligned
return _finalize_residual_frame(
land_use_counts.join(
@@ -950,7 +988,8 @@ def school_shadow_pricing_residuals(rd: RunData, config: Config) -> pl.DataFrame
).sort(["geography_type", "geography_id", "student_type"])
-@summary_contract(
+@summary(
+ id="workplace_shadow_pricing_residual_histogram",
schema={
"geography_type": pl.Utf8,
"bin_start": pl.Float64,
@@ -968,7 +1007,7 @@ def workplace_shadow_pricing_residual_histogram(
) -> pl.DataFrame:
residuals = workplace_shadow_pricing_residuals(rd, config)
if residuals.is_empty():
- return empty_summary_frame(workplace_shadow_pricing_residual_histogram)
+ return workplace_shadow_pricing_residual_histogram.empty()
return _residual_histogram_summary(
residuals,
group_cols=["geography_type"],
@@ -976,7 +1015,8 @@ def workplace_shadow_pricing_residual_histogram(
).sort(["geography_type", "bin_start", "bin_end"])
-@summary_contract(
+@summary(
+ id="school_shadow_pricing_residual_histogram",
schema={
"geography_type": pl.Utf8,
"student_type": pl.Utf8,
@@ -995,7 +1035,7 @@ def school_shadow_pricing_residual_histogram(
) -> pl.DataFrame:
residuals = school_shadow_pricing_residuals(rd, config)
if residuals.is_empty():
- return empty_summary_frame(school_shadow_pricing_residual_histogram)
+ return school_shadow_pricing_residual_histogram.empty()
by_student_type = _residual_histogram_summary(
residuals,
group_cols=["geography_type", "student_type"],
@@ -1021,7 +1061,8 @@ def school_shadow_pricing_residual_histogram(
)
-@summary_contract(
+@summary(
+ id="park_and_ride_location_residuals",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -1032,29 +1073,42 @@ def school_shadow_pricing_residual_histogram(
"percent_error": pl.Float64,
},
required_columns={
- "tours": ("tour_mode", "pnr_zone_id", "finalweight"),
- "land_use": ("MAZ",),
+ "tours": ("tour_mode", "finalweight"),
+ "land_use": (),
},
)
def park_and_ride_location_residuals(rd: RunData, config: Config) -> pl.DataFrame:
base = _park_and_ride_location_counts(rd, config)
if base is None or base.is_empty():
- return empty_summary_frame(park_and_ride_location_residuals)
- return _finalize_residual_frame(
- base.with_columns(
- *_residual_metrics_columns(
- "pnr_lot_capacity",
- "pnr_tour_count",
- target_output_col="pnr_lot_capacity",
- modeled_output_col="pnr_tour_count",
- )
- ),
- target_output_col="pnr_lot_capacity",
- modeled_output_col="pnr_tour_count",
- ).sort(["geography_type", "geography_id"])
+ return park_and_ride_location_residuals.empty()
+ return (
+ _finalize_residual_frame(
+ base.with_columns(
+ *_residual_metrics_columns(
+ "pnr_lot_capacity",
+ "pnr_tour_count",
+ target_output_col="pnr_lot_capacity",
+ modeled_output_col="pnr_tour_count",
+ )
+ ),
+ target_output_col="pnr_lot_capacity",
+ modeled_output_col="pnr_tour_count",
+ )
+ .select(
+ "geography_type",
+ "geography_id",
+ "pnr_tour_count",
+ "pnr_lot_capacity",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
+ )
+ .sort(["geography_type", "geography_id"])
+ )
-@summary_contract(
+@summary(
+ id="park_and_ride_location_residual_histogram",
schema={
"geography_type": pl.Utf8,
"bin_start": pl.Float64,
@@ -1062,8 +1116,8 @@ def park_and_ride_location_residuals(rd: RunData, config: Config) -> pl.DataFram
"geography_count": pl.Float64,
},
required_columns={
- "tours": ("tour_mode", "pnr_zone_id", "finalweight"),
- "land_use": ("MAZ",),
+ "tours": ("tour_mode", "finalweight"),
+ "land_use": (),
},
)
def park_and_ride_location_residual_histogram(
@@ -1072,7 +1126,7 @@ def park_and_ride_location_residual_histogram(
) -> pl.DataFrame:
residuals = park_and_ride_location_residuals(rd, config)
if residuals.is_empty():
- return empty_summary_frame(park_and_ride_location_residual_histogram)
+ return park_and_ride_location_residual_histogram.empty()
return _residual_histogram_summary(
residuals,
group_cols=["geography_type"],
@@ -1080,7 +1134,8 @@ def park_and_ride_location_residual_histogram(
).sort(["geography_type", "bin_start", "bin_end"])
-@summary_contract(
+@summary(
+ id="free_parking_eligibility_by_workplace_geography",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -1099,7 +1154,24 @@ def park_and_ride_location_residual_histogram(
def free_parking(rd: RunData, config: Config) -> pl.DataFrame:
required = {"is_worker", "free_parking_at_work", "workplace_zone_id", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(free_parking)
+ return free_parking.empty()
+
+ free_parking_dtype = rd.per.schema["free_parking_at_work"]
+ if free_parking_dtype == pl.Boolean:
+ free_parking_expr = pl.col("free_parking_at_work")
+ elif free_parking_dtype.is_numeric():
+ # Estimation survey inputs use the model alternative codes 0/1/2;
+ # ActivitySim's free-parking alternative is 2. Model outputs already
+ # contain a Boolean and take the branch above.
+ free_parking_expr = pl.col("free_parking_at_work") == 2
+ else:
+ free_parking_expr = (
+ pl.col("free_parking_at_work")
+ .cast(pl.Utf8)
+ .str.strip_chars()
+ .str.to_lowercase()
+ .is_in(["true", "yes", "y", "2"])
+ )
def aggregate_counts(
df: pl.DataFrame,
@@ -1109,7 +1181,9 @@ def aggregate_counts(
return (
df.group_by(geography_id_col)
.agg(
- workers_without_free_parking_count=pl.when(~pl.col("free_parking_at_work"))
+ workers_without_free_parking_count=pl.when(
+ ~pl.col("free_parking_at_work")
+ )
.then(pl.col("finalweight"))
.otherwise(0.0)
.sum(),
@@ -1133,18 +1207,22 @@ def aggregate_counts(
)
)
- base = rd.per.filter(
- _worker_filter_expr()
- & pl.col("workplace_zone_id").is_not_null()
- & pl.col("free_parking_at_work").is_not_null()
- ).select(
- "workplace_zone_id",
- "free_parking_at_work",
- "finalweight",
- *_configured_geography_columns(rd.per, config=config, role_prefix="work"),
+ base = (
+ rd.per.filter(
+ _worker_filter_expr()
+ & pl.col("workplace_zone_id").is_not_null()
+ & pl.col("free_parking_at_work").is_not_null()
+ )
+ .with_columns(free_parking_expr.alias("free_parking_at_work"))
+ .select(
+ "workplace_zone_id",
+ "free_parking_at_work",
+ "finalweight",
+ *_configured_geography_columns(rd.per, config=config, role_prefix="work"),
+ )
)
if base.is_empty():
- return empty_summary_frame(free_parking)
+ return free_parking.empty()
return (
pl.concat(
diff --git a/processor/summarize/summaries/long_term_person.py b/processor/summarize/summaries/long_term_person.py
index 123263f..0f2f2f9 100644
--- a/processor/summarize/summaries/long_term_person.py
+++ b/processor/summarize/summaries/long_term_person.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.long_term_shared import (
_person_type_distribution_with_total,
_person_type_label_expr,
@@ -18,7 +18,8 @@
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="license_holding_status_distribution",
schema={
"person_type": pl.Utf8,
"license_holding_status": pl.Utf8,
@@ -30,7 +31,7 @@
def license_holding_status(rd: RunData, config: Config) -> pl.DataFrame:
required = {"person_type", "has_license", "finalweight", "age"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(license_holding_status)
+ return license_holding_status.empty()
base = rd.per.filter(
pl.col("person_type").is_not_null()
@@ -66,7 +67,8 @@ def license_holding_status(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="bicycle_comfort_level_distribution",
schema={
"person_type": pl.Utf8,
"bicycle_comfort_level": pl.Utf8,
@@ -78,7 +80,7 @@ def license_holding_status(rd: RunData, config: Config) -> pl.DataFrame:
def bicycle_comfort_level(rd: RunData, config: Config) -> pl.DataFrame:
required = {"person_type", "bike_comfort", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(bicycle_comfort_level)
+ return bicycle_comfort_level.empty()
base = rd.per.filter(
pl.col("person_type").is_not_null() & pl.col("bike_comfort").is_not_null()
@@ -108,7 +110,8 @@ def bicycle_comfort_level(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="transit_pass_ownership_by_person_type",
schema={
"person_type": pl.Utf8,
"transit_pass_ownership_status": pl.Utf8,
@@ -120,7 +123,7 @@ def bicycle_comfort_level(rd: RunData, config: Config) -> pl.DataFrame:
def transit_pass(rd: RunData, config: Config) -> pl.DataFrame:
required = {"person_type", "transit_pass_ownership", "finalweight"}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(transit_pass)
+ return transit_pass.empty()
base = rd.per.filter(
pl.col("person_type").is_not_null()
@@ -154,7 +157,8 @@ def transit_pass(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="transit_subsidy_by_person_type",
schema={
"person_type": pl.Utf8,
"transit_subsidy_status": pl.Utf8,
@@ -180,7 +184,7 @@ def transit_subsidy(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.per.columns)):
- return empty_summary_frame(transit_subsidy)
+ return transit_subsidy.empty()
base = rd.per.filter(
pl.col("person_type").is_not_null()
@@ -216,7 +220,8 @@ def transit_subsidy(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="telecommute_frequency_distribution",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -241,27 +246,24 @@ def telecommute(rd: RunData, config: Config | None = None) -> pl.DataFrame:
"work_from_home",
"home_zone_id",
}.issubset(rd.per.columns):
- return empty_summary_frame(telecommute)
+ return telecommute.empty()
- base = (
- rd.per.filter(
- pl.col("telecommute_frequency").is_not_null()
- & (pl.col("telecommute_frequency") != "")
- & _worker_filter_expr()
- & ~pl.col("work_from_home")
- .cast(pl.Utf8)
- .str.to_lowercase()
- .is_in(["true", "1", "yes", "work_from_home", "home"])
- )
- .select(
- "telecommute_frequency",
- "finalweight",
- "home_zone_id",
- *_configured_geography_columns(rd.per, config=config, role_prefix="home"),
- )
+ base = rd.per.filter(
+ pl.col("telecommute_frequency").is_not_null()
+ & (pl.col("telecommute_frequency") != "")
+ & _worker_filter_expr()
+ & ~pl.col("work_from_home")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .is_in(["true", "1", "yes", "work_from_home", "home"])
+ ).select(
+ "telecommute_frequency",
+ "finalweight",
+ "home_zone_id",
+ *_configured_geography_columns(rd.per, config=config, role_prefix="home"),
)
if base.is_empty():
- return empty_summary_frame(telecommute)
+ return telecommute.empty()
outputs: list[pl.DataFrame] = []
for geography_type, geography_col in _configured_geography_dimensions(
diff --git a/processor/summarize/summaries/long_term_vehicle.py b/processor/summarize/summaries/long_term_vehicle.py
index faab719..c7d98d8 100644
--- a/processor/summarize/summaries/long_term_vehicle.py
+++ b/processor/summarize/summaries/long_term_vehicle.py
@@ -5,18 +5,19 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="autonomous_vehicle_ownership_totals",
schema={"household_with_autonomous_vehicle_count": pl.Float64},
required_columns={"hh": ("av_ownership", "finalweight")},
)
def av_ownership(rd: RunData, config: Config) -> pl.DataFrame:
required = {"av_ownership", "finalweight"}
if not required.issubset(set(rd.hh.columns)):
- return empty_summary_frame(av_ownership)
+ return av_ownership.empty()
return rd.hh.filter(pl.col("av_ownership") == True).select(
pl.col("finalweight")
@@ -26,23 +27,42 @@ def av_ownership(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="auto_ownership_distribution",
schema={
+ "household_size": pl.Utf8,
"household_vehicle_count": pl.Int64,
"household_count": pl.Float64,
},
- required_columns={"hh": ("HHVEH", "finalweight")},
+ required_columns={"hh": ("HHSIZE", "HHVEH", "finalweight")},
)
def auto_ownership(rd: RunData, config: Config) -> pl.DataFrame:
return (
- rd.hh.group_by("HHVEH")
+ rd.hh.with_columns(
+ pl.when(pl.col("HHSIZE").cast(pl.Int64, strict=False) >= 5)
+ .then(pl.lit("5+"))
+ .otherwise(pl.col("HHSIZE").cast(pl.Int64, strict=False).cast(pl.Utf8))
+ .alias("household_size")
+ )
+ .group_by(["household_size", "HHVEH"])
.agg(household_count=pl.col("finalweight").sum())
.rename({"HHVEH": "household_vehicle_count"})
- .sort("household_vehicle_count")
+ .with_columns(
+ pl.col("household_size").cast(pl.Utf8),
+ pl.col("household_vehicle_count").cast(pl.Int64),
+ pl.col("household_count").cast(pl.Float64),
+ pl.when(pl.col("household_size") == "5+")
+ .then(999)
+ .otherwise(pl.col("household_size").cast(pl.Int64, strict=False))
+ .alias("_sort_household_size"),
+ )
+ .sort(["_sort_household_size", "household_vehicle_count"])
+ .select("household_size", "household_vehicle_count", "household_count")
)
-@summary_contract(
+@summary(
+ id="vehicle_age_distribution",
schema={
"age": pl.Utf8,
"vehicle_count": pl.Float64,
@@ -52,9 +72,9 @@ def auto_ownership(rd: RunData, config: Config) -> pl.DataFrame:
def vehicle_char_age(rd: RunData, config: Config) -> pl.DataFrame:
required = {"vehicle_age", "finalweight"}
if not hasattr(rd, "vehicles"):
- return empty_summary_frame(vehicle_char_age)
+ return vehicle_char_age.empty()
if not required.issubset(set(rd.vehicles.columns)):
- return empty_summary_frame(vehicle_char_age)
+ return vehicle_char_age.empty()
return (
rd.vehicles.filter(pl.col("vehicle_age").is_not_null())
@@ -79,7 +99,8 @@ def vehicle_char_age(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="vehicle_fuel_type_distribution",
schema={
"fuel_type": pl.Utf8,
"vehicle_count": pl.Float64,
@@ -89,9 +110,9 @@ def vehicle_char_age(rd: RunData, config: Config) -> pl.DataFrame:
def vehicle_char_fuel(rd: RunData, config: Config) -> pl.DataFrame:
required = {"fuel_type", "finalweight"}
if not hasattr(rd, "vehicles"):
- return empty_summary_frame(vehicle_char_fuel)
+ return vehicle_char_fuel.empty()
if not required.issubset(set(rd.vehicles.columns)):
- return empty_summary_frame(vehicle_char_fuel)
+ return vehicle_char_fuel.empty()
return (
rd.vehicles.filter(pl.col("fuel_type").is_not_null())
@@ -106,7 +127,8 @@ def vehicle_char_fuel(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="vehicle_body_type_distribution",
schema={
"body_type": pl.Utf8,
"vehicle_count": pl.Float64,
@@ -116,9 +138,9 @@ def vehicle_char_fuel(rd: RunData, config: Config) -> pl.DataFrame:
def vehicle_char_body(rd: RunData, config: Config) -> pl.DataFrame:
required = {"body_type", "finalweight"}
if not hasattr(rd, "vehicles"):
- return empty_summary_frame(vehicle_char_body)
+ return vehicle_char_body.empty()
if not required.issubset(set(rd.vehicles.columns)):
- return empty_summary_frame(vehicle_char_body)
+ return vehicle_char_body.empty()
return (
rd.vehicles.filter(pl.col("body_type").is_not_null())
diff --git a/processor/summarize/summaries/skimjoin.py b/processor/summarize/summaries/skimjoin.py
index fc98c9a..eb20983 100644
--- a/processor/summarize/summaries/skimjoin.py
+++ b/processor/summarize/summaries/skimjoin.py
@@ -7,11 +7,13 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import empty_summary_frame, summary
from runtime.config import Config
_PERCENTILES = [index / 100 for index in range(101)]
_ALL_MODES = "All Modes"
+_CHOSEN_MODE_SCENARIO = "chosen_mode"
+_ALL_RECORDS_SCENARIO = "all_records"
def _is_numeric_dtype(dtype: pl.DataType | None) -> bool:
@@ -101,7 +103,12 @@ def _weighted_quantiles_from_sorted_pairs(
return results
-def _weighted_stats_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str, object]]:
+def _weighted_stats_rows(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ skim_scenario: str,
+) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
component_columns = _skim_component_columns(df)
if not component_columns:
@@ -112,7 +119,9 @@ def _weighted_stats_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str
mode_groups = _mode_groups(df, mode_column=mode_column)
for component in component_columns:
- pertinent_df = _pertinent_component_df(df, component=component, mode_column=mode_column)
+ pertinent_df = _pertinent_component_df(
+ df, component=component, mode_column=mode_column
+ )
for mode_value, mode_df in [(_ALL_MODES, pertinent_df), *mode_groups]:
rows.append(
_weighted_stats_row(
@@ -120,13 +129,19 @@ def _weighted_stats_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str
mode_column=mode_column,
mode_value=mode_value,
component=component,
+ skim_scenario=skim_scenario,
)
)
return rows
-def _weighted_ecdf_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str, object]]:
+def _weighted_ecdf_rows(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ skim_scenario: str,
+) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
component_columns = _skim_component_columns(df)
if not component_columns:
@@ -137,7 +152,9 @@ def _weighted_ecdf_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str,
mode_groups = _mode_groups(df, mode_column=mode_column)
for component in component_columns:
- pertinent_df = _pertinent_component_df(df, component=component, mode_column=mode_column)
+ pertinent_df = _pertinent_component_df(
+ df, component=component, mode_column=mode_column
+ )
for mode_value, mode_df in [(_ALL_MODES, pertinent_df), *mode_groups]:
valid_df = mode_df.filter(pl.col(component).is_not_null())
if valid_df.is_empty():
@@ -149,9 +166,13 @@ def _weighted_ecdf_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str,
]
weights = [
float(value)
- for value in valid_df.get_column("finalweight").cast(pl.Float64).to_list()
+ for value in valid_df.get_column("finalweight")
+ .cast(pl.Float64)
+ .to_list()
]
- sorted_values, sorted_weights, n_valid = _sorted_weighted_pairs(values, weights)
+ sorted_values, sorted_weights, n_valid = _sorted_weighted_pairs(
+ values, weights
+ )
if n_valid <= 0:
continue
@@ -166,6 +187,7 @@ def _weighted_ecdf_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str,
):
rows.append(
{
+ "skim_scenario": skim_scenario,
mode_column: mode_value,
"component": component,
"percentile": float(percentile),
@@ -177,8 +199,19 @@ def _weighted_ecdf_rows(df: pl.DataFrame, *, mode_column: str) -> list[dict[str,
return rows
-def _stats_frame(df: pl.DataFrame, *, mode_column: str, builder) -> pl.DataFrame:
- rows = _weighted_stats_rows(df, mode_column=mode_column)
+def _stats_frame(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ sidecar: pl.DataFrame,
+ builder,
+) -> pl.DataFrame:
+ rows = _weighted_stats_rows(
+ df,
+ mode_column=mode_column,
+ skim_scenario=_CHOSEN_MODE_SCENARIO,
+ )
+ rows.extend(_weighted_stats_rows_from_sidecar(sidecar, mode_column=mode_column))
if not rows:
return empty_summary_frame(builder)
empty_frame = empty_summary_frame(builder)
@@ -187,18 +220,38 @@ def _stats_frame(df: pl.DataFrame, *, mode_column: str, builder) -> pl.DataFrame
.select(*empty_frame.columns)
.cast(empty_frame.schema)
.with_columns(
+ pl.when(pl.col("skim_scenario") == _CHOSEN_MODE_SCENARIO)
+ .then(0)
+ .otherwise(1)
+ .alias("__scenario_sort"),
pl.when(pl.col(mode_column) == _ALL_MODES)
.then(0)
.otherwise(1)
- .alias("__mode_sort")
+ .alias("__mode_sort"),
)
- .sort(["__mode_sort", mode_column, "component"])
- .drop("__mode_sort")
+ .sort(["__scenario_sort", "__mode_sort", mode_column, "component"])
+ .drop("__scenario_sort", "__mode_sort")
)
-def _ecdf_frame(df: pl.DataFrame, *, mode_column: str, builder) -> pl.DataFrame:
- rows = _weighted_ecdf_rows(df, mode_column=mode_column)
+def _ecdf_frame(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ sidecar: pl.DataFrame,
+ builder,
+) -> pl.DataFrame:
+ rows = _weighted_ecdf_rows(
+ df,
+ mode_column=mode_column,
+ skim_scenario=_CHOSEN_MODE_SCENARIO,
+ )
+ rows.extend(
+ _weighted_ecdf_rows_from_sidecar(
+ sidecar,
+ mode_column=mode_column,
+ )
+ )
if not rows:
return empty_summary_frame(builder)
empty_frame = empty_summary_frame(builder)
@@ -207,13 +260,19 @@ def _ecdf_frame(df: pl.DataFrame, *, mode_column: str, builder) -> pl.DataFrame:
.select(*empty_frame.columns)
.cast(empty_frame.schema)
.with_columns(
+ pl.when(pl.col("skim_scenario") == _CHOSEN_MODE_SCENARIO)
+ .then(0)
+ .otherwise(1)
+ .alias("__scenario_sort"),
pl.when(pl.col(mode_column) == _ALL_MODES)
.then(0)
.otherwise(1)
- .alias("__mode_sort")
+ .alias("__mode_sort"),
)
- .sort(["__mode_sort", mode_column, "component", "percentile"])
- .drop("__mode_sort")
+ .sort(
+ ["__scenario_sort", "__mode_sort", mode_column, "component", "percentile"]
+ )
+ .drop("__scenario_sort", "__mode_sort")
)
@@ -265,6 +324,7 @@ def _weighted_stats_row(
mode_column: str,
mode_value: str,
component: str,
+ skim_scenario: str,
) -> dict[str, object]:
n_total = float(
df.select(pl.col("finalweight").sum().alias("n_total"))["n_total"][0] or 0.0
@@ -272,6 +332,7 @@ def _weighted_stats_row(
valid_df = df.filter(pl.col(component).is_not_null())
if valid_df.is_empty():
return {
+ "skim_scenario": skim_scenario,
mode_column: mode_value,
"component": component,
"n_total": n_total,
@@ -297,6 +358,7 @@ def _weighted_stats_row(
n_valid = float(sum(weights))
if n_valid <= 0:
return {
+ "skim_scenario": skim_scenario,
mode_column: mode_value,
"component": component,
"n_total": n_total,
@@ -311,7 +373,10 @@ def _weighted_stats_row(
"missing_share": None if n_total == 0 else 1.0,
}
- mean = sum(value * weight for value, weight in zip(values, weights, strict=False)) / n_valid
+ mean = (
+ sum(value * weight for value, weight in zip(values, weights, strict=False))
+ / n_valid
+ )
variance = (
sum(
weight * ((value - mean) ** 2)
@@ -320,11 +385,10 @@ def _weighted_stats_row(
/ n_valid
)
zero_weight = sum(
- weight
- for value, weight in zip(values, weights, strict=False)
- if value == 0.0
+ weight for value, weight in zip(values, weights, strict=False) if value == 0.0
)
return {
+ "skim_scenario": skim_scenario,
mode_column: mode_value,
"component": component,
"n_total": n_total,
@@ -340,8 +404,177 @@ def _weighted_stats_row(
}
-@summary_contract(
+def _weighted_stats_rows_from_sidecar(
+ sidecar: pl.DataFrame,
+ *,
+ mode_column: str,
+) -> list[dict[str, object]]:
+ return _weighted_rows_from_sidecar(
+ sidecar,
+ mode_column=mode_column,
+ row_builder=_weighted_stats_row_from_sidecar_group,
+ )
+
+
+def _weighted_ecdf_rows_from_sidecar(
+ sidecar: pl.DataFrame,
+ *,
+ mode_column: str,
+) -> list[dict[str, object]]:
+ return _weighted_rows_from_sidecar(
+ sidecar,
+ mode_column=mode_column,
+ row_builder=_weighted_ecdf_rows_from_sidecar_group,
+ )
+
+
+def _weighted_rows_from_sidecar(
+ sidecar: pl.DataFrame,
+ *,
+ mode_column: str,
+ row_builder,
+) -> list[dict[str, object]]:
+ if sidecar.is_empty():
+ return []
+ required = {"hypothetical_mode", "component", "value", "finalweight"}
+ if not required.issubset(sidecar.columns):
+ return []
+ rows: list[dict[str, object]] = []
+ for group_key, group in sidecar.group_by(
+ ["hypothetical_mode", "component"],
+ maintain_order=True,
+ ):
+ hypothetical_mode, component = group_key
+ rows.extend(
+ row_builder(
+ group,
+ mode_column=mode_column,
+ mode_value=str(hypothetical_mode),
+ component=str(component),
+ )
+ )
+ return rows
+
+
+def _weighted_stats_row_from_sidecar_group(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ mode_value: str,
+ component: str,
+) -> list[dict[str, object]]:
+ n_total = float(
+ df.select(pl.col("finalweight").sum().alias("n_total"))["n_total"][0] or 0.0
+ )
+ valid_df = df.filter(pl.col("value").is_not_null())
+ if valid_df.is_empty():
+ return [
+ {
+ "skim_scenario": _ALL_RECORDS_SCENARIO,
+ mode_column: mode_value,
+ "component": component,
+ "n_total": n_total,
+ "n_valid": 0.0,
+ "mean": None,
+ "std": None,
+ "min": None,
+ "max": None,
+ "median": None,
+ "mode": None,
+ "zero_share": None,
+ "missing_share": (None if n_total == 0 else 1.0),
+ }
+ ]
+ values = [
+ float(value)
+ for value in valid_df.get_column("value").cast(pl.Float64).to_list()
+ ]
+ weights = [
+ float(value)
+ for value in valid_df.get_column("finalweight").cast(pl.Float64).to_list()
+ ]
+ n_valid = float(sum(weights))
+ if n_valid <= 0:
+ return []
+ mean = (
+ sum(value * weight for value, weight in zip(values, weights, strict=False))
+ / n_valid
+ )
+ variance = (
+ sum(
+ weight * ((value - mean) ** 2)
+ for value, weight in zip(values, weights, strict=False)
+ )
+ / n_valid
+ )
+ zero_weight = sum(
+ weight for value, weight in zip(values, weights, strict=False) if value == 0.0
+ )
+ return [
+ {
+ "skim_scenario": _ALL_RECORDS_SCENARIO,
+ mode_column: mode_value,
+ "component": component,
+ "n_total": n_total,
+ "n_valid": n_valid,
+ "mean": mean,
+ "std": math.sqrt(variance),
+ "min": min(values),
+ "max": max(values),
+ "median": _weighted_quantile(values, weights, 0.5),
+ "mode": _mode_value(values, weights),
+ "zero_share": zero_weight / n_valid,
+ "missing_share": None if n_total == 0 else (n_total - n_valid) / n_total,
+ }
+ ]
+
+
+def _weighted_ecdf_rows_from_sidecar_group(
+ df: pl.DataFrame,
+ *,
+ mode_column: str,
+ mode_value: str,
+ component: str,
+) -> list[dict[str, object]]:
+ valid_df = df.filter(pl.col("value").is_not_null())
+ if valid_df.is_empty():
+ return []
+ values = [
+ float(value)
+ for value in valid_df.get_column("value").cast(pl.Float64).to_list()
+ ]
+ weights = [
+ float(value)
+ for value in valid_df.get_column("finalweight").cast(pl.Float64).to_list()
+ ]
+ sorted_values, sorted_weights, n_valid = _sorted_weighted_pairs(values, weights)
+ if n_valid <= 0:
+ return []
+ quantile_values = _weighted_quantiles_from_sorted_pairs(
+ sorted_values,
+ sorted_weights,
+ n_valid,
+ _PERCENTILES,
+ )
+ return [
+ {
+ "skim_scenario": _ALL_RECORDS_SCENARIO,
+ mode_column: mode_value,
+ "component": component,
+ "percentile": float(percentile),
+ "value": quantile_value,
+ "n_valid": n_valid,
+ }
+ for percentile, quantile_value in zip(
+ _PERCENTILES, quantile_values, strict=False
+ )
+ ]
+
+
+@summary(
+ id="skimjoin_trip_component_stats",
schema={
+ "skim_scenario": pl.Utf8,
"trip_mode": pl.Utf8,
"component": pl.Utf8,
"n_total": pl.Float64,
@@ -358,11 +591,19 @@ def _weighted_stats_row(
required_columns={"trips": ("trip_mode", "finalweight")},
)
def trip_skim_component_stats(rd: RunData, config: Config) -> pl.DataFrame:
- return _stats_frame(rd.trips, mode_column="trip_mode", builder=trip_skim_component_stats)
+ return _stats_frame(
+ rd.trips,
+ mode_column="trip_mode",
+ sidecar=rd.trip_hypothetical_skims,
+ builder=trip_skim_component_stats,
+ )
-@summary_contract(
+@summary(
+ id="skimjoin_trip_component_ecdf",
+ build_by_default=False,
schema={
+ "skim_scenario": pl.Utf8,
"trip_mode": pl.Utf8,
"component": pl.Utf8,
"percentile": pl.Float64,
@@ -372,11 +613,18 @@ def trip_skim_component_stats(rd: RunData, config: Config) -> pl.DataFrame:
required_columns={"trips": ("trip_mode", "finalweight")},
)
def trip_skim_component_ecdf(rd: RunData, config: Config) -> pl.DataFrame:
- return _ecdf_frame(rd.trips, mode_column="trip_mode", builder=trip_skim_component_ecdf)
+ return _ecdf_frame(
+ rd.trips,
+ mode_column="trip_mode",
+ sidecar=rd.trip_hypothetical_skims,
+ builder=trip_skim_component_ecdf,
+ )
-@summary_contract(
+@summary(
+ id="skimjoin_tour_component_stats",
schema={
+ "skim_scenario": pl.Utf8,
"tour_mode": pl.Utf8,
"component": pl.Utf8,
"n_total": pl.Float64,
@@ -393,11 +641,19 @@ def trip_skim_component_ecdf(rd: RunData, config: Config) -> pl.DataFrame:
required_columns={"tours": ("tour_mode", "finalweight")},
)
def tour_skim_component_stats(rd: RunData, config: Config) -> pl.DataFrame:
- return _stats_frame(rd.tours, mode_column="tour_mode", builder=tour_skim_component_stats)
+ return _stats_frame(
+ rd.tours,
+ mode_column="tour_mode",
+ sidecar=rd.tour_hypothetical_skims,
+ builder=tour_skim_component_stats,
+ )
-@summary_contract(
+@summary(
+ id="skimjoin_tour_component_ecdf",
+ build_by_default=False,
schema={
+ "skim_scenario": pl.Utf8,
"tour_mode": pl.Utf8,
"component": pl.Utf8,
"percentile": pl.Float64,
@@ -407,4 +663,9 @@ def tour_skim_component_stats(rd: RunData, config: Config) -> pl.DataFrame:
required_columns={"tours": ("tour_mode", "finalweight")},
)
def tour_skim_component_ecdf(rd: RunData, config: Config) -> pl.DataFrame:
- return _ecdf_frame(rd.tours, mode_column="tour_mode", builder=tour_skim_component_ecdf)
+ return _ecdf_frame(
+ rd.tours,
+ mode_column="tour_mode",
+ sidecar=rd.tour_hypothetical_skims,
+ builder=tour_skim_component_ecdf,
+ )
diff --git a/processor/summarize/summaries/summary_helpers.py b/processor/summarize/summaries/summary_helpers.py
index 72f9e78..4ed1d0f 100644
--- a/processor/summarize/summaries/summary_helpers.py
+++ b/processor/summarize/summaries/summary_helpers.py
@@ -19,7 +19,7 @@ def _summary_purpose_column(df: pl.DataFrame) -> str:
return purpose_column(df)
-def _weighted_group_sum(
+def weighted_group_sum(
df: pl.DataFrame,
group_cols: str | list[str],
*,
@@ -84,7 +84,7 @@ def _dense_zero_fill(
)
-def _aggregate_counts_by_geography(
+def aggregate_counts_by_geography(
df: pl.DataFrame,
*,
geography_type: str,
@@ -114,14 +114,32 @@ def _configured_geography_dimensions(
base_col: str,
role_prefix: str,
) -> list[tuple[str, str]]:
- """Return available geography dimensions for one semantic role."""
+ """Return available native and configured geography dimensions for one role."""
dimensions: list[tuple[str, str]] = []
+ seen_columns: set[str] = set()
+
+ def _append(geography_type: str, column: str) -> None:
+ if column and column in df.columns and column not in seen_columns:
+ dimensions.append((geography_type, column))
+ seen_columns.add(column)
+
if base_col in df.columns:
- dimensions.append((base_type, base_col))
- for aggregation in config.geography_aggregations.aggregations:
+ _append(base_type, base_col)
+ if role_prefix == "home":
+ for geography_type, column in [
+ ("home_taz", "home_taz"),
+ ("home_county", "home_county"),
+ ("home_mpo", "home_mpo"),
+ ]:
+ _append(geography_type, column)
+ geography_aggregations = getattr(
+ getattr(config, "geography_aggregations", None),
+ "aggregations",
+ (),
+ )
+ for aggregation in geography_aggregations:
column = f"{role_prefix}_geo__{aggregation.name}"
- if column in df.columns:
- dimensions.append((aggregation.name, column))
+ _append(aggregation.name, column)
return dimensions
@@ -131,7 +149,7 @@ def _configured_geography_columns(
config,
role_prefix: str,
) -> list[str]:
- """Return configured prepared geography columns present for one role."""
+ """Return native and configured prepared geography columns present for one role."""
return [
column
for _, column in _configured_geography_dimensions(
@@ -163,7 +181,7 @@ def _configured_land_use_geography_dimensions(
return base_dimensions
-def _aggregate_counts_across_geographies(
+def aggregate_counts_across_geographies(
df: pl.DataFrame,
*,
geography_dimensions: list[tuple[str, str]],
@@ -172,7 +190,7 @@ def _aggregate_counts_across_geographies(
) -> pl.DataFrame:
"""Aggregate one frame across multiple geography dimensions."""
outputs = [
- _aggregate_counts_by_geography(
+ aggregate_counts_by_geography(
df.filter(pl.col(column).is_not_null()),
geography_type=geography_type,
geography_id_col=column,
@@ -330,7 +348,7 @@ def _residual_histogram_summary(
)
-def _aggregate_weighted_average_across_geographies(
+def aggregate_weighted_average_across_geographies(
df: pl.DataFrame,
*,
geography_dimensions: list[tuple[str, str]],
diff --git a/processor/summarize/summaries/tour.py b/processor/summarize/summaries/tour.py
index 94861c9..138411f 100644
--- a/processor/summarize/summaries/tour.py
+++ b/processor/summarize/summaries/tour.py
@@ -3,38 +3,20 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import _summary_purpose_column
-from processor.summarize.summaries.tour_geography import (
- avg_mand_tour_distance,
- avg_non_mand_tour_distance,
- ext_non_mand_tour_loc,
- int_vs_ext_non_mand_tour_freq,
-)
-from processor.summarize.summaries.tour_profiles import (
- at_work_sub_tour_freq,
- atwork_subtour_frequency_distribution,
- stop_freq,
- tour_distance,
- tour_mode,
- tour_tod,
-)
-from processor.summarize.summaries.tour_vehicles import (
- allocated_vehicle_age,
- allocated_vehicle_body,
- allocated_vehicle_fuel,
-)
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="tour_category_distribution",
schema={"tour_category": pl.Utf8, "tour_count": pl.Float64},
required_columns={"tours": ("tour_category", "finalweight")},
)
def tour_category(rd: RunData, config: Config) -> pl.DataFrame:
required = {"tour_category", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(tour_category)
+ return tour_category.empty()
return (
rd.tours.filter(pl.col("tour_category").is_not_null())
@@ -49,17 +31,18 @@ def tour_category(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="tour_purpose_distribution",
schema={"tour_purpose": pl.Utf8, "tour_count": pl.Float64},
required_columns={"tours": ("tour_purpose", "finalweight")},
)
def tour_purpose(rd: RunData, config: Config) -> pl.DataFrame:
required = {"tour_purpose", "finalweight"}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(tour_purpose)
+ return tour_purpose.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(tour_purpose)
+ return tour_purpose.empty()
return (
rd.tours.filter(pl.col(purpose_col).is_not_null())
diff --git a/processor/summarize/summaries/tour_geography.py b/processor/summarize/summaries/tour_geography.py
index b4f349c..1018d1b 100644
--- a/processor/summarize/summaries/tour_geography.py
+++ b/processor/summarize/summaries/tour_geography.py
@@ -5,19 +5,19 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import (
- _aggregate_counts_across_geographies,
- _aggregate_counts_by_geography,
+ aggregate_counts_across_geographies,
_configured_geography_columns,
_configured_geography_dimensions,
- _aggregate_weighted_average_across_geographies,
+ aggregate_weighted_average_across_geographies,
_summary_purpose_column,
)
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="average_mandatory_tour_distance_by_purpose_and_geography",
schema={
"mandatory_tour_purpose": pl.Utf8,
"geography_type": pl.Utf8,
@@ -33,7 +33,12 @@ def avg_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
workers = (
rd.per.filter(
(pl.col("workplace_zone_id") > 0)
- & (pl.col("is_worker").cast(pl.Utf8).str.to_lowercase().is_in(["true", "1"]))
+ & (
+ pl.col("is_worker")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .is_in(["true", "1"])
+ )
)
if "is_worker" in rd.per.columns
else rd.per.head(0)
@@ -43,7 +48,12 @@ def avg_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
univ_s = (
rd.per.filter(
(pl.col("school_zone_id") > 0)
- & (pl.col("is_student").cast(pl.Utf8).str.to_lowercase().is_in(["true", "1"]))
+ & (
+ pl.col("is_student")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .is_in(["true", "1"])
+ )
& (pl.col(ptype_col).cast(pl.Utf8) == "3")
)
if "is_student" in rd.per.columns
@@ -52,7 +62,12 @@ def avg_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
schl_s = (
rd.per.filter(
(pl.col("school_zone_id") > 0)
- & (pl.col("is_student").cast(pl.Utf8).str.to_lowercase().is_in(["true", "1"]))
+ & (
+ pl.col("is_student")
+ .cast(pl.Utf8)
+ .str.to_lowercase()
+ .is_in(["true", "1"])
+ )
& (pl.col(ptype_col).cast(pl.Utf8).cast(pl.Int32, strict=False) >= 6)
)
if "is_student" in rd.per.columns
@@ -62,7 +77,9 @@ def avg_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
univ_s = rd.per.head(0)
schl_s = rd.per.head(0)
- def _avg_by_geo(persons: pl.DataFrame, purpose_name: str, dist_col: str) -> pl.DataFrame:
+ def _avg_by_geo(
+ persons: pl.DataFrame, purpose_name: str, dist_col: str
+ ) -> pl.DataFrame:
if dist_col not in persons.columns or len(persons) == 0:
return pl.DataFrame(
schema={
@@ -94,7 +111,7 @@ def _avg_by_geo(persons: pl.DataFrame, purpose_name: str, dist_col: str) -> pl.D
}
)
outputs = [
- _aggregate_weighted_average_across_geographies(
+ aggregate_weighted_average_across_geographies(
base,
geography_dimensions=_configured_geography_dimensions(
base,
@@ -154,7 +171,8 @@ def _avg_by_geo(persons: pl.DataFrame, purpose_name: str, dist_col: str) -> pl.D
).sort(["mandatory_tour_purpose", "geography_type", "geography_id"])
-@summary_contract(
+@summary(
+ id="average_nonmandatory_tour_distance_by_purpose_and_geography",
schema={
"nonmandatory_tour_purpose": pl.Utf8,
"geography_type": pl.Utf8,
@@ -185,7 +203,7 @@ def avg_non_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
if not person_required.issubset(set(rd.per.columns)) or not tour_required.issubset(
set(rd.tours.columns)
):
- return empty_summary_frame(avg_non_mand_tour_distance)
+ return avg_non_mand_tour_distance.empty()
tours = rd.tours.filter(
(pl.col("tour_category").cast(pl.Utf8).str.to_lowercase() == "non_mandatory")
@@ -196,7 +214,7 @@ def avg_non_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(avg_non_mand_tour_distance)
+ return avg_non_mand_tour_distance.empty()
base = (
tours.with_columns(pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose"))
.join(
@@ -226,10 +244,10 @@ def avg_non_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
)
if base.is_empty():
- return empty_summary_frame(avg_non_mand_tour_distance)
+ return avg_non_mand_tour_distance.empty()
outputs = [
- _aggregate_weighted_average_across_geographies(
+ aggregate_weighted_average_across_geographies(
base,
geography_dimensions=_configured_geography_dimensions(
base,
@@ -274,22 +292,28 @@ def avg_non_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
),
]
- return pl.concat(outputs, how="vertical").with_columns(
- pl.col("nonmandatory_tour_purpose").cast(pl.Utf8),
- pl.col("geography_type").cast(pl.Utf8),
- pl.col("geography_id").cast(pl.Utf8),
- pl.col("average_tour_distance").cast(pl.Float64),
- pl.col("tour_count").cast(pl.Float64),
- ).select(
- "nonmandatory_tour_purpose",
- "geography_type",
- "geography_id",
- "average_tour_distance",
- "tour_count",
- ).sort(["nonmandatory_tour_purpose", "geography_type", "geography_id"])
+ return (
+ pl.concat(outputs, how="vertical")
+ .with_columns(
+ pl.col("nonmandatory_tour_purpose").cast(pl.Utf8),
+ pl.col("geography_type").cast(pl.Utf8),
+ pl.col("geography_id").cast(pl.Utf8),
+ pl.col("average_tour_distance").cast(pl.Float64),
+ pl.col("tour_count").cast(pl.Float64),
+ )
+ .select(
+ "nonmandatory_tour_purpose",
+ "geography_type",
+ "geography_id",
+ "average_tour_distance",
+ "tour_count",
+ )
+ .sort(["nonmandatory_tour_purpose", "geography_type", "geography_id"])
+ )
-@summary_contract(
+@summary(
+ id="internal_external_nonmandatory_tour_frequency_by_home_geography",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -304,12 +328,17 @@ def avg_non_mand_tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
def int_vs_ext_non_mand_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
person_required = {"person_id", "home_zone_id"}
tour_required = {"person_id", "tour_category", "is_external_tour", "finalweight"}
- if not person_required.issubset(set(rd.per.columns)) or not tour_required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(int_vs_ext_non_mand_tour_freq)
+ if not person_required.issubset(set(rd.per.columns)) or not tour_required.issubset(
+ set(rd.tours.columns)
+ ):
+ return int_vs_ext_non_mand_tour_freq.empty()
base = (
rd.tours.filter(
- (pl.col("tour_category").cast(pl.Utf8).str.to_lowercase() == "non_mandatory")
+ (
+ pl.col("tour_category").cast(pl.Utf8).str.to_lowercase()
+ == "non_mandatory"
+ )
& pl.col("person_id").is_not_null()
& pl.col("is_external_tour").is_not_null()
)
@@ -335,7 +364,7 @@ def int_vs_ext_non_mand_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
)
)
if base.is_empty():
- return empty_summary_frame(int_vs_ext_non_mand_tour_freq)
+ return int_vs_ext_non_mand_tour_freq.empty()
geography_dimensions = _configured_geography_dimensions(
base,
@@ -411,7 +440,8 @@ def int_vs_ext_non_mand_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="external_nonmandatory_tour_locations",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -429,7 +459,7 @@ def ext_non_mand_tour_loc(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(ext_non_mand_tour_loc)
+ return ext_non_mand_tour_loc.empty()
base = rd.tours.filter(
(pl.col("tour_category").cast(pl.Utf8).str.to_lowercase() == "non_mandatory")
@@ -438,13 +468,15 @@ def ext_non_mand_tour_loc(rd: RunData, config: Config) -> pl.DataFrame:
).select(
"destination",
"finalweight",
- *_configured_geography_columns(rd.tours, config=config, role_prefix="destination"),
+ *_configured_geography_columns(
+ rd.tours, config=config, role_prefix="destination"
+ ),
)
if base.is_empty():
- return empty_summary_frame(ext_non_mand_tour_loc)
+ return ext_non_mand_tour_loc.empty()
outputs = [
- _aggregate_counts_across_geographies(
+ aggregate_counts_across_geographies(
base,
geography_dimensions=_configured_geography_dimensions(
base,
diff --git a/processor/summarize/summaries/tour_profiles.py b/processor/summarize/summaries/tour_profiles.py
index 1b02632..9057ed1 100644
--- a/processor/summarize/summaries/tour_profiles.py
+++ b/processor/summarize/summaries/tour_profiles.py
@@ -5,13 +5,13 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import (
ALL_TOUR_PURPOSES,
_all_purpose_rollup,
_rounded_distance_bin_expr,
_summary_purpose_column,
- _weighted_group_sum,
+ weighted_group_sum,
)
from runtime.config import Config
@@ -26,7 +26,8 @@ def _tour_weights_for_summary(tours: pl.DataFrame) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="tour_mode_by_tour_purpose_and_auto_sufficiency",
schema={
"tour_mode": pl.Utf8,
"tour_purpose": pl.Utf8,
@@ -41,12 +42,15 @@ def _tour_weights_for_summary(tours: pl.DataFrame) -> pl.DataFrame:
)
def tour_mode(rd: RunData, config: Config) -> pl.DataFrame:
required = {"tour_mode", "tour_purpose", "finalweight", "AUTOSUFF"}
- if not required.issubset(set(rd.tours.columns)) or "tour_category" not in rd.tours.columns:
- return empty_summary_frame(tour_mode)
+ if (
+ not required.issubset(set(rd.tours.columns))
+ or "tour_category" not in rd.tours.columns
+ ):
+ return tour_mode.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(tour_mode)
+ return tour_mode.empty()
base = (
rd.tours.filter(
@@ -58,7 +62,7 @@ def tour_mode(rd: RunData, config: Config) -> pl.DataFrame:
.pipe(_tour_weights_for_summary)
)
if base.is_empty():
- return empty_summary_frame(tour_mode)
+ return tour_mode.empty()
aggregated = (
base.group_by(["tour_mode", "tour_purpose", "AUTOSUFF"])
@@ -122,7 +126,8 @@ def tour_mode(rd: RunData, config: Config) -> pl.DataFrame:
return pl.concat([pivot, total], how="vertical")
-@summary_contract(
+@summary(
+ id="tour_stop_frequency_by_tour_purpose",
schema={
"tour_purpose": pl.Utf8,
"outbound_stop_count": pl.Int32,
@@ -143,10 +148,10 @@ def tour_mode(rd: RunData, config: Config) -> pl.DataFrame:
)
def stop_freq(rd: RunData, config: Config) -> pl.DataFrame:
if "tour_purpose" not in rd.tours.columns:
- return empty_summary_frame(stop_freq)
+ return stop_freq.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(stop_freq)
+ return stop_freq.empty()
return (
rd.tours.filter(pl.col("tour_category").is_not_null())
@@ -185,7 +190,8 @@ def stop_freq(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="atwork_subtour_frequency_distribution",
schema={
"atwork_subtour_frequency_category": pl.Utf8,
"atwork_subtour_count": pl.Float64,
@@ -200,9 +206,14 @@ def stop_freq(rd: RunData, config: Config) -> pl.DataFrame:
},
)
def at_work_sub_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
- required = {"tour_purpose", "tour_category", "atwork_subtour_frequency", "finalweight"}
+ required = {
+ "tour_purpose",
+ "tour_category",
+ "atwork_subtour_frequency",
+ "finalweight",
+ }
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(at_work_sub_tour_freq)
+ return at_work_sub_tour_freq.empty()
return (
rd.tours.filter(
@@ -222,12 +233,8 @@ def at_work_sub_tour_freq(rd: RunData, config: Config) -> pl.DataFrame:
)
-def atwork_subtour_frequency_distribution(rd: RunData, config: Config) -> pl.DataFrame:
- """Backward-compatible alias used by summary spec registration."""
- return at_work_sub_tour_freq(rd, config)
-
-
-@summary_contract(
+@summary(
+ id="tour_time_of_day_by_tour_purpose",
schema={
"time_bin": pl.Int32,
"tour_purpose": pl.Utf8,
@@ -238,11 +245,14 @@ def atwork_subtour_frequency_distribution(rd: RunData, config: Config) -> pl.Dat
required_columns={"tours": ("tour_category", "tour_purpose", "finalweight")},
)
def tour_tod(rd: RunData, config: Config) -> pl.DataFrame:
- if "tour_category" not in rd.tours.columns or "tour_purpose" not in rd.tours.columns:
- return empty_summary_frame(tour_tod)
+ if (
+ "tour_category" not in rd.tours.columns
+ or "tour_purpose" not in rd.tours.columns
+ ):
+ return tour_tod.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(tour_tod)
+ return tour_tod.empty()
base = (
rd.tours.filter(pl.col(purpose_col).is_not_null())
@@ -269,7 +279,11 @@ def _hist(df: pl.DataFrame, col: str, filt) -> pl.DataFrame:
.filter(pl.col("time_bin").is_between(1, bins[-1]))
)
counts = sub.group_by("time_bin").agg(pl.col("_tour_weight").sum().alias("n"))
- return pl.DataFrame({"time_bin": bins}).join(counts, on="time_bin", how="left").fill_null(0)
+ return (
+ pl.DataFrame({"time_bin": bins})
+ .join(counts, on="time_bin", how="left")
+ .fill_null(0)
+ )
all_rows: list[dict[str, object]] = []
for purpose_name in purposes:
@@ -289,7 +303,7 @@ def _hist(df: pl.DataFrame, col: str, filt) -> pl.DataFrame:
)
if not all_rows:
- return empty_summary_frame(tour_tod)
+ return tour_tod.empty()
df_long = pl.DataFrame(all_rows, infer_schema_length=None)
total = (
df_long.group_by("time_bin")
@@ -309,10 +323,21 @@ def _hist(df: pl.DataFrame, col: str, filt) -> pl.DataFrame:
"duration_tour_count",
)
)
- return pl.concat([df_long, total], how="vertical").sort(["time_bin", "tour_purpose"])
+ return (
+ pl.concat([df_long, total], how="vertical")
+ .with_columns(
+ pl.col("time_bin").cast(pl.Int32),
+ pl.col("tour_purpose").cast(pl.Utf8),
+ pl.col("departure_tour_count").cast(pl.Float64),
+ pl.col("arrival_tour_count").cast(pl.Float64),
+ pl.col("duration_tour_count").cast(pl.Float64),
+ )
+ .sort(["time_bin", "tour_purpose"])
+ )
-@summary_contract(
+@summary(
+ id="tour_distance_by_tour_purpose",
schema={
"distance_bin": pl.Utf8,
"tour_purpose": pl.Utf8,
@@ -337,16 +362,21 @@ def tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.tours.columns)):
- return empty_summary_frame(tour_distance)
+ return tour_distance.empty()
purpose_col = _summary_purpose_column(rd.tours)
if not purpose_col:
- return empty_summary_frame(tour_distance)
+ return tour_distance.empty()
base = (
- rd.tours.filter(pl.col(purpose_col).is_not_null() & pl.col("SKIMDIST").is_not_null())
+ rd.tours.filter(
+ pl.col(purpose_col).is_not_null() & pl.col("SKIMDIST").is_not_null()
+ )
.with_columns(
pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose"),
- pl.col("SKIMDIST").cast(pl.Float64).round(0).alias("distance_miles_rounded"),
+ pl.col("SKIMDIST")
+ .cast(pl.Float64)
+ .round(0)
+ .alias("distance_miles_rounded"),
)
.with_columns(
pl.when(pl.col("tour_category").cast(pl.Utf8).str.to_lowercase() == "joint")
@@ -362,7 +392,7 @@ def tour_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
)
- by_purpose = _weighted_group_sum(
+ by_purpose = weighted_group_sum(
base,
["distance_bin", "tour_purpose"],
weight_col="adjusted_weight",
diff --git a/processor/summarize/summaries/tour_vehicles.py b/processor/summarize/summaries/tour_vehicles.py
index 683a47a..993d921 100644
--- a/processor/summarize/summaries/tour_vehicles.py
+++ b/processor/summarize/summaries/tour_vehicles.py
@@ -5,7 +5,7 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from runtime.config import Config
@@ -66,7 +66,8 @@ def _prepared_allocated_vehicles_from_tours(rd: RunData) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="allocated_vehicle_age_by_occupancy",
schema={"age": pl.Utf8, "occupancy": pl.Utf8, "vehicle_count": pl.Float64},
required_columns={
"tours": (
@@ -80,7 +81,7 @@ def _prepared_allocated_vehicles_from_tours(rd: RunData) -> pl.DataFrame:
def allocated_vehicle_age(rd: RunData, config: Config) -> pl.DataFrame:
vehicles = _prepared_allocated_vehicles_from_tours(rd)
if vehicles.is_empty():
- return empty_summary_frame(allocated_vehicle_age)
+ return allocated_vehicle_age.empty()
return (
vehicles.group_by(["age", "occupancy"])
@@ -99,7 +100,8 @@ def allocated_vehicle_age(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="allocated_vehicle_fuel_type_by_occupancy",
schema={"fuel_type": pl.Utf8, "occupancy": pl.Utf8, "vehicle_count": pl.Float64},
required_columns={
"tours": (
@@ -113,7 +115,7 @@ def allocated_vehicle_age(rd: RunData, config: Config) -> pl.DataFrame:
def allocated_vehicle_fuel(rd: RunData, config: Config) -> pl.DataFrame:
vehicles = _prepared_allocated_vehicles_from_tours(rd)
if vehicles.is_empty():
- return empty_summary_frame(allocated_vehicle_fuel)
+ return allocated_vehicle_fuel.empty()
return (
vehicles.group_by(["fuel_type", "occupancy"])
@@ -128,7 +130,8 @@ def allocated_vehicle_fuel(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="allocated_vehicle_body_type_by_occupancy",
schema={"body_type": pl.Utf8, "occupancy": pl.Utf8, "vehicle_count": pl.Float64},
required_columns={
"tours": (
@@ -142,7 +145,7 @@ def allocated_vehicle_fuel(rd: RunData, config: Config) -> pl.DataFrame:
def allocated_vehicle_body(rd: RunData, config: Config) -> pl.DataFrame:
vehicles = _prepared_allocated_vehicles_from_tours(rd)
if vehicles.is_empty():
- return empty_summary_frame(allocated_vehicle_body)
+ return allocated_vehicle_body.empty()
return (
vehicles.group_by(["body_type", "occupancy"])
diff --git a/processor/summarize/summaries/trip.py b/processor/summarize/summaries/trip.py
index 6eed25b..af34f0a 100644
--- a/processor/summarize/summaries/trip.py
+++ b/processor/summarize/summaries/trip.py
@@ -3,25 +3,20 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
-from processor.summarize.summaries.trip_distributions import (
- stop_ood_distance,
- trip_distance,
- trip_stop_tod,
-)
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import (
ALL_TOUR_PURPOSES,
- _aggregate_counts_across_geographies,
- _aggregate_counts_by_geography,
+ aggregate_counts_across_geographies,
_all_purpose_rollup as _all_tour_purpose_rollup,
_configured_geography_dimensions,
_summary_purpose_column as _trip_purpose_column,
- _weighted_group_sum,
+ weighted_group_sum,
)
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="trip_purpose_distribution",
schema={
"tour_purpose": pl.Utf8,
"trip_purpose": pl.Utf8,
@@ -32,10 +27,10 @@
def trip_purpose(rd: RunData, config: Config) -> pl.DataFrame:
required = {"trip_purpose", "finalweight"}
if not required.issubset(set(rd.trips.columns)):
- return empty_summary_frame(trip_purpose)
+ return trip_purpose.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(trip_purpose)
+ return trip_purpose.empty()
base = (
rd.trips.filter(pl.col("trip_purpose").is_not_null())
@@ -50,22 +45,19 @@ def trip_purpose(rd: RunData, config: Config) -> pl.DataFrame:
)
.select("tour_purpose", "trip_purpose", "trip_count")
)
- all_purposes = (
- _all_tour_purpose_rollup(
- base,
- group_cols=["trip_purpose"],
- value_col="trip_count",
- )
- .select("tour_purpose", "trip_purpose", "trip_count")
+ all_purposes = _all_tour_purpose_rollup(
+ base,
+ group_cols=["trip_purpose"],
+ value_col="trip_count",
+ ).select("tour_purpose", "trip_purpose", "trip_count")
+
+ return pl.concat([base, all_purposes], how="vertical").sort(
+ ["tour_purpose", "trip_purpose"]
)
- return (
- pl.concat([base, all_purposes], how="vertical")
- .sort(["tour_purpose", "trip_purpose"])
- )
-
-@summary_contract(
+@summary(
+ id="stop_destination_purpose_by_tour_purpose",
schema={
"stop_destination_purpose": pl.Utf8,
"tour_purpose": pl.Utf8,
@@ -79,11 +71,11 @@ def stop_purpose_by_tour_purpose(rd: RunData, config: Config) -> pl.DataFrame:
"""Stop destination purpose by tour purpose."""
required = {"stops", "tour_purpose", "trip_purpose", "finalweight"}
if not required.issubset(rd.trips.columns):
- return empty_summary_frame(stop_purpose_by_tour_purpose)
+ return stop_purpose_by_tour_purpose.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(stop_purpose_by_tour_purpose)
+ return stop_purpose_by_tour_purpose.empty()
return (
rd.trips.filter(pl.col("stops") == 1)
@@ -103,7 +95,8 @@ def stop_purpose_by_tour_purpose(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="trip_mode_by_tour_purpose_and_tour_mode",
schema={
"tour_purpose": pl.Utf8,
"tour_mode": pl.Utf8,
@@ -118,13 +111,13 @@ def trip_mode(rd: RunData, config: Config) -> pl.DataFrame:
"""Returns DataFrame: tour_purpose, tour_mode, trip_mode, trip_count."""
needed = {"tour_mode", "trip_mode"}
if not needed.issubset(rd.trips.columns):
- return empty_summary_frame(trip_mode)
+ return trip_mode.empty()
if "tour_purpose" not in rd.trips.columns:
- return empty_summary_frame(trip_mode)
+ return trip_mode.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(trip_mode)
+ return trip_mode.empty()
base = (
rd.trips.filter(
@@ -133,7 +126,7 @@ def trip_mode(rd: RunData, config: Config) -> pl.DataFrame:
& pl.col("trip_mode").is_not_null()
)
.pipe(
- _weighted_group_sum,
+ weighted_group_sum,
[purpose_col, "tour_mode", "trip_mode"],
weight_col="finalweight",
output_col="trip_count",
@@ -147,14 +140,11 @@ def trip_mode(rd: RunData, config: Config) -> pl.DataFrame:
)
)
- all_purposes = (
- _all_tour_purpose_rollup(
- base,
- group_cols=["tour_mode", "trip_mode"],
- value_col="trip_count",
- )
- .select("tour_purpose", "tour_mode", "trip_mode", "trip_count")
- )
+ all_purposes = _all_tour_purpose_rollup(
+ base,
+ group_cols=["tour_mode", "trip_mode"],
+ value_col="trip_count",
+ ).select("tour_purpose", "tour_mode", "trip_mode", "trip_count")
all_tour_modes = (
base.group_by(["tour_purpose", "trip_mode"])
.agg(trip_count=pl.col("trip_count").sum())
@@ -178,7 +168,8 @@ def trip_mode(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="parking_locations",
schema={
"geography_type": pl.Utf8,
"geography_id": pl.Utf8,
@@ -189,17 +180,17 @@ def trip_mode(rd: RunData, config: Config) -> pl.DataFrame:
def parking_locations(rd: RunData, config: Config) -> pl.DataFrame:
required = {"parking_zone", "finalweight"}
if not required.issubset(set(rd.trips.columns)):
- return empty_summary_frame(parking_locations)
+ return parking_locations.empty()
base = rd.trips.filter(
pl.col("parking_zone").is_not_null()
& (pl.col("parking_zone").cast(pl.Int64, strict=False) > 0)
).select("parking_zone", "finalweight")
if base.is_empty():
- return empty_summary_frame(parking_locations)
+ return parking_locations.empty()
outputs = [
- _aggregate_counts_across_geographies(
+ aggregate_counts_across_geographies(
base,
geography_dimensions=_configured_geography_dimensions(
base,
diff --git a/processor/summarize/summaries/trip_distributions.py b/processor/summarize/summaries/trip_distributions.py
index d5b9aa8..916453d 100644
--- a/processor/summarize/summaries/trip_distributions.py
+++ b/processor/summarize/summaries/trip_distributions.py
@@ -5,18 +5,19 @@
import polars as pl
from processor.models import RunData
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize.contracts import summary
from processor.summarize.summaries.summary_helpers import (
ALL_TOUR_PURPOSES,
_all_purpose_rollup as _all_tour_purpose_rollup,
_dense_zero_fill,
_summary_purpose_column as _trip_purpose_column,
- _weighted_group_sum,
+ weighted_group_sum,
)
from runtime.config import Config
-@summary_contract(
+@summary(
+ id="trip_departure_time_by_purpose",
schema={
"tour_purpose": pl.Utf8,
"time_bin": pl.Int32,
@@ -29,22 +30,22 @@ def trip_stop_tod(rd: RunData, config: Config) -> pl.DataFrame:
"""Stop and trip departure timing profiles."""
dep_col = _departure_time_column(rd.trips)
if dep_col is None or "stops" not in rd.trips.columns:
- return empty_summary_frame(trip_stop_tod)
+ return trip_stop_tod.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(trip_stop_tod)
+ return trip_stop_tod.empty()
all_trips = rd.trips.with_columns(
pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose")
)
if "tour_purpose" not in all_trips.columns:
- return empty_summary_frame(trip_stop_tod)
+ return trip_stop_tod.empty()
stops = all_trips.filter(pl.col("stops") == 1)
bins = _departure_bins(all_trips, dep_col)
if not bins:
- return empty_summary_frame(trip_stop_tod)
+ return trip_stop_tod.empty()
purpose_values = (
all_trips.select(pl.col("tour_purpose"))
@@ -63,18 +64,20 @@ def trip_stop_tod(rd: RunData, config: Config) -> pl.DataFrame:
if purpose_name == ALL_TOUR_PURPOSES
else pl.col("tour_purpose") == purpose_name
)
- stop_sub = stops.filter(purpose_filter & pl.col(dep_col).is_between(1, bins[-1]))
+ stop_sub = stops.filter(
+ purpose_filter & pl.col(dep_col).is_between(1, bins[-1])
+ )
trip_sub = all_trips.filter(
purpose_filter & pl.col(dep_col).is_between(1, bins[-1])
)
- stop_counts = _weighted_group_sum(
+ stop_counts = weighted_group_sum(
stop_sub,
dep_col,
weight_col="finalweight",
output_col="departure_stop_count",
)
- trip_counts = _weighted_group_sum(
+ trip_counts = weighted_group_sum(
trip_sub,
dep_col,
weight_col="finalweight",
@@ -121,7 +124,8 @@ def trip_stop_tod(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="trip_distance_by_purpose",
schema={
"distance_bin": pl.Utf8,
"tour_purpose": pl.Utf8,
@@ -139,11 +143,11 @@ def trip_distance(rd: RunData, config: Config) -> pl.DataFrame:
"finalweight",
}
if not required.issubset(set(rd.trips.columns)):
- return empty_summary_frame(trip_distance)
+ return trip_distance.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(trip_distance)
+ return trip_distance.empty()
base = (
rd.trips.filter(
@@ -171,7 +175,7 @@ def trip_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
)
- by_purpose = _weighted_group_sum(
+ by_purpose = weighted_group_sum(
base,
["distance_bin", "tour_purpose"],
weight_col="adjusted_weight",
@@ -200,7 +204,8 @@ def trip_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
-@summary_contract(
+@summary(
+ id="stop_out_of_direction_distance_by_tour_purpose",
schema={
"distance_bin": pl.Int32,
"tour_purpose": pl.Utf8,
@@ -213,39 +218,40 @@ def trip_distance(rd: RunData, config: Config) -> pl.DataFrame:
def stop_ood_distance(rd: RunData, config: Config) -> pl.DataFrame:
"""Out-of-direction distance for stops, in 41 bins (0-40 miles)."""
if "stops" not in rd.trips.columns:
- return empty_summary_frame(stop_ood_distance)
+ return stop_ood_distance.empty()
purpose_col = _trip_purpose_column(rd.trips)
if not purpose_col:
- return empty_summary_frame(stop_ood_distance)
+ return stop_ood_distance.empty()
stops = rd.trips.filter(pl.col("stops") == 1)
if "out_dir_dist" not in stops.columns:
- return empty_summary_frame(stop_ood_distance)
+ return stop_ood_distance.empty()
- stops2 = stops.filter(pl.col("out_dir_dist").is_not_null()).with_columns(
- pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose"),
- pl.col("out_dir_dist").clip(0, 999).alias("ood"),
- ).with_columns(pl.col("ood").cast(pl.Int32).clip(0, 40).alias("distance_bin"))
+ stops2 = (
+ stops.filter(pl.col("out_dir_dist").is_not_null())
+ .with_columns(
+ pl.col(purpose_col).cast(pl.Utf8).alias("tour_purpose"),
+ pl.col("out_dir_dist").clip(0, 999).alias("ood"),
+ )
+ .with_columns(pl.col("ood").cast(pl.Int32).clip(0, 40).alias("distance_bin"))
+ )
if "tour_purpose" not in stops2.columns or stops2.is_empty():
- return empty_summary_frame(stop_ood_distance)
+ return stop_ood_distance.empty()
bins_df = pl.DataFrame(
{"distance_bin": list(range(0, 41))}, schema={"distance_bin": pl.Int32}
)
- by_purpose = (
- _weighted_group_sum(
- stops2.filter(pl.col("tour_purpose").is_not_null()),
- ["tour_purpose", "distance_bin"],
- weight_col="finalweight",
- output_col="stop_count",
- )
- .select(
- pl.col("distance_bin").cast(pl.Int32),
- pl.col("tour_purpose").cast(pl.Utf8),
- pl.col("stop_count").cast(pl.Float64),
- )
+ by_purpose = weighted_group_sum(
+ stops2.filter(pl.col("tour_purpose").is_not_null()),
+ ["tour_purpose", "distance_bin"],
+ weight_col="finalweight",
+ output_col="stop_count",
+ ).select(
+ pl.col("distance_bin").cast(pl.Int32),
+ pl.col("tour_purpose").cast(pl.Utf8),
+ pl.col("stop_count").cast(pl.Float64),
)
purposes = (
@@ -267,7 +273,7 @@ def stop_ood_distance(rd: RunData, config: Config) -> pl.DataFrame:
)
total = (
- _weighted_group_sum(
+ weighted_group_sum(
stops2,
"distance_bin",
weight_col="finalweight",
diff --git a/processor/summarize/summaries/validation.py b/processor/summarize/summaries/validation.py
index 9683fe9..a035ac6 100644
--- a/processor/summarize/summaries/validation.py
+++ b/processor/summarize/summaries/validation.py
@@ -1,13 +1,36 @@
"""Validation summaries."""
+from runtime.logging import get_logger
import polars as pl
from runtime.config import Config
from processor.models import RunData
-from processor.summarize.contracts import summary_contract
+from processor.summarize.contracts import summary
+from processor.summarize.summaries.summary_helpers import (
+ _configured_geography_dimensions,
+)
+
+
+LOGGER = get_logger("processor.summarize.validation")
+ALL_GEOGRAPHIES = "all_geographies"
+ALL_INCOME_SEGMENTS = "all_income_segments"
+ALL_HOUSEHOLD_SIZES = "all_household_sizes"
+ALL_AUTO_MODES = "All Auto"
+DAILY_TIME_PERIOD = "Daily"
+NON_MOTORIZED_MODES = {"WALK", "BIKE", "EBIKE"}
# TODO: Update with actual fields from Visum outputs/traffic count inputs
# TODO Maybe change to outer join
+@summary(
+ id="traffic_count_comparisons",
+ schema={
+ "count_location_id": pl.Utf8,
+ "direction": pl.Utf8,
+ "count_period": pl.Utf8,
+ "observed_volume": pl.Float64,
+ "modeled_volume": pl.Float64,
+ },
+)
def traffic_count_comparisons(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"count_location_id": pl.Utf8,
@@ -92,6 +115,16 @@ def traffic_count_comparisons(rd: RunData, config: Config) -> pl.DataFrame:
# TODO update based on actual visum output shape; input shape
+@summary(
+ id="screenline_flow_comparisons",
+ schema={
+ "screenline_id": pl.Utf8,
+ "direction": pl.Utf8,
+ "count_period": pl.Utf8,
+ "observed_volume": pl.Float64,
+ "modeled_volume": pl.Float64,
+ },
+)
def screenline_flow_comparisons(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"screenline_id": pl.Utf8,
@@ -165,6 +198,10 @@ def screenline_flow_comparisons(rd: RunData, config: Config) -> pl.DataFrame:
# TODO: Rewrite once I know what the VISUM fields look like
+@summary(
+ id="transit_boardings_by_operator_and_technology",
+ schema={"operator": pl.Utf8, "technology": pl.Utf8, "boardings": pl.Float64},
+)
def total_transit_boardings(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"operator": pl.Utf8,
@@ -236,6 +273,15 @@ def _first_existing(columns: list[str], candidates: list[str]) -> str | None:
# TODO: Update once we know the shape of the Visum transit output
+@summary(
+ id="transit_transfer_rate",
+ schema={
+ "operator": pl.Utf8,
+ "technology": pl.Utf8,
+ "access_mode": pl.Utf8,
+ "transfer_rate": pl.Float64,
+ },
+)
def transit_transfer_rate(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"operator": pl.Utf8,
@@ -342,7 +388,8 @@ def _first_existing(columns: list[str], candidates: list[str]) -> str | None:
)
-@summary_contract(
+@summary(
+ id="auto_vmt_totals",
schema={
"auto_vmt": pl.Float64,
},
@@ -391,7 +438,672 @@ def auto_vmt_totals(rd: RunData, config: Config) -> pl.DataFrame:
)
+def _auto_mode_filter(config: Config | None) -> pl.Expr:
+ auto_modes: list[str] | None = None
+ if config is not None and config.mode_groups and "Auto" in config.mode_groups:
+ auto_modes = config.mode_groups["Auto"]
+
+ if auto_modes is not None:
+ return pl.col("trip_mode").cast(pl.Utf8).is_in(auto_modes)
+ return (
+ pl.col("trip_mode")
+ .cast(pl.Utf8)
+ .str.to_uppercase()
+ .str.contains("DRIVE|SHARED|SOV|HOV|AUTO|TAXI|TNC")
+ )
+
+
+def _distance_base(
+ rd: RunData,
+ config: Config | None,
+) -> tuple[pl.DataFrame, str] | None:
+ trips = rd.trips
+ if "finalweight" not in trips.columns:
+ return None
+
+ if (
+ "skim_auto_distance" in trips.columns
+ and trips["skim_auto_distance"].is_not_null().any()
+ ):
+ LOGGER.info(
+ "[vmt_by_segment] Run %r using distance_source=skim_auto_distance",
+ rd.label,
+ )
+ return (
+ trips.filter(pl.col("skim_auto_distance").is_not_null()).with_columns(
+ pl.col("skim_auto_distance").cast(pl.Float64).alias("_vmt_distance")
+ ),
+ "skim_auto_distance",
+ )
+
+ if "od_dist" not in trips.columns or "trip_mode" not in trips.columns:
+ LOGGER.warning(
+ "[vmt_by_segment] Run %r has no usable auto distance source.",
+ rd.label,
+ )
+ return None
+
+ LOGGER.info(
+ "[vmt_by_segment] Run %r using distance_source=od_dist",
+ rd.label,
+ )
+ return (
+ trips.filter(
+ _auto_mode_filter(config) & pl.col("od_dist").is_not_null()
+ ).with_columns(pl.col("od_dist").cast(pl.Float64).alias("_vmt_distance")),
+ "od_dist",
+ )
+
+
+def _with_time_period(
+ df: pl.DataFrame,
+ rd: RunData,
+ config: Config | None,
+) -> tuple[pl.DataFrame, str]:
+ if "trip_period" in df.columns and df["trip_period"].is_not_null().any():
+ LOGGER.info(
+ "[vmt_by_segment] Run %r using time_period_source=trip_period",
+ rd.label,
+ )
+ return (
+ df.with_columns(
+ pl.col("trip_period")
+ .cast(pl.Utf8)
+ .fill_null(DAILY_TIME_PERIOD)
+ .alias("time_period")
+ ),
+ "trip_period",
+ )
+
+ LOGGER.info(
+ "[vmt_by_segment] Run %r using time_period_source=daily",
+ rd.label,
+ )
+ return df.with_columns(pl.lit(DAILY_TIME_PERIOD).alias("time_period")), "daily"
+
+
+def _household_join_columns(hh: pl.DataFrame) -> list[str]:
+ candidates = [
+ "household_id",
+ "income_segment",
+ "HHSIZE",
+ "hhsize",
+ "home_taz",
+ "home_county",
+ "home_mpo",
+ *sorted(column for column in hh.columns if column.startswith("home_geo__")),
+ ]
+ return list(dict.fromkeys(column for column in candidates if column in hh.columns))
+
+
+def _aggregate_vmt_for_geography(
+ df: pl.DataFrame,
+ *,
+ geography_type: str,
+ geography_col: str | None,
+ distance_source: str,
+ time_period_source: str,
+) -> pl.DataFrame:
+ working = df
+ if geography_col is None:
+ working = working.with_columns(pl.lit(ALL_GEOGRAPHIES).alias("_geography_id"))
+ else:
+ working = working.filter(pl.col(geography_col).is_not_null()).with_columns(
+ pl.col(geography_col).cast(pl.Utf8).alias("_geography_id")
+ )
+
+ if working.is_empty():
+ return auto_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ aggregated = (
+ working.group_by(
+ ["_geography_id", "income_segment", "household_size", "time_period", "mode"]
+ )
+ .agg(
+ pl.col("auto_vmt").sum().alias("auto_vmt"),
+ pl.col("finalweight").sum().alias("trip_count"),
+ )
+ .with_columns(
+ pl.lit(geography_type).alias("geography_type"),
+ pl.col("_geography_id").alias("geography_id"),
+ pl.lit(distance_source).alias("distance_source"),
+ pl.lit(time_period_source).alias("time_period_source"),
+ )
+ .select(
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "mode",
+ "auto_vmt",
+ "trip_count",
+ "distance_source",
+ "time_period_source",
+ )
+ )
+ return _with_derived_daily_vmt_rows(aggregated)
+
+
+def _with_derived_daily_vmt_rows(
+ df: pl.DataFrame,
+ *,
+ value_col: str = "auto_vmt",
+) -> pl.DataFrame:
+ """Add a Daily total for segment groups that have time-period rows."""
+ if df.is_empty() or "time_period" not in df.columns:
+ return df
+
+ group_cols = [
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "mode",
+ "distance_source",
+ "time_period_source",
+ ]
+ if not set(group_cols).issubset(df.columns):
+ return df
+
+ non_daily = df.filter(pl.col("time_period") != DAILY_TIME_PERIOD)
+ if non_daily.is_empty():
+ return df
+
+ groups_with_period_rows = non_daily.select(group_cols).unique()
+ daily_only_rows = df.filter(pl.col("time_period") == DAILY_TIME_PERIOD).join(
+ groups_with_period_rows, on=group_cols, how="anti"
+ )
+ derived_daily_rows = (
+ df.join(groups_with_period_rows, on=group_cols, how="inner")
+ .group_by(group_cols)
+ .agg(
+ pl.col(value_col).sum().alias(value_col),
+ pl.col("trip_count").sum().alias("trip_count"),
+ )
+ .with_columns(pl.lit(DAILY_TIME_PERIOD).alias("time_period"))
+ .select(df.columns)
+ )
+
+ return pl.concat(
+ [non_daily, daily_only_rows, derived_daily_rows],
+ how="vertical",
+ ).select(df.columns)
+
+
+@summary(
+ id="auto_vmt_by_home_geography_income_hhsize_time_period",
+ schema={
+ "geography_type": pl.Utf8,
+ "geography_id": pl.Utf8,
+ "income_segment": pl.Utf8,
+ "household_size": pl.Utf8,
+ "time_period": pl.Utf8,
+ "mode": pl.Utf8,
+ "auto_vmt": pl.Float64,
+ "trip_count": pl.Float64,
+ "distance_source": pl.Utf8,
+ "time_period_source": pl.Utf8,
+ },
+ required_columns={"trips": ("finalweight",)},
+)
+def auto_vmt_by_home_geography_income_hhsize_time_period(
+ rd: RunData,
+ config: Config,
+) -> pl.DataFrame:
+ distance_selection = _distance_base(rd, config)
+ if distance_selection is None:
+ return auto_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ base, distance_source = distance_selection
+ base, time_period_source = _with_time_period(base, rd, config)
+
+ if "household_id" in base.columns and not rd.hh.is_empty():
+ household_columns = _household_join_columns(rd.hh)
+ if "household_id" in household_columns:
+ household_preferred_columns = [
+ column
+ for column in household_columns
+ if column not in {"household_id", "income_segment"}
+ ]
+ base = base.drop(household_preferred_columns, strict=False)
+ base = base.join(
+ rd.hh.select(household_columns).rename(
+ {"income_segment": "income_segment_hh"}
+ if "income_segment" in household_columns
+ else {}
+ ),
+ on="household_id",
+ how="left",
+ )
+
+ income_exprs: list[pl.Expr] = []
+ if "income_segment" in base.columns and "income_segment_hh" in base.columns:
+ income_exprs.append(
+ pl.coalesce([pl.col("income_segment"), pl.col("income_segment_hh")]).alias(
+ "_income_segment"
+ )
+ )
+ elif "income_segment" in base.columns:
+ income_exprs.append(pl.col("income_segment").alias("_income_segment"))
+ elif "income_segment_hh" in base.columns:
+ income_exprs.append(pl.col("income_segment_hh").alias("_income_segment"))
+ else:
+ income_exprs.append(pl.lit(ALL_INCOME_SEGMENTS).alias("_income_segment"))
+
+ household_size_expr = (
+ pl.col("HHSIZE")
+ if "HHSIZE" in base.columns
+ else pl.col("hhsize")
+ if "hhsize" in base.columns
+ else pl.lit(ALL_HOUSEHOLD_SIZES)
+ )
+ occupancy_expr = (
+ pl.col("num_participants").fill_null(1).clip(lower_bound=1)
+ if "num_participants" in base.columns
+ else pl.lit(1)
+ )
+
+ base = (
+ base.with_columns(*income_exprs)
+ .with_columns(
+ pl.col("_income_segment")
+ .cast(pl.Utf8)
+ .fill_null(ALL_INCOME_SEGMENTS)
+ .alias("income_segment"),
+ household_size_expr.cast(pl.Utf8)
+ .fill_null(ALL_HOUSEHOLD_SIZES)
+ .alias("household_size"),
+ (
+ pl.col("trip_mode").cast(pl.Utf8).fill_null(ALL_AUTO_MODES)
+ if "trip_mode" in base.columns
+ else pl.lit(ALL_AUTO_MODES)
+ ).alias("mode"),
+ (
+ pl.col("_vmt_distance")
+ * pl.col("finalweight").cast(pl.Float64)
+ / occupancy_expr
+ ).alias("auto_vmt"),
+ )
+ .filter(pl.col("auto_vmt").is_not_null())
+ )
+
+ geography_dimensions: list[tuple[str, str | None]] = [
+ (ALL_GEOGRAPHIES, None),
+ *_configured_geography_dimensions(
+ base,
+ config=config,
+ base_type="home_taz",
+ base_col="home_taz",
+ role_prefix="home",
+ ),
+ ]
+ LOGGER.info(
+ "[vmt_by_segment] Run %r home geography dimensions: %s",
+ rd.label,
+ ", ".join(geography_type for geography_type, _ in geography_dimensions),
+ )
+ if len(geography_dimensions) == 1:
+ LOGGER.info(
+ "[vmt_by_segment] Run %r using all_geographies only.",
+ rd.label,
+ )
+
+ outputs = [
+ _aggregate_vmt_for_geography(
+ base,
+ geography_type=geography_type,
+ geography_col=geography_col,
+ distance_source=distance_source,
+ time_period_source=time_period_source,
+ )
+ for geography_type, geography_col in geography_dimensions
+ ]
+ outputs = [output for output in outputs if not output.is_empty()]
+ if not outputs:
+ return auto_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ return (
+ pl.concat(outputs, how="vertical")
+ .with_columns(
+ pl.col("geography_type").cast(pl.Utf8),
+ pl.col("geography_id").cast(pl.Utf8),
+ pl.col("income_segment").cast(pl.Utf8),
+ pl.col("household_size").cast(pl.Utf8),
+ pl.col("time_period").cast(pl.Utf8),
+ pl.col("mode").cast(pl.Utf8),
+ pl.col("auto_vmt").cast(pl.Float64),
+ pl.col("trip_count").cast(pl.Float64),
+ pl.col("distance_source").cast(pl.Utf8),
+ pl.col("time_period_source").cast(pl.Utf8),
+ )
+ .sort(
+ [
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "mode",
+ "time_period",
+ ]
+ )
+ )
+
+
+def _non_motorized_mode_filter() -> pl.Expr:
+ return (
+ pl.col("trip_mode")
+ .cast(pl.Utf8)
+ .str.to_uppercase()
+ .is_in(sorted(NON_MOTORIZED_MODES))
+ )
+
+
+def _distance_source_expr(
+ *,
+ mode_expr: pl.Expr,
+ walk_columns: list[str],
+ bike_columns: list[str],
+ prepared_column: str | None,
+) -> pl.Expr:
+ expr = pl.lit(None, dtype=pl.Utf8)
+ bike_modes = mode_expr.is_in(["BIKE", "EBIKE"])
+ if prepared_column is not None:
+ expr = (
+ pl.when(
+ mode_expr.is_in(sorted(NON_MOTORIZED_MODES))
+ & pl.col(prepared_column).is_not_null()
+ )
+ .then(pl.lit(prepared_column))
+ .otherwise(expr)
+ )
+ for column in reversed([column for column in bike_columns if column]):
+ expr = (
+ pl.when(bike_modes & pl.col(column).is_not_null())
+ .then(pl.lit(column))
+ .otherwise(expr)
+ )
+ for column in reversed([column for column in walk_columns if column]):
+ expr = (
+ pl.when((mode_expr == "WALK") & pl.col(column).is_not_null())
+ .then(pl.lit(column))
+ .otherwise(expr)
+ )
+ return expr
+
+
+def _non_motorized_distance_base(rd: RunData) -> tuple[pl.DataFrame, str] | None:
+ trips = rd.trips
+ if "finalweight" not in trips.columns or "trip_mode" not in trips.columns:
+ return None
+
+ walk_precedence = [
+ column
+ for column in ("skim_walk_maz_distance", "skim_walk_distance")
+ if column in trips.columns
+ ]
+ bike_precedence = [
+ column
+ for column in ("skim_bike_maz_distance", "skim_bike_distance")
+ if column in trips.columns
+ ]
+ prepared_column = (
+ "prepared_non_motorized_distance"
+ if "prepared_non_motorized_distance" in trips.columns
+ else None
+ )
+ available_columns = [*walk_precedence, *bike_precedence]
+ if prepared_column is not None:
+ available_columns.append(prepared_column)
+ if not available_columns:
+ LOGGER.warning(
+ "[non_motorized_vmt_by_segment] Run %r has no usable non-motorized distance source.",
+ rd.label,
+ )
+ return None
+
+ prepared_exprs = (
+ [pl.col(prepared_column).cast(pl.Float64)]
+ if prepared_column is not None
+ else []
+ )
+ walk_distance_exprs = [
+ pl.col(column).cast(pl.Float64) for column in walk_precedence
+ ] + prepared_exprs
+ bike_distance_exprs = [
+ pl.col(column).cast(pl.Float64) for column in bike_precedence
+ ] + prepared_exprs
+ base = (
+ trips.filter(_non_motorized_mode_filter())
+ .with_columns(
+ pl.col("trip_mode").cast(pl.Utf8).str.to_uppercase().alias("_nm_mode")
+ )
+ .with_columns(
+ pl.when(pl.col("_nm_mode") == "WALK")
+ .then(
+ pl.coalesce(walk_distance_exprs)
+ if walk_distance_exprs
+ else pl.lit(None, dtype=pl.Float64)
+ )
+ .when(pl.col("_nm_mode").is_in(["BIKE", "EBIKE"]))
+ .then(
+ pl.coalesce(bike_distance_exprs)
+ if bike_distance_exprs
+ else pl.lit(None, dtype=pl.Float64)
+ )
+ .otherwise(None)
+ .alias("_vmt_distance"),
+ _distance_source_expr(
+ mode_expr=pl.col("_nm_mode"),
+ walk_columns=walk_precedence,
+ bike_columns=bike_precedence,
+ prepared_column=prepared_column,
+ ).alias("distance_source"),
+ )
+ .filter(pl.col("_vmt_distance").is_not_null())
+ )
+ if base.is_empty():
+ return None
+ return base, "mixed_non_motorized_distance"
+
+
+def _aggregate_non_motorized_vmt_for_geography(
+ df: pl.DataFrame,
+ *,
+ geography_type: str,
+ geography_col: str | None,
+ time_period_source: str,
+) -> pl.DataFrame:
+ working = df
+ if geography_col is None:
+ working = working.with_columns(pl.lit(ALL_GEOGRAPHIES).alias("_geography_id"))
+ else:
+ working = working.filter(pl.col(geography_col).is_not_null()).with_columns(
+ pl.col(geography_col).cast(pl.Utf8).alias("_geography_id")
+ )
+
+ if working.is_empty():
+ return non_motorized_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ aggregated = (
+ working.group_by(
+ ["_geography_id", "income_segment", "household_size", "time_period", "mode"]
+ )
+ .agg(
+ pl.col("non_motorized_vmt").sum().alias("non_motorized_vmt"),
+ pl.col("finalweight").sum().alias("trip_count"),
+ pl.col("distance_source").drop_nulls().first().alias("distance_source"),
+ )
+ .with_columns(
+ pl.lit(geography_type).alias("geography_type"),
+ pl.col("_geography_id").alias("geography_id"),
+ pl.lit(time_period_source).alias("time_period_source"),
+ )
+ .select(
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "mode",
+ "non_motorized_vmt",
+ "trip_count",
+ "distance_source",
+ "time_period_source",
+ )
+ )
+ return _with_derived_daily_vmt_rows(
+ aggregated,
+ value_col="non_motorized_vmt",
+ )
+
+
+@summary(
+ id="non_motorized_vmt_by_home_geography_income_hhsize_time_period",
+ schema={
+ "geography_type": pl.Utf8,
+ "geography_id": pl.Utf8,
+ "income_segment": pl.Utf8,
+ "household_size": pl.Utf8,
+ "time_period": pl.Utf8,
+ "mode": pl.Utf8,
+ "non_motorized_vmt": pl.Float64,
+ "trip_count": pl.Float64,
+ "distance_source": pl.Utf8,
+ "time_period_source": pl.Utf8,
+ },
+ required_columns={"trips": ("finalweight", "trip_mode")},
+)
+def non_motorized_vmt_by_home_geography_income_hhsize_time_period(
+ rd: RunData,
+ config: Config,
+) -> pl.DataFrame:
+ distance_selection = _non_motorized_distance_base(rd)
+ if distance_selection is None:
+ return non_motorized_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ base, _ = distance_selection
+ base, time_period_source = _with_time_period(base, rd, config)
+
+ if "household_id" in base.columns and not rd.hh.is_empty():
+ household_columns = _household_join_columns(rd.hh)
+ if "household_id" in household_columns:
+ household_preferred_columns = [
+ column
+ for column in household_columns
+ if column not in {"household_id", "income_segment"}
+ ]
+ base = base.drop(household_preferred_columns, strict=False)
+ base = base.join(
+ rd.hh.select(household_columns).rename(
+ {"income_segment": "income_segment_hh"}
+ if "income_segment" in household_columns
+ else {}
+ ),
+ on="household_id",
+ how="left",
+ )
+
+ income_exprs: list[pl.Expr] = []
+ if "income_segment" in base.columns and "income_segment_hh" in base.columns:
+ income_exprs.append(
+ pl.coalesce([pl.col("income_segment"), pl.col("income_segment_hh")]).alias(
+ "_income_segment"
+ )
+ )
+ elif "income_segment" in base.columns:
+ income_exprs.append(pl.col("income_segment").alias("_income_segment"))
+ elif "income_segment_hh" in base.columns:
+ income_exprs.append(pl.col("income_segment_hh").alias("_income_segment"))
+ else:
+ income_exprs.append(pl.lit(ALL_INCOME_SEGMENTS).alias("_income_segment"))
+
+ household_size_expr = (
+ pl.col("HHSIZE")
+ if "HHSIZE" in base.columns
+ else pl.col("hhsize")
+ if "hhsize" in base.columns
+ else pl.lit(ALL_HOUSEHOLD_SIZES)
+ )
+
+ base = (
+ base.with_columns(*income_exprs)
+ .with_columns(
+ pl.col("_income_segment")
+ .cast(pl.Utf8)
+ .fill_null(ALL_INCOME_SEGMENTS)
+ .alias("income_segment"),
+ household_size_expr.cast(pl.Utf8)
+ .fill_null(ALL_HOUSEHOLD_SIZES)
+ .alias("household_size"),
+ pl.col("_nm_mode").cast(pl.Utf8).alias("mode"),
+ (pl.col("_vmt_distance") * pl.col("finalweight").cast(pl.Float64)).alias(
+ "non_motorized_vmt"
+ ),
+ )
+ .filter(pl.col("non_motorized_vmt").is_not_null())
+ )
+
+ geography_dimensions: list[tuple[str, str | None]] = [
+ (ALL_GEOGRAPHIES, None),
+ *_configured_geography_dimensions(
+ base,
+ config=config,
+ base_type="home_taz",
+ base_col="home_taz",
+ role_prefix="home",
+ ),
+ ]
+
+ outputs = [
+ _aggregate_non_motorized_vmt_for_geography(
+ base,
+ geography_type=geography_type,
+ geography_col=geography_col,
+ time_period_source=time_period_source,
+ )
+ for geography_type, geography_col in geography_dimensions
+ ]
+ outputs = [output for output in outputs if not output.is_empty()]
+ if not outputs:
+ return non_motorized_vmt_by_home_geography_income_hhsize_time_period.empty()
+
+ return (
+ pl.concat(outputs, how="vertical")
+ .with_columns(
+ pl.col("geography_type").cast(pl.Utf8),
+ pl.col("geography_id").cast(pl.Utf8),
+ pl.col("income_segment").cast(pl.Utf8),
+ pl.col("household_size").cast(pl.Utf8),
+ pl.col("time_period").cast(pl.Utf8),
+ pl.col("mode").cast(pl.Utf8),
+ pl.col("non_motorized_vmt").cast(pl.Float64),
+ pl.col("trip_count").cast(pl.Float64),
+ pl.col("distance_source").cast(pl.Utf8),
+ pl.col("time_period_source").cast(pl.Utf8),
+ )
+ .sort(
+ [
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "mode",
+ "time_period",
+ ]
+ )
+ )
+
+
# TODO: Update once I know the shape of the Commercial VMT model output
+@summary(
+ id="commercial_vmt_totals",
+ schema={
+ "commercial_vehicle_type": pl.Utf8,
+ "external_vmt": pl.Float64,
+ "internal_vmt": pl.Float64,
+ },
+)
def commercial_vehicle_vmt(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"commercial_vehicle_type": pl.Utf8,
@@ -492,6 +1204,10 @@ def _first_existing(columns: list[str], candidates: list[str]) -> str | None:
# TODO: Update once I know the shape of the Bicycle output
+@summary(
+ id="bicycle_vmt_by_facility_type",
+ schema={"facility_type": pl.Utf8, "bicycle_vmt": pl.Float64},
+)
def bicycle_vmt_by_facility(rd: RunData, config: Config) -> pl.DataFrame:
result_schema = {
"facility_type": pl.Utf8,
diff --git a/processor/summarize/summaries/validation_scaffolds.py b/processor/summarize/summaries/validation_scaffolds.py
new file mode 100644
index 0000000..2015cc1
--- /dev/null
+++ b/processor/summarize/summaries/validation_scaffolds.py
@@ -0,0 +1,225 @@
+"""No-op builders for validation summary tables with external-data schemas."""
+
+from __future__ import annotations
+
+import polars as pl
+
+from processor.models import RunData
+from processor.summarize.contracts import summary
+from runtime.config import Config
+
+
+@summary(
+ id="link_validation_summary",
+ build_by_default=False,
+ schema={
+ "id": pl.Int64,
+ "From_Node": pl.Int64,
+ "To_Node": pl.Int64,
+ "FACTYPE": pl.Int64,
+ "am_vol": pl.Float64,
+ "md_vol": pl.Float64,
+ "pm_vol": pl.Float64,
+ "day_vol": pl.Float64,
+ },
+)
+def link_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return link_validation_summary.empty()
+
+
+@summary(
+ id="count_location_counts_validation_summary",
+ build_by_default=False,
+ schema={
+ "id": pl.Int64,
+ "FACTYPE": pl.Int64,
+ "am_vol": pl.Float64,
+ "md_vol": pl.Float64,
+ "pm_vol": pl.Float64,
+ "day_vol": pl.Float64,
+ },
+)
+def count_location_counts_validation_summary(
+ rd: RunData, config: Config
+) -> pl.DataFrame:
+ return count_location_counts_validation_summary.empty()
+
+
+@summary(
+ id="count_location_volumes_validation_summary",
+ build_by_default=False,
+ schema={
+ "id": pl.Int64,
+ "FACTYPE": pl.Int64,
+ "am_vol": pl.Float64,
+ "md_vol": pl.Float64,
+ "pm_vol": pl.Float64,
+ "day_vol": pl.Float64,
+ },
+)
+def count_location_volumes_validation_summary(
+ rd: RunData, config: Config
+) -> pl.DataFrame:
+ return count_location_volumes_validation_summary.empty()
+
+
+@summary(
+ id="count_location_scatter_validation_summary",
+ build_by_default=False,
+ schema={
+ "id": pl.Int64,
+ "facility_type": pl.Utf8,
+ "period": pl.Utf8,
+ "observed_volume": pl.Float64,
+ "modeled_volume": pl.Float64,
+ },
+)
+def count_location_scatter_validation_summary(
+ rd: RunData, config: Config
+) -> pl.DataFrame:
+ return count_location_scatter_validation_summary.empty()
+
+
+@summary(
+ id="count_location_fit_validation_summary",
+ build_by_default=False,
+ schema={
+ "facility_type": pl.Utf8,
+ "period": pl.Utf8,
+ "slope": pl.Float64,
+ "intercept": pl.Float64,
+ "r_squared": pl.Float64,
+ "n_locations": pl.Int64,
+ "observed_min": pl.Float64,
+ "observed_max": pl.Float64,
+ "equation_label": pl.Utf8,
+ "r_squared_label": pl.Utf8,
+ },
+)
+def count_location_fit_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return count_location_fit_validation_summary.empty()
+
+
+@summary(
+ id="county_flows_validation_summary",
+ build_by_default=False,
+ schema={
+ "": pl.Utf8,
+ "Albany": pl.Float64,
+ "Corvallis": pl.Float64,
+ "Lebanon": pl.Float64,
+ "Philomath": pl.Float64,
+ "Total": pl.Float64,
+ },
+)
+def county_flows_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return county_flows_validation_summary.empty()
+
+
+@summary(
+ id="county_flows_joja_validation_summary",
+ build_by_default=False,
+ schema={
+ "": pl.Utf8,
+ "Benton": pl.Float64,
+ "Linn": pl.Float64,
+ "Marion": pl.Float64,
+ "Total": pl.Float64,
+ },
+)
+def county_flows_joja_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return county_flows_joja_validation_summary.empty()
+
+
+@summary(
+ id="commercial_vehicle_validation_summary",
+ build_by_default=False,
+ schema={
+ "tod": pl.Utf8,
+ "car": pl.Float64,
+ "mu": pl.Float64,
+ "su": pl.Float64,
+ "Total": pl.Float64,
+ },
+)
+def commercial_vehicle_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return commercial_vehicle_validation_summary.empty()
+
+
+@summary(
+ id="commercial_vehicle_vmt_validation_summary",
+ build_by_default=False,
+ schema={
+ "tod": pl.Utf8,
+ "car": pl.Float64,
+ "mu": pl.Float64,
+ "su": pl.Float64,
+ "Total": pl.Float64,
+ },
+)
+def commercial_vehicle_vmt_validation_summary(
+ rd: RunData, config: Config
+) -> pl.DataFrame:
+ return commercial_vehicle_vmt_validation_summary.empty()
+
+
+_EXTERNAL_PURPOSE_SCHEMA = {
+ "tod": pl.Utf8,
+ "hbcoll": pl.Float64,
+ "hbo": pl.Float64,
+ "hbr": pl.Float64,
+ "hbs": pl.Float64,
+ "hbsch": pl.Float64,
+ "hbw": pl.Float64,
+ "nhbnw": pl.Float64,
+ "nhbw": pl.Float64,
+ "truck": pl.Float64,
+ "Total": pl.Float64,
+}
+
+
+@summary(
+ id="external_trip_validation_summary",
+ build_by_default=False,
+ schema=_EXTERNAL_PURPOSE_SCHEMA,
+)
+def external_trip_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return external_trip_validation_summary.empty()
+
+
+@summary(
+ id="external_vmt_validation_summary",
+ build_by_default=False,
+ schema=_EXTERNAL_PURPOSE_SCHEMA,
+)
+def external_vmt_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return external_vmt_validation_summary.empty()
+
+
+@summary(
+ id="auto_vmt_validation_summary",
+ build_by_default=False,
+ schema={
+ "TOD": pl.Utf8,
+ "SOV": pl.Float64,
+ "HOV2": pl.Float64,
+ "HOV3": pl.Float64,
+ "Truck": pl.Float64,
+ "Total": pl.Float64,
+ },
+)
+def auto_vmt_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return auto_vmt_validation_summary.empty()
+
+
+@summary(
+ id="work_from_home_validation_summary",
+ build_by_default=False,
+ schema={
+ "District": pl.Utf8,
+ "Workers": pl.Float64,
+ "WFH": pl.Float64,
+ },
+)
+def work_from_home_validation_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ return work_from_home_validation_summary.empty()
diff --git a/processor/summarize/summary_specs.py b/processor/summarize/summary_specs.py
deleted file mode 100644
index 8bc26d0..0000000
--- a/processor/summarize/summary_specs.py
+++ /dev/null
@@ -1,477 +0,0 @@
-from __future__ import annotations
-
-
-from dataclasses import dataclass
-from typing import Callable
-
-import polars as pl
-
-from processor.models import RunData
-from runtime.config import Config
-from processor.summarize.summaries import (
- daily_travel,
- demographics,
- joint_travel,
- legacy,
- long_term,
- skimjoin,
- tour,
- trip,
- validation,
-)
-
-
-@dataclass(frozen=True)
-class SummarySpec:
- summary_id: str
- filename: str
- builder: Callable[[RunData, Config], pl.DataFrame]
-
-
-SUMMARY_SPECS: tuple[SummarySpec, ...] = (
- ### DEMOGRAPHIC SUMMARIES
- SummarySpec(
- "household_size_distribution",
- "household_size_distribution",
- demographics.hh_size,
- ),
- SummarySpec(
- "person_type_distribution", "person_type_distribution", demographics.person_type
- ),
- SummarySpec(
- "population_totals", "population_totals", demographics.population_totals
- ),
- ### LONG TERM SUMMARIES
- SummarySpec(
- "license_holding_status_distribution",
- "license_holding_status_distribution",
- long_term.license_holding_status,
- ),
- SummarySpec(
- "bicycle_comfort_level_distribution",
- "bicycle_comfort_level_distribution",
- long_term.bicycle_comfort_level,
- ),
- SummarySpec(
- "autonomous_vehicle_ownership_totals",
- "autonomous_vehicle_ownership_totals",
- long_term.av_ownership,
- ),
- SummarySpec(
- "auto_ownership_distribution",
- "auto_ownership_distribution",
- long_term.auto_ownership,
- ),
- SummarySpec(
- "work_from_home_rate_by_geography",
- "work_from_home_rate_by_geography",
- long_term.wfh,
- ),
- SummarySpec(
- "internal_external_worker_by_geography",
- "internal_external_worker_by_geography",
- long_term.internal_vs_external,
- ),
- SummarySpec(
- "external_worker_workplace_locations",
- "external_worker_workplace_locations",
- long_term.external_workplace_loc,
- ),
- SummarySpec(
- "workplace_location_employment_comparison",
- "workplace_location_employment_comparison",
- long_term.workplace_vs_land_use_employment,
- ),
- SummarySpec(
- "workplace_shadow_pricing_residuals",
- "workplace_shadow_pricing_residuals",
- long_term.workplace_shadow_pricing_residuals,
- ),
- SummarySpec(
- "workplace_shadow_pricing_residual_histogram",
- "workplace_shadow_pricing_residual_histogram",
- long_term.workplace_shadow_pricing_residual_histogram,
- ),
- SummarySpec(
- "school_location_enrollment_comparison",
- "school_location_enrollment_comparison",
- long_term.school_loc_vs_land_use_enrollment,
- ),
- SummarySpec(
- "school_shadow_pricing_residuals",
- "school_shadow_pricing_residuals",
- long_term.school_shadow_pricing_residuals,
- ),
- SummarySpec(
- "school_shadow_pricing_residual_histogram",
- "school_shadow_pricing_residual_histogram",
- long_term.school_shadow_pricing_residual_histogram,
- ),
- SummarySpec(
- "park_and_ride_location_residuals",
- "park_and_ride_location_residuals",
- long_term.park_and_ride_location_residuals,
- ),
- SummarySpec(
- "park_and_ride_location_residual_histogram",
- "park_and_ride_location_residual_histogram",
- long_term.park_and_ride_location_residual_histogram,
- ),
- SummarySpec(
- "commuting_flows",
- "commuting_flows",
- long_term.commuting_flows,
- ),
- SummarySpec(
- "work_location_distance_distribution_by_geography",
- "work_location_distance_distribution_by_geography",
- long_term.work_tlfd,
- ),
- SummarySpec(
- "university_location_distance_distribution_by_geography",
- "university_location_distance_distribution_by_geography",
- long_term.univ_tlfd,
- ),
- SummarySpec(
- "school_location_distance_distribution_by_geography",
- "school_location_distance_distribution_by_geography",
- long_term.schl_tlfd,
- ),
- SummarySpec(
- "vehicle_age_distribution",
- "vehicle_age_distribution",
- long_term.vehicle_char_age,
- ),
- SummarySpec(
- "vehicle_fuel_type_distribution",
- "vehicle_fuel_type_distribution",
- long_term.vehicle_char_fuel,
- ),
- SummarySpec(
- "vehicle_body_type_distribution",
- "vehicle_body_type_distribution",
- long_term.vehicle_char_body,
- ),
- SummarySpec(
- "transit_pass_ownership_by_person_type",
- "transit_pass_ownership_by_person_type",
- long_term.transit_pass,
- ),
- SummarySpec(
- "transit_subsidy_by_person_type",
- "transit_subsidy_by_person_type",
- long_term.transit_subsidy,
- ),
- SummarySpec(
- "free_parking_eligibility_by_workplace_geography",
- "free_parking_eligibility_by_workplace_geography",
- long_term.free_parking,
- ),
- SummarySpec(
- "telecommute_frequency_distribution",
- "telecommute_frequency_distribution",
- long_term.telecommute,
- ),
- ### DAILY TRAVEL SUMMARIES
- SummarySpec(
- "daily_activity_pattern_by_person_type",
- "daily_activity_pattern_by_person_type",
- daily_travel.dap_summary,
- ),
- SummarySpec(
- "mandatory_tour_frequency_by_person_type",
- "mandatory_tour_frequency_by_person_type",
- daily_travel.mandatory_tour_freq,
- ),
- SummarySpec(
- "nonmandatory_tour_frequency_by_person_type",
- "nonmandatory_tour_frequency_by_person_type",
- daily_travel.indiv_nm_summary,
- ),
- SummarySpec(
- "escorted_tour_totals",
- "escorted_tour_totals",
- daily_travel.total_escorted_tours,
- ),
- SummarySpec(
- "school_escorted_tours_by_escort_type_and_direction",
- "school_escorted_tours_by_escort_type_and_direction",
- daily_travel.escorted_tours_to_from_school,
- ),
- SummarySpec(
- "adult_escorted_tour_purposes_by_direction",
- "adult_escorted_tour_purposes_by_direction",
- daily_travel.adult_escorted_tour_purposes_by_direction,
- ),
- SummarySpec(
- "adult_escort_trip_stop_frequency",
- "adult_escort_trip_stop_frequency",
- daily_travel.adult_escort_trip_stop_frequency,
- ),
- SummarySpec(
- "adult_escort_event_stop_distribution",
- "adult_escort_event_stop_distribution",
- daily_travel.adult_escort_event_stop_distribution,
- ),
- SummarySpec(
- "adult_escorted_tours_by_person_type_and_direction",
- "adult_escorted_tours_by_person_type_and_direction",
- daily_travel.adult_escorted_tours_by_person_type_and_direction,
- ),
- SummarySpec(
- "student_school_escort_status_by_direction",
- "student_school_escort_status_by_direction",
- daily_travel.student_school_escort_status_by_direction,
- ),
- SummarySpec(
- "student_households_by_student_count",
- "student_households_by_student_count",
- daily_travel.student_households_by_student_count,
- ),
- SummarySpec(
- "households_with_school_escorting_by_student_count_and_direction",
- "households_with_school_escorting_by_student_count_and_direction",
- daily_travel.households_with_school_escorting_by_student_count_and_direction,
- ),
- SummarySpec(
- "schoolkids_per_escorted_tour_by_student_count_and_direction",
- "schoolkids_per_escorted_tour_by_student_count_and_direction",
- daily_travel.schoolkids_per_escorted_tour_by_student_count_and_direction,
- ),
- SummarySpec(
- "adult_escorted_tour_distance_distribution_by_direction",
- "adult_escorted_tour_distance_distribution_by_direction",
- daily_travel.adult_escorted_tour_distance_distribution_by_direction,
- ),
- SummarySpec(
- "adult_escorted_trip_distance_distribution_by_direction",
- "adult_escorted_trip_distance_distribution_by_direction",
- daily_travel.adult_escorted_trip_distance_distribution_by_direction,
- ),
- SummarySpec(
- "tour_rates_by_person_type_and_tour_purpose",
- "tour_rates_by_person_type_and_tour_purpose",
- daily_travel.tour_rate_per_person,
- ),
- SummarySpec(
- "trip_rates_by_person_type_and_trip_purpose",
- "trip_rates_by_person_type_and_trip_purpose",
- daily_travel.trip_rate_per_person,
- ),
- ### JOINT TRAVEL SUMMARIES
- SummarySpec("jtf_distribution", "jtf_distribution", joint_travel.joint_tour_freq),
- SummarySpec(
- "joint_tours_by_household_size",
- "joint_tours_by_household_size",
- joint_travel.joint_tours_hhsize,
- ),
- SummarySpec(
- "joint_tour_party_size_distribution",
- "joint_tour_party_size_distribution",
- joint_travel.joint_party_size,
- ),
- SummarySpec(
- "joint_tour_composition_distribution",
- "joint_tour_composition_distribution",
- joint_travel.joint_composition,
- ),
- SummarySpec(
- "joint_tour_composition_by_party_size",
- "joint_tour_composition_by_party_size",
- joint_travel.joint_composition_by_party_size,
- ),
- SummarySpec(
- "person_jtp_by_household_size",
- "person_jtp_by_household_size",
- joint_travel.joint_participation_person_by_hhsize,
- ),
- SummarySpec(
- "household_jtp_by_household_size_and_jtf",
- "household_jtp_by_household_size_and_jtf",
- joint_travel.jtf_by_hhsize,
- ),
- # TOUR SUMMARIES
- SummarySpec(
- "tour_category_distribution",
- "tour_category_distribution",
- tour.tour_category,
- ),
- SummarySpec(
- "tour_purpose_distribution",
- "tour_purpose_distribution",
- tour.tour_purpose,
- ),
- SummarySpec(
- "allocated_vehicle_age_by_occupancy",
- "allocated_vehicle_age_by_occupancy",
- tour.allocated_vehicle_age,
- ),
- SummarySpec(
- "allocated_vehicle_fuel_type_by_occupancy",
- "allocated_vehicle_fuel_type_by_occupancy",
- tour.allocated_vehicle_fuel,
- ),
- SummarySpec(
- "allocated_vehicle_body_type_by_occupancy",
- "allocated_vehicle_body_type_by_occupancy",
- tour.allocated_vehicle_body,
- ),
- SummarySpec(
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- "tour_mode_by_tour_purpose_and_auto_sufficiency",
- tour.tour_mode,
- ),
- SummarySpec(
- "tour_stop_frequency_by_tour_purpose",
- "tour_stop_frequency_by_tour_purpose",
- tour.stop_freq,
- ),
- SummarySpec(
- "atwork_subtour_frequency_distribution",
- "atwork_subtour_frequency_distribution",
- tour.at_work_sub_tour_freq,
- ),
- SummarySpec(
- "tour_time_of_day_by_tour_purpose",
- "tour_time_of_day_by_tour_purpose",
- tour.tour_tod,
- ),
- SummarySpec(
- "tour_distance_by_tour_purpose",
- "tour_distance_by_tour_purpose",
- tour.tour_distance,
- ),
- SummarySpec(
- "average_mandatory_tour_distance_by_purpose_and_geography",
- "average_mandatory_tour_distance_by_purpose_and_geography",
- tour.avg_mand_tour_distance,
- ),
- SummarySpec(
- "average_nonmandatory_tour_distance_by_purpose_and_geography",
- "average_nonmandatory_tour_distance_by_purpose_and_geography",
- tour.avg_non_mand_tour_distance,
- ),
- SummarySpec(
- "internal_external_nonmandatory_tour_frequency_by_home_geography",
- "internal_external_nonmandatory_tour_frequency_by_home_geography",
- tour.int_vs_ext_non_mand_tour_freq,
- ),
- SummarySpec(
- "external_nonmandatory_tour_locations",
- "external_nonmandatory_tour_locations",
- tour.ext_non_mand_tour_loc,
- ),
- # TRIP SUMMARIES
- SummarySpec(
- "trip_purpose_distribution",
- "trip_purpose_distribution",
- trip.trip_purpose,
- ),
- SummarySpec(
- "stop_destination_purpose_by_tour_purpose",
- "stop_destination_purpose_by_tour_purpose",
- trip.stop_purpose_by_tour_purpose,
- ),
- SummarySpec(
- "trip_mode_by_tour_purpose_and_tour_mode",
- "trip_mode_by_tour_purpose_and_tour_mode",
- trip.trip_mode,
- ),
- SummarySpec(
- "trip_departure_time_by_purpose",
- "trip_departure_time_by_purpose",
- trip.trip_stop_tod,
- ),
- SummarySpec(
- "trip_distance_by_purpose",
- "trip_distance_by_purpose",
- trip.trip_distance,
- ),
- SummarySpec(
- "stop_out_of_direction_distance_by_tour_purpose",
- "stop_out_of_direction_distance_by_tour_purpose",
- trip.stop_ood_distance,
- ),
- SummarySpec(
- "parking_locations",
- "parking_locations",
- trip.parking_locations,
- ),
- SummarySpec(
- "skimjoin_trip_component_stats",
- "skimjoin_trip_component_stats",
- skimjoin.trip_skim_component_stats,
- ),
- # SummarySpec(
- # "skimjoin_trip_component_ecdf",
- # "skimjoin_trip_component_ecdf",
- # skimjoin.trip_skim_component_ecdf,
- # ),
- SummarySpec(
- "skimjoin_tour_component_stats",
- "skimjoin_tour_component_stats",
- skimjoin.tour_skim_component_stats,
- ),
- # SummarySpec(
- # "skimjoin_tour_component_ecdf",
- # "skimjoin_tour_component_ecdf",
- # skimjoin.tour_skim_component_ecdf,
- # ),
- # VALIDATION SUMMARIES
- SummarySpec(
- "traffic_count_comparisons",
- "traffic_count_comparisons",
- validation.traffic_count_comparisons,
- ),
- SummarySpec(
- "screenline_flow_comparisons",
- "screenline_flow_comparisons",
- validation.screenline_flow_comparisons,
- ),
- SummarySpec(
- "transit_boardings_by_operator_and_technology",
- "transit_boardings_by_operator_and_technology",
- validation.total_transit_boardings,
- ),
- SummarySpec(
- "transit_transfer_rate",
- "transit_transfer_rate",
- validation.transit_transfer_rate,
- ),
- SummarySpec("auto_vmt_totals", "auto_vmt_totals", validation.auto_vmt_totals),
- SummarySpec(
- "commercial_vmt_totals",
- "commercial_vmt_totals",
- validation.commercial_vehicle_vmt,
- ),
- SummarySpec(
- "bicycle_vmt_by_facility_type",
- "bicycle_vmt_by_facility_type",
- validation.bicycle_vmt_by_facility,
- ),
- # TEMPORARY LEGACY SUMMARIES
- SummarySpec("geo_flows", "geoFlows", legacy.geo_flows),
- SummarySpec("nm_tour_rates", "nm_tour_rates", legacy.nm_tour_rates),
- SummarySpec(
- "grouped_tour_mode_profile",
- "groupedTmodeProfile_vis",
- legacy.grouped_tour_mode_profile,
- ),
- SummarySpec(
- "destination_distance",
- "destinationDistByPurpose",
- legacy.distance_distribution,
- ),
- SummarySpec(
- "destination_average_distance",
- "destinationAvgDistance",
- legacy.average_distance,
- ),
- SummarySpec("totals", "totals", legacy.system_totals),
-)
-
-SUMMARY_SPEC_BY_ID = {spec.summary_id: spec for spec in SUMMARY_SPECS}
-SUMMARY_FILENAME_BY_ID = {
- spec.summary_id: f"{spec.filename}.csv" for spec in SUMMARY_SPECS
-}
-DEFAULT_SUMMARY_IDS = [spec.summary_id for spec in SUMMARY_SPECS]
diff --git a/processor/summarize/validation_derived.py b/processor/summarize/validation_derived.py
new file mode 100644
index 0000000..e83f6e3
--- /dev/null
+++ b/processor/summarize/validation_derived.py
@@ -0,0 +1,294 @@
+"""Derived summaries built from validation scaffold tables."""
+
+from __future__ import annotations
+
+import math
+
+from runtime.logging import get_logger
+import polars as pl
+
+from processor.summarize.cache_types import SummaryRun, create_summary_run
+from processor.summarize.contracts import empty_summary_frame
+from processor.summarize.summaries import validation_scaffolds
+
+LOGGER = get_logger("processor.summarize")
+
+COUNT_LOCATION_COUNTS_ID = "count_location_counts_validation_summary"
+COUNT_LOCATION_VOLUMES_ID = "count_location_volumes_validation_summary"
+COUNT_LOCATION_SCATTER_ID = "count_location_scatter_validation_summary"
+COUNT_LOCATION_FIT_ID = "count_location_fit_validation_summary"
+
+COUNT_LOCATION_DERIVED_IDS = (
+ COUNT_LOCATION_SCATTER_ID,
+ COUNT_LOCATION_FIT_ID,
+)
+
+COUNT_LOCATION_PERIOD_COLUMNS = {
+ "AM": "am_vol",
+ "MD": "md_vol",
+ "PM": "pm_vol",
+ "Day": "day_vol",
+}
+
+
+def _empty_count_location_scatter_validation_summary() -> pl.DataFrame:
+ return empty_summary_frame(
+ validation_scaffolds.count_location_scatter_validation_summary
+ )
+
+
+def _empty_count_location_fit_validation_summary() -> pl.DataFrame:
+ return empty_summary_frame(
+ validation_scaffolds.count_location_fit_validation_summary
+ )
+
+
+def build_count_location_scatter_validation_summary(
+ counts: pl.DataFrame,
+ volumes: pl.DataFrame,
+) -> pl.DataFrame:
+ """Return long observed/modeled count-location rows from wide source tables."""
+ frames: list[pl.DataFrame] = []
+ for period, volume_col in COUNT_LOCATION_PERIOD_COLUMNS.items():
+ if volume_col not in counts.columns or volume_col not in volumes.columns:
+ continue
+ count_period = counts.select(
+ pl.col("id").cast(pl.Int64, strict=False),
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.lit(period).alias("period"),
+ pl.col(volume_col).cast(pl.Float64).alias("observed_volume"),
+ )
+ volume_period = volumes.select(
+ pl.col("id").cast(pl.Int64, strict=False),
+ pl.col("FACTYPE").cast(pl.Utf8).alias("facility_type"),
+ pl.lit(period).alias("period"),
+ pl.col(volume_col).cast(pl.Float64).alias("modeled_volume"),
+ )
+ frames.append(
+ count_period.join(
+ volume_period,
+ on=["id", "facility_type", "period"],
+ how="inner",
+ )
+ )
+ if not frames:
+ return _empty_count_location_scatter_validation_summary()
+ return (
+ pl.concat(frames, how="vertical")
+ .filter(
+ pl.col("id").is_not_null()
+ & pl.col("facility_type").is_not_null()
+ & pl.col("observed_volume").is_not_null()
+ & pl.col("modeled_volume").is_not_null()
+ )
+ .select(
+ "id",
+ "facility_type",
+ "period",
+ "observed_volume",
+ "modeled_volume",
+ )
+ .sort(["period", "facility_type", "id"])
+ )
+
+
+def _equation_label(slope: float, intercept: float) -> str:
+ sign = "+" if intercept >= 0 else "-"
+ return f"y = {slope:.2f}x {sign} {abs(intercept):.2f}"
+
+
+def _fit_group(
+ df: pl.DataFrame, *, facility_type: str, period: str
+) -> dict[str, object]:
+ points = df.select("observed_volume", "modeled_volume").drop_nulls()
+ n = points.height
+ base: dict[str, object] = {
+ "facility_type": facility_type,
+ "period": period,
+ "slope": None,
+ "intercept": None,
+ "r_squared": None,
+ "n_locations": n,
+ "observed_min": None,
+ "observed_max": None,
+ "equation_label": "",
+ "r_squared_label": "",
+ }
+ if n == 0:
+ return base
+
+ x = [float(value) for value in points["observed_volume"].to_list()]
+ y = [float(value) for value in points["modeled_volume"].to_list()]
+ observed_min = min(x)
+ observed_max = max(x)
+ base["observed_min"] = observed_min
+ base["observed_max"] = observed_max
+ if n < 2:
+ return base
+
+ x_mean = sum(x) / n
+ y_mean = sum(y) / n
+ ss_xx = sum((value - x_mean) ** 2 for value in x)
+ if math.isclose(ss_xx, 0.0):
+ return base
+
+ ss_xy = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y))
+ slope = ss_xy / ss_xx
+ intercept = y_mean - slope * x_mean
+ fitted = [slope * xi + intercept for xi in x]
+ sse = sum((yi - yhat) ** 2 for yi, yhat in zip(y, fitted))
+ ss_yy = sum((yi - y_mean) ** 2 for yi in y)
+ r_squared = (
+ (1.0 if math.isclose(sse, 0.0) else 0.0)
+ if math.isclose(ss_yy, 0.0)
+ else max(0.0, min(1.0, 1.0 - sse / ss_yy))
+ )
+
+ base.update(
+ slope=float(slope),
+ intercept=float(intercept),
+ r_squared=float(r_squared),
+ equation_label=_equation_label(slope, intercept),
+ r_squared_label=f"R^2 = {r_squared:.2f}",
+ )
+ return base
+
+
+def build_count_location_fit_validation_summary(scatter: pl.DataFrame) -> pl.DataFrame:
+ """Return OLS fit rows by period and facility type, including All."""
+ if scatter.is_empty():
+ return _empty_count_location_fit_validation_summary()
+
+ rows: list[dict[str, object]] = []
+ for period in COUNT_LOCATION_PERIOD_COLUMNS:
+ period_df = scatter.filter(pl.col("period") == period)
+ if period_df.is_empty():
+ continue
+ rows.append(_fit_group(period_df, facility_type="All", period=period))
+ facility_types = sorted(
+ str(value)
+ for value in period_df["facility_type"].drop_nulls().unique().to_list()
+ )
+ for facility_type in facility_types:
+ rows.append(
+ _fit_group(
+ period_df.filter(pl.col("facility_type") == facility_type),
+ facility_type=facility_type,
+ period=period,
+ )
+ )
+ if not rows:
+ return _empty_count_location_fit_validation_summary()
+ return pl.DataFrame(rows).with_columns(
+ pl.col("facility_type").cast(pl.Utf8),
+ pl.col("period").cast(pl.Utf8),
+ pl.col("slope").cast(pl.Float64),
+ pl.col("intercept").cast(pl.Float64),
+ pl.col("r_squared").cast(pl.Float64),
+ pl.col("n_locations").cast(pl.Int64),
+ pl.col("observed_min").cast(pl.Float64),
+ pl.col("observed_max").cast(pl.Float64),
+ pl.col("equation_label").cast(pl.Utf8),
+ pl.col("r_squared_label").cast(pl.Utf8),
+ )
+
+
+def apply_validation_derived_summaries(
+ summary_runs: list[SummaryRun],
+) -> list[SummaryRun]:
+ """Rebuild derived validation summaries for every summary run when inputs exist."""
+ if not summary_runs:
+ return []
+
+ derived_runs: list[SummaryRun] = []
+ for summary_run in summary_runs:
+ summaries_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
+ metadata_by_mode: dict[str, dict[str, dict[str, object]]] = {}
+ for mode, tables in summary_run.summaries_by_mode.items():
+ mode_tables = {
+ summary_id: table
+ for summary_id, table in tables.items()
+ if summary_id not in COUNT_LOCATION_DERIVED_IDS
+ }
+ mode_metadata = {
+ summary_id: dict(metadata)
+ for summary_id, metadata in summary_run.summary_metadata_by_mode.get(
+ mode, {}
+ ).items()
+ if summary_id not in COUNT_LOCATION_DERIVED_IDS
+ }
+ counts = mode_tables.get(COUNT_LOCATION_COUNTS_ID)
+ volumes = mode_tables.get(COUNT_LOCATION_VOLUMES_ID)
+ if counts is not None and volumes is not None:
+ scatter = build_count_location_scatter_validation_summary(
+ counts, volumes
+ )
+ fit = build_count_location_fit_validation_summary(scatter)
+ mode_tables[COUNT_LOCATION_SCATTER_ID] = scatter
+ mode_tables[COUNT_LOCATION_FIT_ID] = fit
+ detail = (
+ "derived from count_location_counts_validation_summary and "
+ "count_location_volumes_validation_summary"
+ )
+ mode_metadata[COUNT_LOCATION_SCATTER_ID] = {
+ "state": "empty" if scatter.is_empty() else "available",
+ "source": "derived_summary",
+ "detail": detail,
+ "dependencies": [
+ COUNT_LOCATION_COUNTS_ID,
+ COUNT_LOCATION_VOLUMES_ID,
+ ],
+ }
+ mode_metadata[COUNT_LOCATION_FIT_ID] = {
+ "state": "empty" if fit.is_empty() else "available",
+ "source": "derived_summary",
+ "detail": detail,
+ "dependencies": [
+ COUNT_LOCATION_COUNTS_ID,
+ COUNT_LOCATION_VOLUMES_ID,
+ ],
+ }
+ LOGGER.info(
+ "Built count-location validation derived summaries for %r "
+ "(%s): %d scatter rows, %d fit rows",
+ summary_run.label,
+ mode,
+ scatter.height,
+ fit.height,
+ )
+ summaries_by_mode[mode] = mode_tables
+ metadata_by_mode[mode] = mode_metadata
+
+ derived_runs.append(
+ create_summary_run(
+ label=summary_run.label,
+ run_key=summary_run.run_key,
+ summaries_by_mode=summaries_by_mode,
+ summary_metadata_by_mode=metadata_by_mode,
+ segmentation_type=summary_run.segmentation_type,
+ segment_id=summary_run.segment_id,
+ segment_label=summary_run.segment_label,
+ is_full_segment=summary_run.is_full_segment,
+ segment_source_type=summary_run.segment_source_type,
+ segment_column=summary_run.segment_column,
+ segment_values=summary_run.segment_values,
+ segment_source_table=summary_run.segment_source_table,
+ segment_source_key_column=summary_run.segment_source_key_column,
+ segment_csv_file=summary_run.segment_csv_file,
+ segment_csv_key_column=summary_run.segment_csv_key_column,
+ segment_csv_value_column=summary_run.segment_csv_value_column,
+ source_run_dir=summary_run.source_run_dir,
+ manifest=summary_run.manifest,
+ )
+ )
+ return derived_runs
+
+
+__all__ = [
+ "COUNT_LOCATION_DERIVED_IDS",
+ "COUNT_LOCATION_FIT_ID",
+ "COUNT_LOCATION_SCATTER_ID",
+ "apply_validation_derived_summaries",
+ "build_count_location_fit_validation_summary",
+ "build_count_location_scatter_validation_summary",
+]
diff --git a/processor/summarize/writer.py b/processor/summarize/writer.py
deleted file mode 100644
index 9932418..0000000
--- a/processor/summarize/writer.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Optional CSV writer for calibration compatibility."""
-
-from pathlib import Path
-
-from activitysim_viz_logging import get_logger
-import polars as pl
-
-LOGGER = get_logger("processor.summarize.writer")
-
-
-def write_all(summaries: dict[str, pl.DataFrame], output_dir: str | Path) -> None:
- """
- Write all summary DataFrames to CSV files.
-
- Args:
- summaries: dict mapping filename (without .csv) to DataFrame
- output_dir: directory to write files to
- """
- output_dir = Path(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
-
- for name, df in summaries.items():
- path = output_dir / f"{name}.csv"
- df.write_csv(path)
- LOGGER.info("Written: %s", path)
diff --git a/processor/time_periods.py b/processor/time_periods.py
new file mode 100644
index 0000000..e5f54f3
--- /dev/null
+++ b/processor/time_periods.py
@@ -0,0 +1,38 @@
+"""Shared ActivitySim time-period helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+
+def load_network_los_period_mapping(path: str | Path) -> dict[str, str]:
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
+ skim_time_periods = data.get("skim_time_periods")
+ if not isinstance(skim_time_periods, dict):
+ raise ValueError("network_los_file is missing skim_time_periods.")
+
+ periods = skim_time_periods.get("periods")
+ labels = skim_time_periods.get("labels")
+ if not isinstance(periods, list) or not isinstance(labels, list):
+ raise ValueError("skim_time_periods must define list-valued periods and labels.")
+ if len(periods) < 2:
+ raise ValueError("skim_time_periods.periods must contain at least two breakpoints.")
+ if len(labels) != len(periods) - 1:
+ raise ValueError("skim_time_periods.labels must have exactly len(periods) - 1 entries.")
+
+ mapping: dict[str, str] = {}
+ for idx, label in enumerate(labels):
+ start = periods[idx]
+ end = periods[idx + 1]
+ try:
+ start_int = int(start)
+ end_int = int(end)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("skim_time_periods.periods must contain integers.") from exc
+ # ActivitySim trip-side period buckets are treated as 1..48 rather than 0..47.
+ # The network_los breakpoints remain zero-based, so shift each covered bucket by +1.
+ for period_number in range(start_int, end_int):
+ mapping[str(period_number + 1)] = str(label)
+ return mapping
diff --git a/pyproject.toml b/pyproject.toml
index 1e53912..0236023 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,6 @@ requires-python = ">=3.10"
dependencies = [
"h5py",
"numpy",
- "jupyter",
"pandas",
"panel",
"plotly",
@@ -15,21 +14,41 @@ dependencies = [
"pyarrow",
"openmatrix",
"pyyaml",
- "shiny",
- "shinywidgets",
]
[project.scripts]
activitysim-viz = "run:main"
-activitysim-viz-freeze-panel = "quarto_visualizer.freeze_panel:main"
-activitysim-viz-validate-parity = "quarto_visualizer.validate_parity:main"
+
+[dependency-groups]
+dev = [
+ "pytest",
+ "ruff",
+]
+notebooks = [
+ "jupyter",
+]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-[tool.uv]
-package = false
+[tool.hatch.build.targets.wheel]
+include = [
+ "/dashboard",
+ "/processor",
+ "/runtime",
+ "/run.py",
+ "/config.yaml",
+]
[tool.pytest.ini_options]
testpaths = ["tests"]
+markers = [
+ "full_export: exhaustive offline-export tests that render every default page and state",
+]
+
+[tool.ruff]
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["E9", "F63", "F7", "F82"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..99c5b05
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+# Compatibility entry point for pip-based environments. Project dependencies
+# are owned by pyproject.toml so this file cannot drift independently.
+-e .
diff --git a/run.py b/run.py
index e86538c..cac8221 100644
--- a/run.py
+++ b/run.py
@@ -3,34 +3,24 @@
from __future__ import annotations
import argparse
-from dataclasses import dataclass
import logging
import shutil
import sys
import time
from pathlib import Path
-from activitysim_viz_logging import configure_logging, get_logger, shutdown_logging
+from runtime.logging import configure_logging, get_logger, shutdown_logging
from dashboard.page_registry import (
export_data_requirements,
live_data_requirements,
)
import runtime.workflows as runtime_workflows
+from runtime.workflows import WorkflowPlan
LOGGER = get_logger("main")
_EXPORT_HTML_USE_CONFIG_SENTINEL = "__USE_CONFIG_OR_DEFAULT_EXPORT_HTML__"
-@dataclass(frozen=True)
-class EffectiveWorkflowPlan:
- """Resolved runtime workflow intent after merging CLI and config defaults."""
-
- logical_steps: tuple[str, ...]
- runtime_steps: tuple[str, ...]
- dashboard_mode: str
- overwrite: bool
-
-
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="activitysim-viz",
@@ -82,11 +72,6 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Force summary cache writes during the summarize step.",
)
- parser.add_argument(
- "--no-dashboard",
- action="store_true",
- help="Legacy shortcut to skip the dashboard step during default runs.",
- )
parser.add_argument(
"--from-csvs",
nargs="*",
@@ -145,8 +130,6 @@ def _validate_cli_step_flags(args: argparse.Namespace) -> None:
raise ValueError(
"--prepare-only cannot be combined with --prepare, --summarize, or --dashboard."
)
- if args.dashboard and args.no_dashboard:
- raise ValueError("--dashboard cannot be combined with --no-dashboard.")
def _config_default_logical_steps(config) -> list[str]:
@@ -189,8 +172,6 @@ def resolve_requested_steps(args: argparse.Namespace, config) -> list[str]:
steps = ["dashboard"]
else:
steps = _config_default_logical_steps(config)
- if args.no_dashboard:
- steps = [step for step in steps if step != "dashboard"]
if args.from_csvs is not None and any(
step in {"prepare", "summarize"} for step in steps
@@ -229,14 +210,12 @@ def resolve_effective_dashboard_mode(
return "none"
if args.export_html is not None:
return "export"
- if args.no_dashboard:
- return "none"
if args.dashboard:
return "live"
return str(config.pipeline.dashboard_mode).lower()
-def resolve_effective_plan(args: argparse.Namespace, config) -> EffectiveWorkflowPlan:
+def resolve_effective_plan(args: argparse.Namespace, config) -> WorkflowPlan:
"""Resolve logical steps, runtime steps, dashboard mode, and overwrite policy."""
logical_steps = resolve_requested_steps(args, config)
dashboard_mode = resolve_effective_dashboard_mode(
@@ -253,7 +232,7 @@ def resolve_effective_plan(args: argparse.Namespace, config) -> EffectiveWorkflo
overwrite = True
runtime_steps = tuple(collapse_runtime_steps(logical_steps))
- return EffectiveWorkflowPlan(
+ return WorkflowPlan(
logical_steps=tuple(logical_steps),
runtime_steps=runtime_steps,
dashboard_mode=dashboard_mode,
@@ -308,7 +287,7 @@ def _refresh_requested_caches(
def resolve_cache_preferences(
*,
- plan: EffectiveWorkflowPlan,
+ plan: WorkflowPlan,
refreshed_prepared: bool,
refreshed_summary: bool,
) -> tuple[bool, bool]:
@@ -405,26 +384,45 @@ def main() -> None:
)
effective_processor_config = runtime_workflows.effective_processor_config(
config,
- apply_skimjoin="skimjoin" in plan.logical_steps,
- apply_segmentation="segment" in plan.logical_steps,
+ plan=plan,
+ )
+ dashboard_execution_mode = (
+ resolve_dashboard_execution_mode(plan.dashboard_mode)
+ if "dashboard" in steps
+ else "none"
)
- processor_result = None
+ export_html_path = _resolve_export_html_path(
+ args.export_html,
+ config,
+ dashboard_mode=dashboard_execution_mode,
+ )
+ dashboard_requirements = (
+ (
+ export_data_requirements(config)
+ if export_html_path is not None
+ else live_data_requirements(config)
+ )
+ if "dashboard" in steps
+ else None
+ )
+ prepared_artifact = None
+ summary_artifact = None
summary_runs = []
required_run_keys: list[str] = []
if "prepare" in steps:
- processor_result = runtime_workflows.run_prepare_workflow(
+ prepared_artifact = runtime_workflows.run_prepare_workflow(
config=config,
prepared_root=prepared_root,
run_entries=run_entries,
prefer_cache=prefer_prepared_cache,
write_cache=True,
- existing_result=processor_result,
- apply_skimjoin="skimjoin" in plan.logical_steps,
+ existing=prepared_artifact,
+ plan=plan,
)
if "summarize" in steps:
- processor_result = runtime_workflows.run_summary_workflow(
+ summary_artifact = runtime_workflows.run_summary_workflow(
config=config,
cache_root=cache_root,
prepared_root=prepared_root,
@@ -432,18 +430,19 @@ def main() -> None:
prefer_cache=prefer_summary_cache and not args.write_csvs,
prepared_prefer_cache=prefer_prepared_cache,
write_cache=args.write_csvs or not args.skip_summary_cache_write,
- existing_result=processor_result,
- apply_skimjoin="skimjoin" in plan.logical_steps,
- apply_segmentation="segment" in plan.logical_steps,
+ prepared=prepared_artifact,
+ plan=plan,
)
- summary_runs = processor_result.summary_runs
- required_run_keys = list(processor_result.run_keys)
+ summary_runs = summary_artifact.runs
+ required_run_keys = list(summary_artifact.prepared.run_keys)
elif "dashboard" in steps:
+ assert dashboard_requirements is not None
summary_runs = runtime_workflows.load_summary_runs_from_cache(
config=effective_processor_config,
cache_root=cache_root,
explicit_cache_dirs=args.from_csvs,
run_entries=run_entries,
+ required_summary_ids=dashboard_requirements.required_summary_ids,
)
required_run_keys = [summary_run.run_key for summary_run in summary_runs]
@@ -456,31 +455,19 @@ def main() -> None:
shutdown_logging()
return
- dashboard_execution_mode = resolve_dashboard_execution_mode(
- plan.dashboard_mode
- )
- export_html_path = _resolve_export_html_path(
- args.export_html,
- config,
- dashboard_mode=dashboard_execution_mode,
- )
+ assert dashboard_requirements is not None
prepared_runs = []
- dashboard_requirements = (
- export_data_requirements(config)
- if export_html_path is not None
- else live_data_requirements(config)
- )
- processor_result = runtime_workflows.prune_processor_result(
- processor_result,
- required_summary_ids=dashboard_requirements.required_summary_ids,
+ summary_artifact = runtime_workflows.prune_summary_artifact(
+ summary_artifact,
+ required_summary_ids=dashboard_requirements.summary_ids_for_pruning,
required_prepared_tables=dashboard_requirements.required_prepared_tables,
)
- if processor_result is not None:
- summary_runs = list(processor_result.summary_runs)
+ if summary_artifact is not None:
+ summary_runs = list(summary_artifact.runs)
else:
summary_runs = runtime_workflows.prune_summary_runs(
summary_runs,
- dashboard_requirements.required_summary_ids,
+ dashboard_requirements.summary_ids_for_pruning,
)
requires_prepared_data = dashboard_requirements.prepared_data_mode != "none"
@@ -492,8 +479,8 @@ def main() -> None:
prepared_runs = []
elif requires_prepared_data:
existing_prepared_runs_by_key = (
- processor_result.prepared_runs_by_key
- if processor_result is not None
+ summary_artifact.prepared.by_key
+ if summary_artifact is not None
else None
)
prepared_runs = runtime_workflows.load_prepared_runs_for_dashboard(
@@ -502,7 +489,7 @@ def main() -> None:
required_run_keys=required_run_keys,
required_prepared_tables=dashboard_requirements.required_prepared_tables,
existing_prepared_runs_by_key=existing_prepared_runs_by_key,
- apply_skimjoin="skimjoin" in plan.logical_steps,
+ plan=plan,
)
else:
prepared_runs = []
diff --git a/runtime/config/__init__.py b/runtime/config/__init__.py
index fa7715d..acbdcd9 100644
--- a/runtime/config/__init__.py
+++ b/runtime/config/__init__.py
@@ -11,12 +11,14 @@
ExportHTMLSettings,
ExportSelectorRequest,
PipelineSettings,
+ PrepareNonMotorizedDistanceSkimSettings,
PreparedColumnSegmentationSource,
SegmentationDefinition,
StudentTypeConfig,
)
from .normalize_prepare import config_for_run
from .normalize_skimjoin import resolve_run_skimjoin_settings
+from runtime.weighting import WeightingModeDefinition, WeightingModeRegistry
__all__ = [
"CategorySpec",
@@ -26,9 +28,12 @@
"ExportHTMLSettings",
"ExportSelectorRequest",
"PipelineSettings",
+ "PrepareNonMotorizedDistanceSkimSettings",
"PreparedColumnSegmentationSource",
"SegmentationDefinition",
"StudentTypeConfig",
+ "WeightingModeDefinition",
+ "WeightingModeRegistry",
"config_for_run",
"load_config_from_yaml",
"resolve_run_skimjoin_settings",
diff --git a/runtime/config/legacy.py b/runtime/config/legacy.py
deleted file mode 100644
index c651032..0000000
--- a/runtime/config/legacy.py
+++ /dev/null
@@ -1,109 +0,0 @@
-"""Legacy config-key warnings."""
-
-from __future__ import annotations
-
-from typing import Any
-
-from activitysim_viz_logging import get_logger
-
-LOGGER = get_logger("runtime.config")
-
-
-def _record_legacy_warning(
- collector: list[tuple[str, str, str]] | None,
- *,
- kind: str,
- legacy_field_name: str,
- replacement_field_name: str,
-) -> None:
- if collector is None:
- return
- collector.append((kind, legacy_field_name, replacement_field_name))
-
-
-def warn_ignored_legacy_key(
- *,
- mapping: dict[str, Any],
- key: str,
- legacy_field_name: str,
- replacement_field_name: str,
- collector: list[tuple[str, str, str]] | None = None,
-) -> None:
- if key in mapping:
- _record_legacy_warning(
- collector,
- kind="ignored",
- legacy_field_name=legacy_field_name,
- replacement_field_name=replacement_field_name,
- )
- LOGGER.warning(
- "Ignoring legacy config key '%s'. Use '%s' instead.",
- legacy_field_name,
- replacement_field_name,
- )
-
-
-def warn_supported_legacy_key(
- *,
- mapping: dict[str, Any],
- key: str,
- legacy_field_name: str,
- replacement_field_name: str,
- collector: list[tuple[str, str, str]] | None = None,
-) -> None:
- if key in mapping:
- _record_legacy_warning(
- collector,
- kind="supported",
- legacy_field_name=legacy_field_name,
- replacement_field_name=replacement_field_name,
- )
- LOGGER.warning(
- "Config key '%s' is deprecated but still supported. Use '%s' instead.",
- legacy_field_name,
- replacement_field_name,
- )
-
-
-def emit_grouped_legacy_summary(
- collector: list[tuple[str, str, str]],
-) -> None:
- if not collector:
- return
-
- seen: set[tuple[str, str, str]] = set()
- unique = []
- for item in collector:
- if item in seen:
- continue
- unique.append(item)
- seen.add(item)
-
- ignored = [
- (legacy_field_name, replacement_field_name)
- for kind, legacy_field_name, replacement_field_name in unique
- if kind == "ignored"
- ]
- supported = [
- (legacy_field_name, replacement_field_name)
- for kind, legacy_field_name, replacement_field_name in unique
- if kind == "supported"
- ]
-
- summary_lines = [
- "Deprecated config keys were detected. Prefer the canonical schema:"
- ]
- if supported:
- summary_lines.extend(
- f"- {legacy_field_name} -> {replacement_field_name}"
- for legacy_field_name, replacement_field_name in supported
- )
- if ignored:
- summary_lines.append(
- "Legacy keys ignored because a canonical replacement was also set:"
- )
- summary_lines.extend(
- f"- {legacy_field_name} -> {replacement_field_name}"
- for legacy_field_name, replacement_field_name in ignored
- )
- LOGGER.warning("\n".join(summary_lines))
diff --git a/runtime/config/loader.py b/runtime/config/loader.py
index 1907ca5..2d8db3a 100644
--- a/runtime/config/loader.py
+++ b/runtime/config/loader.py
@@ -9,520 +9,75 @@
import yaml
from .common import (
- normalize_column_aliases,
- normalize_label_mapping,
normalize_optional_bool,
- normalize_optional_path_string,
normalize_string_list,
)
from .constants import DEFAULT_RUN_COLORS, FILE_MAPPING_DEFAULTS
-from .legacy import (
- emit_grouped_legacy_summary,
- warn_ignored_legacy_key,
- warn_supported_legacy_key,
-)
from .models import (
Config,
- ExportDashboardSettings,
- ExportHTMLSettings,
- ExportSelectorRequest,
GeographyAggregationSettings,
- PipelineSettings,
- PrepareAutoSufficiencySettings,
)
from .normalize_categories import (
category_spec_from_mapping,
category_spec_from_sequence,
normalize_categories,
normalize_escort_category_spec,
- normalize_student_types,
)
from .normalize_export import (
normalize_dashboard_page_entries,
- normalize_excluded_ids,
- normalize_export_html_selection,
- normalize_export_page_entries,
)
from .normalize_geography import normalize_geography_aggregations
from .normalize_prepare import (
normalize_fallback_file_mapping,
normalize_file_mapping,
- normalize_prepare_relationship_checks,
- normalize_prepare_vot_bins,
- normalize_prepared_output_file_format,
normalize_runs,
)
from .normalize_segmentation import normalize_segmentation
from .normalize_skimjoin import normalize_skimjoin_settings
+from .schema import validate_canonical_config
+from .sections import mapping, parse_columns, parse_pipeline, parse_zones
+from .sections_dashboard import parse_dashboard_export
+from .sections_prepare import parse_prepare
from .signatures import digest_payload
+from runtime.weighting import (
+ column_weighting_mode_definitions,
+ load_weighting_mode_extensions,
+ normalize_weighting_modes,
+ weighting_mode_definitions,
+)
ConfigT = TypeVar("ConfigT", bound=Config)
-_PIPELINE_STEP_ORDER = ("prepare", "skimjoin", "segment", "summarize", "dashboard")
-_VALID_PIPELINE_STEPS = set(_PIPELINE_STEP_ORDER)
-_VALID_DASHBOARD_MODES = {"none", "live", "export", "host"}
-
-
-def _mapping(raw_value, *, field_name: str) -> dict:
- if raw_value is None:
- return {}
- if not isinstance(raw_value, dict):
- raise ValueError(f"{field_name} must be a mapping when provided.")
- return raw_value
-
-
-def _synthesized_pipeline_steps(
- *,
- legacy_skimjoin_enabled: bool,
- legacy_segmentation_enabled: bool,
-) -> tuple[str, ...]:
- steps = ["summarize", "dashboard"]
- if legacy_skimjoin_enabled:
- steps = ["prepare", "skimjoin", *steps]
- if legacy_segmentation_enabled:
- steps.insert(steps.index("dashboard"), "segment")
- return tuple(steps)
-
-
-def _normalize_pipeline_settings(
- raw_value,
- *,
- legacy_skimjoin_enabled: bool,
- legacy_segmentation_enabled: bool,
-) -> PipelineSettings:
- synthesized_steps = _synthesized_pipeline_steps(
- legacy_skimjoin_enabled=legacy_skimjoin_enabled,
- legacy_segmentation_enabled=legacy_segmentation_enabled,
- )
- if raw_value is None:
- return PipelineSettings(steps=synthesized_steps)
- if not isinstance(raw_value, dict):
- raise ValueError("pipeline must be a mapping when provided.")
-
- steps_raw = raw_value.get("steps")
- if steps_raw is None:
- steps = list(synthesized_steps)
- else:
- if not isinstance(steps_raw, list) or not steps_raw:
- raise ValueError("pipeline.steps must be a non-empty list when provided.")
- steps = []
- seen_steps: set[str] = set()
- for idx, raw_step in enumerate(steps_raw):
- if not isinstance(raw_step, str):
- raise ValueError("pipeline.steps entries must be strings.")
- step = raw_step.strip()
- if step != step.lower():
- raise ValueError(
- f"pipeline.steps[{idx}] must already be normalized lowercase."
- )
- if step not in _VALID_PIPELINE_STEPS:
- raise ValueError(
- "pipeline.steps contains unsupported step "
- f"{step!r}. Allowed steps: {', '.join(_PIPELINE_STEP_ORDER)}."
- )
- if step in seen_steps:
- raise ValueError(f"pipeline.steps contains duplicate step {step!r}.")
- seen_steps.add(step)
- steps.append(step)
-
- dashboard_mode = str(raw_value.get("dashboard_mode", "live")).strip().lower()
- if dashboard_mode not in _VALID_DASHBOARD_MODES:
- raise ValueError(
- "pipeline.dashboard_mode must be one of none, live, export, or host."
- )
-
- overwrite = raw_value.get("overwrite", False)
- if not isinstance(overwrite, bool):
- raise ValueError("pipeline.overwrite must be true or false when provided.")
-
- if "skimjoin" in steps and "prepare" not in steps:
- raise ValueError("pipeline.steps cannot include 'skimjoin' without 'prepare'.")
- if "segment" in steps and "summarize" not in steps:
- raise ValueError("pipeline.steps cannot include 'segment' without 'summarize'.")
- if "dashboard" in steps and steps[-1] != "dashboard":
- raise ValueError("pipeline.steps must place 'dashboard' last when present.")
-
- return PipelineSettings(
- steps=tuple(steps),
- dashboard_mode=dashboard_mode,
- overwrite=overwrite,
- )
-
-
-def _compatibility_normalize_raw_config(
- raw: dict,
-) -> tuple[dict, PipelineSettings, list[tuple[str, str, str]]]:
- legacy_warnings: list[tuple[str, str, str]] = []
- prepare_cfg = dict(_mapping(raw.get("prepare"), field_name="prepare"))
- processor_cfg = dict(_mapping(raw.get("processor"), field_name="processor"))
- processor_summaries_cfg = dict(
- _mapping(processor_cfg.get("summaries"), field_name="processor.summaries")
- )
- visualizer_cfg = dict(_mapping(raw.get("visualizer"), field_name="visualizer"))
- dashboard_cfg = _mapping(raw.get("dashboard"), field_name="dashboard")
- dashboard_live_cfg = _mapping(dashboard_cfg.get("live"), field_name="dashboard.live")
- summarize_cfg = _mapping(raw.get("summarize"), field_name="summarize")
- display_cfg = _mapping(raw.get("display"), field_name="display")
- segment_cfg = _mapping(raw.get("segment"), field_name="segment")
- legacy_segmentation_cfg = _mapping(raw.get("segmentation"), field_name="segmentation")
- legacy_skimjoin_cfg = _mapping(raw.get("skimjoin"), field_name="skimjoin")
- modes_cfg = _mapping(raw.get("modes"), field_name="modes")
-
- pipeline = _normalize_pipeline_settings(
- raw.get("pipeline"),
- legacy_skimjoin_enabled=bool(legacy_skimjoin_cfg.get("enabled", False)),
- legacy_segmentation_enabled=bool(legacy_segmentation_cfg.get("enabled", False)),
- )
-
- normalized = dict(raw)
-
- legacy_distance_skim = raw.get("skim")
- if legacy_distance_skim is not None and not isinstance(legacy_distance_skim, dict):
- raise ValueError("skim must be a mapping when provided.")
-
- if "root" in raw:
- warn_ignored_legacy_key(
- mapping=processor_cfg,
- key="root",
- legacy_field_name="processor.root",
- replacement_field_name="root",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=_mapping(raw.get("summaries"), field_name="summaries"),
- key="root",
- legacy_field_name="summaries.root",
- replacement_field_name="root",
- collector=legacy_warnings,
- )
- processor_cfg["root"] = raw["root"]
- elif "root" in _mapping(raw.get("summaries"), field_name="summaries"):
- warn_supported_legacy_key(
- mapping=_mapping(raw.get("summaries"), field_name="summaries"),
- key="root",
- legacy_field_name="summaries.root",
- replacement_field_name="processor.root",
- collector=legacy_warnings,
- )
-
- if "weighting_modes" in summarize_cfg:
- warn_ignored_legacy_key(
- mapping=processor_summaries_cfg,
- key="weighting_modes",
- legacy_field_name="processor.summaries.weighting_modes",
- replacement_field_name="summarize.weighting_modes",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=_mapping(raw.get("summaries"), field_name="summaries"),
- key="weighting_modes",
- legacy_field_name="summaries.weighting_modes",
- replacement_field_name="summarize.weighting_modes",
- collector=legacy_warnings,
- )
- processor_summaries_cfg["weighting_modes"] = summarize_cfg["weighting_modes"]
- elif "weighting_modes" in _mapping(raw.get("summaries"), field_name="summaries"):
- warn_supported_legacy_key(
- mapping=_mapping(raw.get("summaries"), field_name="summaries"),
- key="weighting_modes",
- legacy_field_name="summaries.weighting_modes",
- replacement_field_name="processor.summaries.weighting_modes",
- collector=legacy_warnings,
- )
-
- if processor_summaries_cfg:
- processor_cfg["summaries"] = processor_summaries_cfg
- if processor_cfg:
- normalized["processor"] = processor_cfg
-
- if "log_level" in raw:
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="log_level",
- legacy_field_name="visualizer.log_level",
- replacement_field_name="log_level",
- collector=legacy_warnings,
- )
- visualizer_cfg["log_level"] = raw["log_level"]
-
- if "title" in dashboard_cfg:
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="dashboard_title",
- legacy_field_name="visualizer.dashboard_title",
- replacement_field_name="dashboard.title",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=raw,
- key="dashboard_title",
- legacy_field_name="dashboard_title",
- replacement_field_name="dashboard.title",
- collector=legacy_warnings,
- )
- visualizer_cfg["dashboard_title"] = dashboard_cfg["title"]
-
- if "pages" in dashboard_live_cfg:
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="dashboard_pages",
- legacy_field_name="visualizer.dashboard_pages",
- replacement_field_name="dashboard.live.pages",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=dashboard_cfg,
- key="pages",
- legacy_field_name="dashboard.pages",
- replacement_field_name="dashboard.live.pages",
- collector=legacy_warnings,
- )
- visualizer_cfg["dashboard_pages"] = dashboard_live_cfg["pages"]
- elif "pages" in dashboard_cfg:
- warn_supported_legacy_key(
- mapping=dashboard_cfg,
- key="pages",
- legacy_field_name="dashboard.pages",
- replacement_field_name="dashboard.live.pages",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="dashboard_pages",
- legacy_field_name="visualizer.dashboard_pages",
- replacement_field_name="dashboard.live.pages",
- collector=legacy_warnings,
- )
- visualizer_cfg["dashboard_pages"] = dashboard_cfg["pages"]
-
- if "export" in dashboard_cfg:
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="export_html",
- legacy_field_name="visualizer.export_html",
- replacement_field_name="dashboard.export",
- collector=legacy_warnings,
- )
- export_cfg = dict(_mapping(dashboard_cfg.get("export"), field_name="dashboard.export"))
- warn_supported_legacy_key(
- mapping=export_cfg,
- key="enabled",
- legacy_field_name="dashboard.export.enabled",
- replacement_field_name="pipeline.dashboard_mode",
- collector=legacy_warnings,
- )
- visualizer_cfg["export_html"] = export_cfg
- elif (
- raw.get("pipeline")
- and pipeline.has_step("dashboard")
- and pipeline.dashboard_mode == "export"
- and "export_html" not in visualizer_cfg
- ):
- visualizer_cfg["export_html"] = {}
-
- if "enable_maz_geographies" in dashboard_cfg:
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="enable_maz_geographies",
- legacy_field_name="visualizer.enable_maz_geographies",
- replacement_field_name="dashboard.enable_maz_geographies",
- collector=legacy_warnings,
- )
- visualizer_cfg["enable_maz_geographies"] = dashboard_cfg["enable_maz_geographies"]
-
- if "run_colors" in display_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key="run_colors",
- legacy_field_name="run_colors",
- replacement_field_name="display.run_colors",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=visualizer_cfg,
- key="run_colors",
- legacy_field_name="visualizer.run_colors",
- replacement_field_name="display.run_colors",
- collector=legacy_warnings,
- )
- visualizer_cfg["run_colors"] = display_cfg["run_colors"]
-
- if visualizer_cfg:
- normalized["visualizer"] = visualizer_cfg
-
- if "labels" in display_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key="dashboard_labels",
- legacy_field_name="dashboard_labels",
- replacement_field_name="display.labels",
- collector=legacy_warnings,
- )
- normalized["dashboard_labels"] = display_cfg["labels"]
-
- if "geography" in summarize_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key="geography",
- legacy_field_name="geography",
- replacement_field_name="summarize.geography",
- collector=legacy_warnings,
- )
- normalized["geography"] = summarize_cfg["geography"]
-
- for key in (
- "group_joint_tour_purposes",
- "group_atwork_tour_purposes",
- "group_school_tour_purposes",
- ):
- if key in summarize_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key=key,
- legacy_field_name=key,
- replacement_field_name=f"summarize.{key}",
- collector=legacy_warnings,
- )
- normalized[key] = summarize_cfg[key]
- else:
- warn_supported_legacy_key(
- mapping=raw,
- key=key,
- legacy_field_name=key,
- replacement_field_name=f"summarize.{key}",
- collector=legacy_warnings,
- )
-
- if "pnr_tour_modes" in summarize_cfg:
- warn_ignored_legacy_key(
- mapping=modes_cfg,
- key="pnr_tour_modes",
- legacy_field_name="modes.pnr_tour_modes",
- replacement_field_name="summarize.pnr_tour_modes",
- collector=legacy_warnings,
- )
- elif "pnr_tour_modes" in modes_cfg:
- warn_supported_legacy_key(
- mapping=modes_cfg,
- key="pnr_tour_modes",
- legacy_field_name="modes.pnr_tour_modes",
- replacement_field_name="summarize.pnr_tour_modes",
- collector=legacy_warnings,
- )
-
- if segment_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key="segmentation",
- legacy_field_name="segmentation",
- replacement_field_name="segment",
- collector=legacy_warnings,
- )
- synthesized_segment = dict(segment_cfg)
- warn_supported_legacy_key(
- mapping=synthesized_segment,
- key="enabled",
- legacy_field_name="segment.enabled",
- replacement_field_name="pipeline.steps",
- collector=legacy_warnings,
- )
- if "enabled" not in synthesized_segment:
- synthesized_segment["enabled"] = pipeline.has_step("segment") or bool(
- synthesized_segment.get("definitions")
- )
- normalized["segmentation"] = synthesized_segment
-
- if legacy_skimjoin_cfg:
- normalized_skimjoin = dict(legacy_skimjoin_cfg)
- if "distance_skim" in normalized_skimjoin:
- if "distance_skim" in prepare_cfg:
- warn_ignored_legacy_key(
- mapping=normalized_skimjoin,
- key="distance_skim",
- legacy_field_name="skimjoin.distance_skim",
- replacement_field_name="prepare.distance_skim",
- collector=legacy_warnings,
- )
- else:
- warn_supported_legacy_key(
- mapping=normalized_skimjoin,
- key="distance_skim",
- legacy_field_name="skimjoin.distance_skim",
- replacement_field_name="prepare.distance_skim",
- collector=legacy_warnings,
- )
- prepare_cfg["distance_skim"] = normalized_skimjoin["distance_skim"]
- warn_ignored_legacy_key(
- mapping=raw,
- key="skim",
- legacy_field_name="skim",
- replacement_field_name="prepare.distance_skim",
- collector=legacy_warnings,
- )
- if "defaults" in normalized_skimjoin:
- warn_ignored_legacy_key(
- mapping=normalized_skimjoin,
- key="config_path",
- legacy_field_name="skimjoin.config_path",
- replacement_field_name="skimjoin.defaults.config_path",
- collector=legacy_warnings,
- )
- if raw.get("pipeline") is not None:
- warn_supported_legacy_key(
- mapping=normalized_skimjoin,
- key="enabled",
- legacy_field_name="skimjoin.enabled",
- replacement_field_name="pipeline.steps",
- collector=legacy_warnings,
- )
- normalized_skimjoin["enabled"] = pipeline.has_step("skimjoin")
- elif "enabled" not in normalized_skimjoin and "defaults" in normalized_skimjoin:
- normalized_skimjoin["enabled"] = True
- normalized["skimjoin"] = normalized_skimjoin
- elif raw.get("pipeline") is not None and pipeline.has_step("skimjoin"):
- normalized["skimjoin"] = {"enabled": True}
-
- if legacy_distance_skim is not None and "distance_skim" not in prepare_cfg:
- warn_supported_legacy_key(
- mapping=raw,
- key="skim",
- legacy_field_name="skim",
- replacement_field_name="prepare.distance_skim",
- collector=legacy_warnings,
- )
- prepare_cfg["distance_skim"] = legacy_distance_skim
-
- if prepare_cfg:
- normalized["prepare"] = prepare_cfg
-
- return normalized, pipeline, legacy_warnings
-
-
def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> ConfigT:
config_path = Path(path).resolve()
config_bytes = config_path.read_bytes()
raw = yaml.safe_load(config_bytes.decode("utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError("config file must parse to a mapping.")
- raw, pipeline, legacy_warnings = _compatibility_normalize_raw_config(raw)
-
- processor_cfg = raw.get("processor") or {}
- if not isinstance(processor_cfg, dict):
- raise ValueError("processor must be a mapping when provided.")
-
- summaries_cfg = raw.get("summaries") or {}
- if not isinstance(summaries_cfg, dict):
- raise ValueError("summaries must be a mapping when provided.")
- processor_summaries_cfg = processor_cfg.get("summaries") or {}
- if not isinstance(processor_summaries_cfg, dict):
- raise ValueError("processor.summaries must be a mapping when provided.")
-
- visualizer_cfg = raw.get("visualizer") or {}
- if not isinstance(visualizer_cfg, dict):
- raise ValueError("visualizer must be a mapping when provided.")
- dashboard_cfg = _mapping(raw.get("dashboard"), field_name="dashboard")
- dashboard_live_cfg = _mapping(dashboard_cfg.get("live"), field_name="dashboard.live")
- summarize_cfg = _mapping(raw.get("summarize"), field_name="summarize")
+ validate_canonical_config(raw)
+ extensions_cfg = mapping(raw.get("extensions"), field_name="extensions")
+ extension_modules = normalize_string_list(
+ extensions_cfg.get("modules"),
+ field_name="extensions.modules",
+ )
+ extension_settings = dict(
+ mapping(
+ extensions_cfg.get("settings"),
+ field_name="extensions.settings",
+ )
+ )
+ load_weighting_mode_extensions(extension_modules)
+ weighting_cfg = mapping(raw.get("weighting"), field_name="weighting")
+ configured_weighting_definitions = column_weighting_mode_definitions(
+ weighting_cfg.get("modes"),
+ field_name="weighting.modes",
+ )
+ pipeline = parse_pipeline(raw.get("pipeline"))
+ dashboard_cfg = mapping(raw.get("dashboard"), field_name="dashboard")
+ dashboard_live_cfg = mapping(dashboard_cfg.get("live"), field_name="dashboard.live")
+ summarize_cfg = mapping(raw.get("summarize"), field_name="summarize")
+ display_cfg = mapping(raw.get("display"), field_name="display")
+ segment_cfg = mapping(raw.get("segment"), field_name="segment")
files = normalize_file_mapping(
raw.get("files"),
@@ -540,31 +95,11 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
config_dir=config_path.parent,
)
- cols = raw.get("columns", {})
- if not isinstance(cols, dict):
- raise ValueError("columns must be a mapping when provided.")
- zones = raw.get("zones", {})
- if not isinstance(zones, dict):
- raise ValueError("zones must be a mapping when provided.")
- geo = raw.get("geography", {})
- if not isinstance(geo, dict):
- raise ValueError("geography must be a mapping when provided.")
+ column_fields = parse_columns(raw.get("columns"))
+ zone_fields = parse_zones(raw.get("zones"))
+ geo = mapping(summarize_cfg.get("geography"), field_name="summarize.geography")
- prepare_cfg = raw.get("prepare", {})
- if prepare_cfg is None:
- prepare_cfg = {}
- if not isinstance(prepare_cfg, dict):
- raise ValueError("prepare must be a mapping when provided.")
- prepare_output_cfg = prepare_cfg.get("output", {})
- if prepare_output_cfg is None:
- prepare_output_cfg = {}
- if not isinstance(prepare_output_cfg, dict):
- raise ValueError("prepare.output must be a mapping when provided.")
- prepare_validation_cfg = prepare_cfg.get("validation", {})
- if prepare_validation_cfg is None:
- prepare_validation_cfg = {}
- if not isinstance(prepare_validation_cfg, dict):
- raise ValueError("prepare.validation must be a mapping when provided.")
+ prepare = parse_prepare(raw.get("prepare"), config_dir=config_path.parent)
geo_enabled = bool(geo.get("enabled", False))
geo_mapping = None
@@ -573,7 +108,7 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
geography_aggregations = (
normalize_geography_aggregations(
geo,
- field_name="geography",
+ field_name="summarize.geography",
config_dir=config_path.parent,
)
if geo_enabled
@@ -581,337 +116,121 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
)
segmentation = normalize_segmentation(
- raw.get("segmentation"),
- field_name="segmentation",
+ {**segment_cfg, "enabled": pipeline.has_step("segment")},
+ field_name="segment",
config_dir=config_path.parent,
)
- skim_cfg = prepare_cfg.get("distance_skim", {})
- if skim_cfg is None:
- skim_cfg = {}
- if not isinstance(skim_cfg, dict):
- raise ValueError("prepare.distance_skim must be a mapping when provided.")
+ skim_cfg = prepare.distance_skim
skimjoin = normalize_skimjoin_settings(
raw.get("skimjoin"),
config_dir=config_path.parent,
+ default_enabled=pipeline.has_step("skimjoin"),
)
- prepare_vot_bins = normalize_prepare_vot_bins(
- prepare_cfg.get("vot_bins"),
- field_name="prepare.vot_bins",
- )
- auto_sufficiency_basis_raw = prepare_cfg.get("auto_sufficiency_basis")
- if auto_sufficiency_basis_raw is None:
- prepare_auto_sufficiency = PrepareAutoSufficiencySettings()
- elif not isinstance(auto_sufficiency_basis_raw, str):
- raise ValueError(
- "prepare.auto_sufficiency_basis must be one of "
- "'licensed_drivers', 'workers', or 'adults'."
- )
- else:
- auto_sufficiency_basis = auto_sufficiency_basis_raw.strip().lower()
- if auto_sufficiency_basis not in {
- "licensed_drivers",
- "workers",
- "adults",
- }:
- raise ValueError(
- "prepare.auto_sufficiency_basis must be one of "
- "'licensed_drivers', 'workers', or 'adults'."
- )
- prepare_auto_sufficiency = PrepareAutoSufficiencySettings(
- basis=auto_sufficiency_basis
- )
- prepare_output_file_format = normalize_prepared_output_file_format(
- prepare_output_cfg.get("file_format"),
- field_name="prepare.output.file_format",
- )
- prepare_relationship_checks = normalize_prepare_relationship_checks(
- prepare_validation_cfg.get("relationship_checks"),
- field_name="prepare.validation.relationship_checks",
- )
-
modes_cfg = raw.get("modes", {})
if not isinstance(modes_cfg, dict):
raise ValueError("modes must be a mapping when provided.")
- outputs_cfg = raw.get("outputs", {})
- if outputs_cfg is None:
- outputs_cfg = {}
- if not isinstance(outputs_cfg, dict):
- raise ValueError("outputs must be a mapping when provided.")
-
- if "dashboard_title" in raw and "dashboard_title" in visualizer_cfg:
- warn_ignored_legacy_key(
- mapping=raw,
- key="dashboard_title",
- legacy_field_name="dashboard_title",
- replacement_field_name="visualizer.dashboard_title",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=raw,
- key="dashboard_pages",
- legacy_field_name="dashboard_pages",
- replacement_field_name="visualizer.dashboard_pages",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=raw,
- key="run_colors",
- legacy_field_name="run_colors",
- replacement_field_name="visualizer.run_colors",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=outputs_cfg,
- key="summary_root",
- legacy_field_name="outputs.summary_root",
- replacement_field_name="processor.root",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=outputs_cfg,
- key="weighting_modes",
- legacy_field_name="outputs.weighting_modes",
- replacement_field_name="processor.summaries.weighting_modes",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=outputs_cfg,
- key="export_html",
- legacy_field_name="outputs.export_html",
- replacement_field_name="visualizer.export_html",
- collector=legacy_warnings,
- )
- warn_supported_legacy_key(
- mapping=summaries_cfg,
- key="root",
- legacy_field_name="summaries.root",
- replacement_field_name="processor.root",
- collector=legacy_warnings,
- )
- warn_supported_legacy_key(
- mapping=summaries_cfg,
- key="weighting_modes",
- legacy_field_name="summaries.weighting_modes",
- replacement_field_name="processor.summaries.weighting_modes",
- collector=legacy_warnings,
- )
-
- dashboard_pages_cfg = visualizer_cfg.get("dashboard_pages")
- dashboard_pages_field_name = "visualizer.dashboard_pages"
- if "pages" in dashboard_live_cfg:
- dashboard_pages_field_name = "dashboard.live.pages"
- elif "pages" in dashboard_cfg:
- dashboard_pages_field_name = "dashboard.pages"
+ dashboard_pages_cfg = dashboard_live_cfg.get("pages")
dashboard_pages = (
None
if dashboard_pages_cfg is None
else normalize_dashboard_page_entries(
dashboard_pages_cfg,
- field_name=dashboard_pages_field_name,
+ field_name="dashboard.live.pages",
)
)
+ include_notes = dashboard_cfg.get(
+ "include_notes", dashboard_cfg.get("calculation_notes", True)
+ )
+ if not isinstance(include_notes, bool):
+ raise ValueError("dashboard.include_notes must be true or false when provided.")
- summary_root_raw = processor_cfg.get("root", summaries_cfg.get("root", "artifacts/summary_cache"))
+ summary_root_raw = raw.get("root", "artifacts/summary_cache")
summary_root = Path(summary_root_raw)
if not summary_root.is_absolute():
summary_root = (config_path.parent / summary_root).resolve()
- weighting_modes_cfg = processor_summaries_cfg.get(
- "weighting_modes",
- summaries_cfg.get("weighting_modes", ["weighted", "unweighted"]),
- )
- raw_weighting_modes = [str(mode).strip().lower() for mode in weighting_modes_cfg]
- supported_weighting_modes = {"weighted", "unweighted"}
- invalid_weighting_modes = [
- mode for mode in raw_weighting_modes if mode and mode not in supported_weighting_modes
- ]
- if invalid_weighting_modes:
- raise ValueError(
- "Unsupported processor.summaries.weighting_modes values: "
- + ", ".join(repr(mode) for mode in invalid_weighting_modes)
- )
- weighting_modes: list[str] = []
- for mode in raw_weighting_modes:
- if mode and mode not in weighting_modes:
- weighting_modes.append(mode)
- if not weighting_modes:
- weighting_modes = ["weighted", "unweighted"]
-
- raw_export_html_cfg = visualizer_cfg.get("export_html")
- export_html_present = raw_export_html_cfg is not None
- export_html_cfg = raw_export_html_cfg or {}
- if not isinstance(export_html_cfg, dict):
- raise ValueError("visualizer.export_html must be a mapping when provided.")
- warn_ignored_legacy_key(
- mapping=export_html_cfg,
- key="weighting",
- legacy_field_name="visualizer.export_html.weighting",
- replacement_field_name="visualizer.export_html.dashboard.weighting",
- collector=legacy_warnings,
- )
- warn_ignored_legacy_key(
- mapping=export_html_cfg,
- key="values",
- legacy_field_name="visualizer.export_html.values",
- replacement_field_name="visualizer.export_html.dashboard.values",
- collector=legacy_warnings,
- )
- export_enabled_raw = export_html_cfg.get("enabled")
- if export_enabled_raw is None:
- export_enabled = export_html_present
- elif isinstance(export_enabled_raw, bool):
- export_enabled = export_enabled_raw
- else:
- raise ValueError("visualizer.export_html.enabled must be true or false.")
-
- dashboard_cfg = export_html_cfg.get("dashboard")
- if dashboard_cfg is None:
- dashboard_cfg = {}
- elif not isinstance(dashboard_cfg, dict):
- raise ValueError("visualizer.export_html.dashboard must be a mapping.")
-
- pages_cfg = export_html_cfg.get("pages")
- pages_configured = pages_cfg is not None
- if pages_cfg is None:
- pages_cfg = {}
- normalized_pages = normalize_export_page_entries(
- pages_cfg,
- field_name="visualizer.export_html.pages",
+ weighting_modes = normalize_weighting_modes(
+ summarize_cfg.get("weighting_modes"),
+ field_name="summarize.weighting_modes",
+ additional_definitions=configured_weighting_definitions,
)
-
- export_html = ExportHTMLSettings(
- enabled=export_enabled,
- output_path=normalize_optional_path_string(
- export_html_cfg.get("output_path"),
- field_name="visualizer.export_html.output_path",
- config_dir=summary_root,
- ),
- dashboard=ExportDashboardSettings(
- weighting=normalize_export_html_selection(
- dashboard_cfg.get("weighting"),
- field_name="visualizer.export_html.dashboard.weighting",
- default=weighting_modes,
- allowed=weighting_modes,
- ),
- values=normalize_export_html_selection(
- dashboard_cfg.get("values"),
- field_name="visualizer.export_html.dashboard.values",
- default=["percent", "count"],
- allowed=["percent", "count"],
- ),
- segmentation_type=(
- None
- if not segmentation.enabled
- else (
- str(dashboard_cfg.get("segmentation_type")).strip().lower()
- if dashboard_cfg.get("segmentation_type") is not None
- else segmentation.dashboard.segmentation_type
- )
- ),
- segmentation_visibility=(
- None
- if not segmentation.enabled
- else str(
- dashboard_cfg.get(
- "segmentation_visibility",
- segmentation.dashboard.visibility,
- )
- )
- .strip()
- .lower()
- ),
- ),
- pages=normalized_pages,
- exclude_pages=normalize_excluded_ids(
- export_html_cfg.get("exclude_pages"),
- field_name="visualizer.export_html.exclude_pages",
- ),
- exclude_groups=normalize_excluded_ids(
- export_html_cfg.get("exclude_groups"),
- field_name="visualizer.export_html.exclude_groups",
- ),
- pages_configured=pages_configured,
- default_selector_request=ExportSelectorRequest(mode="all"),
+ selected_weighting_definitions = weighting_mode_definitions(
+ weighting_modes,
+ field_name="summarize.weighting_modes",
+ additional_definitions=configured_weighting_definitions,
)
- if export_html.dashboard.segmentation_type is not None and (
- export_html.dashboard.segmentation_type not in segmentation.definition_names()
- ):
+ summary_failure_policy = str(
+ summarize_cfg.get("failure_policy", "record")
+ ).strip().lower()
+ if summary_failure_policy not in {"record", "error"}:
raise ValueError(
- "visualizer.export_html.dashboard.segmentation_type must name one configured segmentation definition."
- )
- if export_html.dashboard.segmentation_visibility is not None and (
- export_html.dashboard.segmentation_visibility
- not in {"full_only", "segments_only", "full_and_segments"}
- ):
- raise ValueError(
- "visualizer.export_html.dashboard.segmentation_visibility must be one of full_only, segments_only, or full_and_segments."
+ "summarize.failure_policy must be either 'record' or 'error'."
)
- dashboard_title = visualizer_cfg.get("dashboard_title")
- if dashboard_title is None:
- dashboard_title = raw.get("dashboard_title", "ActivitySim Visualizer")
- log_level = str(visualizer_cfg.get("log_level", "INFO")).strip().upper()
+ export_html = parse_dashboard_export(
+ dashboard_cfg.get("export"),
+ pipeline=pipeline,
+ segmentation=segmentation,
+ summary_root=summary_root,
+ weighting_modes=weighting_modes,
+ weighting_labels={
+ definition.mode_id: definition.label
+ for definition in selected_weighting_definitions
+ },
+ )
+
+ dashboard_title = dashboard_cfg.get("title", "ActivitySim Visualizer")
+ log_level = str(raw.get("log_level", "INFO")).strip().upper()
if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(
- "visualizer.log_level must be one of DEBUG, INFO, WARNING, ERROR, or CRITICAL."
+ "log_level must be one of DEBUG, INFO, WARNING, ERROR, or CRITICAL."
)
- run_colors = visualizer_cfg.get("run_colors", list(DEFAULT_RUN_COLORS))
+ run_colors = display_cfg.get("run_colors", list(DEFAULT_RUN_COLORS))
if not isinstance(run_colors, list):
- raise ValueError("visualizer.run_colors must be a list when provided.")
- missing_data_display = str(visualizer_cfg.get("missing_data_display", "card")).strip().lower()
+ raise ValueError("display.run_colors must be a list when provided.")
+ missing_data_display = str(
+ display_cfg.get("missing_data_display", "card")
+ ).strip().lower()
if missing_data_display not in {"card", "blank"}:
- raise ValueError("visualizer.missing_data_display must be either 'card' or 'blank'.")
- enable_maz_geographies_raw = visualizer_cfg.get("enable_maz_geographies", False)
+ raise ValueError("display.missing_data_display must be either 'card' or 'blank'.")
+ bar_hover_mode = str(display_cfg.get("bar_hover_mode", "closest")).strip().lower()
+ if bar_hover_mode not in {"closest", "all"}:
+ raise ValueError("display.bar_hover_mode must be either 'closest' or 'all'.")
+ density_hover_mode = str(
+ display_cfg.get("density_hover_mode", "closest")
+ ).strip().lower()
+ if density_hover_mode not in {"closest", "all"}:
+ raise ValueError(
+ "display.density_hover_mode must be either 'closest' or 'all'."
+ )
+ enable_maz_geographies_raw = dashboard_cfg.get("enable_maz_geographies", False)
if not isinstance(enable_maz_geographies_raw, bool):
raise ValueError(
- "visualizer.enable_maz_geographies must be true or false when provided."
+ "dashboard.enable_maz_geographies must be true or false when provided."
)
- person_type_labels = normalize_label_mapping(
- raw.get("person_types"),
- field_name="person_types",
- )
- transit_subsidy_labels = normalize_label_mapping(
- raw.get("transit_subsidies"),
- field_name="transit_subsidies",
- )
summary_categories = normalize_categories(
- raw.get("summary_categories"),
- field_name="summary_categories",
+ summarize_cfg.get("category_normalization"),
+ field_name="summarize.category_normalization",
)
dashboard_labels = normalize_categories(
- raw.get("dashboard_labels"),
- field_name="dashboard_labels",
- )
- legacy_categories = normalize_categories(
- raw.get("categories"),
- field_name="categories",
+ display_cfg.get("labels"),
+ field_name="display.labels",
)
- for category_id, spec in legacy_categories.items():
- summary_categories.setdefault(category_id, spec)
- dashboard_labels.setdefault(category_id, spec)
- if "person_type" not in dashboard_labels:
- legacy_person_type_spec = category_spec_from_mapping(person_type_labels)
- if legacy_person_type_spec is not None:
- dashboard_labels["person_type"] = legacy_person_type_spec
- if "transit_subsidy" not in dashboard_labels:
- legacy_transit_subsidy_spec = category_spec_from_mapping(transit_subsidy_labels)
- if legacy_transit_subsidy_spec is not None:
- dashboard_labels["transit_subsidy"] = legacy_transit_subsidy_spec
if "geography" not in summary_categories and geo_mapping:
- legacy_geography_spec = category_spec_from_mapping(geo_mapping)
- if legacy_geography_spec is not None:
- summary_categories["geography"] = legacy_geography_spec
+ geography_spec = category_spec_from_mapping(geo_mapping)
+ if geography_spec is not None:
+ summary_categories["geography"] = geography_spec
if "geography" not in dashboard_labels and geo_mapping:
- legacy_geography_spec = category_spec_from_mapping(geo_mapping)
- if legacy_geography_spec is not None:
- dashboard_labels["geography"] = legacy_geography_spec
+ geography_spec = category_spec_from_mapping(geo_mapping)
+ if geography_spec is not None:
+ dashboard_labels["geography"] = geography_spec
if "mode" not in dashboard_labels:
- legacy_mode_spec = category_spec_from_sequence(modes_cfg.get("order"))
- if legacy_mode_spec is not None:
- dashboard_labels["mode"] = legacy_mode_spec
+ mode_spec = category_spec_from_sequence(modes_cfg.get("order"))
+ if mode_spec is not None:
+ dashboard_labels["mode"] = mode_spec
summary_categories["escort"] = normalize_escort_category_spec(
summary_categories.get("escort")
)
@@ -921,69 +240,28 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
group_joint_tour_purposes = (
normalize_optional_bool(
- summarize_cfg.get(
- "group_joint_tour_purposes",
- raw.get("group_joint_tour_purposes"),
- ),
- field_name=(
- "summarize.group_joint_tour_purposes"
- if "group_joint_tour_purposes" in summarize_cfg
- else "group_joint_tour_purposes"
- ),
- )
- if (
- "group_joint_tour_purposes" in summarize_cfg
- or raw.get("group_joint_tour_purposes") is not None
+ summarize_cfg.get("group_joint_tour_purposes"),
+ field_name="summarize.group_joint_tour_purposes",
)
+ if "group_joint_tour_purposes" in summarize_cfg
else True
)
group_atwork_tour_purposes = (
normalize_optional_bool(
- summarize_cfg.get(
- "group_atwork_tour_purposes",
- raw.get("group_atwork_tour_purposes"),
- ),
- field_name=(
- "summarize.group_atwork_tour_purposes"
- if "group_atwork_tour_purposes" in summarize_cfg
- else "group_atwork_tour_purposes"
- ),
- )
- if (
- "group_atwork_tour_purposes" in summarize_cfg
- or raw.get("group_atwork_tour_purposes") is not None
+ summarize_cfg.get("group_atwork_tour_purposes"),
+ field_name="summarize.group_atwork_tour_purposes",
)
+ if "group_atwork_tour_purposes" in summarize_cfg
else True
)
group_school_tour_purposes = (
normalize_optional_bool(
- summarize_cfg.get(
- "group_school_tour_purposes",
- raw.get("group_school_tour_purposes"),
- ),
- field_name=(
- "summarize.group_school_tour_purposes"
- if "group_school_tour_purposes" in summarize_cfg
- else "group_school_tour_purposes"
- ),
- )
- if (
- "group_school_tour_purposes" in summarize_cfg
- or raw.get("group_school_tour_purposes") is not None
+ summarize_cfg.get("group_school_tour_purposes"),
+ field_name="summarize.group_school_tour_purposes",
)
+ if "group_school_tour_purposes" in summarize_cfg
else True
)
- student_types = normalize_student_types(
- raw.get("student_types"),
- field_name="student_types",
- )
-
- pnr_tour_modes_field_name = (
- "summarize.pnr_tour_modes"
- if "pnr_tour_modes" in summarize_cfg
- else "modes.pnr_tour_modes"
- )
-
config = cls(
config_path=str(config_path),
config_digest=hashlib.sha256(config_bytes).hexdigest(),
@@ -995,301 +273,30 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
log_level=log_level,
pipeline=pipeline,
dashboard_pages=dashboard_pages,
+ include_notes=include_notes,
enable_maz_geographies=enable_maz_geographies_raw,
run_colors=run_colors,
missing_data_display=missing_data_display,
+ bar_hover_mode=bar_hover_mode,
+ density_hover_mode=density_hover_mode,
summary_root=str(summary_root),
weighting_modes=weighting_modes,
+ weighting_mode_definitions=selected_weighting_definitions,
+ extension_modules=tuple(extension_modules),
+ extension_settings=extension_settings,
+ summary_failure_policy=summary_failure_policy,
export_html=export_html,
skimjoin=skimjoin,
- prepare_vot_bins=prepare_vot_bins,
- prepare_auto_sufficiency=prepare_auto_sufficiency,
- prepare_output_file_format=prepare_output_file_format,
- prepare_relationship_checks=prepare_relationship_checks,
+ **prepare.config_fields,
files=files,
fallback_files=fallback_files,
- col_ptype=cols.get("ptype", "ptype"),
- col_hhsize=cols.get("hhsize", "hhsize"),
- col_auto_ownership=cols.get("auto_ownership", "auto_ownership"),
- col_num_workers=cols.get("num_workers", "num_workers"),
- col_num_adults=cols.get("num_adults", "num_adults"),
- col_sample_rate=cols.get("sample_rate") or None,
- col_household_id=normalize_column_aliases(
- cols.get("household_id"),
- field_name="columns.household_id",
- default=["household_id"],
- ),
- col_person_id=normalize_column_aliases(
- cols.get("person_id"),
- field_name="columns.person_id",
- default=["person_id"],
- ),
- col_tour_id=normalize_column_aliases(
- cols.get("tour_id"),
- field_name="columns.tour_id",
- default=["tour_id"],
- ),
- col_trip_id=normalize_column_aliases(
- cols.get("trip_id"),
- field_name="columns.trip_id",
- default=["trip_id"],
- ),
- col_tour_purpose=normalize_column_aliases(
- cols.get("tour_purpose"),
- field_name="columns.tour_purpose",
- default=["tour_purpose", "primary_purpose", "tour_type", "purpose"],
- ),
- col_trip_purpose=normalize_column_aliases(
- cols.get("trip_purpose"),
- field_name="columns.trip_purpose",
- default=["trip_purpose", "purpose"],
- ),
- col_tour_mode=normalize_column_aliases(
- cols.get("tour_mode"),
- field_name="columns.tour_mode",
- default=["tour_mode"],
- ),
- col_trip_mode=normalize_column_aliases(
- cols.get("trip_mode"),
- field_name="columns.trip_mode",
- default=["trip_mode"],
- ),
- col_tour_category=normalize_column_aliases(
- cols.get("tour_category"),
- field_name="columns.tour_category",
- default=["tour_category"],
- ),
- col_tour_start=normalize_column_aliases(
- cols.get("tour_start"),
- field_name="columns.tour_start",
- default=["start", "start_hour"],
- ),
- col_tour_end=normalize_column_aliases(
- cols.get("tour_end"),
- field_name="columns.tour_end",
- default=["end", "end_hour"],
- ),
- col_tour_duration=normalize_column_aliases(
- cols.get("tour_duration"),
- field_name="columns.tour_duration",
- default=["duration", "tourdur"],
- ),
- col_trip_depart=normalize_column_aliases(
- cols.get("trip_depart"),
- field_name="columns.trip_depart",
- default=["depart", "depart_hour"],
- ),
- col_total_employment=normalize_column_aliases(
- cols.get("total_employment"),
- field_name="columns.total_employment",
- default=[
- "EMP_TOTAL",
- "EMP_Total",
- "EMPLOY_TOT",
- "TOTEMP",
- "total_employment",
- "employment",
- ],
- ),
- col_income_segment=normalize_column_aliases(
- cols.get("income_segment"),
- field_name="columns.income_segment",
- default=["income_segment", "income_broad", "income"],
- ),
- col_home_zone_id=normalize_column_aliases(
- cols.get("home_zone_id"),
- field_name="columns.home_zone_id",
- default=["home_zone_id"],
- ),
- col_workplace_zone_id=normalize_column_aliases(
- cols.get("workplace_zone_id"),
- field_name="columns.workplace_zone_id",
- default=["workplace_zone_id"],
- ),
- col_school_zone_id=normalize_column_aliases(
- cols.get("school_zone_id"),
- field_name="columns.school_zone_id",
- default=["school_zone_id"],
- ),
- col_has_license=normalize_column_aliases(
- cols.get("has_license"),
- field_name="columns.has_license",
- default=["has_license"],
- ),
- col_mandatory_tour_frequency=normalize_column_aliases(
- cols.get("mandatory_tour_frequency"),
- field_name="columns.mandatory_tour_frequency",
- default=["mandatory_tour_frequency"],
- ),
- col_is_student=normalize_column_aliases(
- cols.get("is_student"),
- field_name="columns.is_student",
- default=["is_student", "student"],
- ),
- col_is_university=normalize_column_aliases(
- cols.get("is_university"),
- field_name="columns.is_university",
- default=["is_university", "major_uni"],
- ),
- col_school_segment=normalize_column_aliases(
- cols.get("school_segment"),
- field_name="columns.school_segment",
- default=["school_segment"],
- ),
- col_schg=normalize_column_aliases(
- cols.get("schg"),
- field_name="columns.schg",
- default=["SCHG"],
- ),
- col_pstudent=normalize_column_aliases(
- cols.get("pstudent"),
- field_name="columns.pstudent",
- default=["pstudent"],
- ),
- col_tour_origin=normalize_column_aliases(
- cols.get("tour_origin"),
- field_name="columns.tour_origin",
- default=["origin"],
- ),
- col_tour_destination=normalize_column_aliases(
- cols.get("tour_destination"),
- field_name="columns.tour_destination",
- default=["destination"],
- ),
- col_trip_origin=normalize_column_aliases(
- cols.get("trip_origin"),
- field_name="columns.trip_origin",
- default=["origin"],
- ),
- col_trip_destination=normalize_column_aliases(
- cols.get("trip_destination"),
- field_name="columns.trip_destination",
- default=["destination"],
- ),
- col_stop_frequency=normalize_column_aliases(
- cols.get("stop_frequency"),
- field_name="columns.stop_frequency",
- default=["stop_frequency"],
- ),
- col_trip_outbound=normalize_column_aliases(
- cols.get("trip_outbound"),
- field_name="columns.trip_outbound",
- default=["outbound"],
- ),
- col_trip_num=normalize_column_aliases(
- cols.get("trip_num"),
- field_name="columns.trip_num",
- default=["trip_num"],
- ),
- col_pnr_zone_id=normalize_column_aliases(
- cols.get("pnr_zone_id"),
- field_name="columns.pnr_zone_id",
- default=["pnr_zone_id"],
- ),
- col_pnr_lot_capacity=normalize_column_aliases(
- cols.get("pnr_lot_capacity"),
- field_name="columns.pnr_lot_capacity",
- default=["PNR_SPACES"],
- ),
- col_is_worker=normalize_column_aliases(
- cols.get("is_worker"),
- field_name="columns.is_worker",
- default=["is_worker"],
- ),
- col_adult=normalize_column_aliases(
- cols.get("adult"),
- field_name="columns.adult",
- default=["adult", "is_adult"],
- ),
- col_day_id=normalize_column_aliases(
- cols.get("day_id"),
- field_name="columns.day_id",
- default=["day_id"],
- ),
- col_day_weight=normalize_column_aliases(
- cols.get("day_weight"),
- field_name="columns.day_weight",
- default=["day_weight"],
- ),
- col_vehicle_id=normalize_column_aliases(
- cols.get("vehicle_id"),
- field_name="columns.vehicle_id",
- default=["vehicle_id"],
- ),
- col_vehicle_num=normalize_column_aliases(
- cols.get("vehicle_num"),
- field_name="columns.vehicle_num",
- default=["vehicle_num"],
- ),
- col_vehicle_type=normalize_column_aliases(
- cols.get("vehicle_type"),
- field_name="columns.vehicle_type",
- default=["vehicle_type"],
- ),
- col_school_esc_outbound=normalize_column_aliases(
- cols.get("school_esc_outbound"),
- field_name="columns.school_esc_outbound",
- default=["school_esc_outbound"],
- ),
- col_school_esc_inbound=normalize_column_aliases(
- cols.get("school_esc_inbound"),
- field_name="columns.school_esc_inbound",
- default=["school_esc_inbound"],
- ),
- col_num_escortees=normalize_column_aliases(
- cols.get("num_escortees"),
- field_name="columns.num_escortees",
- default=["num_escortees", "num_escorted"],
- ),
- col_out_escorted_tour_ids=normalize_column_aliases(
- cols.get("out_escorted_tour_ids"),
- field_name="columns.out_escorted_tour_ids",
- default=["out_escorted_tour_ids"],
- ),
- col_inb_escorted_tour_ids=normalize_column_aliases(
- cols.get("inb_escorted_tour_ids"),
- field_name="columns.inb_escorted_tour_ids",
- default=["inb_escorted_tour_ids"],
- ),
- col_out_escorting_type=normalize_column_aliases(
- cols.get("out_escorting_type"),
- field_name="columns.out_escorting_type",
- default=["out_escorting_type"],
- ),
- col_inb_escorting_type=normalize_column_aliases(
- cols.get("inb_escorting_type"),
- field_name="columns.inb_escorting_type",
- default=["inb_escorting_type"],
- ),
- col_out_chauffeur_tour_id=normalize_column_aliases(
- cols.get("out_chauffeur_tour_id"),
- field_name="columns.out_chauffeur_tour_id",
- default=["out_chauffeur_tour_id"],
- ),
- col_inb_chauffeur_tour_id=normalize_column_aliases(
- cols.get("inb_chauffeur_tour_id"),
- field_name="columns.inb_chauffeur_tour_id",
- default=["inb_chauffeur_tour_id"],
- ),
+ **column_fields,
summary_categories=summary_categories,
dashboard_labels=dashboard_labels,
- person_type_labels=person_type_labels,
- transit_subsidy_labels=transit_subsidy_labels,
group_joint_tour_purposes=group_joint_tour_purposes,
group_atwork_tour_purposes=group_atwork_tour_purposes,
group_school_tour_purposes=group_school_tour_purposes,
- student_types=student_types,
- use_maz=bool(zones.get("use_maz", True)),
- maz_col=normalize_column_aliases(
- zones.get("maz_col"),
- field_name="zones.maz_col",
- default=["MAZ", "zone_id"],
- ),
- taz_col=normalize_column_aliases(
- zones.get("taz_col"),
- field_name="zones.taz_col",
- default=["TAZ", "taz"],
- ),
+ **zone_fields,
geography_enabled=geo_enabled,
geography_landuse_col=geo.get("landuse_col") if geo_enabled else None,
geography_mapping=geo_mapping,
@@ -1297,27 +304,22 @@ def load_config_from_yaml(path: str | Path, *, cls: type[ConfigT] = Config) -> C
segmentation=segmentation,
skim_file=skim_cfg.get("file"),
skim_matrix=skim_cfg.get("matrix", "SOV_DIST__MD"),
- mode_order=modes_cfg.get("order"),
- mode_groups=modes_cfg.get("groups"),
- pnr_tour_modes=(
- normalize_string_list(
- summarize_cfg.get("pnr_tour_modes", modes_cfg.get("pnr_tour_modes")),
- field_name=pnr_tour_modes_field_name,
- )
- if (
- "pnr_tour_modes" in summarize_cfg
- or "pnr_tour_modes" in modes_cfg
- )
- else ["PNR_TRANSIT"]
- ),
- runs=runs,
- )
+ mode_groups=modes_cfg.get("groups"),
+ pnr_tour_modes=(
+ normalize_string_list(
+ summarize_cfg.get("pnr_tour_modes"),
+ field_name="summarize.pnr_tour_modes",
+ )
+ if "pnr_tour_modes" in summarize_cfg
+ else ["PNR_TRANSIT"]
+ ),
+ runs=runs,
+ )
if not config.pnr_tour_modes:
- raise ValueError(f"{pnr_tour_modes_field_name} must resolve to at least one mode.")
+ raise ValueError("summarize.pnr_tour_modes must resolve to at least one mode.")
config.prepare_config_digest = digest_payload(config.prepare_signature_payload())
config.summary_config_digest = digest_payload(config.summary_signature_payload())
config.presentation_config_digest = digest_payload(
config.presentation_signature_payload()
)
- emit_grouped_legacy_summary(legacy_warnings)
return config
diff --git a/runtime/config/models.py b/runtime/config/models.py
index 5b78e8b..cc79350 100644
--- a/runtime/config/models.py
+++ b/runtime/config/models.py
@@ -4,12 +4,15 @@
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any, Literal, Optional
+from typing import Any, Literal, Optional, TYPE_CHECKING
import polars as pl
from .common import normalize_run_selector_key
+if TYPE_CHECKING:
+ from runtime.weighting import WeightingModeDefinition
+
@dataclass(frozen=True)
class DashboardPageConfigEntry:
@@ -49,6 +52,7 @@ class ExportDashboardSettings:
"""Resolved dashboard-level controls for HTML export."""
weighting: list[str] = field(default_factory=lambda: ["weighted"])
+ weighting_labels: dict[str, str] = field(default_factory=dict)
values: list[str] = field(default_factory=lambda: ["percent"])
segmentation_type: str | None = None
segmentation_visibility: Literal[
@@ -56,7 +60,7 @@ class ExportDashboardSettings:
] | None = None
def panel_weighting_values(self) -> list[str]:
- return [mode.title() for mode in self.weighting]
+ return [self.weighting_labels.get(mode, mode.title()) for mode in self.weighting]
def panel_value_values(self) -> list[str]:
labels = {"percent": "Percent", "count": "Count"}
@@ -144,6 +148,8 @@ class SkimjoinSettings:
normalized_config: Any | None = None
resolved_skim_files: tuple[str, ...] = ()
resolved_network_los_file: str | None = None
+ create_hypothetical_skim_tables: bool = False
+ failure_policy: Literal["record", "error"] = "record"
@dataclass(frozen=True)
@@ -153,6 +159,7 @@ class RunSkimjoinOverrides:
config_path: str | None = None
skim_files: tuple[str, ...] = ()
network_los_file: str | None = None
+ create_hypothetical_skim_tables: bool | None = None
@dataclass(frozen=True)
@@ -176,6 +183,30 @@ class PrepareAutoSufficiencySettings:
basis: Literal["licensed_drivers", "workers", "adults"] = "licensed_drivers"
+@dataclass(frozen=True)
+class PrepareTimePeriodsSettings:
+ """Optional prepared trip/tour period-label derivation."""
+
+ enabled: bool = False
+ network_los_file: str | None = None
+ network_los_digest: str | None = None
+ trip_period_number_column: str = "depart"
+ tour_start_period_number_column: str = "start"
+ tour_end_period_number_column: str = "end"
+
+
+@dataclass(frozen=True)
+class PrepareNonMotorizedDistanceSkimSettings:
+ """Optional non-motorized distance lookup applied during prepare."""
+
+ enabled: bool = False
+ file: str | None = None
+ file_digest: str | None = None
+ matrix: str | None = None
+ source_type: Literal["csv", "omx"] | None = None
+ value_column: str = "DISTWALK"
+
+
@dataclass(frozen=True)
class CategorySpec:
"""Canonical display labels and ordering rules for one categorical domain."""
@@ -318,15 +349,23 @@ class Config:
log_level: str
pipeline: PipelineSettings
dashboard_pages: list[DashboardPageConfigEntry] | None
+ include_notes: bool
enable_maz_geographies: bool
run_colors: list[str]
missing_data_display: str
+ bar_hover_mode: Literal["closest", "all"]
+ density_hover_mode: Literal["closest", "all"]
summary_root: str
weighting_modes: list[str]
+ weighting_mode_definitions: tuple["WeightingModeDefinition", ...]
+ extension_modules: tuple[str, ...]
+ extension_settings: dict[str, Any]
export_html: ExportHTMLSettings
skimjoin: SkimjoinSettings
prepare_vot_bins: PrepareVotBinsSettings
prepare_auto_sufficiency: PrepareAutoSufficiencySettings
+ prepare_time_periods: PrepareTimePeriodsSettings
+ prepare_non_motorized_distance_skim: PrepareNonMotorizedDistanceSkimSettings
prepare_output_file_format: str
prepare_relationship_checks: str
files: dict[str, str]
@@ -389,8 +428,6 @@ class Config:
col_inb_chauffeur_tour_id: list[str]
summary_categories: dict[str, CategorySpec]
dashboard_labels: dict[str, CategorySpec]
- person_type_labels: Optional[dict[str, str]]
- transit_subsidy_labels: Optional[dict[str, str]]
group_joint_tour_purposes: bool
group_atwork_tour_purposes: bool
group_school_tour_purposes: bool
@@ -405,10 +442,10 @@ class Config:
segmentation: SegmentationSettings
skim_file: Optional[str]
skim_matrix: str
- mode_order: Optional[list[str]]
mode_groups: Optional[dict[str, list[str]]]
pnr_tour_modes: list[str]
runs: list[dict]
+ summary_failure_policy: Literal["record", "error"] = "record"
@classmethod
def from_yaml(cls, path: str | Path) -> "Config":
@@ -434,10 +471,21 @@ def presentation_signature_payload(self) -> dict[str, Any]:
def run_color(self, idx: int) -> str:
return self.run_colors[idx % len(self.run_colors)]
- @property
- def categories(self) -> dict[str, CategorySpec]:
- """Compatibility alias for pre-split display-oriented category lookups."""
- return self.dashboard_labels
+ def weighting_mode_definition(self, mode_id: str) -> "WeightingModeDefinition":
+ normalized = str(mode_id).strip().lower()
+ for definition in self.weighting_mode_definitions:
+ if definition.mode_id == normalized:
+ return definition
+ raise ValueError(
+ f"Weighting mode {mode_id!r} is not enabled for this configuration."
+ )
+
+ def weighting_mode_label(self, mode_id: str) -> str:
+ return self.weighting_mode_definition(mode_id).label
+
+ def skimjoin_step_enabled(self) -> bool:
+ """Return whether the active pipeline includes integrated skimjoin."""
+ return self.pipeline.has_step("skimjoin")
def summary_category_spec(self, category_id: str) -> CategorySpec | None:
return self.summary_categories.get(str(category_id))
@@ -445,10 +493,6 @@ def summary_category_spec(self, category_id: str) -> CategorySpec | None:
def dashboard_label_spec(self, category_id: str) -> CategorySpec | None:
return self.dashboard_labels.get(str(category_id))
- def category_spec(self, category_id: str) -> CategorySpec | None:
- """Compatibility shim for existing display-oriented call sites."""
- return self.dashboard_label_spec(category_id)
-
def normalize_escort_value(self, raw_value) -> str:
from .normalize_categories import escort_normalization_key
diff --git a/runtime/config/normalize_export.py b/runtime/config/normalize_export.py
index ac8e147..8354a87 100644
--- a/runtime/config/normalize_export.py
+++ b/runtime/config/normalize_export.py
@@ -248,6 +248,12 @@ def normalize_export_page_entries(
if not isinstance(raw_page_cfg, dict):
raise ValueError(f"{field_name}.{page_id} must be a mapping.")
+ # Preserve explicit no-op overrides. Export pages inherit from the live
+ # page set; an empty mapping does not turn the mapping into an allow-list.
+ if not raw_page_cfg:
+ normalized[page_id] = ExportPageOverride()
+ continue
+
is_leaf_override = any(
str(key).strip().lower() in {"enabled", "parts"}
or not isinstance(value, dict)
diff --git a/runtime/config/normalize_prepare.py b/runtime/config/normalize_prepare.py
index 79a3df3..b7d88ce 100644
--- a/runtime/config/normalize_prepare.py
+++ b/runtime/config/normalize_prepare.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import hashlib
from dataclasses import replace
from pathlib import Path
from typing import Any
@@ -17,7 +18,12 @@
OPTIONAL_PREPARED_TABLE_IDS,
PREPARED_TABLE_MAP_KEYS,
)
-from .models import Config, PrepareVotBinsSettings
+from .models import (
+ Config,
+ PrepareNonMotorizedDistanceSkimSettings,
+ PrepareTimePeriodsSettings,
+ PrepareVotBinsSettings,
+)
from .normalize_skimjoin import normalize_run_skimjoin_overrides, resolve_run_skimjoin_settings
@@ -75,6 +81,109 @@ def normalize_prepare_vot_bins(
)
+def normalize_prepare_time_periods(
+ raw_value,
+ *,
+ field_name: str,
+ config_dir: Path,
+) -> PrepareTimePeriodsSettings:
+ if raw_value in (None, {}):
+ return PrepareTimePeriodsSettings()
+ if not isinstance(raw_value, dict):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+
+ network_los_raw = raw_value.get("network_los_file")
+ if not isinstance(network_los_raw, str) or not network_los_raw.strip():
+ raise ValueError(f"{field_name}.network_los_file must be a non-empty path string.")
+ network_los_path = Path(network_los_raw.strip()).expanduser()
+ if not network_los_path.is_absolute():
+ network_los_path = (config_dir / network_los_path).resolve()
+ else:
+ network_los_path = network_los_path.resolve()
+ if not network_los_path.exists():
+ raise ValueError(
+ f"{field_name}.network_los_file does not exist: {network_los_path}"
+ )
+ if not network_los_path.is_file():
+ raise ValueError(
+ f"{field_name}.network_los_file must point to a file: {network_los_path}"
+ )
+
+ def _column(name: str, default: str) -> str:
+ raw_column = raw_value.get(name, default)
+ if not isinstance(raw_column, str) or not raw_column.strip():
+ raise ValueError(f"{field_name}.{name} must be a non-empty string.")
+ return raw_column.strip()
+
+ return PrepareTimePeriodsSettings(
+ enabled=True,
+ network_los_file=str(network_los_path),
+ network_los_digest=hashlib.sha256(network_los_path.read_bytes()).hexdigest(),
+ trip_period_number_column=_column("trip_period_number_column", "depart"),
+ tour_start_period_number_column=_column(
+ "tour_start_period_number_column", "start"
+ ),
+ tour_end_period_number_column=_column("tour_end_period_number_column", "end"),
+ )
+
+
+def normalize_prepare_non_motorized_distance_skim(
+ raw_value,
+ *,
+ field_name: str,
+ config_dir: Path,
+) -> PrepareNonMotorizedDistanceSkimSettings:
+ if raw_value in (None, {}):
+ return PrepareNonMotorizedDistanceSkimSettings()
+ if not isinstance(raw_value, dict):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+
+ file_raw = raw_value.get("file")
+ if not isinstance(file_raw, str) or not file_raw.strip():
+ raise ValueError(f"{field_name}.file must be a non-empty path string.")
+ path = Path(file_raw.strip()).expanduser()
+ if not path.is_absolute():
+ path = (config_dir / path).resolve()
+ else:
+ path = path.resolve()
+ if not path.exists():
+ raise ValueError(f"{field_name}.file does not exist: {path}")
+ if not path.is_file():
+ raise ValueError(f"{field_name}.file must point to a file: {path}")
+
+ suffix = path.suffix.lower()
+ if suffix == ".csv":
+ source_type = "csv"
+ elif suffix in {".omx", ".h5", ".hdf5"}:
+ source_type = "omx"
+ else:
+ raise ValueError(
+ f"{field_name}.file must end with '.csv', '.omx', '.h5', or '.hdf5'."
+ )
+
+ matrix_raw = raw_value.get("matrix")
+ matrix = None if matrix_raw is None else str(matrix_raw).strip()
+ if matrix == "":
+ matrix = None
+
+ if source_type == "omx" and matrix is None:
+ raise ValueError(f"{field_name}.matrix is required for OMX/HDF5 files.")
+
+ value_column = "DISTWALK"
+ if source_type == "csv" and matrix is not None:
+ prefix = f"{path.stem}__"
+ value_column = matrix[len(prefix) :] if matrix.startswith(prefix) else matrix
+
+ return PrepareNonMotorizedDistanceSkimSettings(
+ enabled=True,
+ file=str(path),
+ file_digest=hashlib.sha256(path.read_bytes()).hexdigest(),
+ matrix=matrix,
+ source_type=source_type,
+ value_column=value_column,
+ )
+
+
def normalize_file_mapping(
raw_value,
*,
@@ -216,6 +325,37 @@ def normalize_prepared_table_map(
return normalized
+def normalize_summary_table_map(
+ raw_value,
+ *,
+ field_name: str,
+ config_dir: Path,
+) -> dict[str, str]:
+ if raw_value is None:
+ raw_value = {}
+ if not isinstance(raw_value, dict):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+
+ normalized: dict[str, str] = {}
+ for raw_key, raw_path in raw_value.items():
+ key = str(raw_key)
+ if not key.strip():
+ raise ValueError(f"{field_name} contains an empty summary id.")
+ if not isinstance(raw_path, str):
+ raise ValueError(f"{field_name}.{key} must be a non-empty path string.")
+ token = raw_path.strip()
+ if not token:
+ raise ValueError(f"{field_name}.{key} must be a non-empty path string.")
+ suffix = Path(token).suffix.lower()
+ if suffix not in {".parquet", ".csv"}:
+ raise ValueError(f"{field_name}.{key} must end with '.parquet' or '.csv'.")
+ resolved = Path(token).expanduser()
+ if not resolved.is_absolute():
+ resolved = (config_dir / resolved).resolve()
+ normalized[key] = str(resolved)
+ return normalized
+
+
def normalize_runs(
raw_value,
*,
@@ -247,6 +387,12 @@ def normalize_runs(
raise ValueError(
f"{field_name}[{index}] cannot define both file_map and prepared_table_map."
)
+ if "summary_table_map" in raw_entry:
+ normalized_entry["summary_table_map"] = normalize_summary_table_map(
+ raw_entry.get("summary_table_map"),
+ field_name=f"{field_name}[{index}].summary_table_map",
+ config_dir=config_dir,
+ )
if "skimjoin" in raw_entry:
normalized_entry["skimjoin"] = normalize_run_skimjoin_overrides(
raw_entry.get("skimjoin"),
diff --git a/runtime/config/normalize_segmentation.py b/runtime/config/normalize_segmentation.py
index 53a8063..c222dcb 100644
--- a/runtime/config/normalize_segmentation.py
+++ b/runtime/config/normalize_segmentation.py
@@ -18,6 +18,38 @@
SegmentationSourceConfig,
)
+SEGMENTATION_TABLE_ALIASES: dict[str, str] = {
+ "households": "hh",
+ "hh": "hh",
+ "persons": "per",
+ "per": "per",
+ "day": "day",
+ "tours": "tours",
+ "trips": "trips",
+ "vehicles": "vehicles",
+ "joint_tour_participants": "joint_participants",
+ "joint_participants": "joint_participants",
+ "land_use": "land_use",
+}
+SEGMENTATION_TABLE_DISPLAY = (
+ "households, persons, day, tours, trips, vehicles, "
+ "joint_tour_participants, land_use"
+)
+
+
+def normalize_segmentation_table_name(
+ raw_value,
+ *,
+ field_name: str,
+) -> str:
+ table_name = str(raw_value).strip()
+ normalized = SEGMENTATION_TABLE_ALIASES.get(table_name)
+ if normalized is None:
+ raise ValueError(
+ f"{field_name} must be one of {SEGMENTATION_TABLE_DISPLAY}."
+ )
+ return normalized
+
def normalize_segment_values(
raw_value,
@@ -48,15 +80,10 @@ def normalize_segmentation_source(
source_table = str(source_table_raw).strip() if source_table_raw is not None else None
if source_table == "":
source_table = None
- if source_table is not None and source_table not in {
- "hh",
- "per",
- "tours",
- "trips",
- "land_use",
- }:
- raise ValueError(
- f"{field_name}.source_table must be one of hh, per, tours, trips, land_use."
+ if source_table is not None:
+ source_table = normalize_segmentation_table_name(
+ source_table,
+ field_name=f"{field_name}.source_table",
)
return PreparedColumnSegmentationSource(column=column, source_table=source_table)
@@ -69,14 +96,14 @@ def normalize_segmentation_source(
join_raw = raw_value.get("join")
if not isinstance(join_raw, dict):
raise ValueError(f"{field_name}.join must be a mapping.")
- join_source_table = str(join_raw.get("source_table", "")).strip()
+ join_source_table_raw = str(join_raw.get("source_table", "")).strip()
join_source_key_column = str(join_raw.get("source_key_column", "")).strip()
csv_key_column = str(join_raw.get("csv_key_column", "")).strip()
segment_value_column = str(raw_value.get("segment_value_column", "")).strip()
- if join_source_table not in {"hh", "per", "tours", "trips", "land_use"}:
- raise ValueError(
- f"{field_name}.join.source_table must be one of hh, per, tours, trips, land_use."
- )
+ join_source_table = normalize_segmentation_table_name(
+ join_source_table_raw,
+ field_name=f"{field_name}.join.source_table",
+ )
if not join_source_key_column:
raise ValueError(f"{field_name}.join.source_key_column is required.")
if not csv_key_column:
diff --git a/runtime/config/normalize_skimjoin.py b/runtime/config/normalize_skimjoin.py
index e33c186..7291ac4 100644
--- a/runtime/config/normalize_skimjoin.py
+++ b/runtime/config/normalize_skimjoin.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import replace
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -42,25 +43,7 @@ def validate_required_period_mappings(
*,
context_label: str,
) -> None:
- period_requires_mapping = any(
- "PERIOD" in getattr(rule, "dimensions_used", [])
- for rule in [
- *getattr(normalized_config, "trip_lookups", []),
- *getattr(normalized_config, "tour_lookups", []),
- ]
- )
- if not period_requires_mapping:
- return
-
- for rule in [*normalized_config.trip_lookups, *normalized_config.tour_lookups]:
- if "PERIOD" not in rule.dimensions_used:
- continue
- period_dimension = rule.dimensions.get("PERIOD")
- if period_dimension is None or period_dimension.values:
- continue
- raise ValueError(
- f"{context_label} requires period mapping for skimjoin dimension 'PERIOD', but no usable network_los_file or explicit dimensions.PERIOD.values were provided."
- )
+ return
def load_resolved_skimjoin_settings(
@@ -68,6 +51,8 @@ def load_resolved_skimjoin_settings(
config_path: str,
skim_files_override: tuple[str, ...] = (),
network_los_file_override: str | None = None,
+ create_hypothetical_skim_tables: bool = False,
+ failure_policy: str = "record",
context_label: str,
) -> SkimjoinSettings:
from .signatures import digest_payload
@@ -115,6 +100,8 @@ def load_resolved_skimjoin_settings(
normalized_config=normalized_config,
resolved_skim_files=tuple(skim_files),
resolved_network_los_file=(None if project is None else project.network_los_file),
+ create_hypothetical_skim_tables=bool(create_hypothetical_skim_tables),
+ failure_policy=failure_policy,
)
@@ -128,6 +115,11 @@ def normalize_run_skimjoin_overrides(
return RunSkimjoinOverrides()
if not isinstance(raw_value, dict):
raise ValueError(f"{field_name} must be a mapping when provided.")
+ if "generate_hypothetical_sidecars" in raw_value:
+ raise ValueError(
+ f"{field_name}.generate_hypothetical_sidecars was renamed to "
+ f"{field_name}.create_hypothetical_skim_tables."
+ )
config_path = normalize_optional_path_string(
raw_value.get("config_path"),
@@ -156,10 +148,18 @@ def normalize_run_skimjoin_overrides(
field_name=f"{field_name}.network_los_file",
config_dir=config_dir,
)
+ create_hypothetical_skim_tables = raw_value.get("create_hypothetical_skim_tables")
+ if create_hypothetical_skim_tables is not None and not isinstance(
+ create_hypothetical_skim_tables, bool
+ ):
+ raise ValueError(
+ f"{field_name}.create_hypothetical_skim_tables must be true or false when provided."
+ )
return RunSkimjoinOverrides(
config_path=config_path,
skim_files=skim_files,
network_los_file=network_los_file,
+ create_hypothetical_skim_tables=create_hypothetical_skim_tables,
)
@@ -174,6 +174,11 @@ def normalize_skimjoin_settings(
return SkimjoinSettings(enabled=bool(default_enabled))
if not isinstance(raw_value, dict):
raise ValueError(f"{field_name} must be a mapping when provided.")
+ if "generate_hypothetical_sidecars" in raw_value:
+ raise ValueError(
+ f"{field_name}.generate_hypothetical_sidecars was renamed to "
+ f"{field_name}.create_hypothetical_skim_tables."
+ )
defaults_raw = raw_value.get("defaults")
defaults = None
@@ -189,6 +194,19 @@ def normalize_skimjoin_settings(
enabled = raw_value.get("enabled", enabled_default)
if not isinstance(enabled, bool):
raise ValueError(f"{field_name}.enabled must be true or false when provided.")
+ create_hypothetical_skim_tables = raw_value.get(
+ "create_hypothetical_skim_tables",
+ False,
+ )
+ if not isinstance(create_hypothetical_skim_tables, bool):
+ raise ValueError(
+ f"{field_name}.create_hypothetical_skim_tables must be true or false when provided."
+ )
+ failure_policy = str(raw_value.get("failure_policy", "record")).strip().lower()
+ if failure_policy not in {"record", "error"}:
+ raise ValueError(
+ f"{field_name}.failure_policy must be either 'record' or 'error'."
+ )
config_path_raw = (
defaults.get("config_path")
if defaults is not None and "config_path" in defaults
@@ -233,21 +251,40 @@ def normalize_skimjoin_settings(
)
if not enabled:
- return SkimjoinSettings(enabled=False, config_path=resolved_config_path)
+ return SkimjoinSettings(
+ enabled=False,
+ config_path=resolved_config_path,
+ create_hypothetical_skim_tables=create_hypothetical_skim_tables,
+ failure_policy=failure_policy,
+ )
if resolved_config_path is None:
- return SkimjoinSettings(enabled=True, config_path=None)
+ return SkimjoinSettings(
+ enabled=True,
+ config_path=None,
+ create_hypothetical_skim_tables=create_hypothetical_skim_tables,
+ failure_policy=failure_policy,
+ )
return load_resolved_skimjoin_settings(
config_path=resolved_config_path,
skim_files_override=skim_files,
network_los_file_override=network_los_file,
+ create_hypothetical_skim_tables=create_hypothetical_skim_tables,
+ failure_policy=failure_policy,
context_label="global",
)
def resolve_run_skimjoin_settings(config: Config, run_entry: dict[str, Any]) -> SkimjoinSettings:
- if not config.skimjoin.enabled:
- return config.skimjoin
+ if not config.skimjoin_step_enabled():
+ return replace(
+ config.skimjoin,
+ enabled=False,
+ config_digest=None,
+ normalized_config=None,
+ resolved_skim_files=(),
+ resolved_network_los_file=None,
+ )
run_label = str(
run_entry.get("label", Path(str(run_entry.get("dir", ""))).name or "run")
@@ -274,14 +311,22 @@ def resolve_run_skimjoin_settings(config: Config, run_entry: dict[str, Any]) ->
overrides.config_path is None
and not overrides.skim_files
and overrides.network_los_file is None
+ and overrides.create_hypothetical_skim_tables is None
and config.skimjoin.config_path == effective_config_path
and config.skimjoin.normalized_config is not None
):
return config.skimjoin
+ create_hypothetical_skim_tables = (
+ config.skimjoin.create_hypothetical_skim_tables
+ if overrides.create_hypothetical_skim_tables is None
+ else overrides.create_hypothetical_skim_tables
+ )
return load_resolved_skimjoin_settings(
config_path=effective_config_path,
skim_files_override=overrides.skim_files,
network_los_file_override=overrides.network_los_file,
+ create_hypothetical_skim_tables=create_hypothetical_skim_tables,
+ failure_policy=config.skimjoin.failure_policy,
context_label=f"run '{run_label}'",
)
diff --git a/runtime/config/schema.py b/runtime/config/schema.py
new file mode 100644
index 0000000..9a3d764
--- /dev/null
+++ b/runtime/config/schema.py
@@ -0,0 +1,298 @@
+"""Validation for the canonical application config surface."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from .sections import CANONICAL_COLUMN_KEYS
+
+
+CANONICAL_TOP_LEVEL_KEYS = {
+ "columns",
+ "dashboard",
+ "display",
+ "extensions",
+ "fallback_files",
+ "files",
+ "log_level",
+ "modes",
+ "name",
+ "pipeline",
+ "prepare",
+ "root",
+ "runs",
+ "segment",
+ "skimjoin",
+ "summarize",
+ "weighting",
+ "zones",
+}
+
+REMOVED_TOP_LEVEL_KEYS = {
+ "categories": "Use summarize.category_normalization and display.labels.",
+ "dashboard_labels": "Use display.labels.",
+ "dashboard_pages": "Use dashboard.live.pages.",
+ "dashboard_title": "Use dashboard.title.",
+ "geography": "Use summarize.geography.",
+ "group_atwork_tour_purposes": "Use summarize.group_atwork_tour_purposes.",
+ "group_joint_tour_purposes": "Use summarize.group_joint_tour_purposes.",
+ "group_school_tour_purposes": "Use summarize.group_school_tour_purposes.",
+ "outputs": "Use root, summarize, and dashboard.export.",
+ "person_types": "Use display.labels.person_type.",
+ "processor": "Use root, prepare, and summarize.",
+ "run_colors": "Use display.run_colors.",
+ "segmentation": "Use segment.",
+ "skim": "Use prepare.distance_skim.",
+ "student_types": "Use prepare.student_types.",
+ "summaries": "Use root and summarize.",
+ "summary_categories": "Use summarize.category_normalization.",
+ "transit_subsidies": "Use display.labels.transit_subsidy.",
+ "visualizer": "Use dashboard and display.",
+}
+
+
+def _mapping(value: object, *, field_name: str) -> Mapping[str, object]:
+ if value is None:
+ return {}
+ if not isinstance(value, Mapping):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+ return value
+
+
+def _reject_keys(
+ mapping: Mapping[str, object],
+ *,
+ field_name: str,
+ replacements: Mapping[str, str],
+) -> None:
+ removed = [key for key in replacements if key in mapping]
+ if not removed:
+ return
+ details = "; ".join(
+ f"{field_name}.{key}: {replacements[key]}" for key in removed
+ )
+ raise ValueError(f"Removed config keys are not supported: {details}")
+
+
+def _reject_unknown_keys(
+ section: Mapping[str, object],
+ *,
+ field_name: str,
+ allowed: set[str],
+) -> None:
+ unknown = sorted(set(section) - allowed)
+ if unknown:
+ raise ValueError(
+ f"Unknown {field_name} config keys: "
+ + ", ".join(repr(key) for key in unknown)
+ )
+
+
+def validate_canonical_config(raw: Mapping[str, object]) -> None:
+ """Reject removed and unknown keys before normalization can ignore them."""
+ removed = [key for key in REMOVED_TOP_LEVEL_KEYS if key in raw]
+ if removed:
+ details = "; ".join(
+ f"{key}: {REMOVED_TOP_LEVEL_KEYS[key]}" for key in removed
+ )
+ raise ValueError(f"Removed config keys are not supported: {details}")
+
+ unknown = sorted(set(raw) - CANONICAL_TOP_LEVEL_KEYS)
+ if unknown:
+ raise ValueError(
+ "Unknown top-level config keys: "
+ + ", ".join(repr(key) for key in unknown)
+ )
+
+ extensions = _mapping(raw.get("extensions"), field_name="extensions")
+ _reject_unknown_keys(
+ extensions,
+ field_name="extensions",
+ allowed={"modules", "settings"},
+ )
+ _mapping(extensions.get("settings"), field_name="extensions.settings")
+
+ weighting = _mapping(raw.get("weighting"), field_name="weighting")
+ _reject_unknown_keys(
+ weighting,
+ field_name="weighting",
+ allowed={"modes"},
+ )
+
+ dashboard = _mapping(raw.get("dashboard"), field_name="dashboard")
+ _reject_unknown_keys(
+ dashboard,
+ field_name="dashboard",
+ allowed={
+ "title",
+ "live",
+ "export",
+ "host",
+ "include_notes",
+ "enable_maz_geographies",
+ "pages",
+ },
+ )
+ _reject_keys(
+ dashboard,
+ field_name="dashboard",
+ replacements={"pages": "Use dashboard.live.pages."},
+ )
+ export = _mapping(dashboard.get("export"), field_name="dashboard.export")
+ host = _mapping(dashboard.get("host"), field_name="dashboard.host")
+ live = _mapping(dashboard.get("live"), field_name="dashboard.live")
+ _reject_unknown_keys(
+ live,
+ field_name="dashboard.live",
+ allowed={"pages"},
+ )
+ _reject_unknown_keys(
+ export,
+ field_name="dashboard.export",
+ allowed={
+ "output_path",
+ "dashboard",
+ "pages",
+ "exclude_pages",
+ "exclude_groups",
+ "enabled",
+ "weighting",
+ "values",
+ },
+ )
+ _reject_unknown_keys(
+ host,
+ field_name="dashboard.host",
+ allowed={"account", "app_id", "title", "verify"},
+ )
+ _reject_keys(
+ export,
+ field_name="dashboard.export",
+ replacements={
+ "enabled": "Use pipeline.dashboard_mode.",
+ "weighting": "Use dashboard.export.dashboard.weighting.",
+ "values": "Use dashboard.export.dashboard.values.",
+ },
+ )
+
+ summarize = _mapping(raw.get("summarize"), field_name="summarize")
+ _reject_unknown_keys(
+ summarize,
+ field_name="summarize",
+ allowed={
+ "weighting_modes",
+ "failure_policy",
+ "geography",
+ "category_normalization",
+ "group_joint_tour_purposes",
+ "group_atwork_tour_purposes",
+ "group_school_tour_purposes",
+ "pnr_tour_modes",
+ "summary_categories",
+ },
+ )
+ _reject_keys(
+ summarize,
+ field_name="summarize",
+ replacements={
+ "summary_categories": "Use summarize.category_normalization."
+ },
+ )
+
+ segment = _mapping(raw.get("segment"), field_name="segment")
+ _reject_unknown_keys(
+ segment,
+ field_name="segment",
+ allowed={"dashboard", "definitions", "enabled"},
+ )
+ _reject_keys(
+ segment,
+ field_name="segment",
+ replacements={"enabled": "Use pipeline.steps."},
+ )
+
+ skimjoin = _mapping(raw.get("skimjoin"), field_name="skimjoin")
+ _reject_unknown_keys(
+ skimjoin,
+ field_name="skimjoin",
+ allowed={
+ "defaults",
+ "create_hypothetical_skim_tables",
+ "generate_hypothetical_sidecars",
+ "config_path",
+ "distance_skim",
+ "enabled",
+ "failure_policy",
+ },
+ )
+ _reject_keys(
+ skimjoin,
+ field_name="skimjoin",
+ replacements={
+ "config_path": "Use skimjoin.defaults.config_path.",
+ "distance_skim": "Use prepare.distance_skim.",
+ "enabled": "Use pipeline.steps.",
+ },
+ )
+
+ modes = _mapping(raw.get("modes"), field_name="modes")
+ _reject_unknown_keys(
+ modes,
+ field_name="modes",
+ allowed={"order", "groups", "pnr_tour_modes"},
+ )
+ _reject_keys(
+ modes,
+ field_name="modes",
+ replacements={"pnr_tour_modes": "Use summarize.pnr_tour_modes."},
+ )
+
+ pipeline = _mapping(raw.get("pipeline"), field_name="pipeline")
+ _reject_unknown_keys(
+ pipeline,
+ field_name="pipeline",
+ allowed={"steps", "dashboard_mode", "overwrite"},
+ )
+
+ display = _mapping(raw.get("display"), field_name="display")
+ _reject_unknown_keys(
+ display,
+ field_name="display",
+ allowed={
+ "labels",
+ "run_colors",
+ "missing_data_display",
+ "bar_hover_mode",
+ "density_hover_mode",
+ },
+ )
+
+ prepare = _mapping(raw.get("prepare"), field_name="prepare")
+ _reject_unknown_keys(
+ prepare,
+ field_name="prepare",
+ allowed={
+ "output",
+ "validation",
+ "distance_skim",
+ "vot_bins",
+ "time_periods",
+ "non_motorized_distance_skim",
+ "auto_sufficiency_basis",
+ "student_types",
+ },
+ )
+
+ columns = _mapping(raw.get("columns"), field_name="columns")
+ _reject_unknown_keys(
+ columns,
+ field_name="columns",
+ allowed=CANONICAL_COLUMN_KEYS,
+ )
+
+ zones = _mapping(raw.get("zones"), field_name="zones")
+ _reject_unknown_keys(
+ zones,
+ field_name="zones",
+ allowed={"use_maz", "maz_col", "taz_col"},
+ )
diff --git a/runtime/config/sections.py b/runtime/config/sections.py
new file mode 100644
index 0000000..32a5e69
--- /dev/null
+++ b/runtime/config/sections.py
@@ -0,0 +1,219 @@
+"""Parsers for independent top-level configuration sections."""
+
+from __future__ import annotations
+
+from .common import normalize_column_aliases
+from .models import PipelineSettings
+
+
+PIPELINE_STEP_ORDER = ("prepare", "skimjoin", "segment", "summarize", "dashboard")
+VALID_PIPELINE_STEPS = set(PIPELINE_STEP_ORDER)
+VALID_DASHBOARD_MODES = {"none", "live", "export", "host"}
+
+
+def mapping(raw_value, *, field_name: str) -> dict:
+ """Return one optional mapping with a consistent validation error."""
+ if raw_value is None:
+ return {}
+ if not isinstance(raw_value, dict):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+ return raw_value
+
+
+def parse_pipeline(raw_value) -> PipelineSettings:
+ """Parse the canonical ``pipeline`` section."""
+ default_steps = ("summarize", "dashboard")
+ if raw_value is None:
+ return PipelineSettings(steps=default_steps)
+ if not isinstance(raw_value, dict):
+ raise ValueError("pipeline must be a mapping when provided.")
+
+ steps_raw = raw_value.get("steps")
+ if steps_raw is None:
+ steps = list(default_steps)
+ else:
+ if not isinstance(steps_raw, list) or not steps_raw:
+ raise ValueError("pipeline.steps must be a non-empty list when provided.")
+ steps = []
+ seen_steps: set[str] = set()
+ for idx, raw_step in enumerate(steps_raw):
+ if not isinstance(raw_step, str):
+ raise ValueError("pipeline.steps entries must be strings.")
+ step = raw_step.strip()
+ if step != step.lower():
+ raise ValueError(
+ f"pipeline.steps[{idx}] must already be normalized lowercase."
+ )
+ if step not in VALID_PIPELINE_STEPS:
+ raise ValueError(
+ "pipeline.steps contains unsupported step "
+ f"{step!r}. Allowed steps: {', '.join(PIPELINE_STEP_ORDER)}."
+ )
+ if step in seen_steps:
+ raise ValueError(f"pipeline.steps contains duplicate step {step!r}.")
+ seen_steps.add(step)
+ steps.append(step)
+
+ dashboard_mode = str(raw_value.get("dashboard_mode", "live")).strip().lower()
+ if dashboard_mode not in VALID_DASHBOARD_MODES:
+ raise ValueError(
+ "pipeline.dashboard_mode must be one of none, live, export, or host."
+ )
+
+ overwrite = raw_value.get("overwrite", False)
+ if not isinstance(overwrite, bool):
+ raise ValueError("pipeline.overwrite must be true or false when provided.")
+
+ if "skimjoin" in steps and "prepare" not in steps:
+ raise ValueError("pipeline.steps cannot include 'skimjoin' without 'prepare'.")
+ if "segment" in steps and "summarize" not in steps:
+ raise ValueError("pipeline.steps cannot include 'segment' without 'summarize'.")
+ if "dashboard" in steps and steps[-1] != "dashboard":
+ raise ValueError("pipeline.steps must place 'dashboard' last when present.")
+
+ return PipelineSettings(
+ steps=tuple(steps),
+ dashboard_mode=dashboard_mode,
+ overwrite=overwrite,
+ )
+
+
+_SCALAR_COLUMN_DEFAULTS = {
+ "col_ptype": ("ptype", "ptype"),
+ "col_hhsize": ("hhsize", "hhsize"),
+ "col_auto_ownership": ("auto_ownership", "auto_ownership"),
+ "col_num_workers": ("num_workers", "num_workers"),
+ "col_num_adults": ("num_adults", "num_adults"),
+}
+
+_ALIAS_COLUMN_DEFAULTS = {
+ "col_household_id": ("household_id", ["household_id"]),
+ "col_person_id": ("person_id", ["person_id"]),
+ "col_tour_id": ("tour_id", ["tour_id"]),
+ "col_trip_id": ("trip_id", ["trip_id"]),
+ "col_tour_purpose": (
+ "tour_purpose",
+ ["tour_purpose", "primary_purpose", "tour_type", "purpose"],
+ ),
+ "col_trip_purpose": ("trip_purpose", ["trip_purpose", "purpose"]),
+ "col_tour_mode": ("tour_mode", ["tour_mode"]),
+ "col_trip_mode": ("trip_mode", ["trip_mode"]),
+ "col_tour_category": ("tour_category", ["tour_category"]),
+ "col_tour_start": ("tour_start", ["start", "start_hour"]),
+ "col_tour_end": ("tour_end", ["end", "end_hour"]),
+ "col_tour_duration": ("tour_duration", ["duration", "tourdur"]),
+ "col_trip_depart": ("trip_depart", ["depart", "depart_hour"]),
+ "col_total_employment": (
+ "total_employment",
+ [
+ "EMP_TOTAL",
+ "EMP_Total",
+ "EMPLOY_TOT",
+ "TOTEMP",
+ "total_employment",
+ "employment",
+ ],
+ ),
+ "col_income_segment": (
+ "income_segment",
+ ["income_segment", "income_broad", "income"],
+ ),
+ "col_home_zone_id": ("home_zone_id", ["home_zone_id"]),
+ "col_workplace_zone_id": ("workplace_zone_id", ["workplace_zone_id"]),
+ "col_school_zone_id": ("school_zone_id", ["school_zone_id"]),
+ "col_has_license": ("has_license", ["has_license"]),
+ "col_mandatory_tour_frequency": (
+ "mandatory_tour_frequency",
+ ["mandatory_tour_frequency"],
+ ),
+ "col_is_student": ("is_student", ["is_student", "student"]),
+ "col_is_university": ("is_university", ["is_university", "major_uni"]),
+ "col_school_segment": ("school_segment", ["school_segment"]),
+ "col_schg": ("schg", ["SCHG"]),
+ "col_pstudent": ("pstudent", ["pstudent"]),
+ "col_tour_origin": ("tour_origin", ["origin"]),
+ "col_tour_destination": ("tour_destination", ["destination"]),
+ "col_trip_origin": ("trip_origin", ["origin"]),
+ "col_trip_destination": ("trip_destination", ["destination"]),
+ "col_stop_frequency": ("stop_frequency", ["stop_frequency"]),
+ "col_trip_outbound": ("trip_outbound", ["outbound"]),
+ "col_trip_num": ("trip_num", ["trip_num"]),
+ "col_pnr_zone_id": ("pnr_zone_id", ["pnr_zone_id"]),
+ "col_pnr_lot_capacity": ("pnr_lot_capacity", ["PNR_SPACES"]),
+ "col_is_worker": ("is_worker", ["is_worker"]),
+ "col_adult": ("adult", ["adult", "is_adult"]),
+ "col_day_id": ("day_id", ["day_id"]),
+ "col_day_weight": ("day_weight", ["day_weight"]),
+ "col_vehicle_id": ("vehicle_id", ["vehicle_id"]),
+ "col_vehicle_num": ("vehicle_num", ["vehicle_num"]),
+ "col_vehicle_type": ("vehicle_type", ["vehicle_type"]),
+ "col_school_esc_outbound": (
+ "school_esc_outbound",
+ ["school_esc_outbound"],
+ ),
+ "col_school_esc_inbound": ("school_esc_inbound", ["school_esc_inbound"]),
+ "col_num_escortees": ("num_escortees", ["num_escortees", "num_escorted"]),
+ "col_out_escorted_tour_ids": (
+ "out_escorted_tour_ids",
+ ["out_escorted_tour_ids"],
+ ),
+ "col_inb_escorted_tour_ids": (
+ "inb_escorted_tour_ids",
+ ["inb_escorted_tour_ids"],
+ ),
+ "col_out_escorting_type": ("out_escorting_type", ["out_escorting_type"]),
+ "col_inb_escorting_type": ("inb_escorting_type", ["inb_escorting_type"]),
+ "col_out_chauffeur_tour_id": (
+ "out_chauffeur_tour_id",
+ ["out_chauffeur_tour_id"],
+ ),
+ "col_inb_chauffeur_tour_id": (
+ "inb_chauffeur_tour_id",
+ ["inb_chauffeur_tour_id"],
+ ),
+}
+
+CANONICAL_COLUMN_KEYS = {
+ yaml_field for yaml_field, _ in _SCALAR_COLUMN_DEFAULTS.values()
+} | {
+ yaml_field for yaml_field, _ in _ALIAS_COLUMN_DEFAULTS.values()
+} | {"sample_rate"}
+
+
+def parse_columns(raw_value) -> dict:
+ """Parse ``columns`` into the corresponding ``Config`` constructor fields."""
+ columns = mapping(raw_value, field_name="columns")
+ fields = {
+ config_field: columns.get(yaml_field, default)
+ for config_field, (yaml_field, default) in _SCALAR_COLUMN_DEFAULTS.items()
+ }
+ fields["col_sample_rate"] = columns.get("sample_rate") or None
+ fields.update(
+ {
+ config_field: normalize_column_aliases(
+ columns.get(yaml_field),
+ field_name=f"columns.{yaml_field}",
+ default=default,
+ )
+ for config_field, (yaml_field, default) in _ALIAS_COLUMN_DEFAULTS.items()
+ }
+ )
+ return fields
+
+
+def parse_zones(raw_value) -> dict:
+ """Parse ``zones`` into the corresponding ``Config`` constructor fields."""
+ zones = mapping(raw_value, field_name="zones")
+ return {
+ "use_maz": bool(zones.get("use_maz", True)),
+ "maz_col": normalize_column_aliases(
+ zones.get("maz_col"),
+ field_name="zones.maz_col",
+ default=["MAZ", "zone_id"],
+ ),
+ "taz_col": normalize_column_aliases(
+ zones.get("taz_col"),
+ field_name="zones.taz_col",
+ default=["TAZ", "taz"],
+ ),
+ }
diff --git a/runtime/config/sections_dashboard.py b/runtime/config/sections_dashboard.py
new file mode 100644
index 0000000..a960ad0
--- /dev/null
+++ b/runtime/config/sections_dashboard.py
@@ -0,0 +1,113 @@
+"""Parsers for canonical dashboard configuration."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from .common import normalize_optional_path_string
+from .models import (
+ ExportDashboardSettings,
+ ExportHTMLSettings,
+ ExportSelectorRequest,
+ PipelineSettings,
+ SegmentationSettings,
+)
+from .normalize_export import (
+ normalize_excluded_ids,
+ normalize_export_html_selection,
+ normalize_export_page_entries,
+)
+from .sections import mapping
+
+
+def parse_dashboard_export(
+ raw_value,
+ *,
+ pipeline: PipelineSettings,
+ segmentation: SegmentationSettings,
+ summary_root: Path,
+ weighting_modes: list[str],
+ weighting_labels: dict[str, str] | None = None,
+) -> ExportHTMLSettings:
+ """Parse ``dashboard.export`` after pipeline and segmentation resolution."""
+ export = mapping(raw_value, field_name="dashboard.export")
+ dashboard = mapping(
+ export.get("dashboard"), field_name="dashboard.export.dashboard"
+ )
+ pages_raw = export.get("pages")
+
+ settings = ExportHTMLSettings(
+ enabled=(
+ pipeline.has_step("dashboard") and pipeline.dashboard_mode == "export"
+ ),
+ output_path=normalize_optional_path_string(
+ export.get("output_path"),
+ field_name="dashboard.export.output_path",
+ config_dir=summary_root,
+ ),
+ dashboard=ExportDashboardSettings(
+ weighting=normalize_export_html_selection(
+ dashboard.get("weighting"),
+ field_name="dashboard.export.dashboard.weighting",
+ default=weighting_modes,
+ allowed=weighting_modes,
+ ),
+ weighting_labels=dict(weighting_labels or {}),
+ values=normalize_export_html_selection(
+ dashboard.get("values"),
+ field_name="dashboard.export.dashboard.values",
+ default=["percent", "count"],
+ allowed=["percent", "count"],
+ ),
+ segmentation_type=(
+ None
+ if not segmentation.enabled
+ else (
+ str(dashboard.get("segmentation_type")).strip().lower()
+ if dashboard.get("segmentation_type") is not None
+ else segmentation.dashboard.segmentation_type
+ )
+ ),
+ segmentation_visibility=(
+ None
+ if not segmentation.enabled
+ else str(
+ dashboard.get(
+ "segmentation_visibility", segmentation.dashboard.visibility
+ )
+ )
+ .strip()
+ .lower()
+ ),
+ ),
+ pages=normalize_export_page_entries(
+ {} if pages_raw is None else pages_raw,
+ field_name="dashboard.export.pages",
+ ),
+ exclude_pages=normalize_excluded_ids(
+ export.get("exclude_pages"),
+ field_name="dashboard.export.exclude_pages",
+ ),
+ exclude_groups=normalize_excluded_ids(
+ export.get("exclude_groups"),
+ field_name="dashboard.export.exclude_groups",
+ ),
+ pages_configured=pages_raw is not None,
+ default_selector_request=ExportSelectorRequest(mode="all"),
+ )
+
+ if settings.dashboard.segmentation_type is not None and (
+ settings.dashboard.segmentation_type not in segmentation.definition_names()
+ ):
+ raise ValueError(
+ "dashboard.export.dashboard.segmentation_type must name one configured segment definition."
+ )
+ if settings.dashboard.segmentation_visibility is not None and (
+ settings.dashboard.segmentation_visibility
+ not in {"full_only", "segments_only", "full_and_segments"}
+ ):
+ raise ValueError(
+ "dashboard.export.dashboard.segmentation_visibility must be one of "
+ "full_only, segments_only, or full_and_segments."
+ )
+ return settings
diff --git a/runtime/config/sections_prepare.py b/runtime/config/sections_prepare.py
new file mode 100644
index 0000000..2b10582
--- /dev/null
+++ b/runtime/config/sections_prepare.py
@@ -0,0 +1,85 @@
+"""Parser for the canonical ``prepare`` section."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from .models import PrepareAutoSufficiencySettings
+from .normalize_categories import normalize_student_types
+from .normalize_prepare import (
+ normalize_prepare_non_motorized_distance_skim,
+ normalize_prepare_relationship_checks,
+ normalize_prepare_time_periods,
+ normalize_prepare_vot_bins,
+ normalize_prepared_output_file_format,
+)
+from .sections import mapping
+
+
+@dataclass(frozen=True)
+class ParsedPrepareSection:
+ """Config constructor fields resolved from ``prepare``."""
+
+ config_fields: dict
+ distance_skim: dict
+
+
+def parse_prepare(raw_value, *, config_dir: Path) -> ParsedPrepareSection:
+ prepare = mapping(raw_value, field_name="prepare")
+ output = mapping(prepare.get("output"), field_name="prepare.output")
+ validation = mapping(
+ prepare.get("validation"), field_name="prepare.validation"
+ )
+ distance_skim = mapping(
+ prepare.get("distance_skim"), field_name="prepare.distance_skim"
+ )
+
+ basis_raw = prepare.get("auto_sufficiency_basis")
+ if basis_raw is None:
+ auto_sufficiency = PrepareAutoSufficiencySettings()
+ elif not isinstance(basis_raw, str) or basis_raw.strip().lower() not in {
+ "licensed_drivers",
+ "workers",
+ "adults",
+ }:
+ raise ValueError(
+ "prepare.auto_sufficiency_basis must be one of "
+ "'licensed_drivers', 'workers', or 'adults'."
+ )
+ else:
+ auto_sufficiency = PrepareAutoSufficiencySettings(
+ basis=basis_raw.strip().lower()
+ )
+
+ return ParsedPrepareSection(
+ config_fields={
+ "prepare_vot_bins": normalize_prepare_vot_bins(
+ prepare.get("vot_bins"), field_name="prepare.vot_bins"
+ ),
+ "prepare_auto_sufficiency": auto_sufficiency,
+ "prepare_time_periods": normalize_prepare_time_periods(
+ prepare.get("time_periods"),
+ field_name="prepare.time_periods",
+ config_dir=config_dir,
+ ),
+ "prepare_non_motorized_distance_skim": (
+ normalize_prepare_non_motorized_distance_skim(
+ prepare.get("non_motorized_distance_skim"),
+ field_name="prepare.non_motorized_distance_skim",
+ config_dir=config_dir,
+ )
+ ),
+ "prepare_output_file_format": normalize_prepared_output_file_format(
+ output.get("file_format"), field_name="prepare.output.file_format"
+ ),
+ "prepare_relationship_checks": normalize_prepare_relationship_checks(
+ validation.get("relationship_checks"),
+ field_name="prepare.validation.relationship_checks",
+ ),
+ "student_types": normalize_student_types(
+ prepare.get("student_types"), field_name="prepare.student_types"
+ ),
+ },
+ distance_skim=distance_skim,
+ )
diff --git a/runtime/config/signatures.py b/runtime/config/signatures.py
index 278422a..d94e9a5 100644
--- a/runtime/config/signatures.py
+++ b/runtime/config/signatures.py
@@ -50,29 +50,6 @@ def _geography_payload(config: Config) -> dict[str, Any]:
return geography_payload
-def _effective_legacy_values(
- config: Config,
-) -> tuple[dict[str, str] | None, dict[str, str] | None, list[str] | None]:
- effective_person_type_labels = (
- None
- if config.dashboard_label_spec("person_type") is not None
- else config.person_type_labels
- )
- effective_transit_subsidy_labels = (
- None
- if config.dashboard_label_spec("transit_subsidy") is not None
- else config.transit_subsidy_labels
- )
- effective_mode_order = (
- None if config.dashboard_label_spec("mode") is not None else config.mode_order
- )
- return (
- effective_person_type_labels,
- effective_transit_subsidy_labels,
- effective_mode_order,
- )
-
-
def _student_types_payload(config: Config) -> list[dict[str, Any]]:
return [
{
@@ -94,11 +71,6 @@ def _student_types_payload(config: Config) -> list[dict[str, Any]]:
def prepare_signature_payload(config: Config) -> dict[str, Any]:
- (
- effective_person_type_labels,
- effective_transit_subsidy_labels,
- effective_mode_order,
- ) = _effective_legacy_values(config)
return {
"files": {key: config.files[key] for key in sorted(config.files)},
"columns": {
@@ -151,14 +123,44 @@ def prepare_signature_payload(config: Config) -> dict[str, Any]:
"geography": _geography_payload(config),
"skim": {"matrix": config.skim_matrix},
"skimjoin": {
- "enabled": config.skimjoin.enabled,
+ "enabled": config.skimjoin_step_enabled(),
"config_digest": config.skimjoin.config_digest,
+ "create_hypothetical_skim_tables": (
+ config.skimjoin.create_hypothetical_skim_tables
+ ),
+ "failure_policy": config.skimjoin.failure_policy,
},
"prepare": {
"auto_sufficiency": {
"basis": config.prepare_auto_sufficiency.basis,
},
"output": {"file_format": config.prepare_output_file_format},
+ "time_periods": {
+ "enabled": config.prepare_time_periods.enabled,
+ "network_los_file": config.prepare_time_periods.network_los_file,
+ "network_los_digest": config.prepare_time_periods.network_los_digest,
+ "trip_period_number_column": (
+ config.prepare_time_periods.trip_period_number_column
+ ),
+ "tour_start_period_number_column": (
+ config.prepare_time_periods.tour_start_period_number_column
+ ),
+ "tour_end_period_number_column": (
+ config.prepare_time_periods.tour_end_period_number_column
+ ),
+ },
+ "non_motorized_distance_skim": {
+ "enabled": config.prepare_non_motorized_distance_skim.enabled,
+ "file": config.prepare_non_motorized_distance_skim.file,
+ "file_digest": config.prepare_non_motorized_distance_skim.file_digest,
+ "matrix": config.prepare_non_motorized_distance_skim.matrix,
+ "source_type": (
+ config.prepare_non_motorized_distance_skim.source_type
+ ),
+ "value_column": (
+ config.prepare_non_motorized_distance_skim.value_column
+ ),
+ },
"vot_bins": {
"enabled": config.prepare_vot_bins.enabled,
"source_column": config.prepare_vot_bins.source_column,
@@ -178,11 +180,6 @@ def prepare_signature_payload(config: Config) -> dict[str, Any]:
def summary_signature_payload(config: Config) -> dict[str, Any]:
- (
- effective_person_type_labels,
- effective_transit_subsidy_labels,
- effective_mode_order,
- ) = _effective_legacy_values(config)
segmentation_payload: dict[str, Any] = {"enabled": config.segmentation.enabled}
if config.segmentation.enabled:
segmentation_payload["definitions"] = [
@@ -224,38 +221,26 @@ def summary_signature_payload(config: Config) -> dict[str, Any]:
for definition in config.segmentation.definitions
]
return {
- "weighting_modes": list(config.weighting_modes),
+ "weighting_modes": [
+ definition.signature_payload()
+ for definition in config.weighting_mode_definitions
+ ],
+ "extension_settings": config.extension_settings,
+ "failure_policy": config.summary_failure_policy,
"files": {key: config.files[key] for key in sorted(config.files)},
"columns": prepare_signature_payload(config)["columns"],
"summary_categories": category_specs_payload(config.summary_categories),
- "dashboard_label_overrides": {
- "person_type": (
- dict(config.dashboard_label_spec("person_type").mapping_items)
- if config.dashboard_label_spec("person_type") is not None
- else None
- ),
- "transit_subsidy": (
- dict(config.dashboard_label_spec("transit_subsidy").mapping_items)
- if config.dashboard_label_spec("transit_subsidy") is not None
+ # These labels are still materialized by demographic/person summaries.
+ # Keep them in the summary identity until phase 4 moves labeling fully
+ # into the dashboard presentation boundary.
+ "display_label_dependencies": {
+ category_id: (
+ dict(config.dashboard_label_spec(category_id).mapping_items)
+ if config.dashboard_label_spec(category_id) is not None
else None
- ),
+ )
+ for category_id in ("person_type", "transit_subsidy")
},
- "person_type_labels": (
- {
- key: effective_person_type_labels[key]
- for key in sorted(effective_person_type_labels)
- }
- if effective_person_type_labels
- else None
- ),
- "transit_subsidy_labels": (
- {
- key: effective_transit_subsidy_labels[key]
- for key in sorted(effective_transit_subsidy_labels)
- }
- if effective_transit_subsidy_labels
- else None
- ),
"tour_purpose_grouping": {
"group_joint_tour_purposes": config.group_joint_tour_purposes,
"group_atwork_tour_purposes": config.group_atwork_tour_purposes,
@@ -270,7 +255,6 @@ def summary_signature_payload(config: Config) -> dict[str, Any]:
"geography": _geography_payload(config),
"skim": {"matrix": config.skim_matrix},
"modes": {
- "order": list(effective_mode_order) if effective_mode_order else None,
"groups": (
[
(group_name, list(mode_names))
@@ -282,8 +266,12 @@ def summary_signature_payload(config: Config) -> dict[str, Any]:
"pnr_tour_modes": list(config.pnr_tour_modes),
},
"skimjoin": {
- "enabled": config.skimjoin.enabled,
+ "enabled": config.skimjoin_step_enabled(),
"config_digest": config.skimjoin.config_digest,
+ "create_hypothetical_skim_tables": (
+ config.skimjoin.create_hypothetical_skim_tables
+ ),
+ "failure_policy": config.skimjoin.failure_policy,
},
"prepare": {
"vot_bins": prepare_signature_payload(config)["prepare"]["vot_bins"],
@@ -308,9 +296,16 @@ def presentation_signature_payload(config: Config) -> dict[str, Any]:
if config.dashboard_pages is not None
else None
),
+ "include_notes": config.include_notes,
"enable_maz_geographies": config.enable_maz_geographies,
"run_colors": list(config.run_colors),
"missing_data_display": config.missing_data_display,
+ "bar_hover_mode": config.bar_hover_mode,
+ "density_hover_mode": config.density_hover_mode,
+ "weighting_modes": [
+ {"mode_id": definition.mode_id, "label": definition.label}
+ for definition in config.weighting_mode_definitions
+ ],
"segmentation": {
"enabled": config.segmentation.enabled,
"dashboard": {
diff --git a/activitysim_viz_logging.py b/runtime/logging.py
similarity index 70%
rename from activitysim_viz_logging.py
rename to runtime/logging.py
index 91468b0..4554262 100644
--- a/activitysim_viz_logging.py
+++ b/runtime/logging.py
@@ -12,6 +12,33 @@
LOGGER_NAMESPACE = "activitysim_viz"
DEFAULT_LOG_FILENAME = "activitysim_visualizer.log"
+_BOKEH_DROPPING_PATCH_WARNING_PREFIX = (
+ "Dropping a patch because it contains a previously known reference"
+)
+
+
+class _DropKnownBokehPatchWarningFilter(logging.Filter):
+ """Suppress noisy Bokeh patch races emitted through the root logger."""
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ return not str(record.getMessage()).startswith(
+ _BOKEH_DROPPING_PATCH_WARNING_PREFIX
+ )
+
+
+_BOKEH_PATCH_WARNING_FILTER = _DropKnownBokehPatchWarningFilter()
+
+
+def _install_root_warning_filters() -> None:
+ root_logger = logging.getLogger()
+ if _BOKEH_PATCH_WARNING_FILTER not in root_logger.filters:
+ root_logger.addFilter(_BOKEH_PATCH_WARNING_FILTER)
+
+
+def _remove_root_warning_filters() -> None:
+ root_logger = logging.getLogger()
+ if _BOKEH_PATCH_WARNING_FILTER in root_logger.filters:
+ root_logger.removeFilter(_BOKEH_PATCH_WARNING_FILTER)
def get_logger(name: str) -> logging.Logger:
@@ -36,6 +63,8 @@ def configure_logging(
log_path: str | Path | None = None,
) -> Path:
"""Configure console and file logging for the application namespace."""
+ _install_root_warning_filters()
+
resolved_log_path = (
Path(log_path) if log_path is not None else default_log_path(config)
)
@@ -74,8 +103,12 @@ def configure_logging(
def shutdown_logging() -> None:
"""Detach and close application logging handlers."""
+ _remove_root_warning_filters()
+
app_logger = logging.getLogger(LOGGER_NAMESPACE)
for handler in list(app_logger.handlers):
app_logger.removeHandler(handler)
if isinstance(handler, logging.FileHandler):
handler.close()
+ app_logger.setLevel(logging.NOTSET)
+ app_logger.propagate = True
diff --git a/runtime/weighting.py b/runtime/weighting.py
new file mode 100644
index 0000000..6cb1046
--- /dev/null
+++ b/runtime/weighting.py
@@ -0,0 +1,621 @@
+"""Versioned weighting-mode definitions and extension discovery."""
+
+from __future__ import annotations
+
+from collections import OrderedDict
+from collections.abc import Callable, Iterable, Mapping, Sequence
+from dataclasses import dataclass, field, replace
+from importlib import import_module, metadata
+import re
+from types import MappingProxyType, ModuleType
+from typing import Any, Literal, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from processor.models import RunData
+ from runtime.config.models import Config
+
+
+WeightingTransform = Callable[["RunData", "Config | None"], "RunData"]
+ExternalSummaryPolicy = Literal["copy", "reject"]
+
+_COLUMN_MODE_TABLES = {
+ "households": "hh",
+ "persons": "per",
+ "trips": "trips",
+}
+
+
+@dataclass(frozen=True)
+class WeightingModeDefinition:
+ """One weighting mode's stable identity and prepared-run transform."""
+
+ mode_id: str
+ label: str
+ transform: WeightingTransform
+ version: str
+ required_columns: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
+ external_summary_policy: ExternalSummaryPolicy = "reject"
+ default_enabled: bool = False
+
+ def __post_init__(self) -> None:
+ mode_id = str(self.mode_id).strip().lower()
+ if not re.fullmatch(r"[a-z][a-z0-9_-]*", mode_id):
+ raise ValueError(
+ "weighting mode_id must start with a letter and contain only "
+ "lowercase letters, digits, underscores, or hyphens."
+ )
+ label = str(self.label).strip()
+ if not label:
+ raise ValueError(f"weighting mode {mode_id!r} requires a non-empty label.")
+ version = str(self.version).strip()
+ if not version:
+ raise ValueError(
+ f"weighting mode {mode_id!r} requires a non-empty version."
+ )
+ if not callable(self.transform):
+ raise TypeError(f"weighting mode {mode_id!r} transform must be callable.")
+ if self.external_summary_policy not in {"copy", "reject"}:
+ raise ValueError(
+ f"weighting mode {mode_id!r} external_summary_policy must be "
+ "'copy' or 'reject'."
+ )
+
+ if not isinstance(self.required_columns, Mapping):
+ raise TypeError(
+ f"weighting mode {mode_id!r} required_columns must be a mapping."
+ )
+ normalized_requirements: dict[str, tuple[str, ...]] = {}
+ for table_name, columns in self.required_columns.items():
+ normalized_table = str(table_name).strip()
+ if not normalized_table:
+ raise ValueError(
+ f"weighting mode {mode_id!r} contains an empty required table name."
+ )
+ if isinstance(columns, (str, bytes)):
+ raise TypeError(
+ f"weighting mode {mode_id!r} required columns for "
+ f"{normalized_table!r} must be a sequence, not a string."
+ )
+ normalized_columns = tuple(
+ dict.fromkeys(
+ str(column).strip() for column in columns if str(column).strip()
+ )
+ )
+ if normalized_columns:
+ normalized_requirements[normalized_table] = normalized_columns
+
+ object.__setattr__(self, "mode_id", mode_id)
+ object.__setattr__(self, "label", label)
+ object.__setattr__(self, "version", version)
+ object.__setattr__(
+ self,
+ "required_columns",
+ MappingProxyType(normalized_requirements),
+ )
+
+ def validate_run(self, run: "RunData") -> None:
+ """Fail clearly when a mode's source columns are not available."""
+ for table_name, columns in self.required_columns.items():
+ table = getattr(run, table_name, None)
+ if table is None or not hasattr(table, "columns"):
+ raise ValueError(
+ f"Weighting mode {self.mode_id!r} requires prepared table "
+ f"{table_name!r}, but the table is unavailable."
+ )
+ missing = [column for column in columns if column not in table.columns]
+ if missing:
+ raise ValueError(
+ f"Weighting mode {self.mode_id!r} requires columns on "
+ f"{table_name!r}: {', '.join(missing)}"
+ )
+
+ def apply(self, run: "RunData", config: "Config | None") -> "RunData":
+ self.validate_run(run)
+ transformed = self.transform(run, config)
+ from processor.models import RunData
+
+ if not isinstance(transformed, RunData):
+ raise TypeError(
+ f"Weighting mode {self.mode_id!r} transform returned "
+ f"{type(transformed).__name__}; expected RunData."
+ )
+ return transformed
+
+ def signature_payload(self) -> dict[str, object]:
+ return {
+ "mode_id": self.mode_id,
+ "version": self.version,
+ "required_columns": {
+ table_name: list(columns)
+ for table_name, columns in sorted(self.required_columns.items())
+ },
+ "external_summary_policy": self.external_summary_policy,
+ }
+
+
+class WeightingModeRegistry:
+ """Ordered registry shared by config, processor, dashboard, and export."""
+
+ def __init__(self) -> None:
+ self._definitions: OrderedDict[str, WeightingModeDefinition] = OrderedDict()
+
+ def register(self, definition: WeightingModeDefinition) -> WeightingModeDefinition:
+ if not isinstance(definition, WeightingModeDefinition):
+ raise TypeError(
+ "weighting registry entries must be WeightingModeDefinition objects."
+ )
+ if definition.mode_id in self._definitions:
+ raise ValueError(f"Duplicate weighting mode id {definition.mode_id!r}.")
+ duplicate_label = next(
+ (
+ existing.mode_id
+ for existing in self._definitions.values()
+ if existing.label.casefold() == definition.label.casefold()
+ ),
+ None,
+ )
+ if duplicate_label is not None:
+ raise ValueError(
+ f"Weighting mode label {definition.label!r} is already used by "
+ f"{duplicate_label!r}."
+ )
+ self._definitions[definition.mode_id] = definition
+ return definition
+
+ def get(self, mode_id: str) -> WeightingModeDefinition:
+ normalized = str(mode_id).strip().lower()
+ try:
+ return self._definitions[normalized]
+ except KeyError as exc:
+ registered = ", ".join(self._definitions) or "(none)"
+ raise ValueError(
+ f"Unsupported weighting mode {mode_id!r}. Registered modes: {registered}"
+ ) from exc
+
+ def definitions(self) -> tuple[WeightingModeDefinition, ...]:
+ return tuple(self._definitions.values())
+
+ def ids(self, *, default_only: bool = False) -> tuple[str, ...]:
+ return tuple(
+ definition.mode_id
+ for definition in self._definitions.values()
+ if not default_only or definition.default_enabled
+ )
+
+ def normalize(
+ self,
+ modes: Sequence[str] | None,
+ *,
+ field_name: str = "weighting modes",
+ ) -> list[str]:
+ if modes is not None and (
+ isinstance(modes, (str, bytes)) or not isinstance(modes, Sequence)
+ ):
+ raise ValueError(f"{field_name} must be a list of registered mode ids.")
+ if modes is None or len(modes) == 0:
+ modes = self.ids(default_only=True)
+
+ normalized: list[str] = []
+ invalid: list[str] = []
+ for raw_mode in modes:
+ mode = str(raw_mode).strip().lower()
+ if not mode:
+ continue
+ if mode not in self._definitions:
+ invalid.append(mode)
+ elif mode not in normalized:
+ normalized.append(mode)
+ if invalid:
+ registered = ", ".join(self._definitions)
+ raise ValueError(
+ f"Unsupported {field_name} values: "
+ + ", ".join(repr(mode) for mode in invalid)
+ + f". Registered modes: {registered}"
+ )
+ if not normalized:
+ defaults = list(self.ids(default_only=True))
+ if not defaults:
+ raise ValueError(f"{field_name} resolved to no modes.")
+ return defaults
+ return normalized
+
+ def definitions_for(
+ self,
+ modes: Sequence[str] | None,
+ *,
+ field_name: str = "weighting modes",
+ ) -> tuple[WeightingModeDefinition, ...]:
+ return tuple(
+ self.get(mode_id)
+ for mode_id in self.normalize(modes, field_name=field_name)
+ )
+
+
+def _registry_with(
+ additional_definitions: Iterable[WeightingModeDefinition] = (),
+) -> WeightingModeRegistry:
+ registry = WeightingModeRegistry()
+ for definition in WEIGHTING_MODES.definitions():
+ registry.register(definition)
+ for definition in additional_definitions:
+ if definition.mode_id in registry.ids():
+ existing = registry.get(definition.mode_id)
+ if existing is definition or existing == definition:
+ continue
+ registry.register(definition)
+ return registry
+
+
+def _with_weight_column(frame: Any, source_column: str) -> Any:
+ import polars as pl
+
+ return frame.with_columns(
+ pl.col(source_column).cast(pl.Float64).alias("finalweight")
+ )
+
+
+def _inherit_weight(
+ frame: Any,
+ source: Any,
+ *,
+ key: str,
+) -> Any:
+ """Attach source weights when both prepared tables expose the join key."""
+ import polars as pl
+
+ if (
+ frame.is_empty()
+ or source.is_empty()
+ or key not in frame.columns
+ or key not in source.columns
+ or "finalweight" not in source.columns
+ ):
+ return frame
+ inherited = "_named_weighting_mode_weight"
+ result = frame.join(
+ source.select(key, pl.col("finalweight").alias(inherited)),
+ on=key,
+ how="left",
+ )
+ fallbacks = [pl.col(inherited)]
+ if "finalweight" in frame.columns:
+ fallbacks.append(pl.col("finalweight"))
+ fallbacks.append(pl.lit(1.0))
+ return result.with_columns(
+ pl.coalesce(fallbacks).cast(pl.Float64).alias("finalweight")
+ ).drop(inherited)
+
+
+def _apply_column_weighting(
+ run: "RunData",
+ columns: Mapping[str, str],
+) -> "RunData":
+ """Apply named source columns and propagate them through prepared relations."""
+ import polars as pl
+
+ hh = run.hh
+ per = run.per
+ day = run.day
+ tours = run.tours
+ trips = run.trips
+ vehicles = run.vehicles
+
+ household_source = columns.get("households")
+ person_source = columns.get("persons")
+ trip_source = columns.get("trips")
+
+ household_changed = household_source is not None
+ person_changed = person_source is not None or household_changed
+
+ if household_source is not None:
+ hh = _with_weight_column(hh, household_source)
+
+ if person_source is not None:
+ per = _with_weight_column(per, person_source)
+ elif household_changed:
+ per = _inherit_weight(per, hh, key="household_id")
+
+ if trip_source is not None:
+ trips = _with_weight_column(trips, trip_source)
+ elif person_changed:
+ inherited = _inherit_weight(trips, per, key="person_id")
+ if inherited is trips and household_changed:
+ inherited = _inherit_weight(trips, hh, key="household_id")
+ trips = inherited
+
+ if trip_source is not None and "tour_id" in trips.columns:
+ tour_weights = trips.group_by("tour_id").agg(
+ pl.col("finalweight").mean().alias("finalweight")
+ )
+ tours = _inherit_weight(tours, tour_weights, key="tour_id")
+ elif person_changed:
+ inherited = _inherit_weight(tours, per, key="person_id")
+ if inherited is tours and household_changed:
+ inherited = _inherit_weight(tours, hh, key="household_id")
+ tours = inherited
+
+ if person_changed:
+ inherited = _inherit_weight(day, per, key="person_id")
+ if inherited is day and household_changed:
+ inherited = _inherit_weight(day, hh, key="household_id")
+ day = inherited
+
+ if household_changed:
+ vehicles = _inherit_weight(vehicles, hh, key="household_id")
+
+ trip_hypothetical_skims = run.trip_hypothetical_skims
+ if trip_source is not None or person_changed:
+ trip_hypothetical_skims = _inherit_weight(
+ trip_hypothetical_skims,
+ trips,
+ key="trip_id",
+ )
+ tour_hypothetical_skims = run.tour_hypothetical_skims
+ if trip_source is not None or person_changed:
+ tour_hypothetical_skims = _inherit_weight(
+ tour_hypothetical_skims,
+ tours,
+ key="tour_id",
+ )
+
+ return replace(
+ run,
+ hh=hh,
+ per=per,
+ day=day,
+ tours=tours,
+ trips=trips,
+ vehicles=vehicles,
+ trip_hypothetical_skims=trip_hypothetical_skims,
+ tour_hypothetical_skims=tour_hypothetical_skims,
+ )
+
+
+def column_weighting_mode_definitions(
+ value: object,
+ *,
+ field_name: str = "weighting.modes",
+) -> tuple[WeightingModeDefinition, ...]:
+ """Parse config-defined modes that select columns on prepared run tables."""
+ if value is None:
+ return ()
+ if not isinstance(value, Mapping):
+ raise ValueError(f"{field_name} must be a mapping when provided.")
+
+ definitions: list[WeightingModeDefinition] = []
+ for raw_mode_id, raw_definition in value.items():
+ mode_id = str(raw_mode_id).strip().lower()
+ mode_field = f"{field_name}.{raw_mode_id}"
+ if not isinstance(raw_definition, Mapping):
+ raise ValueError(f"{mode_field} must be a mapping.")
+ unknown = sorted(
+ set(raw_definition) - {"label", "columns"},
+ key=str,
+ )
+ if unknown:
+ raise ValueError(
+ f"Unknown {mode_field} config keys: "
+ + ", ".join(repr(key) for key in unknown)
+ )
+ raw_columns = raw_definition.get("columns")
+ if not isinstance(raw_columns, Mapping) or not raw_columns:
+ raise ValueError(f"{mode_field}.columns must be a non-empty mapping.")
+ unknown_tables = sorted(
+ set(raw_columns) - set(_COLUMN_MODE_TABLES),
+ key=str,
+ )
+ if unknown_tables:
+ supported = ", ".join(_COLUMN_MODE_TABLES)
+ raise ValueError(
+ f"Unknown {mode_field}.columns table names: "
+ + ", ".join(repr(key) for key in unknown_tables)
+ + f". Supported names: {supported}."
+ )
+ columns: dict[str, str] = {}
+ for table_name, raw_column in raw_columns.items():
+ if not isinstance(raw_column, str):
+ raise ValueError(
+ f"{mode_field}.columns.{table_name} must be a column-name string."
+ )
+ column = raw_column.strip()
+ if not column:
+ raise ValueError(
+ f"{mode_field}.columns.{table_name} must be a non-empty column name."
+ )
+ columns[str(table_name)] = column
+
+ raw_label = raw_definition.get(
+ "label",
+ mode_id.replace("_", " ").replace("-", " ").title(),
+ )
+ if not isinstance(raw_label, str):
+ raise ValueError(f"{mode_field}.label must be a string when provided.")
+ label = raw_label.strip()
+ required_columns = {
+ _COLUMN_MODE_TABLES[table_name]: (column,)
+ for table_name, column in columns.items()
+ }
+
+ def transform(
+ run: "RunData",
+ config: "Config | None",
+ *,
+ selected_columns: Mapping[str, str] = MappingProxyType(dict(columns)),
+ ) -> "RunData":
+ return _apply_column_weighting(run, selected_columns)
+
+ definitions.append(
+ WeightingModeDefinition(
+ mode_id=mode_id,
+ label=label,
+ transform=transform,
+ version="column-v1",
+ required_columns=required_columns,
+ external_summary_policy="reject",
+ )
+ )
+
+ # Reuse registry validation for duplicate IDs/labels and built-in conflicts.
+ _registry_with(definitions)
+ return tuple(definitions)
+
+
+def _identity_weighting(run: "RunData", config: "Config | None") -> "RunData":
+ return run
+
+
+def _unweighted_run(run: "RunData", config: "Config | None") -> "RunData":
+ import polars as pl
+
+ from processor.models import map_run_data_tables
+
+ return map_run_data_tables(
+ run,
+ lambda _table_name, frame: (
+ frame.with_columns(pl.lit(1.0).alias("finalweight"))
+ if "finalweight" in frame.columns
+ else frame
+ ),
+ clear_weight_columns=True,
+ )
+
+
+WEIGHTING_MODES = WeightingModeRegistry()
+WEIGHTING_MODES.register(
+ WeightingModeDefinition(
+ mode_id="weighted",
+ label="Weighted",
+ transform=_identity_weighting,
+ version="1",
+ external_summary_policy="copy",
+ default_enabled=True,
+ )
+)
+WEIGHTING_MODES.register(
+ WeightingModeDefinition(
+ mode_id="unweighted",
+ label="Unweighted",
+ transform=_unweighted_run,
+ version="1",
+ external_summary_policy="copy",
+ default_enabled=True,
+ )
+)
+
+
+_LOADED_MODULES: set[tuple[int, str]] = set()
+_LOADED_ENTRY_POINTS: set[tuple[int, str]] = set()
+
+
+def _register_extension_object(
+ value: object,
+ *,
+ registry: WeightingModeRegistry,
+ source: str,
+) -> None:
+ if isinstance(value, WeightingModeDefinition):
+ registry.register(value)
+ return
+ if isinstance(value, ModuleType):
+ hook = getattr(value, "register_weighting_modes", None)
+ if hook is None:
+ raise ValueError(
+ f"Weighting extension {source} must define register_weighting_modes(registry)."
+ )
+ value = hook
+ if callable(value):
+ value(registry)
+ return
+ raise TypeError(
+ f"Weighting extension {source} must load a WeightingModeDefinition, "
+ "registration callable, or module with register_weighting_modes(registry)."
+ )
+
+
+def load_weighting_mode_extensions(
+ module_names: Iterable[str] = (),
+ *,
+ registry: WeightingModeRegistry = WEIGHTING_MODES,
+ discover_entry_points: bool = True,
+) -> None:
+ """Load installed and project-local weighting extensions exactly once."""
+ if discover_entry_points:
+ discovered = metadata.entry_points()
+ entry_points = (
+ discovered.select(group="activitysim_visualizer.weighting_modes")
+ if hasattr(discovered, "select")
+ else discovered.get("activitysim_visualizer.weighting_modes", ())
+ )
+ for entry_point in sorted(
+ entry_points, key=lambda item: (item.name, item.value)
+ ):
+ key = (id(registry), f"{entry_point.name}:{entry_point.value}")
+ if key in _LOADED_ENTRY_POINTS:
+ continue
+ try:
+ loaded = entry_point.load()
+ _register_extension_object(
+ loaded,
+ registry=registry,
+ source=f"entry point {entry_point.name!r}",
+ )
+ except Exception as exc:
+ raise ValueError(
+ f"Failed to load weighting entry point {entry_point.name!r}: {exc}"
+ ) from exc
+ _LOADED_ENTRY_POINTS.add(key)
+
+ for raw_name in module_names:
+ module_name = str(raw_name).strip()
+ if not module_name:
+ continue
+ key = (id(registry), module_name)
+ if key in _LOADED_MODULES:
+ continue
+ try:
+ module = import_module(module_name)
+ _register_extension_object(
+ module,
+ registry=registry,
+ source=f"module {module_name!r}",
+ )
+ except Exception as exc:
+ raise ValueError(
+ f"Failed to load weighting extension module {module_name!r}: {exc}"
+ ) from exc
+ _LOADED_MODULES.add(key)
+
+
+def normalize_weighting_modes(
+ modes: Sequence[str] | None,
+ *,
+ field_name: str = "weighting modes",
+ additional_definitions: Iterable[WeightingModeDefinition] = (),
+) -> list[str]:
+ return _registry_with(additional_definitions).normalize(
+ modes,
+ field_name=field_name,
+ )
+
+
+def weighting_mode_definitions(
+ modes: Sequence[str] | None,
+ *,
+ field_name: str = "weighting modes",
+ additional_definitions: Iterable[WeightingModeDefinition] = (),
+) -> tuple[WeightingModeDefinition, ...]:
+ return _registry_with(additional_definitions).definitions_for(
+ modes,
+ field_name=field_name,
+ )
+
+
+__all__ = [
+ "WEIGHTING_MODES",
+ "WeightingModeDefinition",
+ "WeightingModeRegistry",
+ "column_weighting_mode_definitions",
+ "load_weighting_mode_extensions",
+ "normalize_weighting_modes",
+ "weighting_mode_definitions",
+]
diff --git a/runtime/workflows/__init__.py b/runtime/workflows/__init__.py
index f01edae..468aab2 100644
--- a/runtime/workflows/__init__.py
+++ b/runtime/workflows/__init__.py
@@ -2,25 +2,22 @@
from __future__ import annotations
-from pathlib import Path
-
-from activitysim_viz_logging import get_logger
-from processor.models import PreparedTableName, ProcessorWorkflowResult, RunData
-from processor.prepare.cache import load_prepared_run_cache, write_prepared_run_cache
-from processor.prepare.enrichment.pipeline import prepare_data
-from processor.prepare.reader import read_run
-from processor.skimjoin.pipeline import apply_skimjoin
-from runtime.config import Config
from runtime.workflows.common import (
load_runtime_config,
load_summary_runs_from_cache,
prepared_cache_root,
- prune_processor_result,
+ prune_summary_artifact,
prune_summary_runs,
resolve_run_entries,
run_entries_with_keys,
summary_cache_root,
)
+from runtime.workflows.artifacts import (
+ PreparedRunsArtifact,
+ SummaryCacheInspection,
+ SummaryRunsArtifact,
+ WorkflowPlan,
+)
from runtime.workflows.dashboard import run_dashboard_workflow
from runtime.workflows.prepare import (
load_prepared_runs_for_dashboard,
@@ -31,22 +28,21 @@
__all__ = [
- "apply_skimjoin",
"effective_processor_config",
- "load_prepared_run_cache",
"load_prepared_runs_for_dashboard",
"load_runtime_config",
"load_summary_runs_from_cache",
- "prepare_data",
"prepared_cache_root",
- "prune_processor_result",
+ "PreparedRunsArtifact",
+ "SummaryCacheInspection",
+ "SummaryRunsArtifact",
+ "WorkflowPlan",
+ "prune_summary_artifact",
"prune_summary_runs",
- "read_run",
"resolve_run_entries",
"run_dashboard_workflow",
"run_entries_with_keys",
"run_prepare_workflow",
"run_summary_workflow",
"summary_cache_root",
- "write_prepared_run_cache",
]
diff --git a/runtime/workflows/artifacts.py b/runtime/workflows/artifacts.py
new file mode 100644
index 0000000..c963e41
--- /dev/null
+++ b/runtime/workflows/artifacts.py
@@ -0,0 +1,70 @@
+"""Explicit values exchanged between runtime workflow operations."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+from processor.models import RunData
+
+
+@dataclass(frozen=True)
+class WorkflowPlan:
+ """Resolved pipeline intent shared by the CLI and workflow operations."""
+
+ logical_steps: tuple[str, ...]
+ runtime_steps: tuple[str, ...]
+ dashboard_mode: str = "none"
+ overwrite: bool = False
+
+ @classmethod
+ def from_config(cls, config: Any) -> "WorkflowPlan":
+ return cls.for_steps(config, config.pipeline.steps)
+
+ @classmethod
+ def for_steps(cls, config: Any, steps: Any) -> "WorkflowPlan":
+ """Resolve runtime operations for an explicit logical step sequence."""
+ logical_steps = tuple(steps)
+ runtime_steps: list[str] = []
+ if any(step in logical_steps for step in ("prepare", "skimjoin")):
+ runtime_steps.append("prepare")
+ if any(step in logical_steps for step in ("summarize", "segment")):
+ runtime_steps.append("summarize")
+ if "dashboard" in logical_steps:
+ runtime_steps.append("dashboard")
+ return cls(
+ logical_steps=logical_steps,
+ runtime_steps=tuple(runtime_steps),
+ dashboard_mode=str(config.pipeline.dashboard_mode).lower(),
+ overwrite=bool(config.pipeline.overwrite),
+ )
+
+ def includes(self, step: str) -> bool:
+ return step in self.logical_steps
+
+
+@dataclass
+class PreparedRunsArtifact:
+ """Prepared tables and their stable run identities."""
+
+ runs: list[tuple[str, RunData]] = field(default_factory=list)
+ by_key: dict[str, tuple[str, RunData]] = field(default_factory=dict)
+ run_keys: list[str] = field(default_factory=list)
+ fingerprints_by_key: dict[str, dict[str, object]] = field(default_factory=dict)
+
+
+@dataclass
+class SummaryRunsArtifact:
+ """Summary tables plus the prepared artifact used to build them."""
+
+ runs: list[Any] = field(default_factory=list)
+ prepared: PreparedRunsArtifact = field(default_factory=PreparedRunsArtifact)
+
+
+@dataclass(frozen=True)
+class SummaryCacheInspection:
+ """Reusable cached summaries and the table ids that still need rebuilding."""
+
+ runs: tuple[Any, ...] = ()
+ reusable_summary_ids: tuple[str, ...] = ()
+ stale_summary_ids: tuple[str, ...] = ()
diff --git a/runtime/workflows/common.py b/runtime/workflows/common.py
index 8b0d45f..09f490f 100644
--- a/runtime/workflows/common.py
+++ b/runtime/workflows/common.py
@@ -5,18 +5,20 @@
from pathlib import Path
from typing import Any
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from processor.cache_identity import build_run_fingerprint, build_run_keys
from processor.models import (
PreparedTableName,
- ProcessorWorkflowResult,
prune_prepared_runs,
)
from processor.prepare.cache import build_prepared_manifest_identity, prepared_root
from processor.prepare.reader import resolve_skim_path
from processor.summarize import cache as summary_cache
+from processor.summarize import builder as summary_builder
+from processor.summarize import cache_types as summary_types
from runtime.config import Config
from runtime.workflows import shared
+from runtime.workflows.artifacts import SummaryRunsArtifact
LOGGER = get_logger("main")
@@ -24,9 +26,13 @@
def load_runtime_config(config_path: str | Path) -> Config:
"""Load config and normalize the shared runtime settings."""
config = Config.from_yaml(config_path)
- config.weighting_modes = summary_cache.normalize_weighting_modes(
- config.weighting_modes
- )
+ from processor.summarize.external import validate_summary_table_map_ids
+
+ for index, entry in enumerate(config.runs):
+ validate_summary_table_map_ids(
+ entry.get("summary_table_map") or None,
+ field_name=f"runs[{index}].summary_table_map",
+ )
return config
@@ -83,8 +89,14 @@ def load_summary_runs_from_cache(
cache_root: Path,
explicit_cache_dirs: list[str] | None,
run_entries: list[dict] | None,
+ required_summary_ids: list[str] | tuple[str, ...] | None = None,
) -> list[Any]:
"""Load validated summary caches for dashboard or export workflows."""
+ from processor.summarize.external import (
+ load_summary_table_map,
+ merge_summary_table_map_run,
+ )
+
cache_dirs, run_entries_by_key = shared.summary_cache_dirs_for_load(
cache_root=cache_root,
explicit_cache_dirs=explicit_cache_dirs,
@@ -93,11 +105,40 @@ def load_summary_runs_from_cache(
discover_cache_dirs_fn=summary_cache.discover_cache_dirs,
)
- if not cache_dirs:
+ required_summary_ids = (
+ list(summary_builder.DEFAULT_SUMMARY_IDS)
+ if required_summary_ids is None
+ else list(required_summary_ids)
+ )
+ if not cache_dirs and not run_entries:
raise ValueError("no summary cache directories were found to load.")
summary_runs: list[Any] = []
- for cache_dir in cache_dirs:
+ cache_dirs_by_key = {cache_dir.name: cache_dir for cache_dir in cache_dirs}
+ ordered_keys = (
+ [run_key for _, run_key in run_entries_with_keys(run_entries or [])]
+ if run_entries
+ else [cache_dir.name for cache_dir in cache_dirs]
+ )
+ for run_key in ordered_keys:
+ cache_dir = cache_dirs_by_key.get(run_key, cache_root / run_key)
+ entry = run_entries_by_key.get(run_key, {})
+ summary_table_map = entry.get("summary_table_map") or None
+ external_summary_run = None
+ if summary_table_map:
+ label = str(entry.get("label", Path(entry.get("dir", "")).name or run_key))
+ external_summary_run = load_summary_table_map(
+ summary_table_map=summary_table_map,
+ label=label,
+ run_key=run_key,
+ config=config,
+ source_run_dir=entry.get("dir") or None,
+ )
+ ids_for_cache = [
+ summary_id
+ for summary_id in required_summary_ids
+ if summary_id not in set(summary_table_map or {})
+ ]
expectations = (
shared.summary_cache_load_expectations(
cache_dir=cache_dir,
@@ -109,29 +150,44 @@ def load_summary_runs_from_cache(
)
or {}
)
+ loaded_cache_runs: list[Any] = []
try:
- summary_runs.extend(
- summary_cache.load_summary_run_bundle(
+ if ids_for_cache or not external_summary_run:
+ loaded_cache_runs = summary_cache.load_summary_run_bundle(
cache_dir,
config,
expected_modes=config.weighting_modes,
- expected_summary_ids=summary_cache.requested_summary_ids(config),
+ expected_summary_ids=ids_for_cache or required_summary_ids,
expected_summary_config_digest=config.summary_config_digest,
- expected_run_fingerprint=expectations.get("expected_run_fingerprint"),
+ expected_run_fingerprint=(
+ None
+ if external_summary_run is not None
+ else expectations.get("expected_run_fingerprint")
+ ),
expected_prepared_manifest_identity=expectations.get(
"expected_prepared_manifest_identity"
),
expected_label=expectations.get("expected_label"),
expected_run_key=expectations.get("expected_run_key"),
)
- )
- except summary_cache.SummaryCacheError as exc:
- raise ValueError(
- "dashboard-only run could not load cached summaries from "
- f"{cache_dir} because the cache is stale or incompatible with the current "
- "config. Run the pipeline with the summarize step enabled to refresh the "
- f"cache. Details: {exc}"
- ) from exc
+ except summary_types.SummaryCacheError as exc:
+ if external_summary_run is not None:
+ LOGGER.warning(
+ "Dashboard-only run could not load cached summaries from %s; continuing with user-supplied summary tables only. Details: %s",
+ cache_dir,
+ exc,
+ )
+ loaded_cache_runs = []
+ else:
+ raise ValueError(
+ "dashboard-only run could not load cached summaries from "
+ f"{cache_dir} because the cache is stale or incompatible with the current "
+ "config. Run the pipeline with the summarize step enabled to refresh the "
+ f"cache. Details: {exc}"
+ ) from exc
+ summary_runs.extend(
+ merge_summary_table_map_run(loaded_cache_runs, external_summary_run)
+ )
return summary_runs
@@ -151,19 +207,19 @@ def prune_summary_runs(
return shared.prune_summary_runs(
summary_runs,
required_summary_ids,
- create_summary_run_fn=summary_cache.create_summary_run,
+ create_summary_run_fn=summary_types.create_summary_run,
)
-def prune_processor_result(
- result: ProcessorWorkflowResult | None,
+def prune_summary_artifact(
+ artifact: SummaryRunsArtifact | None,
*,
required_summary_ids: list[str] | tuple[str, ...],
required_prepared_tables: list[PreparedTableName] | tuple[PreparedTableName, ...],
-) -> ProcessorWorkflowResult | None:
- """Return a processor result trimmed to the next dashboard/export step."""
- return shared.prune_processor_result(
- result,
+) -> SummaryRunsArtifact | None:
+ """Return summary/prepared artifacts trimmed to the next consumer."""
+ return shared.prune_summary_artifact(
+ artifact,
required_summary_ids=required_summary_ids,
required_prepared_tables=required_prepared_tables,
prune_prepared_runs_fn=prune_prepared_runs,
diff --git a/runtime/workflows/dashboard.py b/runtime/workflows/dashboard.py
index e23ba7c..246039f 100644
--- a/runtime/workflows/dashboard.py
+++ b/runtime/workflows/dashboard.py
@@ -4,7 +4,7 @@
from typing import Any
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from dashboard.page_registry import export_data_requirements, live_data_requirements
from processor.models import RunData, prune_prepared_runs
from runtime.config import Config
@@ -36,7 +36,7 @@ def run_dashboard_workflow(
)
summary_runs = prune_summary_runs(
summary_runs,
- requirements.required_summary_ids,
+ requirements.summary_ids_for_pruning,
)
prepared_runs = (
prune_prepared_runs(prepared_runs, requirements.required_prepared_tables)
diff --git a/runtime/workflows/prepare.py b/runtime/workflows/prepare.py
index 33315f9..46f7dd6 100644
--- a/runtime/workflows/prepare.py
+++ b/runtime/workflows/prepare.py
@@ -5,9 +5,9 @@
from pathlib import Path
from typing import Any, Callable
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from processor.cache_identity import build_run_fingerprint
-from processor.models import PreparedTableName, ProcessorWorkflowResult, RunData
+from processor.models import PreparedTableName, RunData
from processor.prepare.availability import (
failed_tables,
has_usable_loaded_tables,
@@ -29,6 +29,7 @@
from processor.skimjoin.pipeline import apply_skimjoin
from runtime.config import Config, config_for_run
from runtime.workflows.common import prepared_cache_root, run_entries_with_keys
+from runtime.workflows.artifacts import PreparedRunsArtifact, WorkflowPlan
from runtime.workflows import shared
LOGGER = get_logger("main")
@@ -327,13 +328,14 @@ def run_prepare_workflow(
run_entries: list[dict],
prefer_cache: bool,
write_cache: bool,
- existing_result: ProcessorWorkflowResult | None = None,
- apply_skimjoin: bool | None = None,
-) -> ProcessorWorkflowResult:
+ existing: PreparedRunsArtifact | None = None,
+ plan: WorkflowPlan | None = None,
+) -> PreparedRunsArtifact:
"""Build or reuse prepared runs for the configured entries."""
+ plan = plan or WorkflowPlan.from_config(config)
config = shared.effective_processor_config(
config,
- apply_skimjoin=apply_skimjoin,
+ plan=plan,
)
prepared_root = prepared_root or prepared_cache_root(config, create=write_cache)
(
@@ -341,9 +343,20 @@ def run_prepare_workflow(
prepared_runs_by_key,
run_keys,
run_fingerprints_by_key,
- ) = shared.init_processor_result(existing_result)
+ ) = shared.init_prepared_artifact(existing)
+ if existing is not None:
+ prepared_runs_by_key.update(existing_prepared_runs_by_key)
+ run_keys = list(existing.run_keys)
+ run_fingerprints_by_key.update(existing.fingerprints_by_key)
for entry, run_key in run_entries_with_keys(run_entries):
+ if shared.is_summary_table_map_only_run(entry):
+ label = str(entry.get("label", run_key))
+ LOGGER.info(
+ "Skipping prepare for summary-table-map-only run: %r",
+ label,
+ )
+ continue
metadata = _run_cache_metadata(entry=entry, run_key=run_key, config=config)
prepared_loaded = _resolve_prepared_run(
entry=entry,
@@ -353,23 +366,23 @@ def run_prepare_workflow(
existing_prepared_runs_by_key=existing_prepared_runs_by_key,
prefer_cache=prefer_cache,
write_cache=write_cache,
- run_skimjoin=bool(config.skimjoin.enabled),
+ run_skimjoin=config.skimjoin_step_enabled(),
)
if prepared_loaded is None:
continue
prepared_runs_by_key[run_key] = prepared_loaded
- run_keys.append(run_key)
+ if run_key not in run_keys:
+ run_keys.append(run_key)
run_fingerprints_by_key[run_key] = dict(metadata["run_fingerprint"])
- return ProcessorWorkflowResult(
- summary_runs=list(existing_result.summary_runs) if existing_result else [],
- prepared_runs=_ordered_prepared_runs(
+ return PreparedRunsArtifact(
+ runs=_ordered_prepared_runs(
prepared_runs_by_key=prepared_runs_by_key,
run_keys=run_keys,
),
- prepared_runs_by_key=prepared_runs_by_key,
+ by_key=prepared_runs_by_key,
run_keys=run_keys,
- run_fingerprints_by_key=run_fingerprints_by_key,
+ fingerprints_by_key=run_fingerprints_by_key,
)
@@ -383,7 +396,7 @@ def load_prepared_runs_for_dashboard(
) = None,
existing_prepared_runs_by_key: dict[str, tuple[str, RunData]] | None = None,
prune_prepared_runs_fn: Callable[..., list[tuple[str, RunData]]] | None = None,
- apply_skimjoin: bool | None = None,
+ plan: WorkflowPlan | None = None,
) -> list[tuple[str, RunData]]:
"""Load prepared runs only when enabled pages require them."""
from processor.models import prune_prepared_runs as default_prune_prepared_runs
@@ -416,26 +429,26 @@ def load_prepared_runs_for_dashboard(
return []
selected_entries = [entries_by_key[run_key] for run_key in required_run_keys]
- prepare_result = ProcessorWorkflowResult(
- prepared_runs=[
+ prepare_artifact = PreparedRunsArtifact(
+ runs=[
existing_prepared_runs_by_key[run_key]
for run_key in required_run_keys
if run_key in existing_prepared_runs_by_key
],
- prepared_runs_by_key=existing_prepared_runs_by_key,
+ by_key=existing_prepared_runs_by_key,
run_keys=list(existing_prepared_runs_by_key),
)
- prepare_result = run_prepare_workflow(
+ prepare_artifact = run_prepare_workflow(
config=config,
prepared_root=prepared_cache_root(config, create=True),
run_entries=selected_entries,
prefer_cache=True,
write_cache=True,
- existing_result=prepare_result,
- apply_skimjoin=apply_skimjoin,
+ existing=prepare_artifact,
+ plan=plan,
)
ordered_runs = _ordered_prepared_runs(
- prepared_runs_by_key=prepare_result.prepared_runs_by_key,
+ prepared_runs_by_key=prepare_artifact.by_key,
run_keys=required_run_keys,
)
if required_prepared_tables:
diff --git a/runtime/workflows/shared.py b/runtime/workflows/shared.py
index 8b5d957..8f1c694 100644
--- a/runtime/workflows/shared.py
+++ b/runtime/workflows/shared.py
@@ -6,26 +6,59 @@
from pathlib import Path
from typing import Any, Callable
-from processor.models import PreparedTableName, ProcessorWorkflowResult, RunData
+from processor.models import PreparedTableName, RunData
from runtime.config import Config
from runtime.config.signatures import digest_payload
+from runtime.workflows.artifacts import (
+ PreparedRunsArtifact,
+ SummaryRunsArtifact,
+ WorkflowPlan,
+)
+
+
+def summary_run_fingerprint(
+ run_fingerprint: dict[str, object],
+ entry: dict,
+) -> dict[str, object]:
+ """Return the run fingerprint used specifically by summary caches."""
+ from processor.summarize.external import summary_table_map_identity
+
+ fingerprint = dict(run_fingerprint)
+ summary_identity = summary_table_map_identity(entry.get("summary_table_map") or None)
+ if summary_identity is not None:
+ fingerprint["summary_table_map_identity"] = summary_identity
+ return fingerprint
+
+
+def is_summary_table_map_only_run(entry: dict) -> bool:
+ """Return whether a run entry only contributes user-supplied summary tables."""
+ return bool(entry.get("summary_table_map")) and not (
+ entry.get("dir") or entry.get("prepared_table_map")
+ )
def effective_processor_config(
config: Config,
*,
- apply_skimjoin: bool | None,
- apply_segmentation: bool | None = None,
+ plan: WorkflowPlan,
) -> Config:
"""Return a runtime-effective config for processor/cache identity decisions."""
effective = config
- if apply_skimjoin is not None and bool(config.skimjoin.enabled) != apply_skimjoin:
- if apply_skimjoin:
+ skimjoin_enabled = effective.skimjoin_step_enabled()
+ run_skimjoin = plan.includes("skimjoin")
+ if skimjoin_enabled != run_skimjoin:
+ if run_skimjoin:
raise ValueError(
"Cannot force integrated skimjoin on when the loaded config has it disabled."
)
effective = replace(
effective,
+ pipeline=replace(
+ effective.pipeline,
+ steps=tuple(
+ step for step in effective.pipeline.steps if step != "skimjoin"
+ ),
+ ),
skimjoin=replace(
effective.skimjoin,
enabled=False,
@@ -35,11 +68,9 @@ def effective_processor_config(
resolved_network_los_file=None,
),
)
- if (
- apply_segmentation is not None
- and bool(effective.segmentation.enabled) != apply_segmentation
- ):
- if apply_segmentation:
+ run_segmentation = plan.includes("segment")
+ if bool(effective.segmentation.enabled) != run_segmentation:
+ if run_segmentation:
raise ValueError(
"Cannot force segmentation on when the loaded config has it disabled."
)
@@ -103,8 +134,9 @@ def summary_cache_load_expectations(
expected_label = entry.get("label", Path(run_dir).name)
expected_run_key = cache_dir.name
uses_custom_prepared_tables = bool(entry.get("prepared_table_map"))
+ uses_summary_table_map_only = is_summary_table_map_only_run(entry)
expected_skimjoin = None
- if not uses_custom_prepared_tables:
+ if not (uses_custom_prepared_tables or uses_summary_table_map_only):
from runtime.config import resolve_run_skimjoin_settings
resolved_skimjoin = resolve_run_skimjoin_settings(config, entry)
@@ -116,48 +148,63 @@ def summary_cache_load_expectations(
"resolved_skim_files": list(resolved_skimjoin.resolved_skim_files),
"resolved_network_los_file": resolved_skimjoin.resolved_network_los_file,
}
- expected_run_fingerprint = build_run_fingerprint_fn(
+ base_run_fingerprint = build_run_fingerprint_fn(
label=expected_label,
- run_dir=None if uses_custom_prepared_tables else run_dir,
+ run_dir=(
+ None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else run_dir
+ ),
skim_file=(
None
- if uses_custom_prepared_tables
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
else resolve_skim_path_fn(
entry.get("skim_file") or None,
config.skim_file,
run_dir,
)
),
- file_map=None if uses_custom_prepared_tables else entry.get("file_map") or None,
+ file_map=None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else entry.get("file_map") or None,
fallback_file_map=(
- None if uses_custom_prepared_tables else config.fallback_files or None
+ None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else config.fallback_files or None
),
skimjoin=expected_skimjoin,
hh_weight_col=None
- if uses_custom_prepared_tables
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
else entry.get("hh_weight_col") or None,
person_weight_col=None
- if uses_custom_prepared_tables
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
else entry.get("person_weight_col") or None,
trip_weight_col=None
- if uses_custom_prepared_tables
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
else entry.get("trip_weight_col") or None,
)
- return {
- "expected_label": expected_label,
- "expected_run_key": expected_run_key,
- "expected_run_fingerprint": expected_run_fingerprint,
- "expected_prepared_manifest_identity": build_prepared_manifest_identity_fn(
+ expected_run_fingerprint = summary_run_fingerprint(
+ base_run_fingerprint,
+ entry,
+ )
+ expected_prepared_manifest_identity = None
+ if not uses_summary_table_map_only:
+ expected_prepared_manifest_identity = build_prepared_manifest_identity_fn(
run_key=expected_run_key,
config=config,
- run_fingerprint=expected_run_fingerprint,
+ run_fingerprint=base_run_fingerprint,
source_type=(
"custom_prepared_table_map"
if uses_custom_prepared_tables
else "prepared_cache"
),
prepared_table_map=entry.get("prepared_table_map") or None,
- ),
+ )
+ return {
+ "expected_label": expected_label,
+ "expected_run_key": expected_run_key,
+ "expected_run_fingerprint": expected_run_fingerprint,
+ "expected_prepared_manifest_identity": expected_prepared_manifest_identity,
}
@@ -231,16 +278,16 @@ def prune_summary_runs(
]
-def prune_processor_result(
- result: ProcessorWorkflowResult | None,
+def prune_summary_artifact(
+ artifact: SummaryRunsArtifact | None,
*,
required_summary_ids: list[str] | tuple[str, ...],
required_prepared_tables: list[PreparedTableName] | tuple[PreparedTableName, ...],
prune_prepared_runs_fn: Callable[[list[tuple[str, RunData]], list[PreparedTableName] | tuple[PreparedTableName, ...]], list[tuple[str, RunData]]],
prune_summary_runs_fn: Callable[[list[Any], list[str] | tuple[str, ...]], list[Any]],
-) -> ProcessorWorkflowResult | None:
- """Return a processor result trimmed to the next dashboard/export step."""
- if result is None:
+) -> SummaryRunsArtifact | None:
+ """Return summary/prepared artifacts trimmed for dashboard or export."""
+ if artifact is None:
return None
pruned_prepared_runs_by_key = {
@@ -250,18 +297,20 @@ def prune_processor_result(
1
],
)
- for run_key, (label, prepared_run) in result.prepared_runs_by_key.items()
+ for run_key, (label, prepared_run) in artifact.prepared.by_key.items()
}
ordered_prepared_runs = ordered_prepared_runs_by_key(
prepared_runs_by_key=pruned_prepared_runs_by_key,
- run_keys=result.run_keys,
+ run_keys=artifact.prepared.run_keys,
)
- return ProcessorWorkflowResult(
- summary_runs=prune_summary_runs_fn(result.summary_runs, required_summary_ids),
- prepared_runs=ordered_prepared_runs,
- prepared_runs_by_key=pruned_prepared_runs_by_key,
- run_keys=list(result.run_keys),
- run_fingerprints_by_key=dict(result.run_fingerprints_by_key),
+ return SummaryRunsArtifact(
+ runs=prune_summary_runs_fn(artifact.runs, required_summary_ids),
+ prepared=PreparedRunsArtifact(
+ runs=ordered_prepared_runs,
+ by_key=pruned_prepared_runs_by_key,
+ run_keys=list(artifact.prepared.run_keys),
+ fingerprints_by_key=dict(artifact.prepared.fingerprints_by_key),
+ ),
)
@@ -279,8 +328,9 @@ def run_cache_metadata(
label = entry.get("label", Path(run_dir).name)
skim = entry.get("skim_file") or None
uses_custom_prepared_tables = bool(entry.get("prepared_table_map"))
+ uses_summary_table_map_only = is_summary_table_map_only_run(entry)
resolved_skimjoin_payload = None
- if not uses_custom_prepared_tables:
+ if not (uses_custom_prepared_tables or uses_summary_table_map_only):
from runtime.config import resolve_run_skimjoin_settings
resolved_skimjoin = resolve_run_skimjoin_settings(config, entry)
@@ -293,27 +343,40 @@ def run_cache_metadata(
"resolved_network_los_file": resolved_skimjoin.resolved_network_los_file,
}
resolved_skim = (
- None if uses_custom_prepared_tables else resolve_skim_path_fn(skim, config.skim_file, run_dir)
+ None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else resolve_skim_path_fn(skim, config.skim_file, run_dir)
)
run_fingerprint = build_run_fingerprint_fn(
label=label,
- run_dir=None if uses_custom_prepared_tables else run_dir,
+ run_dir=(
+ None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else run_dir
+ ),
skim_file=resolved_skim,
skimjoin=resolved_skimjoin_payload,
- file_map=None if uses_custom_prepared_tables else entry.get("file_map") or None,
+ file_map=None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else entry.get("file_map") or None,
fallback_file_map=(
- None if uses_custom_prepared_tables else config.fallback_files or None
+ None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else config.fallback_files or None
),
- hh_weight_col=None if uses_custom_prepared_tables else entry.get("hh_weight_col") or None,
- person_weight_col=None if uses_custom_prepared_tables else entry.get("person_weight_col") or None,
- trip_weight_col=None if uses_custom_prepared_tables else entry.get("trip_weight_col") or None,
+ hh_weight_col=None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else entry.get("hh_weight_col") or None,
+ person_weight_col=None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else entry.get("person_weight_col") or None,
+ trip_weight_col=None
+ if (uses_custom_prepared_tables or uses_summary_table_map_only)
+ else entry.get("trip_weight_col") or None,
)
- return {
- "label": label,
- "run_dir": run_dir,
- "skim": skim,
- "run_fingerprint": run_fingerprint,
- "prepared_manifest_identity": build_prepared_manifest_identity_fn(
+ prepared_manifest_identity = None
+ if not uses_summary_table_map_only:
+ prepared_manifest_identity = build_prepared_manifest_identity_fn(
run_key=run_key,
config=config,
run_fingerprint=run_fingerprint,
@@ -323,21 +386,27 @@ def run_cache_metadata(
else "prepared_cache"
),
prepared_table_map=entry.get("prepared_table_map") or None,
- ),
+ )
+ return {
+ "label": label,
+ "run_dir": run_dir,
+ "skim": skim,
+ "run_fingerprint": run_fingerprint,
+ "prepared_manifest_identity": prepared_manifest_identity,
}
-def init_processor_result(
- existing_result: ProcessorWorkflowResult | None,
+def init_prepared_artifact(
+ existing: PreparedRunsArtifact | None,
) -> tuple[
dict[str, tuple[str, RunData]],
dict[str, tuple[str, RunData]],
list[str],
dict[str, dict[str, object]],
]:
- """Initialize shared workflow collections from an existing processor result."""
+ """Initialize workflow collections from an existing prepared artifact."""
existing_prepared_runs_by_key = dict(
- (existing_result.prepared_runs_by_key if existing_result else {}) or {}
+ (existing.by_key if existing else {}) or {}
)
return existing_prepared_runs_by_key, {}, [], {}
diff --git a/runtime/workflows/summarize.py b/runtime/workflows/summarize.py
index d4a1a49..06a2ba8 100644
--- a/runtime/workflows/summarize.py
+++ b/runtime/workflows/summarize.py
@@ -5,17 +5,29 @@
from pathlib import Path
from typing import Any, Callable
-from activitysim_viz_logging import get_logger
+from runtime.logging import get_logger
from processor.analysis_units import AnalysisUnit
-from processor.models import ProcessorWorkflowResult, RunData
+from processor.models import RunData
from processor.segmentation import build_analysis_units_for_run
from processor.summarize import cache as summary_cache
+from processor.summarize import builder as summary_builder
+from processor.summarize import cache_types as summary_types
+from processor.summarize.external import (
+ load_summary_table_map,
+ merge_summary_table_map_run,
+)
from runtime.config import Config
from runtime.workflows.common import prepared_cache_root, run_entries_with_keys
from runtime.workflows.prepare import run_prepare_workflow
+from runtime.workflows.artifacts import (
+ PreparedRunsArtifact,
+ SummaryCacheInspection,
+ SummaryRunsArtifact,
+ WorkflowPlan,
+)
from runtime.workflows import shared
LOGGER = get_logger("main")
@@ -29,7 +41,14 @@ def _run_cache_metadata(
_run_cache_metadata as prepare_run_cache_metadata,
)
- return prepare_run_cache_metadata(entry=entry, run_key=run_key, config=config)
+ metadata = dict(
+ prepare_run_cache_metadata(entry=entry, run_key=run_key, config=config)
+ )
+ metadata["run_fingerprint"] = shared.summary_run_fingerprint(
+ dict(metadata["run_fingerprint"]),
+ entry,
+ )
+ return metadata
def _load_summary_run_from_cache(
@@ -40,14 +59,14 @@ def _load_summary_run_from_cache(
run_key: str,
run_fingerprint: dict[str, object],
prepared_manifest_identity: dict[str, object],
-) -> Any | None:
+) -> SummaryCacheInspection | None:
"""Load one summary run from cache when valid."""
try:
inspection = summary_cache.inspect_summary_run_bundle(
cache_dir,
config,
expected_modes=config.weighting_modes,
- expected_summary_ids=summary_cache.requested_summary_ids(config),
+ expected_summary_ids=list(summary_builder.DEFAULT_SUMMARY_IDS),
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=run_fingerprint,
expected_prepared_manifest_identity=prepared_manifest_identity,
@@ -76,12 +95,12 @@ def _load_summary_run_from_cache(
label,
", ".join(reusable_summary_ids) if reusable_summary_ids else "(none)",
)
- return {
- "summary_runs": cached_runs,
- "reusable_summary_ids": reusable_summary_ids,
- "stale_summary_ids": stale_summary_ids,
- }
- except summary_cache.SummaryCacheError as exc:
+ return SummaryCacheInspection(
+ runs=tuple(cached_runs),
+ reusable_summary_ids=tuple(reusable_summary_ids),
+ stale_summary_ids=tuple(stale_summary_ids),
+ )
+ except summary_types.SummaryCacheError as exc:
LOGGER.info("Cache miss for %r: %s", label, exc)
return None
@@ -93,13 +112,23 @@ def _build_summary_tables_for_run(
summary_ids: list[str] | None = None,
) -> tuple[dict[str, dict[str, Any]], dict[str, dict[str, dict[str, object]]]]:
"""Build summary tables and metadata for one prepared run."""
- requested_summary_ids = summary_cache.requested_summary_ids(config)
+ requested_summary_ids = list(summary_builder.DEFAULT_SUMMARY_IDS)
+ strict_kwargs = (
+ {"raise_on_error": True}
+ if config.summary_failure_policy == "error"
+ else {}
+ )
if summary_ids is None or list(summary_ids) == requested_summary_ids:
- return summary_cache.build_mode_summaries_with_metadata(prepared_run, config)
- return summary_cache.build_mode_summaries_with_metadata(
+ return summary_builder.build_mode_summaries_with_metadata(
+ prepared_run,
+ config,
+ **strict_kwargs,
+ )
+ return summary_builder.build_mode_summaries_with_metadata(
prepared_run,
config,
summary_ids=summary_ids,
+ **strict_kwargs,
)
@@ -113,7 +142,7 @@ def _build_summary_run_from_analysis_unit(
prepared_run=unit.prepared_run,
config=config,
)
- return summary_cache.create_summary_run(
+ return summary_types.create_summary_run(
label=unit.run_name,
run_key=unit.run_key,
summaries_by_mode=summaries_by_mode,
@@ -165,7 +194,7 @@ def _merge_summary_runs(
**rebuilt.summary_metadata_by_mode.get(mode, {}),
}
merged.append(
- summary_cache.create_summary_run(
+ summary_types.create_summary_run(
label=rebuilt.label,
run_key=rebuilt.run_key,
summaries_by_mode=summaries_by_mode,
@@ -210,32 +239,49 @@ def run_summary_workflow(
prefer_cache: bool,
write_cache: bool,
prepared_prefer_cache: bool = True,
- existing_result: ProcessorWorkflowResult | None = None,
- apply_skimjoin: bool | None = None,
- apply_segmentation: bool | None = None,
-) -> ProcessorWorkflowResult:
+ prepared: PreparedRunsArtifact | None = None,
+ plan: WorkflowPlan | None = None,
+) -> SummaryRunsArtifact:
"""Build or reuse summaries for the configured runs."""
+ plan = plan or WorkflowPlan.from_config(config)
config = shared.effective_processor_config(
config,
- apply_skimjoin=apply_skimjoin,
- apply_segmentation=apply_segmentation,
+ plan=plan,
)
summary_runs: list[Any] = []
prepared_root = prepared_root or prepared_cache_root(config, create=True)
- prepare_result = existing_result
+ prepare_artifact = prepared
(
existing_prepared_runs_by_key,
prepared_runs_by_key,
run_keys,
run_fingerprints_by_key,
- ) = shared.init_processor_result(prepare_result)
+ ) = shared.init_prepared_artifact(prepare_artifact)
runs_with_keys = run_entries_with_keys(run_entries)
for entry, run_key in runs_with_keys:
metadata = _run_cache_metadata(entry=entry, run_key=run_key, config=config)
label = str(metadata["label"])
run_fingerprint = dict(metadata["run_fingerprint"])
- prepared_manifest_identity = dict(metadata["prepared_manifest_identity"])
+ raw_prepared_manifest_identity = metadata["prepared_manifest_identity"]
+ prepared_manifest_identity = (
+ dict(raw_prepared_manifest_identity)
+ if raw_prepared_manifest_identity is not None
+ else None
+ )
+ summary_table_map = entry.get("summary_table_map") or None
+ external_summary_run = None
+ external_summary_ids: set[str] = set()
+ if summary_table_map:
+ LOGGER.info("Loading custom summary tables for %r", label)
+ external_summary_run = load_summary_table_map(
+ summary_table_map=summary_table_map,
+ label=label,
+ run_key=run_key,
+ config=config,
+ source_run_dir=entry.get("dir") or None,
+ )
+ external_summary_ids = set(summary_table_map)
cache_dir = cache_root / run_key
run_keys.append(run_key)
run_fingerprints_by_key[run_key] = run_fingerprint
@@ -251,31 +297,90 @@ def run_summary_workflow(
prepared_manifest_identity=prepared_manifest_identity,
)
if cached_run is not None:
- stale_summary_ids = list(cached_run["stale_summary_ids"])
+ stale_summary_ids = list(cached_run.stale_summary_ids)
if not stale_summary_ids:
- summary_runs.extend(cached_run["summary_runs"])
+ summary_runs.extend(
+ merge_summary_table_map_run(
+ list(cached_run.runs),
+ external_summary_run,
+ )
+ )
cached_prepared_run = existing_prepared_runs_by_key.get(run_key)
if cached_prepared_run is not None:
prepared_runs_by_key[run_key] = cached_prepared_run
continue
- prepare_result = run_prepare_workflow(
+ cached_summary_runs = list(cached_run.runs) if cached_run else []
+ summary_ids_to_build = list(summary_builder.DEFAULT_SUMMARY_IDS)
+ if cached_run is not None:
+ summary_ids_to_build = list(cached_run.stale_summary_ids)
+ summary_ids_to_build = [
+ summary_id
+ for summary_id in summary_ids_to_build
+ if summary_id not in external_summary_ids
+ ]
+
+ has_buildable_inputs = bool(entry.get("dir") or entry.get("prepared_table_map"))
+ if not has_buildable_inputs:
+ run_summary_runs = merge_summary_table_map_run(
+ cached_summary_runs,
+ external_summary_run,
+ )
+ if run_summary_runs:
+ summary_runs.extend(run_summary_runs)
+ if write_cache:
+ LOGGER.info("Writing summary cache for run: %r", label)
+ cache_path = summary_cache.write_summary_run_bundle(
+ run_summary_runs,
+ config,
+ run_fingerprint=run_fingerprint,
+ prepared_manifest_identity=prepared_manifest_identity,
+ )
+ LOGGER.info("Wrote summaries: %s", cache_path)
+ else:
+ LOGGER.info("Skipped cache write for run: %r", label)
+ continue
+ LOGGER.warning(
+ "Skipping summary build for %r because no raw, prepared, or summary table inputs were available.",
+ label,
+ )
+ continue
+
+ prepare_artifact = run_prepare_workflow(
config=config,
prepared_root=prepared_root,
run_entries=[entry],
prefer_cache=prepared_prefer_cache,
write_cache=True,
- existing_result=prepare_result,
- apply_skimjoin=apply_skimjoin,
+ existing=prepare_artifact,
+ plan=plan,
)
- if run_key not in prepare_result.prepared_runs_by_key:
+ if run_key not in prepare_artifact.by_key:
+ run_summary_runs = merge_summary_table_map_run(
+ cached_summary_runs,
+ external_summary_run,
+ )
+ if run_summary_runs:
+ summary_runs.extend(run_summary_runs)
+ if write_cache:
+ LOGGER.info("Writing summary cache for run: %r", label)
+ cache_path = summary_cache.write_summary_run_bundle(
+ run_summary_runs,
+ config,
+ run_fingerprint=run_fingerprint,
+ prepared_manifest_identity=prepared_manifest_identity,
+ )
+ LOGGER.info("Wrote summaries: %s", cache_path)
+ else:
+ LOGGER.info("Skipped cache write for run: %r", label)
+ continue
LOGGER.warning(
"Skipping summary build for %r because no prepared tables were available.",
label,
)
continue
- prepared_loaded = prepare_result.prepared_runs_by_key[run_key]
- existing_prepared_runs_by_key = dict(prepare_result.prepared_runs_by_key)
+ prepared_loaded = prepare_artifact.by_key[run_key]
+ existing_prepared_runs_by_key = dict(prepare_artifact.by_key)
prepared_runs_by_key[run_key] = prepared_loaded
analysis_units = build_analysis_units_for_run(
@@ -284,45 +389,46 @@ def run_summary_workflow(
prepared_run=prepared_loaded[1],
config=config,
)
- requested_summary_ids = summary_cache.requested_summary_ids(config)
- cached_summary_runs = []
- summary_ids_to_build = requested_summary_ids
- if prefer_cache and cached_run is not None:
- cached_summary_runs = list(cached_run["summary_runs"])
- summary_ids_to_build = list(cached_run["stale_summary_ids"])
run_summary_runs = []
- for unit in analysis_units:
- summaries_by_mode, summary_metadata_by_mode = _build_summary_tables_for_run(
- prepared_run=unit.prepared_run,
- config=config,
- summary_ids=summary_ids_to_build,
- )
- run_summary_runs.append(
- summary_cache.create_summary_run(
- label=unit.run_name,
- run_key=unit.run_key,
- summaries_by_mode=summaries_by_mode,
- summary_metadata_by_mode=summary_metadata_by_mode,
- segmentation_type=unit.segmentation_type,
- segment_id=unit.segment_id,
- segment_label=unit.segment_label,
- is_full_segment=unit.is_full,
- segment_source_type=unit.segment_metadata.source_type,
- segment_column=unit.segment_metadata.column,
- segment_values=unit.segment_metadata.values,
- segment_source_table=unit.segment_metadata.source_table,
- segment_source_key_column=unit.segment_metadata.source_key_column,
- segment_csv_file=unit.segment_metadata.csv_file,
- segment_csv_key_column=unit.segment_metadata.csv_key_column,
- segment_csv_value_column=unit.segment_metadata.csv_segment_value_column,
- source_run_dir=str(unit.prepared_run.run_dir),
+ if summary_ids_to_build:
+ for unit in analysis_units:
+ summaries_by_mode, summary_metadata_by_mode = _build_summary_tables_for_run(
+ prepared_run=unit.prepared_run,
+ config=config,
+ summary_ids=summary_ids_to_build,
)
- )
- if cached_summary_runs:
+ run_summary_runs.append(
+ summary_types.create_summary_run(
+ label=unit.run_name,
+ run_key=unit.run_key,
+ summaries_by_mode=summaries_by_mode,
+ summary_metadata_by_mode=summary_metadata_by_mode,
+ segmentation_type=unit.segmentation_type,
+ segment_id=unit.segment_id,
+ segment_label=unit.segment_label,
+ is_full_segment=unit.is_full,
+ segment_source_type=unit.segment_metadata.source_type,
+ segment_column=unit.segment_metadata.column,
+ segment_values=unit.segment_metadata.values,
+ segment_source_table=unit.segment_metadata.source_table,
+ segment_source_key_column=unit.segment_metadata.source_key_column,
+ segment_csv_file=unit.segment_metadata.csv_file,
+ segment_csv_key_column=unit.segment_metadata.csv_key_column,
+ segment_csv_value_column=unit.segment_metadata.csv_segment_value_column,
+ source_run_dir=str(unit.prepared_run.run_dir),
+ )
+ )
+ if cached_summary_runs and run_summary_runs:
run_summary_runs = _merge_summary_runs(
cached_runs=cached_summary_runs,
rebuilt_runs=run_summary_runs,
)
+ elif cached_summary_runs:
+ run_summary_runs = cached_summary_runs
+ run_summary_runs = merge_summary_table_map_run(
+ run_summary_runs,
+ external_summary_run,
+ )
summary_runs.extend(run_summary_runs)
if write_cache:
@@ -339,13 +445,15 @@ def run_summary_workflow(
if not summary_runs:
raise ValueError("no runs were loaded.")
- return ProcessorWorkflowResult(
- summary_runs=summary_runs,
- prepared_runs=_ordered_prepared_runs(
- prepared_runs_by_key=prepared_runs_by_key,
+ return SummaryRunsArtifact(
+ runs=summary_runs,
+ prepared=PreparedRunsArtifact(
+ runs=_ordered_prepared_runs(
+ prepared_runs_by_key=prepared_runs_by_key,
+ run_keys=run_keys,
+ ),
+ by_key=prepared_runs_by_key,
run_keys=run_keys,
+ fingerprints_by_key=run_fingerprints_by_key,
),
- prepared_runs_by_key=prepared_runs_by_key,
- run_keys=run_keys,
- run_fingerprints_by_key=run_fingerprints_by_key,
)
diff --git a/scripts/generate_wiki_catalogs.py b/scripts/generate_wiki_catalogs.py
new file mode 100644
index 0000000..0bccbce
--- /dev/null
+++ b/scripts/generate_wiki_catalogs.py
@@ -0,0 +1,182 @@
+"""Generate drift-prone wiki catalog sections from runtime registries."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+from typing import Iterable
+
+
+ROOT = Path(__file__).resolve().parents[1]
+WIKI = ROOT / "wiki"
+
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+
+def _escape_cell(value: object) -> str:
+ text = str(value)
+ return text.replace("|", "\\|").replace("\n", "
")
+
+
+def _inline_list(values: Iterable[object]) -> str:
+ items = [f"`{_escape_cell(value)}`" for value in values if str(value)]
+ return ", ".join(items) if items else "-"
+
+
+def _replace_generated_section(
+ path: Path,
+ *,
+ marker: str,
+ generated: str,
+) -> None:
+ start = f""
+ end = f""
+ text = path.read_text(encoding="utf-8")
+ if start not in text or end not in text:
+ raise ValueError(f"{path} is missing generated markers for {marker}.")
+ before, rest = text.split(start, 1)
+ _, after = rest.split(end, 1)
+ next_text = before + start + "\n" + generated.rstrip() + "\n" + end + after
+ if next_text != text:
+ path.write_text(next_text, encoding="utf-8")
+
+
+def _type_name(dtype: object) -> str:
+ return str(dtype).replace("Datetime(", "Datetime(")
+
+
+def build_summary_catalog() -> str:
+ from processor.summarize.catalog import SUMMARY_DEFINITIONS
+
+ lines: list[str] = [
+ "_Generated from `processor.summarize.catalog.SUMMARY_DEFINITIONS`._",
+ "",
+ f"Total registered summaries: **{len(SUMMARY_DEFINITIONS)}**",
+ "",
+ "| Summary ID | Filename | Builder | Output schema | Required inputs |",
+ "|---|---|---|---|---|",
+ ]
+
+ for definition in sorted(
+ SUMMARY_DEFINITIONS, key=lambda item: item.summary_id
+ ):
+ contract = definition.contract
+ builder_name = (
+ f"{definition.builder.__module__}."
+ f"{getattr(definition.builder, '__name__', '')}"
+ )
+ schema = "
".join(
+ f"`{_escape_cell(name)}: {_escape_cell(_type_name(dtype))}`"
+ for name, dtype in contract.schema.items()
+ ) or "-"
+ required_parts: list[str] = []
+ if contract.required_tables:
+ required_parts.append("tables: " + _inline_list(contract.required_tables))
+ for table_name, columns in sorted(contract.required_columns.items()):
+ required_parts.append(
+ f"{_escape_cell(table_name)}: " + _inline_list(columns)
+ )
+ required = "
".join(required_parts) if required_parts else "-"
+
+ lines.append(
+ "| "
+ + " | ".join(
+ [
+ f"`{_escape_cell(definition.summary_id)}`",
+ f"`{_escape_cell(definition.filename)}.csv`",
+ f"`{_escape_cell(builder_name)}`",
+ schema,
+ required,
+ ]
+ )
+ + " |"
+ )
+
+ return "\n".join(lines)
+
+
+def build_dashboard_page_catalog() -> str:
+ from dashboard.page_registry import all_group_definitions, all_page_definitions
+
+ groups = {group.group_id: group for group in all_group_definitions()}
+ pages = sorted(
+ all_page_definitions(),
+ key=lambda page: (
+ groups[page.group_id].order if page.group_id in groups else page.order,
+ page.order,
+ page.page_id,
+ ),
+ )
+
+ lines: list[str] = [
+ "_Generated from the dashboard page registry._",
+ "",
+ f"Total registered pages: **{len(pages)}**",
+ "",
+ "| Page ID | Title | Group | Default | Prepared data | Required summaries | Optional summaries | Required prepared tables |",
+ "|---|---|---|---|---|---|---|---|",
+ ]
+
+ for page in pages:
+ group_title = groups[page.group_id].title if page.group_id in groups else "-"
+ lines.append(
+ "| "
+ + " | ".join(
+ [
+ f"`{_escape_cell(page.page_id)}`",
+ _escape_cell(page.title),
+ _escape_cell(group_title),
+ "yes" if page.default_enabled else "no",
+ f"`{_escape_cell(page.prepared_data_mode)}`",
+ _inline_list(page.required_summary_ids),
+ _inline_list(page.optional_summary_ids),
+ _inline_list(page.required_prepared_tables),
+ ]
+ )
+ + " |"
+ )
+
+ lines.extend(
+ [
+ "",
+ "## Registered Page Groups",
+ "",
+ "| Group ID | Title | Default page | Default enabled |",
+ "|---|---|---|---|",
+ ]
+ )
+ for group in sorted(groups.values(), key=lambda item: (item.order, item.group_id)):
+ lines.append(
+ "| "
+ + " | ".join(
+ [
+ f"`{_escape_cell(group.group_id)}`",
+ _escape_cell(group.title),
+ f"`{_escape_cell(group.default_page_id)}`"
+ if group.default_page_id
+ else "-",
+ "yes" if group.default_enabled else "no",
+ ]
+ )
+ + " |"
+ )
+
+ return "\n".join(lines)
+
+
+def main() -> None:
+ _replace_generated_section(
+ WIKI / "24-summary-catalog.md",
+ marker="SUMMARY-CATALOG",
+ generated=build_summary_catalog(),
+ )
+ _replace_generated_section(
+ WIKI / "31-dashboard-pages.md",
+ marker="DASHBOARD-PAGE-CATALOG",
+ generated=build_dashboard_page_catalog(),
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/_dashboard_expectations.py b/tests/_dashboard_expectations.py
index 516a257..55245e7 100644
--- a/tests/_dashboard_expectations.py
+++ b/tests/_dashboard_expectations.py
@@ -19,7 +19,7 @@
("individual_choices", "Individual Choices"),
("vehicle_ownership_type", "Vehicle Ownership and Type"),
("mandatory_location_choice", "Mandatory Location Choice"),
- ("shadow_pricing", "Shadow Pricing"),
+ ("shadow_pricing", "Employment\\Enrollment Match By Geography"),
("tour_skims", "Tour Skims"),
("trip_skims", "Trip Skims"),
("tour_purpose", "Tour Purpose"),
diff --git a/tests/conftest.py b/tests/conftest.py
index 990c927..23644e8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,28 +1,27 @@
from __future__ import annotations
-from pathlib import Path
-import re
-from uuid import uuid4
-
import pytest
-def _slugify(value: str) -> str:
- slug = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
- return slug or "test"
-
-
-@pytest.fixture
-def tmp_path(request: pytest.FixtureRequest) -> Path:
- """Provide a workspace-local temp directory.
-
- The default pytest temp root is not reliably accessible in this Windows
- environment, so tests use a repo-local directory instead.
- """
-
- path = (
- Path("tmp_export_test_artifacts")
- / f"{_slugify(request.node.name)}_{uuid4().hex}"
+@pytest.fixture(scope="session")
+def representative_full_export_html(
+ tmp_path_factory: pytest.TempPathFactory,
+) -> str:
+ """Build the immutable all-page export once for cross-cutting assertions."""
+ from dashboard.export.html import build_export_html_document
+ from test_export_html import _full_summary_run, _write_config
+
+ path = tmp_path_factory.mktemp("full_export")
+ config = _write_config(
+ path,
+ export_html_lines=[
+ "dashboard:",
+ " weighting: all",
+ " values: all",
+ ],
+ )
+ return build_export_html_document(
+ [],
+ config,
+ summary_runs=[_full_summary_run()],
)
- path.mkdir(parents=True, exist_ok=True)
- return path
diff --git a/tests/test_calculation_notes.py b/tests/test_calculation_notes.py
new file mode 100644
index 0000000..7e3c06c
--- /dev/null
+++ b/tests/test_calculation_notes.py
@@ -0,0 +1,268 @@
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+import sys
+
+import panel as pn
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from dashboard.app import build_dashboard
+from dashboard.calculation_notes import (
+ CALCULATION_NOTE_STYLESHEET,
+ CalculationNote,
+ calculation_note,
+ get_calculation_note,
+ load_calculation_notes,
+ render_calculation_note_html,
+)
+from dashboard.export.html import build_export_html_document
+from dashboard.export.serializer import serialize_viewable
+from dashboard.page_base import DashboardPage, RegisteredPageSection
+from test_export_html import _full_summary_run, _write_config
+
+
+def test_calculation_note_registry_covers_every_plot_and_table_note_reference() -> None:
+ notes = load_calculation_notes()
+ page_root = Path("dashboard/pages")
+ referenced_note_ids: set[str] = set()
+ note_call_count = 0
+
+ for page_path in page_root.rglob("*.py"):
+ tree = ast.parse(page_path.read_text(encoding="utf-8"), filename=str(page_path))
+ for node in ast.walk(tree):
+ if (
+ isinstance(node, ast.Constant)
+ and isinstance(node.value, str)
+ and node.value in notes
+ ):
+ referenced_note_ids.add(node.value)
+ if not isinstance(node, ast.Call):
+ continue
+ function_name = (
+ node.func.attr
+ if isinstance(node.func, ast.Attribute)
+ else node.func.id
+ if isinstance(node.func, ast.Name)
+ else None
+ )
+ if function_name in {"noted_view", "noted_section", "section_note"}:
+ note_call_count += 1
+
+ assert note_call_count >= 80
+ assert len(referenced_note_ids) >= 80
+ for note_id in referenced_note_ids:
+ note = notes[note_id]
+ assert note.summary
+ assert note.method_explanation
+ assert note.method_text
+ assert note.sources
+ assert get_calculation_note("vmt.personal_auto").formula == (
+ "trip distance × final trip weight ÷ vehicle occupancy"
+ )
+
+
+def test_calculation_note_renderer_escapes_configured_text() -> None:
+ note = CalculationNote(
+ note_id="test.note",
+ label="How works",
+ summary="Uses A & B.",
+ formula="A < B",
+ details=(("Included", ("One ",)),),
+ method_explanation="Generic grouping text that should stay internal.",
+ method_text="Join by their shared key.",
+ sources=("source_one",),
+ source_filters=("Only A & B.",),
+ )
+
+ rendered = render_calculation_note_html(note)
+
+ assert rendered.startswith(
+ ""
+ )
+ assert "
Uses A & B.
" in rendered
+ assert "How <this> works" in rendered
+ assert "
A < B" in rendered
+ assert "Join <records> by their shared key." in rendered
+ assert "Generic grouping text" not in rendered
+ assert "Only A & B." in rendered
+ assert "source_one" in rendered
+ assert "Summary Tables Used:" in rendered
+ assert "Prepared summaries used:" not in rendered
+ assert "One <record>" not in rendered
+ assert rendered.endswith("
")
+
+
+def test_calculation_note_is_collapsed_html_pane_exported_without_conversion() -> None:
+ pane = calculation_note("traffic.link_volume")
+
+ assert isinstance(pane, pn.pane.HTML)
+ assert "data-calculation-note-id='traffic.link_volume'" in pane.object
+ assert "Notes" in pane.object
+ assert " open" not in pane.object
+
+ payload = serialize_viewable(pane, disable_widgets=False)
+ assert payload["kind"] == "html"
+ assert payload["html"] == pane.object
+ assert "facility-type volume" in payload["html"]
+
+
+def test_noted_section_associates_note_with_registered_visualization() -> None:
+ page = DashboardPage.__new__(DashboardPage)
+ page.name = "Test Page"
+ visualization = pn.Column()
+ page._registered_sections = {
+ "test_section": RegisteredPageSection(
+ section_id="test_section",
+ container=visualization,
+ selector_ids=(),
+ export=True,
+ export_data_mode="none",
+ render=lambda: [],
+ )
+ }
+
+ wrapper = page.noted_section("overview.demographics", visualization)
+
+ assert wrapper.objects[0] is visualization
+ assert wrapper._calculation_note_id == "overview.demographics"
+ assert wrapper._calculation_note_section_id == "test_section"
+ assert wrapper.objects[1]._calculation_note_target_id == id(visualization)
+
+
+def test_noted_view_places_one_note_directly_below_one_visualization() -> None:
+ page = DashboardPage.__new__(DashboardPage)
+ visualization = pn.pane.Markdown("A plot or table")
+
+ wrapper = page.noted_view("overview.person_type", visualization)
+
+ assert wrapper.objects[0] is visualization
+ assert wrapper._calculation_note_id == "overview.person_type"
+ assert wrapper.objects[1]._calculation_note_target_id == id(visualization)
+
+
+def test_all_built_dashboard_notes_follow_their_visualization(tmp_path: Path) -> None:
+ config = _write_config(tmp_path, dashboard_pages=None)
+ template = build_dashboard([], config, summary_runs=[_full_summary_run()])
+ visited: set[int] = set()
+
+ def assert_note_order(container: pn.viewable.Viewable) -> None:
+ if id(container) in visited:
+ return
+ visited.add(id(container))
+ children = list(getattr(container, "objects", []) or [])
+ positions = {id(child): index for index, child in enumerate(children)}
+ for index, child in enumerate(children):
+ target_id = getattr(child, "_calculation_note_target_id", None)
+ if target_id == id(container):
+ assert index == len(children) - 1
+ elif target_id is not None:
+ assert target_id in positions
+ assert positions[target_id] < index
+ if isinstance(child, pn.viewable.Viewable):
+ assert_note_order(child)
+
+ for page in template._dashboard_pages:
+ assert_note_order(page.view)
+
+ overview_page = next(
+ page for page in template._dashboard_pages if page.page_id() == "overview"
+ )
+ kpi_wrapper, comparison_heading, comparison_wrapper = (
+ overview_page._kpi_section.objects
+ )
+ assert kpi_wrapper._calculation_note_id == "overview.kpis"
+ assert kpi_wrapper.objects[1]._calculation_note_target_id == id(
+ kpi_wrapper.objects[0]
+ )
+ assert comparison_heading.object == "### Percent Difference vs Base Run"
+ assert comparison_wrapper._calculation_note_id == "overview.percent_difference"
+
+
+def test_note_summary_and_specific_method_are_both_inside_collapsible_control() -> None:
+ rendered = render_calculation_note_html(get_calculation_note("overview.person_type"))
+
+ assert "This plot sums person weights by person type." in rendered
+ assert rendered.index(""
+ )
+ assert "Notes
" in rendered
+ assert "Method:" in rendered
+ assert ".calculation-note-summary" in CALCULATION_NOTE_STYLESHEET
+ assert "color: inherit;" in CALCULATION_NOTE_STYLESHEET
+ assert ".calculation-note-content .calculation-note-formula" in (
+ CALCULATION_NOTE_STYLESHEET
+ )
+ assert "margin-top: 8px;" in CALCULATION_NOTE_STYLESHEET
+
+
+def test_complex_note_uses_visualization_specific_method_text() -> None:
+ rendered = render_calculation_note_html(get_calculation_note("vmt.personal_auto"))
+
+ assert "Method:" in rendered
+ assert "shared-ride passenger records" in rendered
+ assert "calculates VMT at the trip or network-link level" not in rendered
+
+
+def test_calculation_notes_can_be_disabled_for_page_views() -> None:
+ page = DashboardPage.__new__(DashboardPage)
+ page.config = type("Config", (), {"include_notes": False})()
+ visualization = pn.pane.Markdown("A plot or table")
+
+ assert page.noted_view("overview.person_type", visualization) is visualization
+
+
+def test_include_notes_config_defaults_on_and_can_disable_export(
+ tmp_path: Path,
+) -> None:
+ default_config = _write_config(tmp_path / "default", dashboard_pages=["overview"])
+ disabled_config = _write_config(
+ tmp_path / "disabled",
+ dashboard_pages=["overview"],
+ extra_lines=["dashboard:", " include_notes: false"],
+ )
+
+ assert default_config.include_notes is True
+ assert disabled_config.include_notes is False
+
+ html = build_export_html_document(
+ [], disabled_config, summary_runs=[_full_summary_run()]
+ )
+ assert "data-calculation-note-id" not in html
+
+
+def test_export_hides_note_wrapper_when_its_visualization_part_is_disabled() -> None:
+ visualization = pn.Column(pn.pane.Markdown("A plot or table"))
+ note_wrapper = pn.Column(
+ visualization,
+ calculation_note("overview.demographics"),
+ css_classes=["calculation-note-view"],
+ )
+ page = pn.Column(note_wrapper, pn.pane.Markdown("Still visible"))
+
+ payload = serialize_viewable(
+ page,
+ disable_widgets=False,
+ hidden_view_ids={id(visualization)},
+ )
+
+ assert payload["child_count"] == 1
+ assert payload["children"] == [{"kind": "html", "html": "Still visible
"}]
+
+
+def test_export_hides_adjacent_note_when_its_visualization_part_is_disabled() -> None:
+ visualization = pn.Column(pn.pane.Markdown("A plot or table"))
+ note = calculation_note("trip_skims.summary_table")
+ note._calculation_note_target_id = id(visualization)
+ page = pn.Column(visualization, note, pn.pane.Markdown("Still visible"))
+
+ payload = serialize_viewable(
+ page,
+ disable_widgets=False,
+ hidden_view_ids={id(visualization)},
+ )
+
+ assert payload["child_count"] == 1
+ assert payload["children"] == [{"kind": "html", "html": "Still visible
"}]
diff --git a/tests/test_config_refactor_phase1.py b/tests/test_config_refactor_phase1.py
index acb2bb2..79778c8 100644
--- a/tests/test_config_refactor_phase1.py
+++ b/tests/test_config_refactor_phase1.py
@@ -44,6 +44,42 @@ def _write_config(tmp_path: Path, lines: list[str]) -> Config:
return Config.from_yaml(config_path)
+def test_summary_failure_policy_defaults_to_record_and_accepts_error(
+ tmp_path: Path,
+) -> None:
+ default_config = _write_config(tmp_path / "default", [])
+ strict_config = _write_config(
+ tmp_path / "strict",
+ ["summarize:", " failure_policy: error"],
+ )
+
+ assert default_config.summary_failure_policy == "record"
+ assert strict_config.summary_failure_policy == "error"
+
+
+def test_summary_failure_policy_rejects_unknown_values(tmp_path: Path) -> None:
+ with pytest.raises(
+ ValueError,
+ match="summarize.failure_policy must be either 'record' or 'error'",
+ ):
+ _write_config(
+ tmp_path,
+ ["summarize:", " failure_policy: keep-going"],
+ )
+
+
+def test_skimjoin_failure_policy_is_normalized_without_enabling_skimjoin(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ ["skimjoin:", " failure_policy: error"],
+ )
+
+ assert config.skimjoin.enabled is False
+ assert config.skimjoin.failure_policy == "error"
+
+
def test_new_config_layout_normalizes_to_existing_runtime_fields(tmp_path: Path) -> None:
skim_path = tmp_path / "override.omx"
network_los_path = tmp_path / "network_los.yaml"
@@ -177,340 +213,93 @@ def test_new_config_layout_normalizes_to_existing_runtime_fields(tmp_path: Path)
assert config.segmentation.dashboard.visibility == "segments_only"
assert config.skim_file == str(skim_path.name)
assert config.skim_matrix == "SOV_TIME__EA"
- assert config.skimjoin.enabled is True
+ assert config.skimjoin_step_enabled() is True
assert config.skimjoin.config_path == str(skimjoin_config_path.resolve())
assert config.skimjoin.resolved_skim_files == (str(skim_path.resolve()),)
assert config.skimjoin.resolved_network_los_file == str(network_los_path.resolve())
-def test_legacy_distance_skim_locations_still_normalize_to_prepare_distance_skim(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
-) -> None:
- skim_path = tmp_path / "legacy.omx"
- network_los_path = tmp_path / "network_los.yaml"
- skimjoin_config_path = tmp_path / "skimjoin.yaml"
- _write_omx(skim_path, matrix_name="SOV_DIST__MD")
- _write_network_los(network_los_path)
- skimjoin_config_path.write_text(
- "\n".join(
- [
- "project:",
- f" skim_files: [{skim_path.name}]",
- f" network_los_file: {network_los_path.name}",
- "activitysim:",
- " trip_mode_column: trip_mode",
- " trip_id_column: trip_id",
- " tour_id_column: tour_id",
- " outbound_column: outbound",
- "dimensions:",
- " PERIOD:",
- " source_columns:",
- " trip_source_column: depart",
- " outbound_tour_source_column: start",
- " inbound_tour_source_column: first_inbound_trip_depart",
- " values_from_network_los: true",
- "modes:",
- " SOV:",
- " distance:",
- " matrix: SOV_DIST__{PERIOD}",
- ]
- ),
- encoding="utf-8",
- )
-
- config = _write_config(
- tmp_path,
- [
- 'name: "Legacy Distance Skim"',
- "skimjoin:",
- " defaults:",
- f" config_path: {skimjoin_config_path.name}",
- f" skim_files: [{skim_path.name}]",
- f" network_los_file: {network_los_path.name}",
- " distance_skim:",
- f" file: {skim_path.name}",
- " matrix: SOV_DIST__MD",
- "runs: []",
- ],
- )
-
- assert config.skim_file == str(skim_path.name)
- assert config.skim_matrix == "SOV_DIST__MD"
- captured = capsys.readouterr()
- combined_output = caplog.text + captured.err + captured.out
- assert "skimjoin.distance_skim" in combined_output
- assert "prepare.distance_skim" in combined_output
-
-
-def test_new_keys_take_precedence_over_legacy_equivalents_and_warn(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
-) -> None:
- config = _write_config(
- tmp_path,
- [
- 'name: "Precedence Test"',
- "root: new_root",
- "log_level: error",
- "processor:",
- " root: old_root",
- " summaries:",
- " weighting_modes: [unweighted]",
- "summaries:",
- " root: older_root",
- " weighting_modes: [weighted, unweighted]",
- "visualizer:",
- ' dashboard_title: "Legacy Dashboard"',
- " log_level: info",
- " run_colors: ['#aaaaaa']",
- "dashboard:",
- ' title: "New Dashboard"',
- "display:",
- " run_colors:",
- ' - "#222222"',
- "summarize:",
- " weighting_modes: [weighted]",
- "runs: []",
- ],
- )
-
- assert config.summary_root == str((tmp_path / "new_root").resolve())
- assert config.log_level == "ERROR"
- assert config.dashboard_title == "New Dashboard"
- assert config.weighting_modes == ["weighted"]
- assert config.run_colors == ["#222222"]
- captured = capsys.readouterr()
- combined_output = caplog.text + captured.err + captured.out
- assert "processor.root" in combined_output
- assert "summaries.root" in combined_output
- assert "visualizer.log_level" in combined_output
- assert "visualizer.dashboard_title" in combined_output
- assert "processor.summaries.weighting_modes" in combined_output
- assert "visualizer.run_colors" in combined_output
- assert "Deprecated config keys were detected. Prefer the canonical schema:" in combined_output
-
-
@pytest.mark.parametrize(
("lines", "message"),
[
+ (["summaries: {}"], "summaries: Use root and summarize"),
+ (["visualizer: {}"], "visualizer: Use dashboard and display"),
+ (["processor: {}"], "processor: Use root, prepare, and summarize"),
+ (["dashboard:", " pages: []"], "dashboard.pages: Use dashboard.live.pages"),
+ (["segment:", " enabled: true"], "segment.enabled: Use pipeline.steps"),
(
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [prepare, prepare]",
- "runs: []",
- ],
- "duplicate step",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [prepare, publish]",
- "runs: []",
- ],
- "unsupported step",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [skimjoin, summarize]",
- "runs: []",
- ],
- "without 'prepare'",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [segment, dashboard]",
- "runs: []",
- ],
- "without 'summarize'",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [dashboard, summarize]",
- "runs: []",
- ],
- "place 'dashboard' last",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [summarize]",
- " dashboard_mode: deploy",
- "runs: []",
- ],
- "dashboard_mode",
- ),
- (
- [
- 'name: "Invalid Pipeline"',
- "pipeline:",
- " steps: [summarize]",
- " overwrite: maybe",
- "runs: []",
- ],
- "pipeline.overwrite",
+ ["skimjoin:", " config_path: skimjoin.yaml"],
+ "skimjoin.config_path: Use skimjoin.defaults.config_path",
),
],
)
-def test_pipeline_validation_rejects_invalid_configurations(
+def test_removed_config_keys_name_the_canonical_replacement(
tmp_path: Path,
lines: list[str],
message: str,
) -> None:
with pytest.raises(ValueError, match=message):
- _write_config(tmp_path, lines)
-
+ _write_config(tmp_path, [*lines, "runs: []"])
-def test_equivalent_old_and_new_configs_produce_matching_runtime_values_and_digests(
- tmp_path: Path,
-) -> None:
- shared_root = str((tmp_path / "shared_cache").resolve()).replace("\\", "/")
- old_config = _write_config(
- tmp_path / "old",
- [
- 'name: "Equivalent Config"',
- "summaries:",
- f" root: {shared_root}",
- " weighting_modes: [weighted]",
- "visualizer:",
- ' dashboard_title: "Equivalent Dashboard"',
- " log_level: warning",
- " dashboard_pages:",
- " - overview",
- " run_colors:",
- ' - "#333333"',
- "dashboard_labels:",
- " mode:",
- " mapping:",
- " WALK: Walk",
- "runs: []",
- ],
- )
- new_config = _write_config(
- tmp_path / "new",
- [
- 'name: "Equivalent Config"',
- f"root: {shared_root}",
- "log_level: warning",
- "dashboard:",
- ' title: "Equivalent Dashboard"',
- " live:",
- " pages:",
- " - overview",
- "display:",
- " labels:",
- " mode:",
- " mapping:",
- " WALK: Walk",
- " run_colors:",
- ' - "#333333"',
- "summarize:",
- " weighting_modes: [weighted]",
- "runs: []",
- ],
- )
- assert old_config.summary_root == new_config.summary_root
- assert old_config.log_level == new_config.log_level
- assert old_config.dashboard_title == new_config.dashboard_title
- assert [entry.page_id for entry in old_config.dashboard_pages or []] == [
- entry.page_id for entry in new_config.dashboard_pages or []
- ]
- assert old_config.run_colors == new_config.run_colors
- assert old_config.weighting_modes == new_config.weighting_modes
- assert old_config.prepare_config_digest == new_config.prepare_config_digest
- assert old_config.summary_config_digest == new_config.summary_config_digest
- assert (
- old_config.presentation_config_digest
- == new_config.presentation_config_digest
- )
+def test_unknown_top_level_config_key_is_rejected(tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="Unknown top-level config keys: 'dashbaord'"):
+ _write_config(tmp_path, ["dashbaord: {}", "runs: []"])
-def test_dashboard_live_pages_take_precedence_over_dashboard_pages_and_warn(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
-) -> None:
+def test_dashboard_host_placeholder_is_validated_and_ignored(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
[
- 'name: "Dashboard Live Pages Precedence"',
+ "pipeline:",
+ " steps: [dashboard]",
+ " dashboard_mode: live",
"dashboard:",
- " pages:",
- " - overview",
- " live:",
- " pages:",
- " - trip_mode",
+ " host:",
+ " account: example-account",
+ " app_id: 12345",
+ " title: Example Dashboard",
+ " verify: true",
"runs: []",
],
)
- assert [entry.page_id for entry in config.dashboard_pages or []] == ["trip_mode"]
- captured = capsys.readouterr()
- combined_output = caplog.text + captured.err + captured.out
- assert "dashboard.pages" in combined_output
- assert "dashboard.live.pages" in combined_output
-
+ assert config.pipeline.dashboard_mode == "live"
+ assert not hasattr(config, "host")
-def test_summarize_grouping_flags_take_precedence_over_top_level_and_warn(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
-) -> None:
- config = _write_config(
- tmp_path,
- [
- 'name: "Summarize Grouping Precedence"',
- "group_joint_tour_purposes: false",
- "group_atwork_tour_purposes: false",
- "group_school_tour_purposes: false",
- "summarize:",
- " group_joint_tour_purposes: true",
- " group_atwork_tour_purposes: true",
- " group_school_tour_purposes: true",
- "runs: []",
- ],
- )
- assert config.group_joint_tour_purposes is True
- assert config.group_atwork_tour_purposes is True
- assert config.group_school_tour_purposes is True
- captured = capsys.readouterr()
- combined_output = caplog.text + captured.err + captured.out
- assert "group_joint_tour_purposes" in combined_output
- assert "summarize.group_joint_tour_purposes" in combined_output
+def test_dashboard_host_placeholder_rejects_unknown_fields(tmp_path: Path) -> None:
+ with pytest.raises(
+ ValueError,
+ match="Unknown dashboard.host config keys: 'acount'",
+ ):
+ _write_config(
+ tmp_path,
+ [
+ "dashboard:",
+ " host:",
+ " acount: typo",
+ "runs: []",
+ ],
+ )
-def test_summarize_pnr_tour_modes_take_precedence_over_legacy_modes_and_warn(
+@pytest.mark.parametrize(
+ ("lines", "message"),
+ [
+ (["pipeline:", " steps: [prepare, prepare]"], "duplicate step"),
+ (["pipeline:", " steps: [prepare, publish]"], "unsupported step"),
+ (["pipeline:", " steps: [skimjoin, summarize]"], "without 'prepare'"),
+ (["pipeline:", " steps: [segment, dashboard]"], "without 'summarize'"),
+ (["pipeline:", " steps: [dashboard, summarize]"], "place 'dashboard' last"),
+ (["pipeline:", " dashboard_mode: deploy"], "dashboard_mode"),
+ (["pipeline:", " overwrite: maybe"], "pipeline.overwrite"),
+ ],
+)
+def test_pipeline_validation_rejects_invalid_configurations(
tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
+ lines: list[str],
+ message: str,
) -> None:
- config = _write_config(
- tmp_path,
- [
- 'name: "PNR Mode Precedence"',
- "modes:",
- " pnr_tour_modes: [PNR_LOCAL]",
- "summarize:",
- " pnr_tour_modes: [PNR_PREMIUM]",
- "runs: []",
- ],
- )
-
- assert config.pnr_tour_modes == ["PNR_PREMIUM"]
- captured = capsys.readouterr()
- combined_output = caplog.text + captured.err + captured.out
- assert "modes.pnr_tour_modes" in combined_output
- assert "summarize.pnr_tour_modes" in combined_output
+ with pytest.raises(ValueError, match=message):
+ _write_config(tmp_path, [*lines, "runs: []"])
diff --git a/tests/test_dashboard_helpers_phase1.py b/tests/test_dashboard_helpers_phase1.py
index c48b1ab..fa9da15 100644
--- a/tests/test_dashboard_helpers_phase1.py
+++ b/tests/test_dashboard_helpers_phase1.py
@@ -5,11 +5,16 @@
import panel as pn
import polars as pl
+import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
-from dashboard.components import bar_chart
+from dashboard.rendering import (
+ column_titles,
+ data_table,
+)
+from dashboard.data_access import RunTables
from dashboard.helpers.category_helpers import (
column_value_intersection,
common_column_options,
@@ -18,13 +23,20 @@
numeric_like_sort_expr,
)
from dashboard.helpers.comparison_helpers import (
+ build_ab_comparison_row,
+ build_ab_comparison_table,
build_base_run_percent_difference_table,
format_percent_error_table,
weighted_average_lookup,
)
from dashboard.helpers.geography_helpers import (
+ all_within_geography_type_label,
+ export_geography_name_options,
geography_level_options,
+ geography_name_options_for_type,
+ geography_name_selector_label,
geography_options_for_level,
+ geography_type_options,
normalize_geography_columns,
export_geography_options,
filter_geography_level,
@@ -43,10 +55,99 @@
timebin_label,
)
from dashboard.page_base import DashboardPage
+from dashboard.pages.trip_summaries.parking_location import parking_scatter_data
from dashboard.state import DashboardState
+from processor.models import RunData
from test_export_html import _full_summary_run, _write_config
+def test_run_table_view_filters_transforms_and_joins_by_run_label() -> None:
+ counts = RunTables.from_runs(
+ [
+ ("Base", pl.DataFrame({"direction": ["outbound", "inbound"], "n": [2, 3]})),
+ ("Build", pl.DataFrame({"direction": ["outbound"], "n": [5]})),
+ ]
+ )
+ totals = RunTables.from_runs(
+ [
+ ("Base", pl.DataFrame({"direction": ["outbound"], "total": [10]})),
+ ("Build", pl.DataFrame({"direction": ["outbound"], "total": [20]})),
+ ]
+ )
+
+ result = (
+ counts.where(direction="outbound")
+ .join(totals, on="direction")
+ .with_columns((pl.col("n") / pl.col("total") * 100).alias("pct"))
+ .select("direction", "pct")
+ )
+
+ assert result.values("direction") == ["outbound"]
+ assert [frame["pct"][0] for _, frame in result] == [20.0, 25.0]
+
+ empty_build = counts.where(direction="inbound")
+ assert [label for label, _ in empty_build] == ["Base", "Build"]
+ assert empty_build[1][1].is_empty()
+
+ complete = RunTables.from_runs(
+ [
+ ("Base", pl.DataFrame({"id": [1], "value": [2]})),
+ ("Build", pl.DataFrame({"id": [2]})),
+ ]
+ ).requiring("id", "value")
+ assert [label for label, _ in complete] == ["Base"]
+ assert [label for label, _ in empty_build.drop_empty()] == ["Base"]
+
+ outer = RunTables.from_runs(
+ [("Base", pl.DataFrame({"id": [1], "left": [10]}))]
+ ).join(
+ RunTables.from_runs(
+ [("Base", pl.DataFrame({"id": [2], "right": [20]}))]
+ ),
+ on="id",
+ how="full",
+ coalesce=True,
+ )
+ assert outer[0][1].sort("id")["id"].to_list() == [1, 2]
+
+
+def test_parking_query_joins_summary_and_prepared_tables_by_run() -> None:
+ empty = pl.DataFrame()
+ prepared = RunData(
+ label="Base",
+ run_dir="base",
+ skim_file=None,
+ hh=empty,
+ per=empty,
+ tours=empty,
+ trips=empty,
+ joint_participants=empty,
+ land_use=pl.DataFrame({"MAZ": [1, 2], "PRKSPACES": [10, 20]}),
+ skim_matrix=None,
+ )
+ summaries = [
+ (
+ "Base",
+ pl.DataFrame(
+ {
+ "geography_type": ["maz", "maz"],
+ "geography_id": ["1", "3"],
+ "trip_count": [4, 6],
+ }
+ ),
+ )
+ ]
+
+ result = parking_scatter_data(summaries, [("Base", prepared.land_use)])
+
+ assert [label for label, _ in result] == ["Base"]
+ assert result[0][1].to_dict(as_series=False) == {
+ "geography_id": ["1", "2", "3"],
+ "parking_capacity": [10.0, 20.0, 0.0],
+ "trip_count": [4.0, 0.0, 6.0],
+ }
+
+
def test_category_helpers_support_intersection_normalization_and_numeric_sort(
tmp_path: Path,
) -> None:
@@ -68,7 +169,7 @@ def test_category_helpers_support_intersection_normalization_and_numeric_sort(
)
completed = complete_category_counts(
[("Base", pl.DataFrame({"bin": ["10", "40+"], "count": [2, 1]}))],
- category_col="bin",
+ category="bin",
category_values=["2", "10", "40+"],
value_cols=("count",),
)
@@ -92,14 +193,35 @@ def test_geography_helpers_normalize_and_build_options(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
geography_lines=["enabled: true", "landuse_col: COUNTY"],
+ extra_lines=[
+ "display:",
+ " labels:",
+ " geography:",
+ " mapping:",
+ " all_geographies: All Geographies",
+ " home_county: County",
+ " district: District",
+ ],
)
summary = [
(
"Base",
pl.DataFrame(
{
- "geography_type": ["all_geographies", "district", "district"],
- "geography_id": ["all_geographies", "Urban", "Suburban"],
+ "geography_type": [
+ "all_geographies",
+ "district",
+ "district",
+ "home_county",
+ "home_county",
+ ],
+ "geography_id": [
+ "all_geographies",
+ "Urban",
+ "Suburban",
+ "Wake",
+ "Durham",
+ ],
}
),
)
@@ -118,17 +240,48 @@ def test_geography_helpers_normalize_and_build_options(tmp_path: Path) -> None:
normalized = normalize_geography_columns(summary[0][1])
geo_levels = geography_level_options(summary, flow_summary, config=config)
+ geo_type_options, geo_type_raw_by_label = geography_type_options(
+ summary,
+ flow_summary,
+ config=config,
+ include_all_types=True,
+ )
+ county_options, county_raw_by_label = geography_name_options_for_type(
+ "home_county",
+ summary,
+ config=config,
+ )
district_options = geography_options_for_level("district", summary, config=config)
flattened = export_geography_options(
{"district": district_options, "all_geographies": ["All Geographies"]},
config=config,
)
+ flattened_display, flattened_raw_by_label = export_geography_name_options(
+ {
+ "home_county": (county_options, county_raw_by_label),
+ },
+ config=config,
+ )
filtered = filter_geography_level(summary, "district")
assert {"geography_level", "geography"}.issubset(normalized.columns)
- assert geo_levels == ["All Geographies", "district"]
+ assert geo_levels == ["All Geography Types", "County", "District"]
+ assert geo_type_options == [
+ "All Geography Types",
+ "County",
+ "District",
+ ]
+ assert geo_type_raw_by_label["All Geography Types"] == "all_geographies"
+ assert geo_type_raw_by_label["County"] == "home_county"
+ assert all_within_geography_type_label("home_county", config=config) == "All Counties"
+ assert geography_name_selector_label("home_county", config=config) == "County Name"
+ assert county_options == ["All Counties", "Durham", "Wake"]
+ assert county_raw_by_label["All Counties"] == "All"
+ assert county_raw_by_label["Wake"] == "Wake"
assert district_options == ["All", "Suburban", "Urban"]
assert flattened == ["All", "Suburban", "Urban"]
+ assert flattened_display == ["All", "Durham", "Wake"]
+ assert flattened_raw_by_label["Wake"] == "Wake"
assert filtered[0][1]["geography_id"].to_list() == ["Urban", "Suburban"]
@@ -214,7 +367,7 @@ def test_comparison_helpers_format_and_build_comparison_tables() -> None:
"tour_count": [1.0, 3.0, 2.0],
}
),
- category_col="purpose",
+ category="purpose",
average_col="average_distance",
weight_col="tour_count",
)
@@ -243,30 +396,104 @@ def test_comparison_helpers_format_and_build_comparison_tables() -> None:
assert renamed_table.columns == ["Metric", "Reference (Base Run)", "Build"]
-def test_bar_chart_omits_pct_hover_lines() -> None:
- chart = bar_chart(
+def test_ab_comparison_helper_formats_difference_columns() -> None:
+ table = build_ab_comparison_table(
+ [
+ build_ab_comparison_row(
+ keys={"Metric": "Tours"},
+ quantity_a=110.0,
+ quantity_b=100.0,
+ quantity_a_column="Build Value",
+ quantity_b_column="Base Value",
+ ),
+ build_ab_comparison_row(
+ keys={"Metric": "Trips"},
+ quantity_a=45.0,
+ quantity_b=0.0,
+ quantity_a_column="Build Value",
+ quantity_b_column="Base Value",
+ ),
+ build_ab_comparison_row(
+ keys={"Metric": "Distance"},
+ quantity_a=None,
+ quantity_b=10.0,
+ quantity_a_column="Build Value",
+ quantity_b_column="Base Value",
+ ),
+ ],
+ key_columns=["Metric"],
+ quantity_a_column="Build Value",
+ quantity_b_column="Base Value",
+ )
+
+ assert table.to_dicts() == [
+ {
+ "Metric": "Tours",
+ "Build Value": 110.0,
+ "Base Value": 100.0,
+ "Difference": 10.0,
+ "% Difference": "10.00%",
+ },
+ {
+ "Metric": "Trips",
+ "Build Value": 45.0,
+ "Base Value": 0.0,
+ "Difference": 45.0,
+ "% Difference": "",
+ },
+ {
+ "Metric": "Distance",
+ "Build Value": None,
+ "Base Value": 10.0,
+ "Difference": None,
+ "% Difference": "",
+ },
+ ]
+
+
+def test_data_table_drops_index_columns_and_hides_pandas_index() -> None:
+ table = data_table(
[
(
- "Base",
+ "Run",
pl.DataFrame(
{
- "mode": ["Walk"],
- "trip_count": [5.0],
- "pct": [100.0],
+ "index": [0],
+ "__index_level_0__": [99],
+ "metric": ["Tours"],
+ "value": [10.0],
}
),
)
- ],
- x_col="mode",
- y_col="trip_count",
- pct_col="pct",
- xaxis_categoryarray=["Walk", "Bike"],
+ ]
)
+ tabulator = table.objects[0]
+
+ assert tabulator.show_index is False
+ assert tabulator.value.columns.tolist() == ["metric", "value"]
+ assert tabulator.titles == {"metric": "Metric", "value": "Value"}
- hover = list(chart.object.data[0].customdata)
- assert "Pct:" not in hover[0]
- assert "Pct:" not in hover[1]
+def test_column_titles_for_display_humanizes_machine_column_names() -> None:
+ titles = column_titles(
+ [
+ "id",
+ "facility_type",
+ "From_Node",
+ "auto_vmt",
+ "pm_vol",
+ "nonmandatory_tour_purpose",
+ ]
+ )
+
+ assert titles == {
+ "id": "ID",
+ "facility_type": "Facility Type",
+ "From_Node": "From Node",
+ "auto_vmt": "Auto VMT",
+ "pm_vol": "PM Volume",
+ "nonmandatory_tour_purpose": "Non-Mandatory Tour Purpose",
+ }
def test_dashboard_page_phase1_convenience_helpers(tmp_path: Path) -> None:
@@ -278,11 +505,11 @@ def test_dashboard_page_phase1_convenience_helpers(tmp_path: Path) -> None:
class ProbePage(DashboardPage):
def __init__(self) -> None:
- super().__init__("Probe", state, config)
+ super().__init__(state, config)
self.view = pn.Column()
def _refresh(self) -> None:
- self.summary_dict = self.optional_summaries_dict(
+ self.summary_dict = self.data.summaries(
"population_totals",
"missing_summary",
)
@@ -296,5 +523,5 @@ def _refresh(self) -> None:
assert isinstance(no_runs, pn.pane.Markdown)
assert no_runs.object == "No runs loaded."
assert unavailable.title == "Data Not Available"
- assert page.summary_dict["population_totals"] is not None
- assert page.summary_dict["missing_summary"] is None
+ assert page.summary_dict["population_totals"]
+ assert not page.summary_dict["missing_summary"]
diff --git a/tests/test_dashboard_live.py b/tests/test_dashboard_live.py
index 5a182a7..583d098 100644
--- a/tests/test_dashboard_live.py
+++ b/tests/test_dashboard_live.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import importlib
-import logging
import pkgutil
from pathlib import Path
import sys
@@ -22,109 +21,2032 @@
from test_export_html import _full_summary_run, _write_config
from dashboard.app import build_dashboard
from dashboard.data_access import DashboardPreparedRunProvider
+from dashboard.export.selector_states import resolve_export_section_states
from dashboard.page_base import DashboardPage
from dashboard.page_base import PAGE_SELECTOR_STYLESHEET
from dashboard.page_definitions import DashboardPageDefinition
from dashboard.pages.skim_summaries.trip_skims import TripSkimsPage
from dashboard.pages.skim_summaries.tour_skims import TourSkimsPage
+from dashboard.pages.long_term_choices.mandatory_location_choice import (
+ MandatoryLocationChoicePage,
+)
+from dashboard.pages.tour_summaries.tour_distance import TourDistancePage
from dashboard.pages.trip_summaries.trip_mode import TripModePage
+from dashboard.pages.validation.regional import (
+ RegionalValidationPage,
+ flow_comparison_data,
+ flow_heatmap,
+ normalize_flow_matrix,
+)
+from dashboard.pages.validation.traffic import (
+ demo_count_scatter_data,
+ demo_count_scatter_data_from_sources,
+ demo_facility_comparison_table,
+ demo_count_fit_line_data,
+ demo_link_aggregate_data,
+ demo_volume_comparison_table,
+)
+from dashboard.pages.validation.vmt import (
+ NON_MOTORIZED_VMT_SUMMARY_ID,
+ PERSONAL_AUTO_VMT_SUMMARY_ID,
+ VMTValidationPage,
+ demo_commercial_filter_options,
+ demo_commercial_vehicle_chart_data,
+ external_travel_chart_data,
+ external_travel_filter_options,
+ personal_auto_vmt_chart_data,
+ wide_tod_chart_data,
+)
import dashboard.pages as dashboard_pages_package
from dashboard.page_registry import (
+ _validate_page_definition,
all_page_definitions,
data_requirements_for_pages,
default_page_definitions,
enabled_prepared_data_mode,
page_definition_by_id,
- resolve_page_definitions,
+ resolve_live_page_definitions,
)
from dashboard.state import DashboardState
from processor.models import RunData
-from processor.summarize.cache import SUMMARY_SPEC_BY_ID
+from processor.summarize.cache_types import create_summary_run
+from processor.summarize.catalog import SUMMARY_BY_ID
+
+
+def _collect_tabulators(viewable) -> list[pn.widgets.Tabulator]:
+ tables: list[pn.widgets.Tabulator] = []
+ if isinstance(viewable, pn.widgets.Tabulator):
+ return [viewable]
+ for child in getattr(viewable, "objects", []):
+ tables.extend(_collect_tabulators(child))
+ return tables
+
+
+def _raw_trip_run() -> RunData:
+ return RunData(
+ label="Base",
+ run_dir="C:/runs/base",
+ skim_file=None,
+ hh=pl.DataFrame({"household_id": [1], "finalweight": [2.0]}),
+ per=pl.DataFrame({"person_id": [1], "household_id": [1], "finalweight": [3.0]}),
+ tours=pl.DataFrame({"tour_id": [10], "finalweight": [4.0]}),
+ trips=pl.DataFrame(
+ {
+ "trip_id": [100, 101, 102],
+ "tour_id": [10, 10, 10],
+ "trip_mode": ["DRIVEALONE", "WALK", "WALK"],
+ "finalweight": [5.0, 2.0, 1.0],
+ }
+ ),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+
+def test_page_registry_exposes_expected_default_definitions() -> None:
+ definitions = default_page_definitions()
+
+ assert [
+ definition.page_id for definition in definitions
+ ] == EXPECTED_DEFAULT_LEAF_PAGE_IDS
+ assert [
+ definition.title for definition in definitions
+ ] == EXPECTED_DEFAULT_LEAF_PAGE_TITLES
+ assert page_definition_by_id("daily_activity_pattern") is not None
+ assert (
+ page_definition_by_id("daily_activity_pattern").title
+ == "Daily Activity Pattern"
+ )
+ assert page_definition_by_id("daily_activity_pattern").group_id == "daily_travel"
+ assert not hasattr(page_definition_by_id("daily_activity_pattern"), "child_id")
+ assert page_definition_by_id("trip_mode").page_cls is not None
+ assert page_definition_by_id("raw_trip_demo") is not None
+ assert page_definition_by_id("raw_trip_demo").default_enabled is False
+ assert page_definition_by_id("raw_trip_demo").title == "Prepared Trip Demo"
+ assert page_definition_by_id("raw_trip_demo").prepared_data_mode == "required"
+ assert page_definition_by_id("raw_trip_demo").required_prepared_tables == ("trips",)
+
+
+def test_discovered_page_modules_declare_decorated_page_classes() -> None:
+ discovered_modules = []
+ for module_info in pkgutil.iter_modules(dashboard_pages_package.__path__):
+ if module_info.name.startswith("_"):
+ continue
+ module = importlib.import_module(
+ f"{dashboard_pages_package.__name__}.{module_info.name}"
+ )
+ discovered_modules.append(module)
+ if module_info.ispkg:
+ discovered_modules.extend(
+ importlib.import_module(f"{module.__name__}.{child_info.name}")
+ for child_info in pkgutil.iter_modules(module.__path__)
+ if not child_info.name.startswith("_")
+ )
+
+ assert discovered_modules
+ assert any(hasattr(module, "GROUP") for module in discovered_modules)
+ assert all(
+ hasattr(module, "GROUP")
+ or any(
+ isinstance(value, type)
+ and issubclass(value, DashboardPage)
+ and value is not DashboardPage
+ and value.__module__ == module.__name__
+ and isinstance(value.definition, DashboardPageDefinition)
+ for value in vars(module).values()
+ )
+ for module in discovered_modules
+ )
+ assert all(not hasattr(module, "PAGE") for module in discovered_modules)
+ assert all(not hasattr(module, "build") for module in discovered_modules)
+
+
+def test_page_registry_smoke_checks_metadata_and_class_attachment() -> None:
+ definitions = all_page_definitions()
+
+ assert all(definition.page_id for definition in definitions)
+ assert all(definition.title for definition in definitions)
+ assert len({definition.page_id for definition in definitions}) == len(definitions)
+ assert all(definition.page_cls is not None for definition in definitions)
+
+ for definition in definitions:
+ assert definition.page_cls.definition is definition
+ assert definition.prepared_data_mode in {"none", "optional", "required"}
+ assert len(set(definition.required_summary_ids)) == len(
+ definition.required_summary_ids
+ )
+ assert all(
+ summary_id in SUMMARY_BY_ID
+ for summary_id in definition.required_summary_ids
+ )
+ assert len(set(definition.optional_summary_ids)) == len(
+ definition.optional_summary_ids
+ )
+ assert all(
+ summary_id in SUMMARY_BY_ID
+ for summary_id in definition.optional_summary_ids
+ )
+
+
+def test_page_registry_accepts_non_default_registered_summary_id() -> None:
+ class ValidationAutoVmtPage(DashboardPage):
+ pass
+
+ definition = DashboardPageDefinition(
+ page_id="auto_vmt_validation",
+ title="Auto VMT Validation",
+ page_cls=ValidationAutoVmtPage,
+ required_summary_ids=("auto_vmt_validation_summary",),
+ default_enabled=False,
+ )
+
+ _validate_page_definition(definition)
+ requirements = data_requirements_for_pages([definition])
+
+ assert requirements.required_summary_ids == ("auto_vmt_validation_summary",)
+
+
+def test_dashboard_state_reports_missing_non_default_summary_as_diagnostic() -> None:
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {"population_totals": pl.DataFrame({"population": [1.0]})},
+ "unweighted": {"population_totals": pl.DataFrame({"population": [1.0]})},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=["weighted", "unweighted"],
+ )
+
+ selection = state.inspect_summary_table("auto_vmt_validation_summary")
+
+ assert selection.usable_runs == []
+ assert [excluded.status for excluded in selection.excluded_runs] == ["missing"]
+ assert selection.excluded_runs[0].source_id == "auto_vmt_validation_summary"
+
+
+def test_external_traffic_helpers_filter_period_and_facility_type(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ counts = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [3, 4],
+ "am_vol": [10.0, 20.0],
+ "day_vol": [100.0, 200.0],
+ }
+ ),
+ )
+ ]
+ volumes = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [3, 4],
+ "am_vol": [11.0, 21.0],
+ "day_vol": [110.0, 210.0],
+ }
+ ),
+ )
+ ]
+ links = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [10, 11],
+ "From_Node": [100, 101],
+ "To_Node": [200, 201],
+ "FACTYPE": [3, 4],
+ "day_vol": [5.0, 15.0],
+ }
+ ),
+ )
+ ]
+
+ scatter = demo_count_scatter_data_from_sources(
+ counts,
+ volumes,
+ volume_col="day_vol",
+ facility_type="4",
+ )
+ aggregate = demo_link_aggregate_data(
+ links,
+ volume_col="day_vol",
+ facility_type="All",
+ )
+
+ assert scatter[0][1].to_dicts() == [
+ {
+ "id": 2,
+ "facility_type": "4",
+ "observed_volume": 200.0,
+ "modeled_volume": 210.0,
+ }
+ ]
+ assert aggregate[0][1].to_dicts() == [
+ {"FACTYPE": "3", "volume": 5.0},
+ {"FACTYPE": "4", "volume": 15.0},
+ ]
+ comparison = demo_volume_comparison_table(
+ counts,
+ volumes,
+ link_list=[
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "From_Node": [100, 101],
+ "To_Node": [200, 201],
+ }
+ ),
+ )
+ ],
+ volume_col="day_vol",
+ facility_type="4",
+ top_n=10,
+ )
+ assert comparison[0][1].to_dicts() == [
+ {
+ "link_id": 2,
+ "facility_type": "4",
+ "From_Node": 101,
+ "To_Node": 201,
+ "Observed Link Volume": 200.0,
+ "Modeled Link Volume": 210.0,
+ "Difference": 10.0,
+ "% Difference": "5.00%",
+ }
+ ]
+ comparison_without_metadata = demo_volume_comparison_table(
+ counts,
+ volumes,
+ volume_col="day_vol",
+ facility_type="4",
+ top_n=10,
+ )
+ assert comparison_without_metadata[0][1].columns == [
+ "link_id",
+ "facility_type",
+ "Observed Link Volume",
+ "Modeled Link Volume",
+ "Difference",
+ "% Difference",
+ ]
+ comparison_with_empty_metadata = demo_volume_comparison_table(
+ counts,
+ volumes,
+ link_list=[
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "From_Node": [None, None],
+ "To_Node": [None, None],
+ }
+ ),
+ )
+ ],
+ volume_col="day_vol",
+ facility_type="4",
+ top_n=10,
+ )
+ assert comparison_with_empty_metadata[0][1].columns == [
+ "link_id",
+ "facility_type",
+ "Observed Link Volume",
+ "Modeled Link Volume",
+ "Difference",
+ "% Difference",
+ ]
+ comparison_top_modeled = demo_volume_comparison_table(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [4, 4],
+ "day_vol": [999.0, 100.0],
+ }
+ ),
+ )
+ ],
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [4, 4],
+ "day_vol": [50.0, 200.0],
+ }
+ ),
+ )
+ ],
+ volume_col="day_vol",
+ facility_type="All",
+ top_n=1,
+ )
+ assert comparison_top_modeled[0][1]["link_id"].to_list() == [2]
+
+ derived_scatter = demo_count_scatter_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2, 3],
+ "facility_type": ["3", "4", "4"],
+ "period": ["Day", "AM", "Day"],
+ "observed_volume": [100.0, 20.0, 300.0],
+ "modeled_volume": [110.0, 21.0, 310.0],
+ }
+ ),
+ )
+ ],
+ period="Day",
+ facility_type="4",
+ )
+ assert derived_scatter[0][1].to_dicts() == [
+ {
+ "id": 3,
+ "facility_type": "4",
+ "period": "Day",
+ "observed_volume": 300.0,
+ "modeled_volume": 310.0,
+ }
+ ]
+ facility_comparison = demo_facility_comparison_table(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "id": [1, 2, 3],
+ "facility_type": ["4", "4", "3"],
+ "period": ["Day", "Day", "Day"],
+ "observed_volume": [100.0, 300.0, 50.0],
+ "modeled_volume": [110.0, 330.0, 75.0],
+ }
+ ),
+ )
+ ],
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "facility_type": ["4"],
+ "period": ["Day"],
+ "r_squared": [0.875],
+ }
+ ),
+ )
+ ],
+ period="Day",
+ facility_type="4",
+ config=config,
+ )
+
+ assert facility_comparison[0][1].to_dicts() == [
+ {
+ "Facility Type": "4",
+ "n": 2,
+ "Total Observed Count": 400.0,
+ "Total Modeled Count": 440.0,
+ "% Difference": "10.00%",
+ "RMSE": 22.360679774997898,
+ "R^2": 0.875,
+ }
+ ]
+
+
+def test_demo_count_fit_line_helper_builds_plot_data() -> None:
+ fit_lines = demo_count_fit_line_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "facility_type": ["All", "4"],
+ "period": ["Day", "Day"],
+ "slope": [2.0, 3.0],
+ "intercept": [5.0, 7.0],
+ "r_squared": [1.0, 0.9],
+ "n_locations": [3, 2],
+ "observed_min": [10.0, 20.0],
+ "observed_max": [30.0, 40.0],
+ "equation_label": ["y = 2.00x + 5.00", "y = 3.00x + 7.00"],
+ "r_squared_label": ["R^2 = 1.00", "R^2 = 0.90"],
+ }
+ ),
+ )
+ ],
+ period="Day",
+ facility_type="4",
+ )
+
+ assert fit_lines[0][1].select("observed_volume", "modeled_volume").to_dicts() == [
+ {"observed_volume": 20.0, "modeled_volume": 67.0},
+ {"observed_volume": 40.0, "modeled_volume": 127.0},
+ ]
+ assert "y = 3.00x + 7.00" in fit_lines[0][1]["annotation"][0]
+
+
+def test_external_vmt_helper_reshapes_wide_tod_table() -> None:
+ data = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "TOD": ["AM", "Daily"],
+ "SOV": [10.0, 20.0],
+ "HOV2": [2.0, 4.0],
+ "Total": [12.0, 24.0],
+ }
+ ),
+ )
+ ]
+
+ chart_data = wide_tod_chart_data(
+ data,
+ tod_col="TOD",
+ value_columns=["SOV", "HOV2"],
+ )
+
+ assert chart_data[0][1].to_dicts() == [
+ {"tod": "AM", "category": "SOV", "value": 10.0},
+ {"tod": "AM", "category": "HOV2", "value": 2.0},
+ ]
+
+
+def test_demo_commercial_vehicle_helper_aggregates_selected_breakdown(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " commercial_vehicle_type:",
+ " mapping:",
+ " car: Car",
+ " su: Single-Unit Truck",
+ " mu: Multi-Unit Truck",
+ ],
+ )
+ data = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "tod": ["AM", "PM", "Daily"],
+ "car": [10.0, 20.0, 100.0],
+ "mu": [30.0, 10.0, 200.0],
+ "su": [5.0, 15.0, 300.0],
+ }
+ ),
+ )
+ ]
+
+ by_period = demo_commercial_vehicle_chart_data(
+ data,
+ breakdown="Time Period",
+ )
+ by_type = demo_commercial_vehicle_chart_data(
+ data,
+ breakdown="Commercial Vehicle Type",
+ time_period="AM",
+ )
+ by_type_daily = demo_commercial_vehicle_chart_data(
+ data,
+ breakdown="Commercial Vehicle Type",
+ time_period="Daily",
+ )
+ time_period_options, (vehicle_type_options, raw_by_label) = (
+ demo_commercial_filter_options(data, config=config)
+ )
+
+ assert by_period[0][1].to_dicts() == [
+ {
+ "category": "AM",
+ "value": 45.0,
+ "value_percent": pytest.approx(7.5),
+ },
+ {
+ "category": "PM",
+ "value": 45.0,
+ "value_percent": pytest.approx(7.5),
+ },
+ {
+ "category": "Daily",
+ "value": 600.0,
+ "value_percent": 100.0,
+ },
+ ]
+ assert by_period[0][1].select("category", "value_percent").to_dicts() == [
+ {"category": "AM", "value_percent": pytest.approx(7.5)},
+ {"category": "PM", "value_percent": pytest.approx(7.5)},
+ {"category": "Daily", "value_percent": 100.0},
+ ]
+ assert by_type[0][1].to_dicts() == [
+ {"category": "car", "value": 10.0},
+ {"category": "mu", "value": 30.0},
+ {"category": "su", "value": 5.0},
+ ]
+ assert by_type_daily[0][1].to_dicts() == [
+ {"category": "car", "value": 100.0},
+ {"category": "mu", "value": 200.0},
+ {"category": "su", "value": 300.0},
+ ]
+ assert time_period_options == ["Daily", "AM", "PM"]
+ assert vehicle_type_options == [
+ "All",
+ "Car",
+ "Single-Unit Truck",
+ "Multi-Unit Truck",
+ ]
+ assert raw_by_label["Car"] == "car"
+ assert raw_by_label["Multi-Unit Truck"] == "mu"
+
+
+def test_external_travel_helper_aggregates_selected_breakdown(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " trip_purpose:",
+ " mapping:",
+ " hbw: Work",
+ " nhbw: Non-Home-Based Work",
+ ],
+ )
+ data = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "tod": ["AM", "PM", "Daily"],
+ "hbw": [10.0, 20.0, 100.0],
+ "nhbw": [30.0, 10.0, 200.0],
+ "truck": [5.0, 15.0, 300.0],
+ "Total": [45.0, 45.0, 600.0],
+ }
+ ),
+ )
+ ]
+
+ by_period = external_travel_chart_data(
+ data,
+ breakdown="Time Period",
+ )
+ by_purpose = external_travel_chart_data(
+ data,
+ breakdown="Trip Purpose",
+ time_period="AM",
+ )
+ time_period_options, (purpose_options, raw_by_label) = (
+ external_travel_filter_options(
+ data,
+ config=config,
+ )
+ )
+
+ assert by_period[0][1].to_dicts() == [
+ {
+ "category": "AM",
+ "value": 45.0,
+ "value_percent": pytest.approx(7.5),
+ },
+ {
+ "category": "PM",
+ "value": 45.0,
+ "value_percent": pytest.approx(7.5),
+ },
+ {
+ "category": "Daily",
+ "value": 600.0,
+ "value_percent": 100.0,
+ },
+ ]
+ assert by_purpose[0][1].to_dicts() == [
+ {"category": "hbw", "value": 10.0},
+ {"category": "nhbw", "value": 30.0},
+ {"category": "truck", "value": 5.0},
+ ]
+ assert time_period_options == ["Daily", "AM", "PM"]
+ assert purpose_options[:3] == ["All", "Work", "Non-Home-Based Work"]
+ assert raw_by_label["Work"] == "hbw"
+
+
+def test_personal_auto_vmt_helper_aggregates_time_period_with_filters() -> None:
+ data = [
+ (
+ "Period Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 4,
+ "geography_id": ["all_geographies"] * 4,
+ "income_segment": ["low", "low", "high", "low"],
+ "household_size": ["1", "2", "1", "1"],
+ "time_period": ["AM", "PM", "AM", "EA"],
+ "auto_vmt": [10.0, 5.0, 99.0, 3.0],
+ "trip_count": [2.0, 1.0, 9.0, 1.0],
+ }
+ ),
+ ),
+ (
+ "Daily Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"],
+ "geography_id": ["all_geographies"],
+ "income_segment": ["low"],
+ "household_size": ["1"],
+ "time_period": ["Daily"],
+ "auto_vmt": [7.0],
+ "trip_count": [2.0],
+ }
+ ),
+ ),
+ ]
+
+ chart_data = personal_auto_vmt_chart_data(
+ data,
+ breakdown="Time Period",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="All",
+ income_segment="low",
+ household_size="All",
+ )
+
+ assert chart_data[0][1].select("category", "auto_vmt", "trip_count").to_dicts() == [
+ {"category": "EA", "auto_vmt": 3.0, "trip_count": 1.0},
+ {"category": "AM", "auto_vmt": 10.0, "trip_count": 2.0},
+ {"category": "PM", "auto_vmt": 5.0, "trip_count": 1.0},
+ ]
+ assert chart_data[1][1].select("category", "auto_vmt", "trip_count").to_dicts() == [
+ {"category": "Daily", "auto_vmt": 7.0, "trip_count": 2.0},
+ ]
+
+
+def test_personal_auto_vmt_time_period_percent_uses_daily_total() -> None:
+ chart_data = personal_auto_vmt_chart_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 3,
+ "geography_id": ["all_geographies"] * 3,
+ "income_segment": ["low"] * 3,
+ "household_size": ["1"] * 3,
+ "time_period": ["AM", "PM", "Daily"],
+ "auto_vmt": [10.0, 5.0, 15.0],
+ "trip_count": [2.0, 1.0, 3.0],
+ }
+ ),
+ )
+ ],
+ breakdown="Time Period",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="All",
+ income_segment="low",
+ household_size="1",
+ )
+
+ rows = chart_data[0][1].select("category", "auto_vmt_percent").to_dicts()
+ assert rows == [
+ {"category": "AM", "auto_vmt_percent": pytest.approx(66.6666666667)},
+ {"category": "PM", "auto_vmt_percent": pytest.approx(33.3333333333)},
+ {"category": "Daily", "auto_vmt_percent": 100.0},
+ ]
+
+
+def test_personal_auto_vmt_all_time_period_filter_prefers_daily_totals() -> None:
+ chart_data = personal_auto_vmt_chart_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 3,
+ "geography_id": ["all_geographies"] * 3,
+ "income_segment": ["low"] * 3,
+ "household_size": ["1"] * 3,
+ "time_period": ["AM", "PM", "Daily"],
+ "auto_vmt": [10.0, 5.0, 15.0],
+ "trip_count": [2.0, 1.0, 3.0],
+ }
+ ),
+ )
+ ],
+ breakdown="Income Segment",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="All",
+ income_segment="All",
+ household_size="1",
+ )
+
+ assert chart_data[0][1].to_dicts() == [
+ {"category": "low", "auto_vmt": 15.0, "trip_count": 3.0},
+ ]
+
+
+def test_personal_auto_vmt_helper_breaks_down_by_mode() -> None:
+ chart_data = personal_auto_vmt_chart_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 4,
+ "geography_id": ["all_geographies"] * 4,
+ "income_segment": ["low"] * 4,
+ "household_size": ["1"] * 4,
+ "time_period": ["AM", "Daily", "AM", "Daily"],
+ "mode": ["SOV", "SOV", "HOV2", "HOV2"],
+ "auto_vmt": [10.0, 10.0, 3.0, 3.0],
+ "trip_count": [1.0, 1.0, 1.0, 1.0],
+ }
+ ),
+ )
+ ],
+ breakdown="Mode",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="All",
+ income_segment="low",
+ household_size="1",
+ )
+
+ assert chart_data[0][1].to_dicts() == [
+ {"category": "SOV", "auto_vmt": 10.0, "trip_count": 1.0},
+ {"category": "HOV2", "auto_vmt": 3.0, "trip_count": 1.0},
+ ]
+
+
+def test_personal_auto_vmt_helper_filters_by_mode() -> None:
+ chart_data = personal_auto_vmt_chart_data(
+ [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 4,
+ "geography_id": ["all_geographies"] * 4,
+ "income_segment": ["low", "low", "high", "high"],
+ "household_size": ["1"] * 4,
+ "time_period": ["Daily"] * 4,
+ "mode": ["SOV", "HOV2", "SOV", "HOV2"],
+ "auto_vmt": [10.0, 3.0, 20.0, 7.0],
+ "trip_count": [1.0, 1.0, 2.0, 2.0],
+ }
+ ),
+ )
+ ],
+ breakdown="Income Segment",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="Daily",
+ mode="HOV2",
+ income_segment="All",
+ household_size="1",
+ )
+
+ assert chart_data[0][1].to_dicts() == [
+ {"category": "high", "auto_vmt": 7.0, "trip_count": 2.0},
+ {"category": "low", "auto_vmt": 3.0, "trip_count": 1.0},
+ ]
+
+
+def test_vmt_page_labels_personal_auto_modes_from_display_config(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " SOV: Drive Alone",
+ " HOV2: Shared Ride 2",
+ " TAXI: Taxi",
+ ],
+ )
+ personal_vmt = pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 3,
+ "geography_id": ["all_geographies"] * 3,
+ "income_segment": ["low"] * 3,
+ "household_size": ["1"] * 3,
+ "time_period": ["Daily"] * 3,
+ "mode": ["SOV", "HOV2", "TAXI"],
+ "auto_vmt": [10.0, 3.0, 4.0],
+ "trip_count": [1.0, 1.0, 1.0],
+ "distance_source": ["od_dist"] * 3,
+ "time_period_source": ["trip_period"] * 3,
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ "unweighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ state.value_mode = "Count"
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ assert list(page.personal_vmt_mode_sel.options) == [
+ "All",
+ "Drive Alone",
+ "Shared Ride 2",
+ "Taxi",
+ ]
+ page.personal_vmt_breakdown_sel.value = "Income Segment"
+ page.personal_vmt_mode_sel.value = "Taxi"
+ page.refresh(force=True)
+
+ assert page.selected_personal_vmt_mode_raw() == "TAXI"
+
+
+def test_non_motorized_vmt_section_mirrors_personal_auto_controls(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " WALK: Walk",
+ " BIKE: Bike",
+ " geography:",
+ " mapping:",
+ " all_geographies: All Geographies",
+ " home_county: County",
+ ],
+ )
+ personal_vmt = pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"],
+ "geography_id": ["all_geographies"],
+ "income_segment": ["all_income_segments"],
+ "household_size": ["all_household_sizes"],
+ "time_period": ["Daily"],
+ "mode": ["SOV"],
+ "auto_vmt": [10.0],
+ "trip_count": [1.0],
+ "distance_source": ["skim_auto_distance"],
+ "time_period_source": ["trip_period"],
+ }
+ )
+ non_motorized_vmt = pl.DataFrame(
+ {
+ "geography_type": [
+ "all_geographies",
+ "all_geographies",
+ "home_county",
+ "home_county",
+ ],
+ "geography_id": ["all_geographies", "all_geographies", "Wake", "Wake"],
+ "income_segment": ["low", "high", "low", "high"],
+ "household_size": ["1", "2", "1", "2"],
+ "time_period": ["Daily", "Daily", "AM", "PM"],
+ "mode": ["WALK", "BIKE", "WALK", "BIKE"],
+ "non_motorized_vmt": [5.0, 3.0, 2.0, 3.0],
+ "trip_count": [3.0, 2.0, 1.0, 2.0],
+ "distance_source": ["prepared_non_motorized_distance"] * 4,
+ "time_period_source": ["trip_period"] * 4,
+ }
+ )
+ external_vmt = pl.DataFrame(
+ {
+ "tod": ["Daily"],
+ "hbo": [20.0],
+ "Total": [20.0],
+ }
+ )
+ commercial_vmt = pl.DataFrame(
+ {
+ "tod": ["Daily"],
+ "car": [7.0],
+ "mu": [3.0],
+ "su": [0.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {
+ PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt,
+ NON_MOTORIZED_VMT_SUMMARY_ID: non_motorized_vmt,
+ "external_vmt_validation_summary": external_vmt,
+ "commercial_vehicle_vmt_validation_summary": commercial_vmt,
+ },
+ "unweighted": {
+ PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt,
+ NON_MOTORIZED_VMT_SUMMARY_ID: non_motorized_vmt,
+ "external_vmt_validation_summary": external_vmt,
+ "commercial_vehicle_vmt_validation_summary": commercial_vmt,
+ },
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ state.value_mode = "Count"
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ markdown_titles = [
+ obj.object for obj in page.view.objects if isinstance(obj, pn.pane.Markdown)
+ ]
+ assert markdown_titles[:4] == [
+ "## VMT Validation",
+ "### VMT Overview",
+ "### Personal Auto VMT",
+ "### Non-Motorized VMT",
+ ]
+ assert markdown_titles[4] == "### External VMT and Travel"
+ overview_tables = _collect_tabulators(page._vmt_overview_body)
+ assert len(overview_tables) == 1
+ assert overview_tables[0].value.to_dict("records") == [
+ {
+ "Category": "Personal Auto",
+ "VMT": "10",
+ "% Share of Total": "20.83",
+ },
+ {
+ "Category": "Non-Motorized",
+ "VMT": "8",
+ "% Share of Total": "16.67",
+ },
+ {
+ "Category": "External",
+ "VMT": "20",
+ "% Share of Total": "41.67",
+ },
+ {
+ "Category": "Commercial",
+ "VMT": "10",
+ "% Share of Total": "20.83",
+ },
+ ]
+ assert list(page.non_motorized_vmt_mode_sel.options) == [
+ "All",
+ "Walk",
+ "Bike",
+ ]
+ assert page.non_motorized_vmt_geography_type_sel.disabled is False
+ assert page.non_motorized_vmt_geography_sel.disabled is False
+
+ page.non_motorized_vmt_breakdown_sel.value = "Home Geography"
+ page.refresh(force=True)
+ page.non_motorized_vmt_geography_type_sel.value = "County"
+ page.refresh(force=True)
+
+ assert page.non_motorized_vmt_geography_type_sel.disabled is False
+ assert page.non_motorized_vmt_geography_sel.name == "County Name"
+ assert page.selected_non_motorized_vmt_geography_type_raw() == "home_county"
+
+ page.non_motorized_vmt_breakdown_sel.value = "Mode"
+ page.refresh(force=True)
+ assert page.non_motorized_vmt_geography_type_sel.disabled is False
+ assert page.non_motorized_vmt_geography_sel.disabled is False
+ assert page.non_motorized_vmt_mode_sel.disabled is True
+ assert page.non_motorized_vmt_mode_sel.value == "All"
+
+ chart = page.render_non_motorized_vmt_section()[0]
+ assert chart.object.layout.title.text == "Non-Motorized VMT by Mode"
+ assert chart.object.layout.yaxis.title.text == "Non-Motorized Miles Traveled"
+ assert list(chart.object.layout.xaxis.categoryarray) == ["Walk", "Bike"]
+
+
+def test_personal_auto_vmt_helper_ignores_active_breakdown_selector() -> None:
+ data = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["all_geographies"] * 3,
+ "geography_id": ["all_geographies"] * 3,
+ "income_segment": ["low", "high", "low"],
+ "household_size": ["1", "1", "2"],
+ "time_period": ["AM", "AM", "PM"],
+ "auto_vmt": [10.0, 20.0, 30.0],
+ "trip_count": [1.0, 2.0, 3.0],
+ }
+ ),
+ )
+ ]
+
+ chart_data = personal_auto_vmt_chart_data(
+ data,
+ breakdown="Income Segment",
+ geography_type="all_geographies",
+ geography_id="all_geographies",
+ time_period="AM",
+ income_segment="low",
+ household_size="All",
+ )
+
+ assert chart_data[0][1].to_dicts() == [
+ {"category": "high", "auto_vmt": 20.0, "trip_count": 2.0},
+ {"category": "low", "auto_vmt": 10.0, "trip_count": 1.0},
+ ]
+
+
+def test_personal_auto_vmt_helper_caps_home_geography_breakdown() -> None:
+ data = [
+ (
+ "Run",
+ pl.DataFrame(
+ {
+ "geography_type": ["home_taz"] * 30,
+ "geography_id": [str(value) for value in range(30)],
+ "income_segment": ["all_income_segments"] * 30,
+ "household_size": ["all_household_sizes"] * 30,
+ "time_period": ["Daily"] * 30,
+ "auto_vmt": [float(value) for value in range(30)],
+ "trip_count": [1.0] * 30,
+ }
+ ),
+ )
+ ]
+
+ chart_data = personal_auto_vmt_chart_data(
+ data,
+ breakdown="Home Geography",
+ geography_type="home_taz",
+ geography_id="All",
+ time_period="Daily",
+ income_segment="All",
+ household_size="All",
+ )
+
+ rows = chart_data[0][1].to_dicts()
+ assert len(rows) == 25
+ assert rows[0] == {"category": "29", "auto_vmt": 29.0, "trip_count": 1.0}
+ assert rows[-1] == {"category": "5", "auto_vmt": 5.0, "trip_count": 1.0}
+
+
+def test_vmt_page_registers_personal_auto_vmt_and_renders_missing_card(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {"population_totals": pl.DataFrame({"population": [1.0]})},
+ "unweighted": {"population_totals": pl.DataFrame({"population": [1.0]})},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ assert PERSONAL_AUTO_VMT_SUMMARY_ID in page.required_summary_ids
+ assert NON_MOTORIZED_VMT_SUMMARY_ID in page.required_summary_ids
+ assert page._personal_vmt_body.objects
+ assert page._non_motorized_vmt_body.objects
+
+
+def test_vmt_page_disables_active_personal_auto_vmt_filter_selector(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ personal_vmt = pl.DataFrame(
+ {
+ "geography_type": ["all_geographies", "all_geographies"],
+ "geography_id": ["all_geographies", "all_geographies"],
+ "income_segment": ["low", "high"],
+ "household_size": ["1", "2"],
+ "time_period": ["AM", "PM"],
+ "mode": ["SOV", "HOV2"],
+ "auto_vmt": [10.0, 20.0],
+ "trip_count": [1.0, 2.0],
+ "distance_source": ["skim_auto_distance", "skim_auto_distance"],
+ "time_period_source": ["trip_period", "trip_period"],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ "unweighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+ assert page.personal_vmt_time_period_sel.disabled is True
+ assert page.personal_vmt_mode_sel.disabled is False
+ assert page.personal_vmt_income_segment_sel.disabled is False
+ assert page.personal_vmt_household_size_sel.disabled is False
+
+ page.personal_vmt_breakdown_sel.value = "Home Geography"
+ page.refresh(force=True)
+
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+
+ page.personal_vmt_breakdown_sel.value = "Income Segment"
+ page.refresh(force=True)
+
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+ assert page.personal_vmt_time_period_sel.disabled is False
+ assert page.personal_vmt_mode_sel.disabled is False
+ assert page.personal_vmt_income_segment_sel.disabled is True
+ assert page.personal_vmt_income_segment_sel.value == "All"
+ assert page.personal_vmt_household_size_sel.disabled is False
+
+ page.personal_vmt_breakdown_sel.value = "Mode"
+ page.refresh(force=True)
+
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+ assert page.personal_vmt_time_period_sel.disabled is False
+ assert page.personal_vmt_mode_sel.disabled is True
+ assert page.personal_vmt_mode_sel.value == "All"
+
+
+def test_vmt_page_geography_selectors_use_display_labels_and_raw_filters(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " geography:",
+ " mapping:",
+ " all_geographies: All Geographies",
+ " home_county: County",
+ ],
+ )
+ personal_vmt = pl.DataFrame(
+ {
+ "geography_type": ["all_geographies", "home_county", "home_county"],
+ "geography_id": ["all_geographies", "Wake", "Durham"],
+ "income_segment": ["low", "low", "low"],
+ "household_size": ["1", "1", "1"],
+ "time_period": ["Daily", "Daily", "Daily"],
+ "mode": ["SOV", "SOV", "SOV"],
+ "auto_vmt": [30.0, 10.0, 20.0],
+ "trip_count": [3.0, 1.0, 2.0],
+ "distance_source": ["skim_auto_distance"] * 3,
+ "time_period_source": ["trip_period"] * 3,
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ "unweighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ assert list(page.personal_vmt_geography_type_sel.options) == [
+ "All Geography Types",
+ "County",
+ ]
+ assert page.personal_vmt_breakdown_sel.value == "Time Period"
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+
+ page.personal_vmt_geography_type_sel.value = "County"
+ page.refresh(force=True)
+
+ assert page.personal_vmt_geography_type_sel.disabled is False
+ assert page.personal_vmt_geography_sel.disabled is False
+ assert page.personal_vmt_geography_sel.name == "County Name"
+ assert list(page.personal_vmt_geography_sel.options) == [
+ "All Counties",
+ "Durham",
+ "Wake",
+ ]
+ page.personal_vmt_geography_sel.value = "Durham"
+
+ assert page.selected_personal_vmt_geography_type_raw() == "home_county"
+ assert page.selected_personal_vmt_geography_raw() == "Durham"
+
+
+def test_vmt_export_states_collapse_ignored_personal_auto_selectors(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ personal_vmt = pl.DataFrame(
+ {
+ "geography_type": [
+ "all_geographies",
+ "home_county",
+ "home_county",
+ "home_county",
+ ],
+ "geography_id": [
+ "all_geographies",
+ "Wake",
+ "Durham",
+ "Wake",
+ ],
+ "income_segment": ["low", "low", "high", "low"],
+ "household_size": ["1", "1", "2", "1"],
+ "time_period": ["Daily", "AM", "PM", "AM"],
+ "mode": ["SOV", "SOV", "HOV2", "HOV2"],
+ "auto_vmt": [30.0, 10.0, 20.0, 5.0],
+ "trip_count": [3.0, 1.0, 2.0, 1.0],
+ "distance_source": ["skim_auto_distance"] * 4,
+ "time_period_source": ["trip_period"] * 4,
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ "unweighted": {PERSONAL_AUTO_VMT_SUMMARY_ID: personal_vmt},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+
+ selector_widgets = {
+ selector.selector_id: selector.widget for selector in page.registered_selectors
+ }
+ assert selector_widgets["personal_auto_vmt_geography_type"].disabled is False
+ assert selector_widgets["personal_auto_vmt_geography"].disabled is False
+
+ active_selector_ids = [
+ "personal_auto_vmt_breakdown",
+ "personal_auto_vmt_geography_type",
+ "personal_auto_vmt_geography",
+ "personal_auto_vmt_time_period",
+ "personal_auto_vmt_mode",
+ "personal_auto_vmt_income_segment",
+ "personal_auto_vmt_household_size",
+ ]
+ request_modes = {
+ "personal_auto_vmt_breakdown": "explicit",
+ "personal_auto_vmt_geography_type": "all",
+ "personal_auto_vmt_geography": "all",
+ "personal_auto_vmt_time_period": "default",
+ "personal_auto_vmt_mode": "all",
+ "personal_auto_vmt_income_segment": "default",
+ "personal_auto_vmt_household_size": "default",
+ }
+ requested_values = {
+ "personal_auto_vmt_breakdown": ["Mode", "Home Geography"],
+ }
+ selector_metadata = {
+ selector_id: {
+ "id": selector_id,
+ "label": selector_id,
+ "available": True,
+ "request_mode": request_modes[selector_id],
+ "requested_values": requested_values.get(selector_id, []),
+ "resolved_values": [
+ str(option) for option in selector_widgets[selector_id].options
+ ],
+ "default_value": str(selector_widgets[selector_id].value),
+ "options": [
+ str(option) for option in selector_widgets[selector_id].options
+ ],
+ "export_enabled": True,
+ }
+ for selector_id in active_selector_ids
+ }
+
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=VMTValidationPage.definition,
+ part_def=type("Part", (), {"part_id": "personal_auto_vmt_body"})(),
+ active_selector_ids=active_selector_ids,
+ selector_widgets=selector_widgets,
+ selector_metadata_by_id=selector_metadata,
+ )
+
+ assert all(
+ state["personal_auto_vmt_mode"] == "All"
+ for state in states
+ if state["personal_auto_vmt_breakdown"] == "Mode"
+ )
+ mode_states = [
+ state for state in states if state["personal_auto_vmt_breakdown"] == "Mode"
+ ]
+ assert (
+ len({state["personal_auto_vmt_geography_type"] for state in mode_states}) == 1
+ )
+ assert {state["personal_auto_vmt_geography_type"] for state in mode_states} == {
+ "All Geography Types"
+ }
+ assert len({state["personal_auto_vmt_geography"] for state in mode_states}) == 1
+ home_state_groups = {
+ tuple(
+ value
+ for selector_id, value in state.items()
+ if selector_id != "personal_auto_vmt_geography"
+ )
+ for state in states
+ if state["personal_auto_vmt_breakdown"] == "Home Geography"
+ }
+ home_states = [
+ state
+ for state in states
+ if state["personal_auto_vmt_breakdown"] == "Home Geography"
+ ]
+ assert len(home_states) == len(home_state_groups)
+ assert len({state["personal_auto_vmt_geography_type"] for state in home_states}) > 1
+ assert aliases
+
+
+def test_vmt_demo_commercial_vehicle_chart_uses_breakdown_and_percent_mode(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " commercial_vehicle_type:",
+ " mapping:",
+ " car: Car",
+ " su: Single-Unit Truck",
+ " mu: Multi-Unit Truck",
+ ],
+ )
+ demo_commercial = pl.DataFrame(
+ {
+ "tod": ["AM", "PM", "Daily"],
+ "car": [10.0, 20.0, 100.0],
+ "mu": [30.0, 10.0, 200.0],
+ "su": [5.0, 15.0, 300.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {
+ "commercial_vehicle_validation_summary": demo_commercial,
+ },
+ "unweighted": {
+ "commercial_vehicle_validation_summary": demo_commercial,
+ },
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ state.value_mode = "Percent"
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+ page.demo_commercial_metric_sel.value = "Trips"
+ page.demo_commercial_breakdown_sel.value = "Time Period"
+ page.demo_commercial_vehicle_type_sel.value = "Car"
+
+ chart = page.render_demo_commercial_chart()
+ fig = chart.object
+
+ assert fig.layout.title.text == "Commercial Vehicle Trips by Time Period"
+ assert fig.layout.showlegend is True
+ assert fig.layout.yaxis.title.text == "Percent of Trips (%)"
+ assert len(fig.data) == 1
+ assert list(fig.data[0].x) == ["AM", "PM", "Daily"]
+ assert list(fig.data[0].y) == [10.0, 20.0, 100.0]
+ assert list(fig.layout.xaxis.categoryarray) == ["AM", "PM", "Daily"]
+ assert list(page.demo_commercial_time_period_sel.options) == [
+ "Daily",
+ "AM",
+ "PM",
+ ]
+ assert list(page.demo_commercial_vehicle_type_sel.options) == [
+ "All",
+ "Car",
+ "Single-Unit Truck",
+ "Multi-Unit Truck",
+ ]
+ assert page.selected_demo_commercial_vehicle_type_raw() == "car"
+
+ page.refresh(force=True)
+ assert page.demo_commercial_time_period_sel.disabled is True
+ assert page.demo_commercial_time_period_sel.value == "Daily"
+ assert page.demo_commercial_vehicle_type_sel.disabled is False
+
+ page.demo_commercial_breakdown_sel.value = "Commercial Vehicle Type"
+ page.demo_commercial_time_period_sel.value = "AM"
+ chart = page.render_demo_commercial_chart()
+
+ assert chart.object.layout.title.text == (
+ "Commercial Vehicle Trips by Commercial Vehicle Type"
+ )
+ assert list(chart.object.layout.xaxis.categoryarray) == [
+ "Car",
+ "Single-Unit Truck",
+ "Multi-Unit Truck",
+ ]
+
+
+def test_vmt_external_travel_chart_uses_metric_breakdown_and_filters(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " trip_purpose:",
+ " mapping:",
+ " hbw: Work",
+ " nhbw: Non-Home-Based Work",
+ ],
+ )
+ external_travel = pl.DataFrame(
+ {
+ "tod": ["AM", "PM", "Daily"],
+ "hbw": [10.0, 20.0, 100.0],
+ "nhbw": [30.0, 10.0, 200.0],
+ "truck": [5.0, 15.0, 300.0],
+ "Total": [45.0, 45.0, 600.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {
+ "external_trip_validation_summary": external_travel,
+ },
+ "unweighted": {
+ "external_trip_validation_summary": external_travel,
+ },
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ state.value_mode = "Percent"
+
+ page = VMTValidationPage(state, config)
+ page.refresh(force=True)
+ page.external_travel_metric_sel.value = "Trips"
+ page.external_travel_breakdown_sel.value = "Time Period"
+ page.external_travel_trip_purpose_sel.value = "Work"
+
+ chart = page.render_external_travel_chart()
+ fig = chart.object
+
+ assert fig.layout.title.text == "External Trips by Time Period"
+ assert fig.layout.yaxis.title.text == "Percent of Trips (%)"
+ assert list(fig.data[0].x) == ["AM", "PM", "Daily"]
+ assert list(fig.data[0].y) == [10.0, 20.0, 100.0]
+ assert list(fig.layout.xaxis.categoryarray) == ["AM", "PM", "Daily"]
+ assert list(page.external_travel_time_period_sel.options) == [
+ "Daily",
+ "AM",
+ "PM",
+ ]
+ assert page.external_travel_time_period_sel.disabled is True
+ assert page.external_travel_time_period_sel.value == "Daily"
+ assert page.external_travel_trip_purpose_sel.disabled is False
+ assert page.selected_external_travel_trip_purpose_raw() == "hbw"
+
+ page.external_travel_breakdown_sel.value = "Trip Purpose"
+ page.external_travel_time_period_sel.value = "AM"
+ chart = page.render_external_travel_chart()
+
+ assert chart.object.layout.title.text == "External Trips by Trip Purpose"
+ assert list(chart.object.layout.xaxis.categoryarray) == [
+ "Work",
+ "Non-Home-Based Work",
+ "truck",
+ ]
+ assert list(chart.object.data[0].x) == [
+ "Work",
+ "Non-Home-Based Work",
+ "truck",
+ ]
-def _raw_trip_run() -> RunData:
- return RunData(
+def _export_selector_widgets(page) -> dict[str, pn.widgets.Widget]:
+ return {
+ selector.selector_id: selector.widget for selector in page.registered_selectors
+ }
+
+
+def _export_selector_metadata(
+ selector_widgets: dict[str, pn.widgets.Widget],
+ selector_ids: list[str],
+ *,
+ request_modes: dict[str, str] | None = None,
+ requested_values: dict[str, list[str]] | None = None,
+) -> dict[str, dict]:
+ request_modes = request_modes or {}
+ requested_values = requested_values or {}
+ return {
+ selector_id: {
+ "id": selector_id,
+ "label": selector_id,
+ "available": True,
+ "request_mode": request_modes.get(selector_id, "all"),
+ "requested_values": requested_values.get(selector_id, []),
+ "resolved_values": [
+ str(option) for option in selector_widgets[selector_id].options
+ ],
+ "default_value": str(selector_widgets[selector_id].value),
+ "options": [
+ str(option) for option in selector_widgets[selector_id].options
+ ],
+ "export_enabled": True,
+ }
+ for selector_id in selector_ids
+ }
+
+
+def test_mandatory_location_choice_geography_labels_filter_raw_and_export(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " geography:",
+ " mapping:",
+ " all_geographies: All Geographies",
+ " home_county: County",
+ ],
+ )
+ internal_external = pl.DataFrame(
+ {
+ "geography_type": [
+ "all_geographies",
+ "home_county",
+ "home_county",
+ ],
+ "geography_id": ["all_geographies", "Wake", "Durham"],
+ "internal_worker_count": [30.0, 10.0, 20.0],
+ "external_worker_count": [3.0, 1.0, 2.0],
+ }
+ )
+ summary_run = create_summary_run(
label="Base",
- run_dir="C:/runs/base",
- skim_file=None,
- hh=pl.DataFrame({"household_id": [1], "finalweight": [2.0]}),
- per=pl.DataFrame({"person_id": [1], "household_id": [1], "finalweight": [3.0]}),
- tours=pl.DataFrame({"tour_id": [10], "finalweight": [4.0]}),
- trips=pl.DataFrame(
- {
- "trip_id": [100, 101, 102],
- "tour_id": [10, 10, 10],
- "trip_mode": ["DRIVEALONE", "WALK", "WALK"],
- "finalweight": [5.0, 2.0, 1.0],
- }
- ),
- joint_participants=pl.DataFrame(),
- land_use=pl.DataFrame(),
- skim_matrix=None,
- skim_zone_map=None,
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {"internal_external_worker_by_geography": internal_external},
+ "unweighted": {"internal_external_worker_by_geography": internal_external},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ export_mode=True,
)
+ page = MandatoryLocationChoicePage(state, config)
+ page.refresh(force=True)
-def test_page_registry_exposes_expected_default_definitions() -> None:
- definitions = default_page_definitions()
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "County",
+ ]
+ assert "Durham" in page.geography_sel.options
+ assert "Wake" in page.geography_sel.options
- assert [definition.page_id for definition in definitions] == EXPECTED_DEFAULT_LEAF_PAGE_IDS
- assert [definition.title for definition in definitions] == EXPECTED_DEFAULT_LEAF_PAGE_TITLES
- assert page_definition_by_id("daily_activity_pattern") is not None
- assert page_definition_by_id("daily_activity_pattern").title == "Daily Activity Pattern"
- assert page_definition_by_id("daily_activity_pattern").group_id == "daily_travel"
- assert not hasattr(page_definition_by_id("daily_activity_pattern"), "child_id")
- assert page_definition_by_id("trip_mode").page_cls is not None
- assert page_definition_by_id("raw_trip_demo") is not None
- assert page_definition_by_id("raw_trip_demo").default_enabled is False
- assert page_definition_by_id("raw_trip_demo").title == "Prepared Trip Demo"
- assert page_definition_by_id("raw_trip_demo").prepared_data_mode == "required"
- assert page_definition_by_id("raw_trip_demo").required_prepared_tables == ("trips",)
+ page.geo_level_sel.value = "County"
+ page.geography_sel.value = "Durham"
+ assert page._selected_geography() == ("home_county", "Durham")
-def test_discovered_page_modules_export_page_definitions_without_legacy_build_api() -> None:
- discovered_modules = []
- for module_info in pkgutil.iter_modules(dashboard_pages_package.__path__):
- if module_info.name.startswith("_"):
- continue
- module = importlib.import_module(f"{dashboard_pages_package.__name__}.{module_info.name}")
- discovered_modules.append(module)
- if module_info.ispkg:
- discovered_modules.extend(
- importlib.import_module(f"{module.__name__}.{child_info.name}")
- for child_info in pkgutil.iter_modules(module.__path__)
- if not child_info.name.startswith("_")
- )
- assert discovered_modules
- assert any(hasattr(module, "GROUP") for module in discovered_modules)
- assert all(
- isinstance(getattr(module, "PAGE", None), DashboardPageDefinition)
- or hasattr(module, "GROUP")
- for module in discovered_modules
+def test_mandatory_location_choice_export_aliases_invalid_geography_pairs(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " geography:",
+ " mapping:",
+ " all_geographies: All Geography Types",
+ " home_county: County",
+ " district: District",
+ ],
)
- assert all(not hasattr(module, "build") for module in discovered_modules)
+ internal_external = pl.DataFrame(
+ {
+ "geography_type": [
+ "all_geographies",
+ "home_county",
+ "home_county",
+ "district",
+ ],
+ "geography_id": ["all_geographies", "Wake", "Durham", "North"],
+ "internal_worker_count": [30.0, 10.0, 20.0, 15.0],
+ "external_worker_count": [3.0, 1.0, 2.0, 1.5],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {"internal_external_worker_by_geography": internal_external},
+ "unweighted": {"internal_external_worker_by_geography": internal_external},
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ export_mode=True,
+ )
+ page = MandatoryLocationChoicePage(state, config)
+ page.refresh(force=True)
+ selector_ids = ["geography_level", "geography"]
+ selector_widgets = _export_selector_widgets(page)
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=MandatoryLocationChoicePage.definition,
+ part_def=type("Part", (), {"part_id": "remote_work"})(),
+ active_selector_ids=selector_ids,
+ selector_widgets=selector_widgets,
+ selector_metadata_by_id=_export_selector_metadata(
+ selector_widgets,
+ selector_ids,
+ ),
+ )
-def test_page_registry_smoke_checks_ids_titles_and_selector_uniqueness() -> None:
- definitions = all_page_definitions()
+ state_pairs = {(state["geography_level"], state["geography"]) for state in states}
+ assert ("County", "North") not in state_pairs
+ assert ("District", "Durham") not in state_pairs
+ assert ("District", "Wake") not in state_pairs
+ assert ("County", "Durham") in state_pairs
+ assert ("County", "Wake") in state_pairs
+ assert ("District", "North") in state_pairs
+ assert aliases['["County","North"]'] == '["County","All"]'
+ assert aliases['["District","Durham"]'] == '["District","All"]'
+ assert len(states) < (
+ len(page.geo_level_sel.options) * len(page.geography_sel.options)
+ )
- assert all(definition.page_id for definition in definitions)
- assert all(definition.title for definition in definitions)
- assert len({definition.page_id for definition in definitions}) == len(definitions)
- assert all(definition.page_cls is not None for definition in definitions)
- for definition in definitions:
- selector_ids = [selector.selector_id for selector in definition.selectors]
- assert len(selector_ids) == len(set(selector_ids))
- assert definition.prepared_data_mode in {"none", "optional", "required"}
- assert len(set(definition.required_summary_ids)) == len(
- definition.required_summary_ids
+def test_tour_distance_export_geography_pairs_follow_selected_level(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ average_mandatory = pl.DataFrame(
+ {
+ "mandatory_tour_purpose": ["work"],
+ "geography_level": ["Region"],
+ "average_tour_distance": [8.0],
+ }
+ )
+ average_nonmandatory = pl.DataFrame(
+ {
+ "nonmandatory_tour_purpose": ["shopping", "shopping", "shopping"],
+ "geography_type": ["district", "district", "county"],
+ "geography_id": ["North", "South", "Wake"],
+ "average_tour_distance": [4.0, 8.0, 12.0],
+ "tour_count": [2.0, 3.0, 4.0],
+ }
+ )
+ distance = pl.DataFrame(
+ {
+ "tour_purpose": ["all_tour_purposes"],
+ "distance_bin": [0],
+ "tour_count": [5.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {
+ "tour_distance_by_tour_purpose": distance,
+ "average_mandatory_tour_distance_by_purpose_and_geography": average_mandatory,
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": average_nonmandatory,
+ },
+ "unweighted": {
+ "tour_distance_by_tour_purpose": distance,
+ "average_mandatory_tour_distance_by_purpose_and_geography": average_mandatory,
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": average_nonmandatory,
+ },
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ page = TourDistancePage(state, config)
+ page.refresh(force=True)
+
+ selector_ids = [
+ "geography_level",
+ "geography",
+ ]
+ selector_widgets = _export_selector_widgets(page)
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=TourDistancePage.definition,
+ part_def=type("Part", (), {"part_id": "tour_distance_averages"})(),
+ active_selector_ids=selector_ids,
+ selector_widgets=selector_widgets,
+ selector_metadata_by_id=_export_selector_metadata(
+ selector_widgets,
+ selector_ids,
+ ),
+ )
+
+ state_pairs = {(state["geography_level"], state["geography"]) for state in states}
+ assert ("District", "North") in state_pairs
+ assert ("District", "South") in state_pairs
+ assert ("County", "Wake") in state_pairs
+ assert ("District", "Wake") not in state_pairs
+ assert ("County", "North") not in state_pairs
+ assert aliases == {}
+
+
+def test_regional_helpers_rename_blank_origin() -> None:
+ matrix = pl.DataFrame(
+ {
+ "": ["A", "Total"],
+ "A": [1.0, 2.0],
+ "Total": [3.0, 4.0],
+ }
+ )
+
+ normalized = normalize_flow_matrix(matrix, include_totals=False)
+
+ assert normalized.to_dicts() == [{"Origin": "A", "A": 1.0}]
+
+
+def test_regional_flow_heatmap_labels_cells_with_matrix_values() -> None:
+ matrix = pl.DataFrame(
+ {
+ "": ["A", "B"],
+ "A": [1200.0, 30.0],
+ "B": [45.0, 6789.0],
+ }
+ )
+
+ heatmap = flow_heatmap(
+ [("Run", matrix)],
+ include_totals=False,
+ title="District flows",
+ )
+ plot = heatmap.objects[0][0]
+ trace = plot.object.data[0]
+
+ assert trace.text == (["1,200", "45"], ["30", "6,789"])
+ assert trace.texttemplate == "%{text}"
+
+
+def test_regional_flow_comparison_aligns_observed_and_modeled_flows() -> None:
+ observed = [
+ (
+ "Base",
+ pl.DataFrame(
+ {
+ "": ["A", "B", "Total"],
+ "A": [10.0, 3.0, 13.0],
+ "B": [5.0, 20.0, 25.0],
+ "Total": [15.0, 23.0, 38.0],
+ }
+ ),
)
- assert all(
- summary_id in SUMMARY_SPEC_BY_ID
- for summary_id in definition.required_summary_ids
+ ]
+ modeled = [
+ (
+ "Base",
+ pl.DataFrame(
+ {
+ "origin_geography_type": ["home_county"] * 4,
+ "origin_geography_id": ["A", "A", "B", "B"],
+ "destination_geography_type": ["home_county"] * 4,
+ "destination_geography_id": ["A", "B", "A", "B"],
+ "commuter_count": [12.0, 4.0, 3.0, 18.0],
+ }
+ ),
)
+ ]
+
+ comparison = flow_comparison_data(
+ observed,
+ modeled,
+ geography_type="home_county",
+ include_totals=False,
+ )
+
+ assert comparison[0][1].select(
+ "Origin",
+ "Destination",
+ "observed",
+ "modeled",
+ "difference",
+ "percent_difference",
+ ).to_dicts() == [
+ {
+ "Origin": "A",
+ "Destination": "A",
+ "observed": 10.0,
+ "modeled": 12.0,
+ "difference": 2.0,
+ "percent_difference": 20.0,
+ },
+ {
+ "Origin": "A",
+ "Destination": "B",
+ "observed": 5.0,
+ "modeled": 4.0,
+ "difference": -1.0,
+ "percent_difference": -20.0,
+ },
+ {
+ "Origin": "B",
+ "Destination": "A",
+ "observed": 3.0,
+ "modeled": 3.0,
+ "difference": 0.0,
+ "percent_difference": 0.0,
+ },
+ {
+ "Origin": "B",
+ "Destination": "B",
+ "observed": 20.0,
+ "modeled": 18.0,
+ "difference": -2.0,
+ "percent_difference": -10.0,
+ },
+ ]
+
+
+def test_regional_validation_page_compares_county_flows_to_commuting_flows(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ observed = pl.DataFrame(
+ {
+ "": ["A", "B", "Total"],
+ "A": [10.0, 3.0, 13.0],
+ "B": [5.0, 20.0, 25.0],
+ "Total": [15.0, 23.0, 38.0],
+ }
+ )
+ modeled = pl.DataFrame(
+ {
+ "origin_geography_type": ["home_county"] * 4,
+ "origin_geography_id": ["A", "A", "B", "B"],
+ "destination_geography_type": ["home_county"] * 4,
+ "destination_geography_id": ["A", "B", "A", "B"],
+ "commuter_count": [12.0, 4.0, 3.0, 18.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode={
+ "weighted": {
+ "county_flows_joja_validation_summary": observed,
+ "commuting_flows": modeled,
+ },
+ "unweighted": {
+ "county_flows_joja_validation_summary": observed,
+ "commuting_flows": modeled,
+ },
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = RegionalValidationPage(state, config)
+ page.refresh(force=True)
+
+ assert list(page.flow_matrix_sel.options) == ["County flows"]
+ assert list(page.comparison_metric_sel.options) == [
+ "Observed",
+ "Difference",
+ "Percent Difference",
+ "Absolute Percent Difference",
+ "Modeled",
+ ]
+ chart = page.render_flow_section()
+ tabs = chart.objects[0]
+ plot = tabs.objects[0][0]
+
+ assert plot.object.layout.title.text == "Observed County flows"
+ assert plot.object.data[0].z == ([10.0, 5.0], [3.0, 20.0])
+
+ page.comparison_metric_sel.value = "Difference"
+ chart = page.render_flow_section()
+ tabs = chart.objects[0]
+ plot = tabs.objects[0][0]
+
+ assert plot.object.layout.title.text == "Difference County flows"
+ assert plot.object.data[0].z == ([2.0, -1.0], [0.0, -2.0])
def test_resolve_page_definitions_defaults_to_default_pages_when_unconfigured(
@@ -132,7 +2054,7 @@ def test_resolve_page_definitions_defaults_to_default_pages_when_unconfigured(
) -> None:
config = _write_config(tmp_path, dashboard_pages=None)
- resolved_pages = resolve_page_definitions(config)
+ resolved_pages = resolve_live_page_definitions(config)
assert [page.title for page in resolved_pages] == EXPECTED_DEFAULT_LEAF_PAGE_TITLES
@@ -150,7 +2072,7 @@ def test_page_selectors_render_with_widget_label_instead_of_duplicate_markdown(
selector_row = page.view.objects[1]
assert isinstance(selector_row, pn.Row)
- assert selector_row.objects == [page.tour_purpose_sel]
+ assert selector_row.objects == [page.tour_purpose_sel, page.hide_drive_alone]
assert page.tour_purpose_sel.name == "Tour Purpose"
assert "page-selector-widget" in page.tour_purpose_sel.css_classes
assert PAGE_SELECTOR_STYLESHEET in page.tour_purpose_sel.stylesheets
@@ -164,7 +2086,7 @@ def test_resolve_page_definitions_respects_configured_page_order_and_subset(
dashboard_pages=["trip_mode", "overview", "joint_travel"],
)
- resolved_pages = resolve_page_definitions(config)
+ resolved_pages = resolve_live_page_definitions(config)
assert [page.page_id for page in resolved_pages] == [
"trip_mode",
@@ -185,7 +2107,7 @@ def test_resolve_page_definitions_supports_nested_group_child_selection(
],
)
- resolved_pages = resolve_page_definitions(config)
+ resolved_pages = resolve_live_page_definitions(config)
assert [page.page_id for page in resolved_pages] == [
"overview",
@@ -208,7 +2130,9 @@ def test_enabled_prepared_data_mode_tracks_optional_and_required_page_sets(
assert enabled_prepared_data_mode(raw_demo_config) == "required"
-def test_data_requirements_for_pages_aggregates_summary_and_prepared_dependencies() -> None:
+def test_data_requirements_for_pages_aggregates_summary_and_prepared_dependencies() -> (
+ None
+):
overview = page_definition_by_id("overview")
raw_trip_demo = page_definition_by_id("raw_trip_demo")
@@ -219,15 +2143,31 @@ def test_data_requirements_for_pages_aggregates_summary_and_prepared_dependencie
assert requirements.required_summary_ids == overview.required_summary_ids
+def test_data_requirements_for_pages_tracks_optional_summary_dependencies() -> None:
+ vmt = page_definition_by_id("vmt")
+ regional = page_definition_by_id("regional_validation")
+
+ requirements = data_requirements_for_pages([vmt, regional])
+
+ assert PERSONAL_AUTO_VMT_SUMMARY_ID in requirements.required_summary_ids
+ assert NON_MOTORIZED_VMT_SUMMARY_ID in requirements.required_summary_ids
+ assert "commercial_vmt_totals" not in requirements.required_summary_ids
+ assert "commercial_vmt_totals" not in requirements.optional_summary_ids
+ assert "auto_vmt_validation_summary" not in requirements.optional_summary_ids
+ assert "county_flows_validation_summary" in requirements.optional_summary_ids
+ assert "commuting_flows" in requirements.optional_summary_ids
+ assert "auto_vmt_validation_summary" not in requirements.summary_ids_for_pruning
+
+
def test_resolve_page_definitions_rejects_unknown_configured_page_ids(
tmp_path: Path,
) -> None:
config = _write_config(tmp_path, dashboard_pages=["overview", "unknown_page"])
with pytest.raises(
- ValueError, match="Unsupported visualizer.dashboard_pages entries"
+ ValueError, match="Unsupported dashboard.live.pages entries"
):
- resolve_page_definitions(config)
+ resolve_live_page_definitions(config)
def test_resolve_page_definitions_rejects_duplicate_configured_page_ids(
@@ -235,7 +2175,7 @@ def test_resolve_page_definitions_rejects_duplicate_configured_page_ids(
) -> None:
with pytest.raises(
ValueError,
- match="visualizer.dashboard_pages contains duplicate page id 'overview'",
+ match="dashboard.live.pages contains duplicate page id 'overview'",
):
_write_config(tmp_path, dashboard_pages=["overview", "overview"])
@@ -244,9 +2184,15 @@ def test_build_dashboard_uses_expected_default_page_order(tmp_path: Path) -> Non
config = _write_config(tmp_path)
template = build_dashboard([], config, summary_runs=[_full_summary_run()])
- assert [page.name for page in template._dashboard_pages] == EXPECTED_DEFAULT_PAGE_TITLES
- assert [page.page_id() for page in template._dashboard_pages] == EXPECTED_DEFAULT_PAGE_IDS
- assert [page.page_id() for page in template._dashboard_leaf_pages] == EXPECTED_DEFAULT_LEAF_PAGE_IDS
+ assert [
+ page.name for page in template._dashboard_pages
+ ] == EXPECTED_DEFAULT_PAGE_TITLES
+ assert [
+ page.page_id() for page in template._dashboard_pages
+ ] == EXPECTED_DEFAULT_PAGE_IDS
+ assert [
+ page.page_id() for page in template._dashboard_leaf_pages
+ ] == EXPECTED_DEFAULT_LEAF_PAGE_IDS
def test_build_dashboard_sidebar_uses_shared_run_legend_markup(tmp_path: Path) -> None:
@@ -272,7 +2218,9 @@ def test_build_dashboard_can_refresh_every_default_page_from_precomputed_summari
for index, page in enumerate(pages):
tabs.active = index
assert page.view is not None
- assert state.page_state["Overview"]["last_rendered_state"] == state.global_state_key()
+ assert (
+ state.page_state["overview"]["last_rendered_state"] == state.global_state_key()
+ )
leaf_pages = {page.page_id(): page for page in template._dashboard_leaf_pages}
assert [
@@ -280,25 +2228,37 @@ def test_build_dashboard_can_refresh_every_default_page_from_precomputed_summari
for selector in leaf_pages["trip_stop_distance"].registered_selectors
] == [
"tour_purpose",
+ "trip_stop_distance_min",
+ "trip_stop_distance_max",
]
assert leaf_pages["daily_activity_pattern"].person_type_sel.options == [
- "Total",
+ "All Person Types",
"worker",
]
- assert leaf_pages["joint_travel"].hhsize_sel.options == ["All", "2", "3"]
- assert leaf_pages["tour_time"].purpose_sel.options == ["Total", "work"]
- assert leaf_pages["tour_mode"].purpose_sel.options == ["Total", "work"]
- assert leaf_pages["tour_stop_frequency"].purpose_sel.options == [
+ assert leaf_pages["joint_travel"].hhsize_sel.options == [
"All",
+ "2",
+ "3",
+ "4",
+ "5+",
+ ]
+ assert leaf_pages["tour_time"].purpose_sel.options == ["All Tour Purposes", "work"]
+ assert leaf_pages["tour_mode"].purpose_sel.options == ["All Tour Purposes", "work"]
+ assert leaf_pages["tour_stop_frequency"].purpose_sel.options == [
+ "All Tour Purposes",
"eatout",
"social",
]
assert leaf_pages["trip_stop_time"].tour_purpose_sel.options == [
- "Total",
+ "All Tour Purposes",
+ "eatout",
+ "social",
+ ]
+ assert leaf_pages["trip_mode"].tour_purpose_sel.options == [
+ "All Tour Purposes",
"eatout",
"social",
]
- assert leaf_pages["trip_mode"].tour_purpose_sel.options == ["All", "eatout", "social"]
def test_build_dashboard_loads_prepared_runs_for_optional_default_pages_when_available(
@@ -312,13 +2272,17 @@ def test_build_dashboard_loads_prepared_runs_for_optional_default_pages_when_ava
)
assert template._dashboard_state.prepared_run_availability == "loaded"
- weighted_runs = template._dashboard_state.get_prepared_runs_if_loaded(weighted=True)
+ weighted_runs = template._dashboard_state.get_prepared_runs_if_loaded(
+ weighting_mode="weighted"
+ )
assert weighted_runs is not None
assert weighted_runs[0][0] == "Base"
-def test_build_dashboard_loads_prepared_runs_when_demo_page_is_enabled(tmp_path: Path) -> None:
+def test_build_dashboard_loads_prepared_runs_when_demo_page_is_enabled(
+ tmp_path: Path,
+) -> None:
config = _write_config(tmp_path, dashboard_pages=["raw_trip_demo"])
template = build_dashboard([("Base", _raw_trip_run())], config)
page = template._dashboard_pages[0]
@@ -336,7 +2300,9 @@ def test_build_dashboard_shows_unavailable_card_when_demo_page_has_no_prepared_r
page = template._dashboard_pages[0]
assert template._dashboard_state.prepared_run_availability == "unavailable"
- assert any(getattr(obj, "title", "") == "Data Not Available" for obj in page.view.objects)
+ assert any(
+ getattr(obj, "title", "") == "Data Not Available" for obj in page.view.objects
+ )
def test_dashboard_state_exposes_summary_first_accessors(tmp_path: Path) -> None:
@@ -353,7 +2319,7 @@ def test_dashboard_state_exposes_summary_first_accessors(tmp_path: Path) -> None
assert state.has_summary_table_set("missing_summary", "weighted") is False
assert totals[0][0] == "Base"
assert totals[0][1]["person_count"][0] == 100.0
- assert state.get_prepared_runs_if_loaded(weighted=True) is None
+ assert state.get_prepared_runs_if_loaded(weighting_mode="weighted") is None
assert state.prepared_run_availability == "not_requested"
@@ -383,8 +2349,12 @@ def test_dashboard_state_prepared_run_provider_supports_loaded_and_unavailable_m
prepared_run_provider=DashboardPreparedRunProvider.unavailable(),
)
- weighted_runs = loaded_state.get_prepared_runs_if_loaded(weighted=True)
- unweighted_runs = loaded_state.get_prepared_runs_if_loaded(weighted=False)
+ weighted_runs = loaded_state.get_prepared_runs_if_loaded(
+ weighting_mode="weighted"
+ )
+ unweighted_runs = loaded_state.get_prepared_runs_if_loaded(
+ weighting_mode="unweighted"
+ )
assert loaded_state.prepared_run_availability == "loaded"
assert weighted_runs is not None
@@ -393,7 +2363,10 @@ def test_dashboard_state_prepared_run_provider_supports_loaded_and_unavailable_m
assert unweighted_runs is not None
assert unweighted_runs[0][1].hh["finalweight"][0] == 1.0
assert unavailable_state.prepared_run_availability == "unavailable"
- assert unavailable_state.get_prepared_runs_if_loaded(weighted=True) is None
+ assert (
+ unavailable_state.get_prepared_runs_if_loaded(weighting_mode="weighted")
+ is None
+ )
def test_build_dashboard_switches_tabs_and_refreshes_only_the_active_page(
@@ -402,41 +2375,46 @@ def test_build_dashboard_switches_tabs_and_refreshes_only_the_active_page(
config = _write_config(tmp_path)
template = build_dashboard([], config, summary_runs=[_full_summary_run()])
state = template._dashboard_state
- pages = template._dashboard_pages
tabs = template.main[0]
assert state.active_tab == 0
- assert state.page_state["Overview"]["last_rendered_state"] == state.global_state_key()
- assert state.page_state["Daily Activity Pattern"].get("last_rendered_state") is None
+ assert (
+ state.page_state["overview"]["last_rendered_state"] == state.global_state_key()
+ )
+ assert state.page_state["daily_activity_pattern"].get("last_rendered_state") is None
tabs.active = 1
assert state.active_tab == 1
- assert state.page_state["Overview"]["last_rendered_state"] == state.global_state_key()
assert (
- state.page_state["Daily Activity Pattern"]["last_rendered_state"]
+ state.page_state["overview"]["last_rendered_state"] == state.global_state_key()
+ )
+ assert (
+ state.page_state["daily_activity_pattern"]["last_rendered_state"]
== state.global_state_key()
)
- assert state.page_state["Tour Purpose"].get("last_rendered_state") is None
+ assert state.page_state["tour_purpose"].get("last_rendered_state") is None
state.weight_mode = "Unweighted"
- assert state.page_state["Overview"]["last_rendered_state"] is None
+ assert state.page_state["overview"]["last_rendered_state"] is None
assert (
- state.page_state["Daily Activity Pattern"]["last_rendered_state"]
+ state.page_state["daily_activity_pattern"]["last_rendered_state"]
== state.global_state_key()
)
- assert state.page_state["Tour Purpose"].get("last_rendered_state") is None
+ assert state.page_state["tour_purpose"].get("last_rendered_state") is None
state.value_mode = "Count"
assert (
- state.page_state["Daily Activity Pattern"]["last_rendered_state"]
+ state.page_state["daily_activity_pattern"]["last_rendered_state"]
== state.global_state_key()
)
- assert state.page_state["Overview"].get("last_rendered_state") is None
- assert state.page_state["Mandatory Location Choice"].get("last_rendered_state") is None
- assert state.page_state["Tour Purpose"].get("last_rendered_state") is None
+ assert state.page_state["overview"].get("last_rendered_state") is None
+ assert (
+ state.page_state["mandatory_location_choice"].get("last_rendered_state") is None
+ )
+ assert state.page_state["tour_purpose"].get("last_rendered_state") is None
def test_build_dashboard_preserves_widget_state_across_tab_switches(
@@ -452,7 +2430,10 @@ def test_build_dashboard_preserves_widget_state_across_tab_switches(
)
tabs.active = 1
- assert daily_activity_pattern_page.person_type_sel.options == ["Total", "worker"]
+ assert daily_activity_pattern_page.person_type_sel.options == [
+ "All Person Types",
+ "worker",
+ ]
daily_activity_pattern_page.person_type_sel.value = "worker"
tabs.active = 0
@@ -461,7 +2442,89 @@ def test_build_dashboard_preserves_widget_state_across_tab_switches(
assert daily_activity_pattern_page.person_type_sel.value == "worker"
-def test_dashboard_page_cache_helpers_reuse_summary_and_filtered_view_results(
+def test_build_dashboard_preserves_individual_choices_person_type_across_tab_switches(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ summary_run = _full_summary_run()
+ worker_summary_run = type(summary_run)(
+ label=summary_run.label,
+ run_key=summary_run.run_key,
+ summaries_by_mode={
+ mode: {
+ **summary_run.summaries_by_mode[mode],
+ "license_holding_status_distribution": pl.DataFrame(
+ {
+ "person_type": ["all_person_types", "worker"],
+ "license_holding_status": ["has_license", "has_license"],
+ "person_count": [80.0, 40.0],
+ "pct": [0.8, 1.0],
+ }
+ ),
+ "bicycle_comfort_level_distribution": pl.DataFrame(
+ {
+ "person_type": ["all_person_types", "worker"],
+ "bicycle_comfort_level": [
+ "InterestedButConcerned",
+ "StrongAndFearless",
+ ],
+ "person_count": [50.0, 20.0],
+ "pct": [0.5, 0.5],
+ }
+ ),
+ "transit_pass_ownership_by_person_type": pl.DataFrame(
+ {
+ "person_type": ["all_person_types", "worker"],
+ "transit_pass_ownership_status": ["no_pass", "has_pass"],
+ "person_count": [70.0, 15.0],
+ "pct": [0.7, 0.375],
+ }
+ ),
+ "transit_subsidy_by_person_type": pl.DataFrame(
+ {
+ "person_type": ["all_person_types", "worker"],
+ "transit_subsidy_status": ["none", "full"],
+ "person_count": [65.0, 10.0],
+ "pct": [0.65, 0.25],
+ }
+ ),
+ }
+ for mode in summary_run.summaries_by_mode
+ },
+ source_run_dir=summary_run.source_run_dir,
+ manifest=summary_run.manifest,
+ )
+ template = build_dashboard([], config, summary_runs=[worker_summary_run])
+ top_tabs = template.main[0]
+ long_term_choices_index = next(
+ index
+ for index, page in enumerate(template._dashboard_pages)
+ if page.page_id() == "long_term_choices"
+ )
+ long_term_choices_page = template._dashboard_pages[long_term_choices_index]
+ individual_choices_index = next(
+ index
+ for index, page in enumerate(long_term_choices_page.pages)
+ if page.page_id() == "individual_choices"
+ )
+ individual_choices_page = long_term_choices_page.pages[individual_choices_index]
+
+ top_tabs.active = long_term_choices_index
+ long_term_choices_page.view.active = individual_choices_index
+ assert individual_choices_page.person_type_sel.options == [
+ "All Person Types",
+ "worker",
+ ]
+
+ individual_choices_page.person_type_sel.value = "worker"
+ top_tabs.active = 0
+ top_tabs.active = long_term_choices_index
+ long_term_choices_page.view.active = individual_choices_index
+
+ assert individual_choices_page.person_type_sel.value == "worker"
+
+
+def test_dashboard_page_cache_helpers_reuse_summary_and_query_results(
tmp_path: Path,
) -> None:
config = _write_config(tmp_path)
@@ -470,8 +2533,14 @@ def test_dashboard_page_cache_helpers_reuse_summary_and_filtered_view_results(
label=summary_run.label,
run_key=summary_run.run_key,
summaries_by_mode={
- "weighted": {**summary_run.summaries_by_mode["weighted"], "probe_summary": pl.DataFrame({"value": ["summary"]})},
- "unweighted": {**summary_run.summaries_by_mode["unweighted"], "probe_summary": pl.DataFrame({"value": ["summary"]})},
+ "weighted": {
+ **summary_run.summaries_by_mode["weighted"],
+ "probe_summary": pl.DataFrame({"value": ["summary"]}),
+ },
+ "unweighted": {
+ **summary_run.summaries_by_mode["unweighted"],
+ "probe_summary": pl.DataFrame({"value": ["summary"]}),
+ },
},
source_run_dir=summary_run.source_run_dir,
manifest=summary_run.manifest,
@@ -480,24 +2549,20 @@ def test_dashboard_page_cache_helpers_reuse_summary_and_filtered_view_results(
summary_runs=[probe_summary_run],
weighting_modes=config.weighting_modes,
)
- call_counts = {"filtered_view": 0}
+ call_counts = {"query": 0}
class CacheProbePage(DashboardPage):
def __init__(self) -> None:
- super().__init__("Cache Probe", state, config)
+ super().__init__(state, config)
self.view = pn.Column()
- def _filtered_view_factory(self) -> dict[str, str]:
- call_counts["filtered_view"] += 1
- return {"kind": "filtered_view"}
+ def _query_factory(self) -> dict[str, str]:
+ call_counts["query"] += 1
+ return {"kind": "query"}
def _refresh(self) -> None:
- self.summary_value = self.require_summary("probe_summary")
- self.filtered_view_value = self.get_filtered_view(
- "probe_view",
- "default",
- factory=self._filtered_view_factory,
- )
+ self.summary_value = self.data.summary("probe_summary")
+ self.query_value = self.query(self._query_factory)
page = CacheProbePage()
@@ -506,10 +2571,10 @@ def _refresh(self) -> None:
page.mark_stale()
page.refresh_if_needed()
- assert call_counts == {"filtered_view": 1}
+ assert call_counts == {"query": 1}
assert page.summary_value[0][1]["value"][0] == "summary"
- assert page.filtered_view_value == {"kind": "filtered_view"}
- assert state.cache_stats["filtered_view"] == {"hits": 2, "misses": 1}
+ assert page.query_value == {"kind": "query"}
+ assert state.cache_stats["page_query"] == {"hits": 2, "misses": 1}
def test_skim_pages_render_selector_controls_and_independent_sections(
@@ -877,7 +2942,11 @@ def test_skim_pages_render_disaggregated_distribution_plots_when_prepared_runs_a
"skimjoin_trip_component_ecdf": pl.DataFrame(
{
"trip_mode": ["SOV", "SOV", "SOV"],
- "component": ["skim_auto_time", "skim_auto_time", "skim_auto_time"],
+ "component": [
+ "skim_auto_time",
+ "skim_auto_time",
+ "skim_auto_time",
+ ],
"percentile": [0.0, 0.99, 1.0],
"value": [10.0, 14.0, 200.0],
"n_valid": [100.0, 100.0, 100.0],
@@ -968,9 +3037,9 @@ def test_skim_pages_render_disaggregated_distribution_plots_when_prepared_runs_a
trip_page.refresh(force=True)
assert isinstance(trip_page._distribution_section.objects[-1], pn.pane.Plotly)
- assert tuple(trip_page._distribution_section.objects[-1].object.layout.xaxis.range) == pytest.approx(
- (10.0, 200.0)
- )
+ assert tuple(
+ trip_page._distribution_section.objects[-1].object.layout.xaxis.range
+ ) == pytest.approx((10.0, 200.0))
assert trip_page._distribution_section.objects[-1].object.layout.title.text == (
"Trip Distribution - skim_auto_time / All Modes"
)
@@ -978,30 +3047,32 @@ def test_skim_pages_render_disaggregated_distribution_plots_when_prepared_runs_a
trip_page.trip_min_sel.value = 11.0
trip_page.trip_max_sel.value = 13.0
- assert tuple(trip_page._distribution_section.objects[-1].object.layout.xaxis.range) == pytest.approx(
- (11.0, 13.0)
- )
+ assert tuple(
+ trip_page._distribution_section.objects[-1].object.layout.xaxis.range
+ ) == pytest.approx((11.0, 13.0))
trip_page.trip_reset_btn.clicks = trip_page.trip_reset_btn.clicks + 1
- assert tuple(trip_page._distribution_section.objects[-1].object.layout.xaxis.range) == pytest.approx(
- (10.0, 200.0)
- )
+ assert tuple(
+ trip_page._distribution_section.objects[-1].object.layout.xaxis.range
+ ) == pytest.approx((10.0, 200.0))
tour_page = TourSkimsPage(state, config)
tour_page.refresh(force=True)
- outbound_plot = tour_page._distribution_section.objects[1]
- inbound_plot = tour_page._distribution_section.objects[3]
+ outbound_note_view = tour_page._distribution_section.objects[1]
+ inbound_note_view = tour_page._distribution_section.objects[3]
+ assert "calculation-note-view" in outbound_note_view.css_classes
+ assert "calculation-note-view" in inbound_note_view.css_classes
+ outbound_plot = outbound_note_view.objects[0]
+ inbound_plot = inbound_note_view.objects[0]
assert isinstance(outbound_plot, pn.pane.Plotly)
assert isinstance(inbound_plot, pn.pane.Plotly)
assert tuple(outbound_plot.object.layout.xaxis.range) == pytest.approx(
(10.0, 200.0)
)
- assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx(
- (10.0, 200.0)
- )
+ assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx((10.0, 200.0))
assert outbound_plot.object.layout.title.text == (
"Outbound Tour Distribution - skim_auto_time_outbound / All Modes"
)
@@ -1014,23 +3085,17 @@ def test_skim_pages_render_disaggregated_distribution_plots_when_prepared_runs_a
tour_page.inbound_min_sel.value = 11.0
tour_page.inbound_max_sel.value = 13.0
- outbound_plot = tour_page._distribution_section.objects[1]
- inbound_plot = tour_page._distribution_section.objects[3]
- assert tuple(outbound_plot.object.layout.xaxis.range) == pytest.approx(
- (11.0, 13.0)
- )
- assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx(
- (11.0, 13.0)
- )
+ outbound_plot = tour_page._distribution_section.objects[1].objects[0]
+ inbound_plot = tour_page._distribution_section.objects[3].objects[0]
+ assert tuple(outbound_plot.object.layout.xaxis.range) == pytest.approx((11.0, 13.0))
+ assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx((11.0, 13.0))
tour_page.outbound_reset_btn.clicks = tour_page.outbound_reset_btn.clicks + 1
tour_page.inbound_reset_btn.clicks = tour_page.inbound_reset_btn.clicks + 1
- outbound_plot = tour_page._distribution_section.objects[1]
- inbound_plot = tour_page._distribution_section.objects[3]
+ outbound_plot = tour_page._distribution_section.objects[1].objects[0]
+ inbound_plot = tour_page._distribution_section.objects[3].objects[0]
assert tuple(outbound_plot.object.layout.xaxis.range) == pytest.approx(
(10.0, 200.0)
)
- assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx(
- (10.0, 200.0)
- )
+ assert tuple(inbound_plot.object.layout.xaxis.range) == pytest.approx((10.0, 200.0))
diff --git a/tests/test_export_html.py b/tests/test_export_html.py
index b7ab2fd..6dee1b6 100644
--- a/tests/test_export_html.py
+++ b/tests/test_export_html.py
@@ -14,12 +14,13 @@
from _dashboard_expectations import EXPECTED_DEFAULT_LEAF_PAGES, EXPECTED_DEFAULT_PAGES
from dashboard.export.html import (
ExportBuildError,
+ _iter_script_safe_payload_json,
build_export_html_document,
write_export_html_document,
)
from dashboard.export.types import EXPORT_CLIENT_RUNTIME, EXPORT_SCHEMA_VERSION
from processor.models import RunData
-from processor.summarize.cache import create_summary_run
+from processor.summarize.cache_types import create_summary_run
from runtime.config import Config
@@ -31,7 +32,7 @@ def _write_config(
modes_lines: list[str] | None = None,
geography_lines: list[str] | None = None,
export_html_lines: list[str] | None = None,
- visualizer_lines: list[str] | None = None,
+ display_lines: list[str] | None = None,
extra_lines: list[str] | None = None,
) -> Config:
weighting_modes = weighting_modes or ["weighted", "unweighted"]
@@ -39,45 +40,59 @@ def _write_config(
lines = [
'name: "Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
+ "root: summary_cache",
+ "summarize:",
" weighting_modes:",
]
lines.extend(f" - {mode}" for mode in weighting_modes)
+ if geography_lines:
+ lines.append(" geography:")
+ lines.extend(f" {line}" for line in geography_lines)
lines.extend(
[
- "visualizer:",
- ' dashboard_title: "Test Dashboard"',
+ "dashboard:",
+ ' title: "Test Dashboard"',
]
)
- if visualizer_lines:
- lines.extend(f" {line}" for line in visualizer_lines)
if dashboard_pages is ...:
dashboard_pages = [page_id for page_id, _ in EXPECTED_DEFAULT_PAGES]
if dashboard_pages is not None:
- lines.append(" dashboard_pages:")
+ lines.extend([" live:", " pages:"])
for entry in dashboard_pages:
if isinstance(entry, str):
- lines.append(f" - {entry}")
+ lines.append(f" - {entry}")
continue
if isinstance(entry, dict) and len(entry) == 1:
page_id, children = next(iter(entry.items()))
- lines.append(f" - {page_id}:")
+ lines.append(f" - {page_id}:")
for child_id in children:
- lines.append(f" - {child_id}")
+ lines.append(f" - {child_id}")
continue
raise ValueError("dashboard_pages test helper only supports strings or single-key child mappings.")
- if export_html_lines:
- lines.append(" export_html:")
+ if export_html_lines is not None:
+ lines.append(" export:")
lines.extend(f" {line}" for line in export_html_lines)
+ if display_lines:
+ lines.append("display:")
+ lines.extend(f" {line}" for line in display_lines)
if modes_lines:
lines.append("modes:")
lines.extend(f" {line}" for line in modes_lines)
else:
lines.append("modes: {}")
- if geography_lines:
- lines.append("geography:")
- lines.extend(f" {line}" for line in geography_lines)
+ if export_html_lines is not None:
+ pipeline_steps = (
+ "segment, summarize, dashboard"
+ if extra_lines and "segment:" in extra_lines
+ else "summarize, dashboard"
+ )
+ lines.extend(
+ [
+ "pipeline:",
+ f" steps: [{pipeline_steps}]",
+ " dashboard_mode: export",
+ ]
+ )
if extra_lines:
lines.extend(extra_lines)
@@ -138,7 +153,8 @@ def _full_summary_run():
"auto_vmt_totals": pl.DataFrame({"auto_vmt": [180.0]}),
"auto_ownership_distribution": pl.DataFrame(
{
- "household_vehicle_count": [0, 1],
+ "household_size": ["1", "5+"],
+ "household_vehicle_count": [0, 4],
"household_count": [12.0, 18.0],
}
),
@@ -184,13 +200,6 @@ def _full_summary_run():
"person_count": [5.0, 1.0, 3.0, 0.5, 2.0, 0.5],
}
),
- "geo_flows": pl.DataFrame(
- {
- "Home Geography": ["Urban", "Suburban"],
- "Work Geography": ["Urban", "Suburban"],
- "Workers": [7.0, 4.0],
- }
- ),
"internal_external_worker_by_geography": pl.DataFrame(
{
"geography_level": ["Urban", "Suburban"],
@@ -337,19 +346,6 @@ def _full_summary_run():
"household_percent": [40.0, 60.0, 37.5, 62.5],
}
),
- "destination_distance": pl.DataFrame(
- {
- "purpose": ["All NM", "All NM", "eatout", "eatout", "social", "social"],
- "distbin": [0, 1, 0, 1, 0, 1],
- "freq": [5.0, 7.5, 2.0, 4.0, 3.0, 2.0],
- }
- ),
- "destination_average_distance": pl.DataFrame(
- {
- "purpose": ["eatout", "social"],
- "avg_distance": [3.25, 4.5],
- }
- ),
"tour_time_of_day_by_tour_purpose": pl.DataFrame(
{
"tour_purpose": [
@@ -404,6 +400,12 @@ def _full_summary_run():
"tour_count": [18.0, 5.0, 10.0, 5.0, 8.0],
}
),
+ "atwork_subtour_frequency_distribution": pl.DataFrame(
+ {
+ "atwork_subtour_frequency_category": ["0", "1+"],
+ "atwork_subtour_count": [6.0, 4.0],
+ }
+ ),
"stop_destination_purpose_by_tour_purpose": pl.DataFrame(
{
"tour_purpose": [
@@ -670,8 +672,7 @@ def test_export_html_config_segmentation_defaults_to_live_dashboard_settings(
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: segments_only",
@@ -695,9 +696,7 @@ def test_export_html_config_segmentation_defaults_to_live_dashboard_settings(
" label: Male",
" values: [1]",
],
- export_html_lines=[
- "enabled: true",
- ],
+ export_html_lines=[],
)
assert config.export_html.dashboard.segmentation_type == "signup_platform"
@@ -710,8 +709,7 @@ def test_export_html_config_supports_segmentation_overrides(
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: segments_only",
@@ -765,7 +763,7 @@ def test_export_html_config_supports_new_summaries_and_visualizer_sections(
assert config.summary_root.endswith("summary_cache")
assert _configured_page_ids(config) == ["overview", "trip_mode"]
assert config.export_html.enabled is True
- assert list(config.export_html.pages) == ["trip_mode"]
+ assert list(config.export_html.pages) == ["trip_mode", "overview"]
assert config.export_html.pages_configured is True
@@ -775,7 +773,6 @@ def test_export_html_config_resolves_output_path_relative_to_root(
config = _write_config(
tmp_path,
export_html_lines=[
- "enabled: true",
"output_path: exports/dashboard.html",
],
)
@@ -791,9 +788,7 @@ def test_export_html_enabled_without_pages_uses_all_dashboard_states_and_all_sel
config = _write_config(
tmp_path,
dashboard_pages=["overview", "trip_mode"],
- export_html_lines=[
- "enabled: true",
- ],
+ export_html_lines=[],
)
assert config.export_html.enabled is True
@@ -914,26 +909,14 @@ def test_config_allows_missing_dashboard_pages(tmp_path: Path) -> None:
assert config.dashboard_pages is None
-def test_config_defaults_when_summaries_and_visualizer_sections_are_absent(
+def test_config_defaults_when_optional_sections_are_absent(
tmp_path: Path,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"\n".join(
[
- 'name: "Legacy Layout"',
- 'dashboard_title: "Ignored Legacy Title"',
- "run_colors:",
- ' - "#111111"',
- "outputs:",
- " summary_root: ignored_summary_cache",
- " weighting_modes:",
- " - weighted",
- " export_html:",
- " dashboard:",
- " weighting: all",
- "dashboard_pages:",
- " - raw_trip_demo",
+ 'name: "Minimal Layout"',
"runs: []",
]
),
@@ -946,7 +929,7 @@ def test_config_defaults_when_summaries_and_visualizer_sections_are_absent(
(tmp_path / "artifacts" / "summary_cache").resolve()
)
assert config.weighting_modes == ["weighted", "unweighted"]
- assert config.dashboard_title == "Ignored Legacy Title"
+ assert config.dashboard_title == "ActivitySim Visualizer"
assert config.dashboard_pages is None
assert config.run_colors == [
"#1f77b4",
@@ -961,55 +944,9 @@ def test_config_defaults_when_summaries_and_visualizer_sections_are_absent(
assert config.export_html.dashboard.weighting == ["weighted", "unweighted"]
assert config.export_html.dashboard.values == ["percent", "count"]
assert config.export_html.pages == {}
-
-
-def test_config_prefers_visualizer_dashboard_title_over_legacy_top_level_title(
- tmp_path: Path,
-) -> None:
- config_path = tmp_path / "config.yaml"
- config_path.write_text(
- "\n".join(
- [
- 'name: "Dashboard Title Precedence"',
- 'dashboard_title: "Legacy Dashboard Title"',
- "runs: []",
- "visualizer:",
- ' dashboard_title: "Visualizer Dashboard Title"',
- ]
- ),
- encoding="utf-8",
- )
-
- config = Config.from_yaml(config_path)
-
- assert config.dashboard_title == "Visualizer Dashboard Title"
-
-
-def test_config_ignores_flat_export_html_dashboard_aliases(tmp_path: Path) -> None:
- config_path = tmp_path / "config.yaml"
- config_path.write_text(
- "\n".join(
- [
- 'name: "Flat Export Legacy"',
- "runs: []",
- "visualizer:",
- " export_html:",
- " weighting: all",
- " values: all",
- ]
- ),
- encoding="utf-8",
- )
-
- config = Config.from_yaml(config_path)
-
- assert config.export_html.dashboard.weighting == ["weighted", "unweighted"]
- assert config.export_html.dashboard.values == ["percent", "count"]
-
-
def test_export_html_config_rejects_invalid_or_empty_values(tmp_path: Path) -> None:
with pytest.raises(
- ValueError, match="Unsupported visualizer.export_html.dashboard.weighting"
+ ValueError, match="Unsupported dashboard.export.dashboard.weighting"
):
_write_config(
tmp_path / "invalid",
@@ -1023,14 +960,13 @@ def test_export_html_config_rejects_invalid_or_empty_values(tmp_path: Path) -> N
with pytest.raises(
ValueError,
- match="visualizer.export_html.dashboard.segmentation_type must name one configured segmentation definition",
+ match="dashboard.export.dashboard.segmentation_type must name one configured segment definition",
):
_write_config(
tmp_path / "invalid_export_segmentation_type",
extra_lines=[
- "segmentation:",
- " enabled: true",
- " definitions:",
+ "segment:",
+ " definitions:",
" signup_platform:",
" source:",
" type: prepared_column",
@@ -1049,14 +985,13 @@ def test_export_html_config_rejects_invalid_or_empty_values(tmp_path: Path) -> N
with pytest.raises(
ValueError,
- match="visualizer.export_html.dashboard.segmentation_visibility must be one of full_only, segments_only, or full_and_segments",
+ match="dashboard.export.dashboard.segmentation_visibility must be one of full_only, segments_only, or full_and_segments",
):
_write_config(
tmp_path / "invalid_export_segmentation_visibility",
extra_lines=[
- "segmentation:",
- " enabled: true",
- " definitions:",
+ "segment:",
+ " definitions:",
" signup_platform:",
" source:",
" type: prepared_column",
@@ -1077,7 +1012,7 @@ def test_export_html_config_rejects_invalid_or_empty_values(tmp_path: Path) -> N
def test_config_rejects_duplicate_dashboard_pages(tmp_path: Path) -> None:
with pytest.raises(
ValueError,
- match="visualizer.dashboard_pages contains duplicate page id",
+ match="dashboard.live.pages contains duplicate page id",
):
_write_config(
tmp_path,
@@ -1086,7 +1021,7 @@ def test_config_rejects_duplicate_dashboard_pages(tmp_path: Path) -> None:
with pytest.raises(
ValueError,
- match="visualizer.export_html.dashboard.values resolved to no values",
+ match="dashboard.export.dashboard.values resolved to no values",
):
_write_config(
tmp_path / "empty",
@@ -1098,7 +1033,7 @@ def test_config_rejects_duplicate_dashboard_pages(tmp_path: Path) -> None:
with pytest.raises(
ValueError,
- match="visualizer\\.export_html\\.pages\\.trip_summaries(\\.children)?\\.trip_mode\\.tour_purpose resolved to no values",
+ match="dashboard\\.export\\.pages\\.trip_summaries(\\.children)?\\.trip_mode\\.tour_purpose resolved to no values",
):
_write_config(
tmp_path / "empty_page_values",
@@ -1148,6 +1083,10 @@ def _flatten_page_descriptors(pages: list[dict]) -> dict[str, dict]:
return by_id
+def _selector_by_id(page: dict, selector_id: str) -> dict:
+ return next(selector for selector in page["selectors"] if selector["id"] == selector_id)
+
+
def _walk_nodes(node: dict) -> list[dict]:
if node.get("kind") == "page":
return _walk_nodes(node["content"])
@@ -1182,15 +1121,11 @@ def _plot_node_by_title(node: dict, title: str) -> dict:
)
+@pytest.mark.full_export
def test_build_export_html_document_serializes_dashboard_states_and_pages(
- tmp_path: Path,
+ representative_full_export_html: str,
) -> None:
- config = _write_config(tmp_path)
- html = build_export_html_document(
- [],
- config,
- summary_runs=[_full_summary_run()],
- )
+ html = representative_full_export_html
payload = _extract_payload(html)
assert payload["schema_version"] == EXPORT_SCHEMA_VERSION
@@ -1213,9 +1148,13 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
assert payload["client_runtime"] == EXPORT_CLIENT_RUNTIME
enabled_selectors = payload["page_export_support"]["enabled_page_selectors"]
assert {"page_id": "daily_activity_pattern", "selector_id": "person_type"} in enabled_selectors
+ assert {"page_id": "vehicle_ownership_type", "selector_id": "household_size"} in enabled_selectors
assert {"page_id": "joint_travel", "selector_id": "household_size"} in enabled_selectors
+ assert {"page_id": "joint_travel", "selector_id": "hide_no_joint_tours"} in enabled_selectors
assert {"page_id": "tour_stop_frequency", "selector_id": "tour_purpose"} in enabled_selectors
assert {"page_id": "trip_mode", "selector_id": "tour_purpose"} in enabled_selectors
+ assert {"page_id": "tour_mode", "selector_id": "hide_drive_alone"} in enabled_selectors
+ assert {"page_id": "trip_mode", "selector_id": "hide_drive_alone"} in enabled_selectors
assert sorted(payload["states"]) == [
"Unweighted||Count",
"Unweighted||Percent",
@@ -1239,6 +1178,25 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
"worker",
]
assert page_defs["daily_activity_pattern"]["selectors"][0]["export_enabled"] is True
+ vehicle_household_size = _selector_by_id(
+ page_defs["vehicle_ownership_type"], "household_size"
+ )
+ assert vehicle_household_size["request_mode"] == "all"
+ assert vehicle_household_size["resolved_values"] == [
+ "All",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5+",
+ ]
+ assert vehicle_household_size["export_enabled"] is True
+ joint_travel_hide_no_joint_tours = _selector_by_id(
+ page_defs["joint_travel"], "hide_no_joint_tours"
+ )
+ assert joint_travel_hide_no_joint_tours["request_mode"] == "all"
+ assert joint_travel_hide_no_joint_tours["resolved_values"] == ["False", "True"]
+ assert joint_travel_hide_no_joint_tours["export_enabled"] is True
assert page_defs["tour_time"]["selectors"][0]["id"] == "tour_purpose"
assert page_defs["tour_time"]["selectors"][0]["request_mode"] == "all"
assert page_defs["tour_time"]["selectors"][0]["resolved_values"] == [
@@ -1246,13 +1204,17 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
"work",
]
assert page_defs["tour_time"]["selectors"][0]["export_enabled"] is True
- assert page_defs["tour_mode"]["selectors"][0]["id"] == "tour_purpose"
- assert page_defs["tour_mode"]["selectors"][0]["request_mode"] == "all"
- assert page_defs["tour_mode"]["selectors"][0]["resolved_values"] == [
+ tour_mode_purpose = _selector_by_id(page_defs["tour_mode"], "tour_purpose")
+ tour_mode_hide_drive_alone = _selector_by_id(page_defs["tour_mode"], "hide_drive_alone")
+ assert tour_mode_purpose["request_mode"] == "all"
+ assert tour_mode_purpose["resolved_values"] == [
"All Tour Purposes",
"work",
]
- assert page_defs["tour_mode"]["selectors"][0]["export_enabled"] is True
+ assert tour_mode_purpose["export_enabled"] is True
+ assert tour_mode_hide_drive_alone["request_mode"] == "all"
+ assert tour_mode_hide_drive_alone["resolved_values"] == ["False", "True"]
+ assert tour_mode_hide_drive_alone["export_enabled"] is True
assert page_defs["tour_stop_frequency"]["selectors"][0]["id"] == "tour_purpose"
assert page_defs["tour_stop_frequency"]["selectors"][0]["request_mode"] == "all"
assert page_defs["tour_stop_frequency"]["selectors"][0]["resolved_values"] == [
@@ -1269,14 +1231,18 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
"social",
]
assert page_defs["trip_stop_time"]["selectors"][0]["export_enabled"] is True
- assert page_defs["trip_mode"]["selectors"][0]["id"] == "tour_purpose"
- assert page_defs["trip_mode"]["selectors"][0]["request_mode"] == "all"
- assert page_defs["trip_mode"]["selectors"][0]["resolved_values"] == [
+ trip_mode_purpose = _selector_by_id(page_defs["trip_mode"], "tour_purpose")
+ trip_mode_hide_drive_alone = _selector_by_id(page_defs["trip_mode"], "hide_drive_alone")
+ assert trip_mode_purpose["request_mode"] == "all"
+ assert trip_mode_purpose["resolved_values"] == [
"All Tour Purposes",
"eatout",
"social",
]
- assert page_defs["trip_mode"]["selectors"][0]["export_enabled"] is True
+ assert trip_mode_purpose["export_enabled"] is True
+ assert trip_mode_hide_drive_alone["request_mode"] == "all"
+ assert trip_mode_hide_drive_alone["resolved_values"] == ["False", "True"]
+ assert trip_mode_hide_drive_alone["export_enabled"] is True
weighted_percent = payload["states"]["Weighted||Percent"]
overview = weighted_percent["overview"]
assert overview["kind"] == "page"
@@ -1287,6 +1253,30 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
'["All Person Types"]',
'["worker"]',
]
+ vehicle_ownership_type = weighted_percent["vehicle_ownership_type"]
+ assert vehicle_ownership_type["kind"] == "page"
+ assert _region_nodes(vehicle_ownership_type)["vehicle_ownership_summary"][
+ "selector_ids"
+ ] == ["household_size"]
+ assert sorted(
+ _region_nodes(vehicle_ownership_type)["vehicle_ownership_summary"]["variants"]
+ ) == [
+ '["1"]',
+ '["2"]',
+ '["3"]',
+ '["4"]',
+ '["5+"]',
+ '["All"]',
+ ]
+ joint_travel = weighted_percent["joint_travel"]
+ assert joint_travel["kind"] == "page"
+ assert _region_nodes(joint_travel)["joint_travel_frequency"]["selector_ids"] == [
+ "hide_no_joint_tours",
+ ]
+ assert sorted(_region_nodes(joint_travel)["joint_travel_frequency"]["variants"]) == [
+ '["False"]',
+ '["True"]',
+ ]
tour_time = weighted_percent["tour_time"]
assert tour_time["kind"] == "page"
assert _region_nodes(tour_time)["tour_time_body"]["selector_ids"] == ["tour_purpose"]
@@ -1298,10 +1288,13 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
assert tour_mode["kind"] == "page"
assert _region_nodes(tour_mode)["tour_mode_modes"]["selector_ids"] == [
"tour_purpose",
+ "hide_drive_alone",
]
assert sorted(_region_nodes(tour_mode)["tour_mode_modes"]["variants"]) == [
- '["All Tour Purposes"]',
- '["work"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["work","False"]',
+ '["work","True"]',
]
tour_stop_frequency = weighted_percent["tour_stop_frequency"]
assert tour_stop_frequency["kind"] == "page"
@@ -1325,11 +1318,17 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
]
trip_mode = weighted_percent["trip_mode"]
assert trip_mode["kind"] == "page"
- assert _region_nodes(trip_mode)["trip_summary_mode_body"]["selector_ids"] == ["tour_purpose"]
+ assert _region_nodes(trip_mode)["trip_summary_mode_body"]["selector_ids"] == [
+ "tour_purpose",
+ "hide_drive_alone",
+ ]
assert sorted(_region_nodes(trip_mode)["trip_summary_mode_body"]["variants"]) == [
- '["All Tour Purposes"]',
- '["eatout"]',
- '["social"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["eatout","False"]',
+ '["eatout","True"]',
+ '["social","False"]',
+ '["social","True"]',
]
widget_nodes = [
node for node in _walk_nodes(daily_activity_pattern) if node.get("kind") == "widget"
@@ -1343,7 +1342,7 @@ def test_build_export_html_document_serializes_dashboard_states_and_pages(
)
-def test_build_export_html_document_respects_configured_dashboard_page_subset_and_order(
+def test_build_export_html_document_applies_overrides_without_narrowing_live_pages(
tmp_path: Path,
) -> None:
config = _write_config(
@@ -1454,10 +1453,10 @@ def test_build_export_html_document_keeps_summary_safe_skims_content_and_hides_p
) -> None:
config = _write_config(
tmp_path,
- dashboard_pages=["skims"],
+ dashboard_pages=["skim_summaries"],
export_html_lines=[
"pages:",
- " skims: {}",
+ " skim_summaries: {}",
],
)
@@ -1469,7 +1468,7 @@ def test_build_export_html_document_keeps_summary_safe_skims_content_and_hides_p
)
assert [(page["id"], page["title"]) for page in payload["pages"]] == [
- ("skims", "Skim Summaries")
+ ("skim_summaries", "Skim Summaries")
]
assert not any(
node.get("selector_id") in {"trip_min", "trip_max", "tour_min", "tour_max"}
@@ -1489,16 +1488,21 @@ def test_build_export_html_document_validates_page_selector_requests_against_reg
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[
+ {"daily_travel": ["daily_activity_pattern"]},
+ {"tour_summaries": ["tour_time", "tour_stop_frequency", "tour_mode"]},
+ {"trip_summaries": ["trip_mode", "trip_stop_time"]},
+ ],
export_html_lines=[
"dashboard:",
- " weighting: all",
- " values: all",
+ " weighting: weighted",
+ " values: percent",
"pages:",
" daily_travel:",
" children:",
" daily_activity_pattern:",
" person_type:",
- " - total",
+ " - all",
" - worker",
" tour_summaries:",
" children:",
@@ -1603,17 +1607,23 @@ def test_build_export_html_document_validates_page_selector_requests_against_reg
assert tour_mode_weighted_percent["kind"] == "page"
assert _region_nodes(tour_mode_weighted_percent)["tour_mode_modes"]["selector_ids"] == [
"tour_purpose",
+ "hide_drive_alone",
]
assert sorted(_region_nodes(tour_mode_weighted_percent)["tour_mode_modes"]["variants"]) == [
- '["All Tour Purposes"]',
- '["work"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["work","False"]',
+ '["work","True"]',
]
trip_mode_weighted_percent = payload["states"]["Weighted||Percent"]["trip_mode"]
assert trip_mode_weighted_percent["kind"] == "page"
assert sorted(_region_nodes(trip_mode_weighted_percent)["trip_summary_mode_body"]["variants"]) == [
- '["All Tour Purposes"]',
- '["eatout"]',
- '["social"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["eatout","False"]',
+ '["eatout","True"]',
+ '["social","False"]',
+ '["social","True"]',
]
@@ -1622,6 +1632,7 @@ def test_build_export_html_document_keeps_grouped_tour_mode_chart_when_mode_grou
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"tour_summaries": ["tour_mode"]}],
modes_lines=[
"groups:",
" Auto:",
@@ -1644,14 +1655,17 @@ def test_build_export_html_document_keeps_grouped_tour_mode_chart_when_mode_grou
assert tour_mode["kind"] == "page"
assert _region_nodes(tour_mode)["tour_mode_modes"]["selector_ids"] == [
"tour_purpose",
+ "hide_drive_alone",
]
assert sorted(_region_nodes(tour_mode)["tour_mode_modes"]["variants"]) == [
- '["All Tour Purposes"]',
- '["work"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["work","False"]',
+ '["work","True"]',
]
region_nodes = _region_nodes(tour_mode)
variant_nodes = _walk_nodes(
- region_nodes["tour_mode_modes"]["variants"]['["All Tour Purposes"]']
+ region_nodes["tour_mode_modes"]["variants"]['["All Tour Purposes","False"]']
)
assert any(
node.get("kind") == "plotly"
@@ -1674,7 +1688,7 @@ def test_build_export_html_document_serializes_vehicle_occupancy_variants_for_to
) -> None:
config = _write_config(
tmp_path,
- dashboard_pages=["tour_summaries"],
+ dashboard_pages=[{"tour_summaries": ["tour_mode"]}],
export_html_lines=[
"pages:",
" tour_mode:",
@@ -1718,6 +1732,7 @@ def test_build_export_html_document_serializes_long_term_geography_variants(
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"long_term_choices": ["mandatory_location_choice"]}],
geography_lines=[
"enabled: true",
"landuse_col: COUNTY",
@@ -1737,24 +1752,146 @@ def test_build_export_html_document_serializes_long_term_geography_variants(
assert page_defs["mandatory_location_choice"]["selectors"][0]["id"] == "geography_level"
assert page_defs["mandatory_location_choice"]["selectors"][0]["request_mode"] == "all"
assert set(page_defs["mandatory_location_choice"]["selectors"][0]["resolved_values"]) == {
- "All Geographies",
+ "All Geography Types",
"Suburban",
"Urban",
}
assert page_defs["mandatory_location_choice"]["selectors"][0]["export_enabled"] is True
assert page_defs["mandatory_location_choice"]["selectors"][1]["id"] == "geography"
- assert page_defs["mandatory_location_choice"]["selectors"][1]["export_enabled"] is True
- assert "All" in page_defs["mandatory_location_choice"]["selectors"][1]["resolved_values"]
- assert len(page_defs["mandatory_location_choice"]["selectors"][1]["resolved_values"]) > 1
+ geography_selector = page_defs["mandatory_location_choice"]["selectors"][1]
+ assert geography_selector["export_enabled"] is True
+ assert "All" in geography_selector["resolved_values"]
+ assert len(geography_selector["resolved_values"]) > 1
+ assert geography_selector["parent_selector_id"] == "geography_level"
+ assert set(geography_selector["options_by_parent_value"]) == {
+ "All Geography Types",
+ "Suburban",
+ "Urban",
+ }
+ assert geography_selector["options_by_parent_value"]["All Geography Types"] == [
+ "All"
+ ]
+ assert geography_selector["disabled_parent_values"] == ["All Geography Types"]
mandatory_location_choice = payload["states"]["Weighted||Percent"]["mandatory_location_choice"]
assert mandatory_location_choice["kind"] == "page"
- commuting_variants = sorted(
- _region_nodes(mandatory_location_choice)["commuting_flows"]["variants"]
+ assert "commuting_flows" not in _region_nodes(mandatory_location_choice)
+
+
+def test_mandatory_location_export_filters_dependent_names_to_exported_types(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=[{"long_term_choices": ["mandatory_location_choice"]}],
+ export_html_lines=[
+ "pages:",
+ " long_term_choices:",
+ " mandatory_location_choice:",
+ " geography_level:",
+ " - All Geography Types",
+ " - County",
+ " - MPO",
+ ],
)
- assert '["All","All"]' in commuting_variants
- assert any("Urban" in key for key in commuting_variants)
- assert any("Suburban" in key for key in commuting_variants)
+ base_run = _full_summary_run()
+ summaries_by_mode: dict[str, dict[str, pl.DataFrame]] = {}
+ for weighting_mode, summaries in base_run.summaries_by_mode.items():
+ updated = dict(summaries)
+ factor = 1.0 if weighting_mode == "weighted" else 0.5
+ updated["internal_external_worker_by_geography"] = pl.DataFrame(
+ {
+ "geography_level": ["county", "mpo", "taz", "taz"],
+ "geography": ["Alpha County", "Regional MPO", "101", "102"],
+ "internal_worker_count": [10.0, 20.0, 3.0, 4.0],
+ "external_worker_count": [1.0, 2.0, 0.0, 1.0],
+ }
+ ).with_columns(
+ pl.col("internal_worker_count") * factor,
+ pl.col("external_worker_count") * factor,
+ )
+ distance = pl.DataFrame(
+ {
+ "geography_level": ["county", "mpo", "taz", "taz"],
+ "geography": ["Alpha County", "Regional MPO", "101", "102"],
+ "distance_bin": [1.0, 1.0, 1.0, 1.0],
+ "person_count": [10.0, 20.0, 3.0, 4.0],
+ }
+ ).with_columns(pl.col("person_count") * factor)
+ for summary_id in (
+ "work_location_distance_distribution_by_geography",
+ "school_location_distance_distribution_by_geography",
+ "university_location_distance_distribution_by_geography",
+ ):
+ updated[summary_id] = distance
+ updated["average_mandatory_tour_distance_by_purpose_and_geography"] = (
+ pl.DataFrame(
+ {
+ "geography_level": ["county", "mpo", "taz", "taz"],
+ "geography": ["Alpha County", "Regional MPO", "101", "102"],
+ "mandatory_tour_purpose": ["work", "work", "work", "work"],
+ "average_tour_distance": [8.0, 9.0, 4.0, 5.0],
+ }
+ )
+ )
+ updated["work_from_home_rate_by_geography"] = pl.DataFrame(
+ {
+ "geography_level": ["county", "mpo", "taz", "taz"],
+ "geography": ["Alpha County", "Regional MPO", "101", "102"],
+ "worker_count": [10.0, 20.0, 3.0, 4.0],
+ "work_from_home_worker_count": [1.0, 2.0, 0.0, 1.0],
+ }
+ ).with_columns(
+ pl.col("worker_count") * factor,
+ pl.col("work_from_home_worker_count") * factor,
+ )
+ summaries_by_mode[weighting_mode] = updated
+
+ summary_run = create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode=summaries_by_mode,
+ source_run_dir="C:/runs/base",
+ )
+ html = build_export_html_document([], config, summary_runs=[summary_run])
+ payload = _extract_payload(html)
+ page = _flatten_page_descriptors(payload["pages"])["mandatory_location_choice"]
+ geography_type = _selector_by_id(page, "geography_level")
+ geography_name = _selector_by_id(page, "geography")
+
+ assert geography_type["resolved_values"] == [
+ "All Geography Types",
+ "County",
+ "MPO",
+ ]
+ assert geography_name["options_by_parent_value"] == {
+ "All Geography Types": ["All"],
+ "County": ["All", "Alpha County"],
+ "MPO": ["All", "Regional MPO"],
+ }
+ assert geography_name["resolved_values"] == [
+ "All",
+ "Alpha County",
+ "Regional MPO",
+ ]
+ assert "101" not in geography_name["options"]
+ assert "102" not in geography_name["options"]
+
+ page_node = payload["states"]["Weighted||Percent"]["mandatory_location_choice"]
+ geography_widget = next(
+ node
+ for node in _walk_nodes(page_node)
+ if node.get("kind") == "widget" and node.get("selector_id") == "geography"
+ )
+ assert geography_widget["parent_selector_id"] == "geography_level"
+ assert geography_widget["disabled_parent_values"] == ["All Geography Types"]
+ assert geography_widget["options_by_parent_value"] == (
+ geography_name["options_by_parent_value"]
+ )
+
+ regions = _region_nodes(page_node)
+ assert regions
+ assert all(not region["variant_aliases"] for region in regions.values())
def test_build_export_html_document_warns_and_falls_back_when_long_term_geography_is_unavailable(
@@ -1762,6 +1899,7 @@ def test_build_export_html_document_warns_and_falls_back_when_long_term_geograph
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"long_term_choices": ["shadow_pricing"]}],
export_html_lines=[
"pages:",
" long_term_choices:",
@@ -1789,6 +1927,7 @@ def test_build_export_html_document_serializes_stop_frequency_four_chart_variant
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"tour_summaries": ["tour_stop_frequency"]}],
export_html_lines=[
"pages:",
" tour_stop_frequency:",
@@ -1817,10 +1956,30 @@ def test_build_export_html_document_serializes_stop_frequency_four_chart_variant
if node.get("kind") == "plotly"
}
assert {
- "Tour Stop Frequency - eatout, Both",
- "Tour Stop Frequency - eatout, Outbound",
- "Tour Stop Frequency - eatout, Inbound",
+ "Tour Stop Frequency - Purpose: eatout, Direction: Both",
+ "Tour Stop Frequency - Purpose: eatout, Direction: Outbound",
+ "Tour Stop Frequency - Purpose: eatout, Direction: Inbound",
}.issubset(plotly_titles)
+ direction_rows = [
+ node
+ for node in variant_nodes
+ if node.get("kind") == "container"
+ and node.get("layout") == "row"
+ and {
+ descendant.get("figure", {})
+ .get("layout", {})
+ .get("title", {})
+ .get("text")
+ for child in node.get("children", [])
+ for descendant in _walk_nodes(child)
+ if descendant.get("kind") == "plotly"
+ }
+ == {
+ "Tour Stop Frequency - Purpose: eatout, Direction: Outbound",
+ "Tour Stop Frequency - Purpose: eatout, Direction: Inbound",
+ }
+ ]
+ assert len(direction_rows) == 1
def test_build_export_html_document_serializes_stop_timing_two_chart_variant(
@@ -1828,6 +1987,7 @@ def test_build_export_html_document_serializes_stop_timing_two_chart_variant(
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=["trip_stop_time"],
export_html_lines=[
"pages:",
" trip_stop_time:",
@@ -1853,11 +2013,112 @@ def test_build_export_html_document_serializes_stop_timing_two_chart_variant(
assert sum(1 for node in variant_nodes if node.get("kind") == "plotly") == 2
+def test_build_export_html_document_serializes_trip_stop_distance_two_chart_variant(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["trip_stop_distance"],
+ export_html_lines=[
+ "pages:",
+ " trip_stop_distance:",
+ " tour_purpose: all",
+ ],
+ )
+
+ summary_run = _full_summary_run()
+ distance_summary_run = type(summary_run)(
+ label=summary_run.label,
+ run_key=summary_run.run_key,
+ summaries_by_mode={
+ mode: {
+ **summary_run.summaries_by_mode[mode],
+ "trip_distance_by_purpose": pl.DataFrame(
+ {
+ "tour_purpose": [
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "eatout",
+ "eatout",
+ "social",
+ "social",
+ ],
+ "distance_bin": ["0-1", "1-2", "0-1", "1-2", "0-1", "1-2"],
+ "trip_count": [10.0, 8.0, 4.0, 2.0, 3.0, 1.0],
+ "pct": [0.56, 0.44, 0.67, 0.33, 0.75, 0.25],
+ }
+ ),
+ "stop_out_of_direction_distance_by_tour_purpose": pl.DataFrame(
+ {
+ "tour_purpose": [
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "eatout",
+ "eatout",
+ "social",
+ "social",
+ ],
+ "distance_bin": ["0-1", "1-2", "0-1", "1-2", "0-1", "1-2"],
+ "stop_count": [7.0, 5.0, 3.0, 1.0, 2.0, 1.0],
+ "pct": [0.58, 0.42, 0.75, 0.25, 0.67, 0.33],
+ }
+ ),
+ }
+ for mode in summary_run.summaries_by_mode
+ },
+ source_run_dir=summary_run.source_run_dir,
+ manifest=summary_run.manifest,
+ )
+
+ html = build_export_html_document([], config, summary_runs=[distance_summary_run])
+ payload = _extract_payload(html)
+ trip_stop_distance = payload["states"]["Weighted||Percent"]["trip_stop_distance"]
+
+ assert trip_stop_distance["kind"] == "page"
+ region = _region_nodes(trip_stop_distance)["trip_stop_distance_body"]
+ assert region["selector_ids"] == ["tour_purpose"]
+ assert sorted(
+ region["variants"]
+ ) == [
+ '["All Tour Purposes"]',
+ '["eatout"]',
+ '["social"]',
+ ]
+ variant_nodes = _walk_nodes(
+ region["variants"]['["All Tour Purposes"]']
+ )
+ assert sum(1 for node in variant_nodes if node.get("kind") == "plotly") == 2
+ range_widgets = [
+ node
+ for node in _walk_nodes(trip_stop_distance)
+ if node.get("kind") == "widget"
+ and node.get("name")
+ in {"Distance Min", "Distance Max", "Reset distance range"}
+ ]
+ assert {node["widget_type"] for node in range_widgets} == {
+ "button",
+ "float_input",
+ "select",
+ }
+ assert sum(node["widget_type"] == "button" for node in range_widgets) >= 1
+ assert sum(node["widget_type"] == "float_input" for node in range_widgets) >= 1
+ assert sum(node["widget_type"] == "select" for node in range_widgets) >= 1
+ assert any(
+ node["widget_type"] == "select" and node.get("value") == "40+"
+ for node in range_widgets
+ )
+ assert all(node.get("disabled") for node in range_widgets)
+ assert all(not node.get("export_enabled") for node in range_widgets)
+ all_variant_plots = [node for node in variant_nodes if node.get("kind") == "plotly"]
+ assert all_variant_plots[0]["figure"]["layout"]["xaxis"]["range"] == [0.0, 40.0]
+
+
def test_build_export_html_document_serializes_joint_tours_hh_size_variants(
tmp_path: Path,
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"daily_travel": ["daily_activity_pattern"]}],
export_html_lines=[
"pages:",
" daily_travel:",
@@ -1883,16 +2144,95 @@ def test_build_export_html_document_serializes_joint_tours_hh_size_variants(
)
+def test_build_export_html_document_serializes_joint_frequency_hide_no_joint_tours_variant(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["joint_travel"],
+ export_html_lines=[
+ "pages:",
+ " joint_travel:",
+ " hide_no_joint_tours: all",
+ ],
+ )
+ summary_run = _full_summary_run()
+ summary_run = create_summary_run(
+ label=summary_run.label,
+ run_key=summary_run.run_key,
+ summaries_by_mode={
+ mode: {
+ **summary_run.summaries_by_mode[mode],
+ "joint_tours_by_household_size": pl.DataFrame(
+ {
+ "household_size": [2, 3],
+ "household_count": [6.0, 4.0],
+ "joint_tour_hh_count": [3.0, 2.0],
+ }
+ ),
+ "joint_tour_composition_by_party_size": pl.DataFrame(
+ {
+ "tour_composition": ["adults", "mixed"],
+ "party_size": [2, 3],
+ "joint_tour_count": [3.0, 2.0],
+ }
+ ),
+ "person_jtp_by_household_size": pl.DataFrame(
+ {
+ "household_size": [2, 3],
+ "joint_tour_person_count": [2.0, 3.0],
+ "total_person_count": [4.0, 3.0],
+ }
+ ),
+ }
+ for mode in summary_run.summaries_by_mode
+ },
+ summary_metadata_by_mode=summary_run.summary_metadata_by_mode,
+ )
+
+ html = build_export_html_document([], config, summary_runs=[summary_run])
+ payload = _extract_payload(html)
+ joint_travel = payload["states"]["Weighted||Percent"]["joint_travel"]
+ frequency_region = _region_nodes(joint_travel)["joint_travel_frequency"]
+
+ assert frequency_region["selector_ids"] == ["hide_no_joint_tours"]
+ assert sorted(frequency_region["variants"]) == ['["False"]', '["True"]']
+
+ checked_nodes = _walk_nodes(frequency_region["variants"]['["True"]'])
+ checked_plot = next(
+ node
+ for node in checked_nodes
+ if node.get("kind") == "plotly"
+ and node.get("figure", {}).get("layout", {}).get("title", {}).get("text")
+ == "Joint Tour Frequency by Joint Tour Pattern"
+ )
+ checked_x_values = [
+ value
+ for trace in checked_plot.get("figure", {}).get("data", [])
+ for value in trace.get("x", [])
+ ]
+ checked_y_values = [
+ value
+ for trace in checked_plot.get("figure", {}).get("data", [])
+ for value in trace.get("y", [])
+ ]
+
+ assert "No Joint Tours" not in checked_x_values
+ assert checked_x_values == ["1 Shopping", "1 Maintenance"]
+ assert checked_y_values == pytest.approx([25.0, 15.0])
+
+
def test_build_export_html_document_serializes_trip_mode_tour_purpose_variants(
tmp_path: Path,
) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=["trip_mode"],
export_html_lines=[
"pages:",
" trip_summaries:",
" trip_mode:",
- " tour_purpose: all",
+ " tour_purpose: all",
],
)
@@ -1902,9 +2242,12 @@ def test_build_export_html_document_serializes_trip_mode_tour_purpose_variants(
assert trip_mode["kind"] == "page"
assert sorted(_region_nodes(trip_mode)["trip_summary_mode_body"]["variants"]) == [
- '["All Tour Purposes"]',
- '["eatout"]',
- '["social"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["eatout","False"]',
+ '["eatout","True"]',
+ '["social","False"]',
+ '["social","True"]',
]
page_nodes = _walk_nodes(trip_mode)
widget_nodes = [node for node in page_nodes if node.get("kind") == "widget"]
@@ -1914,9 +2257,15 @@ def test_build_export_html_document_serializes_trip_mode_tour_purpose_variants(
and not node.get("disabled")
for node in widget_nodes
)
+ assert any(
+ node.get("selector_id") == "hide_drive_alone"
+ and node.get("export_enabled")
+ and not node.get("disabled")
+ for node in widget_nodes
+ )
assert not any(node.get("selector_id") == "tour_mode" for node in widget_nodes)
variant_nodes = _walk_nodes(
- _region_nodes(trip_mode)["trip_summary_mode_body"]["variants"]['["eatout"]']
+ _region_nodes(trip_mode)["trip_summary_mode_body"]["variants"]['["eatout","False"]']
)
assert sum(1 for node in variant_nodes if node.get("kind") == "plotly") == 3
@@ -1933,7 +2282,7 @@ def test_build_export_html_document_rejects_unknown_page_and_selector_ids(
],
)
with pytest.raises(
- ValueError, match="Unsupported visualizer.export_html.pages entries"
+ ValueError, match="Unsupported dashboard.export.pages entries"
):
build_export_html_document(
[], bad_page_config, summary_runs=[_full_summary_run()]
@@ -1950,7 +2299,7 @@ def test_build_export_html_document_rejects_unknown_page_and_selector_ids(
)
with pytest.raises(
ValueError,
- match="Unsupported visualizer.export_html.pages.trip_summaries.trip_mode entries",
+ match="Unsupported dashboard.export.pages.trip_summaries.trip_mode entries",
):
build_export_html_document(
[],
@@ -1962,10 +2311,13 @@ def test_build_export_html_document_rejects_unknown_page_and_selector_ids(
def test_export_html_save_writes_single_client_side_html_file(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=[{"trip_summaries": ["trip_mode"]}],
export_html_lines=[
"dashboard:",
" weighting: all",
" values: all",
+ "pages:",
+ " trip_mode: {}",
],
)
out_dir = tmp_path / "html_export"
@@ -2001,10 +2353,57 @@ def test_export_html_save_writes_single_client_side_html_file(tmp_path: Path) ->
assert "Weighted||Percent" in diagnostics["states"]
+def test_export_html_save_streams_without_building_full_payload_or_document(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["overview"],
+ export_html_lines=["pages:", " overview: {}"],
+ )
+ out_path = tmp_path / "dashboard.html"
+
+ def fail_in_memory_build(*args, **kwargs):
+ raise AssertionError("production writer used an in-memory export representation")
+
+ monkeypatch.setattr(
+ "dashboard.export.html._serialize_export_payload_json",
+ fail_in_memory_build,
+ )
+ monkeypatch.setattr(
+ "dashboard.export.html._build_export_html_shell_document",
+ fail_in_memory_build,
+ )
+
+ write_export_html_document(out_path, [], config, summary_runs=[_full_summary_run()])
+
+ payload = _extract_payload(out_path.read_text(encoding="utf-8"))
+ assert payload["title"] == config.dashboard_title
+ assert payload["states"]["Weighted||Percent"]["overview"]
+
+
+def test_streamed_payload_escapes_script_end_token_across_encoder_chunks(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def chunked_iterencode(self, payload):
+ del self, payload
+ return iter(['{"html": "<', '/script>"}'])
+
+ monkeypatch.setattr(json.JSONEncoder, "iterencode", chunked_iterencode)
+
+ payload_json = "".join(
+ _iter_script_safe_payload_json({"html": ""})
+ )
+
+ assert payload_json == '{"html": "<\\/script>"}'
+ assert json.loads(payload_json) == {"html": ""}
+
+
def test_export_html_config_supports_missing_data_display(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
- visualizer_lines=["missing_data_display: blank"],
+ display_lines=["missing_data_display: blank"],
)
assert config.missing_data_display == "blank"
@@ -2015,11 +2414,11 @@ def test_export_html_config_rejects_invalid_missing_data_display(
) -> None:
with pytest.raises(
ValueError,
- match="visualizer.missing_data_display must be either 'card' or 'blank'",
+ match="display.missing_data_display must be either 'card' or 'blank'",
):
_write_config(
tmp_path,
- visualizer_lines=["missing_data_display: loud"],
+ display_lines=["missing_data_display: loud"],
)
@@ -2048,33 +2447,44 @@ def test_export_html_save_fails_fast_without_final_files_when_html_write_fails(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- config = _write_config(tmp_path)
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["overview"],
+ export_html_lines=["pages:", " overview: {}"],
+ )
out_path = tmp_path / "dashboard.html"
- def fail_html_temp_write(path: Path, contents: str) -> None:
- if "dashboard.html" in path.name and path.suffix == ".tmp":
- raise OSError("disk full")
- path.write_text(contents, encoding="utf-8")
+ def fail_html_temp_write(path: Path, **kwargs) -> None:
+ path.write_text("partial", encoding="utf-8")
+ raise OSError("disk full")
- monkeypatch.setattr("dashboard.export.html._write_text_file", fail_html_temp_write)
+ monkeypatch.setattr(
+ "dashboard.export.html._write_streamed_export_html",
+ fail_html_temp_write,
+ )
with pytest.raises(ExportBuildError, match="write HTML atomically"):
write_export_html_document(out_path, [], config, summary_runs=[_full_summary_run()])
assert not out_path.exists()
assert not (tmp_path / "dashboard.diagnostics.json").exists()
+ assert not list(tmp_path.glob(".*.tmp"))
def test_export_html_save_rejects_malformed_assembled_html_before_finalizing(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- config = _write_config(tmp_path)
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["overview"],
+ export_html_lines=["pages:", " overview: {}"],
+ )
out_path = tmp_path / "dashboard.html"
monkeypatch.setattr(
- "dashboard.export.html.build_export_html_shell",
- lambda **kwargs: "broken export",
+ "dashboard.export.html.build_export_html_shell_parts",
+ lambda **kwargs: ("broken export", ""),
)
with pytest.raises(ExportBuildError, match="validate assembled HTML"):
diff --git a/tests/test_export_html_smoke.py b/tests/test_export_html_smoke.py
index 9776065..33f7790 100644
--- a/tests/test_export_html_smoke.py
+++ b/tests/test_export_html_smoke.py
@@ -3,7 +3,6 @@
import json
from pathlib import Path
import sys
-from uuid import uuid4
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -18,7 +17,7 @@
def _workspace_tmp_dir(label: str) -> Path:
- path = Path("tmp_export_test_artifacts") / f"{label}_{uuid4().hex}"
+ path = Path(".pytest_tmp") / "export_helpers" / label
path.mkdir(parents=True, exist_ok=True)
return path
@@ -27,10 +26,13 @@ def test_export_html_smoke_writes_single_self_contained_file() -> None:
tmp_path = _workspace_tmp_dir("html_smoke_file")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"trip_summaries": ["trip_mode"]}],
export_html_lines=[
"dashboard:",
" weighting: all",
" values: all",
+ "pages:",
+ " trip_mode: {}",
],
)
out_path = tmp_path / "smoke" / "dashboard.html"
@@ -52,6 +54,10 @@ def test_export_html_smoke_writes_single_self_contained_file() -> None:
assert "activitysim-export-data" in html
assert "Export payload JSON could not be parsed." in html
assert "Plotly.react" in html
+ assert 'displayModeBar: "hover"' in html
+ assert "scale: 2" in html
+ assert 'name: "Download CSV"' in html
+ assert "modeBarButtonsToAdd: [makePlotCsvDownloadButton(figure)]" in html
assert "Offline export failed to load" in html
assert "This HTML export encountered a runtime rendering error." in html
assert "Unknown export node kind encountered:" in html
@@ -66,7 +72,7 @@ def test_export_html_smoke_embeds_versioned_payload_and_runtime() -> None:
tmp_path = _workspace_tmp_dir("html_smoke_payload")
config = _write_config(
tmp_path,
- dashboard_pages=["trip_summaries"],
+ dashboard_pages=[{"trip_summaries": ["trip_mode"]}],
export_html_lines=[
"pages:",
" trip_summaries:",
@@ -94,27 +100,24 @@ def test_export_html_smoke_embeds_versioned_payload_and_runtime() -> None:
("trip_summaries", "Trip Summaries")
]
trip_summaries = payload["pages"][0]
- assert trip_summaries["default_page_id"] == "trip_stop_purpose"
+ assert trip_summaries["default_page_id"] == "trip_mode"
assert [(child["id"], child["title"]) for child in trip_summaries["children"]] == [
- ("trip_stop_purpose", "Trip and Stop Purpose"),
("trip_mode", "Trip Mode"),
- ("trip_stop_time", "Trip and Stop Time"),
- ("trip_stop_distance", "Trip and Stop Distance"),
]
trip_mode = next(child for child in trip_summaries["children"] if child["id"] == "trip_mode")
- assert trip_mode["selectors"] == [
- {
- "id": "tour_purpose",
- "label": "Tour Purpose",
- "available": True,
- "request_mode": "all",
- "requested_values": [],
- "resolved_values": ["All", "eatout", "social"],
- "default_value": "All",
- "options": ["All", "eatout", "social"],
- "export_enabled": True,
- }
- ]
+ selectors = {selector["id"]: selector for selector in trip_mode["selectors"]}
+ assert selectors["tour_purpose"] == {
+ "id": "tour_purpose",
+ "label": "Tour Purpose",
+ "available": True,
+ "request_mode": "all",
+ "requested_values": [],
+ "resolved_values": ["All Tour Purposes", "eatout", "social"],
+ "default_value": "All Tour Purposes",
+ "options": ["All Tour Purposes", "eatout", "social"],
+ "export_enabled": True,
+ }
+ assert selectors["hide_drive_alone"]["resolved_values"] == ["False", "True"]
assert payload["states"]["Weighted||Percent"]["trip_mode"]["kind"] == "page"
assert "Unsupported export schema version." in html
assert "__EXPORT_SCHEMA_VERSION__" not in html
@@ -126,8 +129,10 @@ def test_export_runtime_assets_are_loaded_from_source_files() -> None:
assert ".export-shell" in css
assert ".export-error-panel" in css
+ assert ".export-table-sort" in css
assert "function validatePayloadSchema(candidate)" in runtime_js
assert "function renderPlot(node, context)" in runtime_js
+ assert "function renderTable(node)" in runtime_js
assert "function renderNode(node, context, actions, leafPageId)" in runtime_js
assert "function renderRegion(node, context, actions, leafPageId)" in runtime_js
assert "function getLeafPageId(currentPayload, currentState)" in runtime_js
@@ -141,6 +146,11 @@ def test_export_html_smoke_serializes_grouped_default_page_as_leaf_page_id() ->
tmp_path = _workspace_tmp_dir("html_smoke_grouped_defaults")
config = _write_config(
tmp_path,
+ dashboard_pages=[
+ {"daily_travel": ["daily_activity_pattern"]},
+ {"tour_summaries": ["tour_purpose"]},
+ {"trip_summaries": ["trip_stop_purpose"]},
+ ],
export_html_lines=[
"dashboard:",
" weighting: all",
diff --git a/tests/test_export_payload.py b/tests/test_export_payload.py
index d73e7eb..6628c65 100644
--- a/tests/test_export_payload.py
+++ b/tests/test_export_payload.py
@@ -2,7 +2,8 @@
from pathlib import Path
import sys
-from uuid import uuid4
+
+import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -11,12 +12,19 @@
from dashboard import DashboardState
from dashboard.data_access import DashboardPreparedRunProvider
from dashboard.export.context import ExportBuildContext
-from dashboard.export.payload import build_export_payload
+from dashboard.export.page_serializer import (
+ VMT_EXPORT_DROPDOWN_NOTE,
+ _with_export_page_notes,
+)
+from dashboard.export.payload import (
+ build_export_payload,
+)
from dashboard.export.types import (
EXPORT_CLIENT_RUNTIME,
EXPORT_PAGE_SELECTOR_RUNTIME,
EXPORT_SCHEMA_VERSION,
)
+from dashboard.page_definitions import DashboardPageDefinition
from test_export_html import (
_full_summary_run,
_segmented_summary_runs,
@@ -28,7 +36,7 @@
def _workspace_tmp_dir(label: str) -> Path:
- path = Path("tmp_export_test_artifacts") / f"{label}_{uuid4().hex}"
+ path = Path(".pytest_tmp") / "export_helpers" / label
path.mkdir(parents=True, exist_ok=True)
return path
@@ -42,6 +50,36 @@ def _plot_nodes(page_payload: dict) -> list[dict]:
return [node for node in _walk_nodes(page_payload) if node.get("kind") == "plotly"]
+def test_vmt_export_content_includes_dropdown_availability_note() -> None:
+ title_node = {"kind": "html", "html": "VMT Validation
"}
+ section_node = {"kind": "html", "html": "Personal Auto VMT
"}
+ content = {
+ "kind": "container",
+ "layout": "column",
+ "child_count": 2,
+ "children": [title_node, section_node],
+ "styles": {},
+ "css_classes": [],
+ }
+
+ vmt_content = _with_export_page_notes(
+ DashboardPageDefinition(page_id="vmt", title="VMT Validation"),
+ content,
+ )
+ overview_content = _with_export_page_notes(
+ DashboardPageDefinition(page_id="overview", title="Overview"),
+ content,
+ )
+
+ assert overview_content is content
+ assert vmt_content["kind"] == "container"
+ assert vmt_content["child_count"] == 3
+ assert vmt_content["children"][0] is title_node
+ assert VMT_EXPORT_DROPDOWN_NOTE in vmt_content["children"][1]["html"]
+ assert vmt_content["children"][2] is section_node
+
+
+@pytest.mark.full_export
def test_build_export_payload_has_stable_top_level_contract() -> None:
tmp_path = _workspace_tmp_dir("payload_contract")
config = _write_config(
@@ -99,25 +137,45 @@ def test_trip_mode_export_keeps_explicit_height_for_overall_plot() -> None:
plot
for plot in plots
if plot.get("figure", {}).get("layout", {}).get("title", {}).get("text")
- == "Trip Mode Distribution - All Tour Purposes"
+ == "Trip Mode Distribution for All Tours"
)
assert overall_plot["height"] == 400
assert overall_plot["figure"]["layout"]["height"] == 400
+def test_density_hover_mode_is_serialized_in_export_payload() -> None:
+ tmp_path = _workspace_tmp_dir("payload_density_hover")
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["tour_time"],
+ extra_lines=[
+ "display:",
+ " density_hover_mode: all",
+ ],
+ )
+
+ payload = build_export_payload([], config, summary_runs=[_full_summary_run()])
+ plots = _plot_nodes(payload["states"]["Weighted||Percent"]["tour_time"])
+
+ assert any(
+ plot.get("figure", {}).get("layout", {}).get("hovermode") == "x unified"
+ for plot in plots
+ )
+
+
def test_build_export_payload_defaults_to_live_segmentation_filter() -> None:
tmp_path = _workspace_tmp_dir("payload_segmentation_fallback")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"daily_travel": ["daily_activity_pattern"]}],
export_html_lines=[
"pages:",
" daily_travel:",
" daily_activity_pattern: {}",
],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: segments_only",
@@ -161,6 +219,7 @@ def test_build_export_payload_honors_export_segmentation_overrides() -> None:
tmp_path = _workspace_tmp_dir("payload_segmentation_override")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"daily_travel": ["daily_activity_pattern"]}],
export_html_lines=[
"dashboard:",
" segmentation_type: person_sex",
@@ -170,8 +229,7 @@ def test_build_export_payload_honors_export_segmentation_overrides() -> None:
" daily_activity_pattern: {}",
],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: segments_only",
@@ -215,6 +273,7 @@ def test_build_export_payload_supports_export_full_only_segmentation() -> None:
tmp_path = _workspace_tmp_dir("payload_segmentation_full_only")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"daily_travel": ["daily_activity_pattern"]}],
export_html_lines=[
"dashboard:",
" segmentation_type: signup_platform",
@@ -224,8 +283,7 @@ def test_build_export_payload_supports_export_full_only_segmentation() -> None:
" daily_activity_pattern: {}",
],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: full_and_segments",
@@ -263,8 +321,7 @@ def test_export_build_context_does_not_change_live_segmentation_defaults() -> No
" segmentation_visibility: full_only",
],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: signup_platform",
" visibility: segments_only",
@@ -315,12 +372,22 @@ def test_build_export_payload_serializes_representative_page_region_structure(
tmp_path = _workspace_tmp_dir("payload_variants")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"trip_summaries": ["trip_mode"]}],
export_html_lines=[
"pages:",
" trip_summaries:",
" trip_mode:",
" tour_purpose: all",
],
+ extra_lines=[
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " DRIVEALONE: Drive Alone",
+ " WALK: Walk",
+ " SHARED: Shared Ride",
+ ],
)
payload = build_export_payload([], config, summary_runs=[_full_summary_run()])
@@ -335,11 +402,22 @@ def test_build_export_payload_serializes_representative_page_region_structure(
"available": True,
"request_mode": "all",
"requested_values": [],
- "resolved_values": ["All", "eatout", "social"],
- "default_value": "All",
- "options": ["All", "eatout", "social"],
+ "resolved_values": ["All Tour Purposes", "eatout", "social"],
+ "default_value": "All Tour Purposes",
+ "options": ["All Tour Purposes", "eatout", "social"],
"export_enabled": True,
- }
+ },
+ {
+ "id": "hide_drive_alone",
+ "label": "Hide Auto Modes",
+ "available": True,
+ "request_mode": "all",
+ "requested_values": [],
+ "resolved_values": ["False", "True"],
+ "default_value": "False",
+ "options": ["False", "True"],
+ "export_enabled": True,
+ },
]
trip_mode = payload["states"]["Weighted||Percent"]["trip_mode"]
@@ -347,12 +425,15 @@ def test_build_export_payload_serializes_representative_page_region_structure(
regions = _region_nodes(trip_mode)
assert sorted(regions) == ["trip_summary_mode_body"]
trip_mode_region = regions["trip_summary_mode_body"]
- assert trip_mode_region["selector_ids"] == ["tour_purpose"]
- assert trip_mode_region["default_key"] == '["All"]'
+ assert trip_mode_region["selector_ids"] == ["tour_purpose", "hide_drive_alone"]
+ assert trip_mode_region["default_key"] == '["All Tour Purposes","False"]'
assert sorted(trip_mode_region["variants"]) == [
- '["All"]',
- '["eatout"]',
- '["social"]',
+ '["All Tour Purposes","False"]',
+ '["All Tour Purposes","True"]',
+ '["eatout","False"]',
+ '["eatout","True"]',
+ '["social","False"]',
+ '["social","True"]',
]
page_nodes = _walk_nodes(trip_mode)
assert any(
@@ -362,13 +443,28 @@ def test_build_export_payload_serializes_representative_page_region_structure(
and node.get("export_enabled")
for node in page_nodes
)
+ assert any(
+ node.get("kind") == "widget"
+ and node.get("selector_id") == "hide_drive_alone"
+ and node.get("name") == "Hide Auto Modes"
+ and node.get("export_enabled")
+ for node in page_nodes
+ )
assert not any(
node.get("kind") == "html" and "Tour Purpose:" in node.get("html", "")
for node in page_nodes
)
assert not any(node.get("selector_id") == "tour_mode" for node in page_nodes)
- variant_nodes = _walk_nodes(trip_mode_region["variants"]['["eatout"]'])
+ variant_nodes = _walk_nodes(trip_mode_region["variants"]['["eatout","False"]'])
assert any(node.get("kind") == "plotly" for node in variant_nodes)
+ checked_variant_nodes = _walk_nodes(trip_mode_region["variants"]['["eatout","True"]'])
+ checked_plot = next(node for node in checked_variant_nodes if node.get("kind") == "plotly")
+ checked_x_values = [
+ value
+ for trace in checked_plot.get("figure", {}).get("data", [])
+ for value in trace.get("x", [])
+ ]
+ assert "Drive Alone" not in checked_x_values
def test_build_export_payload_keeps_static_pages_when_no_page_selectors_are_enabled() -> None:
@@ -403,6 +499,11 @@ def test_build_export_payload_normalizes_group_default_page_ids_to_leaf_page_ids
tmp_path = _workspace_tmp_dir("payload_group_defaults")
config = _write_config(
tmp_path,
+ dashboard_pages=[
+ {"daily_travel": ["daily_activity_pattern"]},
+ {"tour_summaries": ["tour_purpose"]},
+ {"trip_summaries": ["trip_stop_purpose"]},
+ ],
export_html_lines=[
"dashboard:",
" weighting: all",
@@ -429,10 +530,48 @@ def test_build_export_payload_normalizes_group_default_page_ids_to_leaf_page_ids
assert page_by_id["trip_summaries"]["default_page_id"] != "purpose"
+def test_build_export_payload_keeps_grouped_trip_selector_pages_export_ready() -> None:
+ tmp_path = _workspace_tmp_dir("payload_grouped_trip_selectors")
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=[{"trip_summaries": ["trip_mode", "trip_stop_time"]}],
+ export_html_lines=[
+ "pages:",
+ " trip_summaries:",
+ " trip_mode:",
+ " tour_purpose: all",
+ " trip_stop_time:",
+ " tour_purpose: all",
+ ],
+ )
+
+ payload = build_export_payload([], config, summary_runs=[_full_summary_run()])
+ grouped_page = payload["pages"][0]
+ weighted_percent = payload["states"]["Weighted||Percent"]
+
+ assert grouped_page["id"] == "trip_summaries"
+ assert grouped_page["default_page_id"] == "trip_mode"
+ assert [child["id"] for child in grouped_page["children"]] == [
+ "trip_mode",
+ "trip_stop_time",
+ ]
+ assert any(
+ selector["id"] == "tour_purpose" and selector["export_enabled"]
+ for selector in grouped_page["children"][0]["selectors"]
+ )
+ assert any(
+ selector["id"] == "tour_purpose" and selector["export_enabled"]
+ for selector in grouped_page["children"][1]["selectors"]
+ )
+ assert "trip_mode" in weighted_percent
+ assert "trip_stop_time" in weighted_percent
+
+
def test_build_export_payload_applies_excluded_pages_and_groups() -> None:
tmp_path = _workspace_tmp_dir("payload_exclusions")
config = _write_config(
tmp_path,
+ dashboard_pages=["overview", "shadow_pricing", "validation"],
export_html_lines=[
"exclude_pages:",
" - shadow_pricing",
@@ -456,6 +595,7 @@ def test_build_export_payload_disables_shadow_pricing_table_parts() -> None:
tmp_path = _workspace_tmp_dir("payload_shadow_parts")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"long_term_choices": ["shadow_pricing"]}],
export_html_lines=[
"pages:",
" long_term_choices:",
@@ -481,74 +621,31 @@ def test_build_export_payload_skips_prepared_only_sections_but_keeps_summary_saf
tmp_path = _workspace_tmp_dir("payload_skims_summary_safe")
config = _write_config(
tmp_path,
- dashboard_pages=["skims"],
+ dashboard_pages=["skim_summaries"],
export_html_lines=[
"pages:",
- " skims: {}",
+ " skim_summaries: {}",
],
)
payload = build_export_payload([], config, summary_runs=[_skim_summary_run()])
- assert payload["pages"] == [
- {
- "id": "skims",
- "title": "Skim Summaries",
- "selectors": [],
- "children": [
- {
- "id": "tour_skims",
- "title": "Tour Skims",
- "selectors": [
- {
- "id": "tour_skim_family",
- "label": "Tour Skim Family",
- "available": True,
- "request_mode": "all",
- "requested_values": [],
- "resolved_values": ["Walk Skims"],
- "default_value": "Walk Skims",
- "options": ["Walk Skims"],
- "export_enabled": False,
- },
- {
- "id": "tour_skim_direction",
- "label": "Direction",
- "available": True,
- "request_mode": "all",
- "requested_values": [],
- "resolved_values": ["Outbound"],
- "default_value": "Outbound",
- "options": ["Outbound"],
- "export_enabled": False,
- },
- ],
- "children": [],
- "default_page_id": None,
- },
- {
- "id": "trip_skims",
- "title": "Trip Skims",
- "selectors": [
- {
- "id": "trip_skim_family",
- "label": "Trip Skim Family",
- "available": True,
- "request_mode": "all",
- "requested_values": [],
- "resolved_values": ["Walk Skims"],
- "default_value": "Walk Skims",
- "options": ["Walk Skims"],
- "export_enabled": False,
- }
- ],
- "children": [],
- "default_page_id": None,
- },
- ],
- "default_page_id": "tour_skims",
- }
+ assert len(payload["pages"]) == 1
+ skim_group = payload["pages"][0]
+ assert skim_group["id"] == "skim_summaries"
+ assert skim_group["default_page_id"] == "tour_skims"
+ assert [child["id"] for child in skim_group["children"]] == [
+ "tour_skims",
+ "trip_skims",
]
+ selector_ids_by_page = {
+ child["id"]: {selector["id"] for selector in child["selectors"]}
+ for child in skim_group["children"]
+ }
+ assert {"tour_skim_family", "tour_skim_direction"}.issubset(
+ selector_ids_by_page["tour_skims"]
+ )
+ assert "trip_skim_family" in selector_ids_by_page["trip_skims"]
weighted_state = payload["states"]["Weighted||Percent"]
nodes = _walk_nodes(weighted_state["tour_skims"]) + _walk_nodes(
weighted_state["trip_skims"]
@@ -558,9 +655,12 @@ def test_build_export_payload_skips_prepared_only_sections_but_keeps_summary_saf
+ list(_region_nodes(weighted_state["trip_skims"]).keys())
)
- assert region_ids == ["tour_skim_summary_section", "trip_skim_summary_section"]
+ assert region_ids == ["summary.body", "summary.body"]
assert not any(node.get("widget_type") == "float_input" for node in nodes)
- assert not any(node.get("selector_id") in {"trip_min", "trip_max", "tour_min", "tour_max"} for node in nodes)
+ assert not any(
+ node.get("selector_id") in {"trip_min", "trip_max", "tour_min", "tour_max"}
+ for node in nodes
+ )
def test_build_export_payload_omits_prepared_only_pages() -> None:
diff --git a/tests/test_export_runtime_contract.py b/tests/test_export_runtime_contract.py
index d6ed215..4e03d4a 100644
--- a/tests/test_export_runtime_contract.py
+++ b/tests/test_export_runtime_contract.py
@@ -9,9 +9,17 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-from dashboard.export.payload import temporary_widget_values
+from dashboard.export.selector_states import (
+ resolve_export_section_states,
+ selector_options as _selector_options,
+ selector_values_for_current_state,
+ scoped_widget_values,
+)
+from dashboard.export.page_serializer import _refresh_page_part_view
from dashboard.export.runtime_assets import build_export_html_shell, load_export_runtime_js
+from dashboard.export.serializer import serialize_viewable
from dashboard.export.types import EXPORT_SCHEMA_VERSION
+from dashboard.page_definitions import DashboardPageDefinition
from test_export_html import _full_summary_run, _write_config
from dashboard.export.html import build_export_html_document
@@ -31,6 +39,88 @@
}
+def test_explicit_selector_enumeration_uses_resolved_display_values() -> None:
+ widget = pn.widgets.Select(
+ options=["All Person Types", "worker"],
+ value="All Person Types",
+ )
+ metadata = {
+ "request_mode": "explicit",
+ "requested_values": ["all", "worker"],
+ "resolved_values": ["All Person Types", "worker"],
+ }
+
+ assert selector_values_for_current_state(
+ selector_id="person_type",
+ widget=widget,
+ selector_metadata=metadata,
+ ) == ["All Person Types", "worker"]
+
+
+def test_dependent_selector_enumeration_uses_only_current_parent_options() -> None:
+ widget = pn.widgets.Select(
+ options=["All", "Alpha County", "Regional MPO", "101", "102"],
+ value="All",
+ )
+ metadata = {
+ "request_mode": "all",
+ "requested_values": [],
+ "resolved_values": ["All", "Alpha County", "Regional MPO"],
+ "parent_selector_id": "geography_level",
+ "options_by_parent_value": {
+ "All Geography Types": ["All"],
+ "County": ["All", "Alpha County"],
+ "MPO": ["All", "Regional MPO"],
+ },
+ }
+
+ assert selector_values_for_current_state(
+ selector_id="geography",
+ widget=widget,
+ selector_metadata=metadata,
+ selected_values={"geography_level": "County"},
+ ) == ["All", "Alpha County"]
+
+
+def test_export_part_refresh_targets_only_the_current_registered_section() -> None:
+ view = pn.Column()
+
+ class ProbePage:
+ def __init__(self) -> None:
+ self.cleared = 0
+ self.stale_sections: list[str] = []
+ self.refresh_forces: list[bool] = []
+
+ def clear_query_cache(self) -> None:
+ self.cleared += 1
+
+ def mark_section_stale(self, section_id: str) -> None:
+ self.stale_sections.append(section_id)
+
+ def refresh(self, force: bool = False) -> None:
+ self.refresh_forces.append(force)
+
+ page = ProbePage()
+ part = type(
+ "Part",
+ (),
+ {"part_id": "distance.distribution", "view_for": lambda self, page: view},
+ )()
+
+ assert (
+ _refresh_page_part_view(
+ page,
+ part,
+ page_id="mandatory_location_choice",
+ context_label="during test",
+ )
+ is view
+ )
+ assert page.cleared == 1
+ assert page.stale_sections == ["distance.distribution"]
+ assert page.refresh_forces == [False]
+
+
def _load_fixture(name: str) -> dict:
return json.loads((FIXTURE_DIR / name).read_text(encoding="utf-8"))
@@ -130,15 +220,76 @@ def test_runtime_asset_contains_explicit_context_action_and_region_helpers() ->
assert "function makeButton(config)" in runtime_js
assert "function buildRegionVariantKey(selectorValues)" in runtime_js
assert "const PLOT_RESIZE_RETRY_DELAYS_MS = [60, 180, 320];" in runtime_js
+ assert 'displayModeBar: "hover"' in runtime_js
+ assert "scale: 2" in runtime_js
+ assert 'name: "Download CSV"' in runtime_js
+ assert 'title: "Download plot data as CSV"' in runtime_js
+ assert "modeBarButtonsToAdd: [makePlotCsvDownloadButton(figure)]" in runtime_js
+
+
+def test_runtime_asset_contains_plot_csv_export_helpers() -> None:
+ runtime_js = load_export_runtime_js()
+
+ assert "function csvEscape(value)" in runtime_js
+ assert "function slugifyFilenameBase(value)" in runtime_js
+ assert "function resolvePlotCsvFilename(figure)" in runtime_js
+ assert "function buildTraceCsvRows(gd)" in runtime_js
+ assert '"run_name"' in runtime_js
+ assert '"x"' in runtime_js
+ assert '"y"' in runtime_js
+ assert '"trace_index"' not in runtime_js
+ assert '"customdata"' not in runtime_js
+ assert '"-" + valueMode + ".csv"' in runtime_js
+ assert 'return normalized || "plot-data";' in runtime_js
+
+
+def test_runtime_asset_restores_export_selector_widgets_from_runtime_state() -> None:
+ runtime_js = load_export_runtime_js()
+
+ assert (
+ "function resolveWidgetValue(node, context, leafPageId, effectiveOptions)"
+ in runtime_js
+ )
+ assert "const pageSelectorState = getPageSelectorState(context.state, leafPageId);" in runtime_js
+ assert "const effectiveOptions = resolveWidgetOptions(node, context, leafPageId);" in runtime_js
+ assert "const effectiveValue = resolveWidgetValue(" in runtime_js
+ assert 'node.widget_type === "checkbox"' in runtime_js
+ assert 'node.widget_type === "float_input"' in runtime_js
+ assert 'node.widget_type === "button"' in runtime_js
+ assert 'checkbox.checked ? "True" : "False"' in runtime_js
+ assert 'pageId === "vmt"' in runtime_js
+ assert 'selectorId === "personal_auto_vmt_breakdown"' in runtime_js
+ assert (
+ 'pageState.personal_auto_vmt_geography_type = "All Geography Types"'
+ in runtime_js
+ )
+ assert "isVmtGeographyTypeUnavailable" in runtime_js
+ assert "function resolveWidgetOptions(node, context, leafPageId)" in runtime_js
+ assert "function selectorHasDependents(context, leafPageId, selectorId)" in runtime_js
+ assert "selector.parent_selector_id !== selectorId" in runtime_js
+ assert "pageState[selector.id] = dependentOptions[0]" in runtime_js
+
+
+def test_runtime_asset_contains_sortable_export_table_helpers() -> None:
+ runtime_js = load_export_runtime_js()
+
+ assert "function parseSortableNumber(value)" in runtime_js
+ assert "function compareCellValues(leftValue, rightValue)" in runtime_js
+ assert "function toggleSort(column)" in runtime_js
+ assert 'className: "export-table-sort"' in runtime_js
+ assert 'className: "export-table-sort-indicator"' in runtime_js
def test_generated_export_html_contains_no_raw_nan_or_infinity(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
+ dashboard_pages=["overview"],
export_html_lines=[
"dashboard:",
" weighting: all",
" values: all",
+ "pages:",
+ " overview: {}",
],
)
@@ -152,7 +303,7 @@ def test_generated_export_html_contains_no_raw_nan_or_infinity(tmp_path: Path) -
assert "Infinity" not in payload_json
-def test_temporary_widget_values_restores_original_values_after_success() -> None:
+def test_scoped_widget_values_restores_original_values_after_success() -> None:
widgets = {
"tour_purpose": pn.widgets.Select(
name="Tour Purpose",
@@ -166,7 +317,7 @@ def test_temporary_widget_values_restores_original_values_after_success() -> Non
),
}
- with temporary_widget_values(
+ with scoped_widget_values(
widgets,
{"tour_purpose": "eatout", "tour_mode": "Walk"},
):
@@ -177,7 +328,7 @@ def test_temporary_widget_values_restores_original_values_after_success() -> Non
assert widgets["tour_mode"].value == "Drive"
-def test_temporary_widget_values_restores_original_values_after_exception() -> None:
+def test_scoped_widget_values_restores_original_values_after_exception() -> None:
widgets = {
"tour_purpose": pn.widgets.Select(
name="Tour Purpose",
@@ -188,7 +339,7 @@ def test_temporary_widget_values_restores_original_values_after_exception() -> N
}
with pytest.raises(RuntimeError, match="boom"):
- with temporary_widget_values(
+ with scoped_widget_values(
widgets,
{"tour_purpose": "eatout", "missing_safe": "ignored"},
):
@@ -196,3 +347,174 @@ def test_temporary_widget_values_restores_original_values_after_exception() -> N
raise RuntimeError("boom")
assert widgets["tour_purpose"].value == "All"
+
+
+def _selector_meta(
+ values: list[str],
+ *,
+ default_value: str,
+ request_mode: str = "all",
+) -> dict:
+ return {
+ "id": "selector",
+ "label": "Selector",
+ "available": True,
+ "request_mode": request_mode,
+ "requested_values": [],
+ "resolved_values": values,
+ "default_value": default_value,
+ "options": values,
+ "export_enabled": len(values) > 1,
+ }
+
+
+class _ExportPartProbe:
+ part_id = "probe_region"
+
+
+def test_checkbox_selector_exports_string_options_and_sets_boolean_values() -> None:
+ checkbox = pn.widgets.Checkbox(name="Include Totals", value=False)
+
+ assert _selector_options(checkbox) == ["False", "True"]
+ node = serialize_viewable(
+ checkbox,
+ disable_widgets=False,
+ widget_metadata={
+ id(checkbox): (
+ "include_totals",
+ {
+ "label": "Include Totals",
+ "export_enabled": True,
+ "resolved_values": ["False", "True"],
+ },
+ )
+ },
+ )
+ assert node["widget_type"] == "checkbox"
+ assert node["value"] == "False"
+ assert node["options"] == ["False", "True"]
+ assert node["selector_id"] == "include_totals"
+ assert node["export_enabled"] is True
+ with scoped_widget_values({"include_totals": checkbox}, {"include_totals": "True"}):
+ assert checkbox.value is True
+
+ assert checkbox.value is False
+
+
+def test_export_section_state_resolver_discovers_dependent_child_options() -> None:
+ class ProbePage:
+ def __init__(self) -> None:
+ self.parent = pn.widgets.Select(options=["A", "B"], value="A")
+ self.child = pn.widgets.Select(options=["a1", "a2"], value="a1")
+
+ def sync_controls(self) -> None:
+ self.child.options = (
+ ["a1", "a2"] if self.parent.value == "A" else ["b1"]
+ )
+ if self.child.value not in self.child.options:
+ self.child.value = self.child.options[0]
+
+ page = ProbePage()
+
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=DashboardPageDefinition(page_id="probe", title="Probe"),
+ part_def=_ExportPartProbe(),
+ active_selector_ids=["parent", "child"],
+ selector_widgets={"parent": page.parent, "child": page.child},
+ selector_metadata_by_id={
+ "parent": _selector_meta(["A", "B"], default_value="A"),
+ "child": _selector_meta(["a1", "a2"], default_value="a1"),
+ },
+ )
+
+ assert states == [
+ {"parent": "A", "child": "a1"},
+ {"parent": "A", "child": "a2"},
+ {"parent": "B", "child": "b1"},
+ ]
+ assert aliases == {}
+
+
+def test_export_section_state_resolver_collapses_disabled_selector_values() -> None:
+ class ProbePage:
+ def __init__(self) -> None:
+ self.mode = pn.widgets.Select(options=["By Segment", "Filtered"], value="By Segment")
+ self.segment = pn.widgets.Select(options=["All", "Low", "High"], value="All")
+
+ def sync_controls(self) -> None:
+ self.segment.disabled = self.mode.value == "By Segment"
+ if self.segment.disabled:
+ self.segment.value = "All"
+
+ page = ProbePage()
+
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=DashboardPageDefinition(page_id="probe", title="Probe"),
+ part_def=_ExportPartProbe(),
+ active_selector_ids=["mode", "segment"],
+ selector_widgets={"mode": page.mode, "segment": page.segment},
+ selector_metadata_by_id={
+ "mode": _selector_meta(["By Segment", "Filtered"], default_value="By Segment"),
+ "segment": _selector_meta(["All", "Low", "High"], default_value="All"),
+ },
+ )
+
+ assert states == [
+ {"mode": "By Segment", "segment": "All"},
+ {"mode": "Filtered", "segment": "All"},
+ {"mode": "Filtered", "segment": "Low"},
+ {"mode": "Filtered", "segment": "High"},
+ ]
+ assert aliases['["By Segment","Low"]'] == '["By Segment","All"]'
+ assert aliases['["By Segment","High"]'] == '["By Segment","All"]'
+
+
+def test_export_section_state_resolver_aliases_canonical_mapped_values() -> None:
+ class ProbePage:
+ def __init__(self) -> None:
+ self.parent = pn.widgets.Select(options=["A", "B"], value="A")
+ self.child = pn.widgets.Select(options=["All", "a1", "b1"], value="All")
+
+ def sync_controls(self) -> None:
+ self.child.options = ["All", "a1", "b1"]
+
+ def export_canonical_selector_value(
+ self,
+ section_id: str,
+ selector_id: str,
+ value: str,
+ selected_values: dict[str, str],
+ ) -> str:
+ if selector_id != "child":
+ return value
+ valid_by_parent = {"A": {"All", "a1"}, "B": {"All", "b1"}}
+ if value in valid_by_parent[selected_values["parent"]]:
+ return value
+ return "All"
+
+ page = ProbePage()
+
+ states, aliases = resolve_export_section_states(
+ page,
+ page_def=DashboardPageDefinition(page_id="probe", title="Probe"),
+ part_def=_ExportPartProbe(),
+ active_selector_ids=["parent", "child"],
+ selector_widgets={"parent": page.parent, "child": page.child},
+ selector_metadata_by_id={
+ "parent": _selector_meta(["A", "B"], default_value="A"),
+ "child": _selector_meta(["All", "a1", "b1"], default_value="All"),
+ },
+ )
+
+ assert states == [
+ {"parent": "A", "child": "All"},
+ {"parent": "A", "child": "a1"},
+ {"parent": "B", "child": "All"},
+ {"parent": "B", "child": "b1"},
+ ]
+ assert aliases == {
+ '["A","b1"]': '["A","All"]',
+ '["B","a1"]': '["B","All"]',
+ }
diff --git a/tests/test_export_serializer.py b/tests/test_export_serializer.py
index 4655bb8..6620064 100644
--- a/tests/test_export_serializer.py
+++ b/tests/test_export_serializer.py
@@ -11,9 +11,14 @@
import plotly.graph_objects as go
import numpy as np
-from dashboard.export.serializer import sanitize_export_payload, serialize_viewable, variant_key
+from dashboard.export.serializer import (
+ sanitize_export_payload,
+ sanitize_export_payload_in_place,
+ serialize_viewable,
+ variant_key,
+)
from dashboard.pages.tour_summaries.tour_mode import (
- _auto_sufficiency_definitions_markdown,
+ auto_sufficiency_definitions_markdown,
)
from runtime.config import Config
@@ -54,7 +59,10 @@ def test_serialize_viewable_supports_plotly_and_table_nodes() -> None:
disable_widgets=True,
)
table_payload = serialize_viewable(
- pn.widgets.Tabulator(pd.DataFrame({"alpha": [1.2345], "beta": ["x"], "gamma": [2.0]})),
+ pn.widgets.Tabulator(
+ pd.DataFrame({"alpha": [1.2345], "beta": ["x"], "gamma": [2.0]}),
+ titles={"alpha": "Alpha Value", "gamma": "Gamma Value"},
+ ),
disable_widgets=True,
)
@@ -62,8 +70,8 @@ def test_serialize_viewable_supports_plotly_and_table_nodes() -> None:
assert plot_payload["figure"]["data"][0]["type"] == "bar"
assert table_payload == {
"kind": "table",
- "columns": ["alpha", "beta", "gamma"],
- "rows": [{"alpha": "1.2", "beta": "x", "gamma": "2"}],
+ "columns": ["Alpha Value", "beta", "Gamma Value"],
+ "rows": [{"Alpha Value": "1.2", "beta": "x", "Gamma Value": "2"}],
}
@@ -157,7 +165,7 @@ def test_serialize_viewable_dedents_indented_markdown_blocks() -> None:
**Auto sufficiency definitions**
- **Zero Auto**: household has no vehicles.
- - **Auto Deficient**: household has fewer vehicles than licensed drivers.
+ - **Fewer Vehicles Than Drivers**: household has fewer vehicles than licensed drivers.
"""
),
disable_widgets=True,
@@ -176,11 +184,10 @@ def test_serialize_viewable_preserves_configured_auto_sufficiency_basis_text(
"\n".join(
[
'name: "Export Serializer Test"',
+ "root: summary_cache",
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Export Serializer Test"',
+ "dashboard:",
+ ' title: "Export Serializer Test"',
"prepare:",
" auto_sufficiency_basis: adults",
]
@@ -190,7 +197,7 @@ def test_serialize_viewable_preserves_configured_auto_sufficiency_basis_text(
config = Config.from_yaml(config_path)
markdown_payload = serialize_viewable(
- pn.pane.Markdown(_auto_sufficiency_definitions_markdown(config)),
+ pn.pane.Markdown(auto_sufficiency_definitions_markdown(config)),
disable_widgets=True,
)
@@ -231,3 +238,24 @@ def test_sanitize_export_payload_removes_nan_and_infinity() -> None:
"numpy_inf": None,
"nested": [1.0, 2.5, None],
}
+
+
+def test_sanitize_export_payload_in_place_retains_existing_containers() -> None:
+ nested = [1.0, np.float64(2.5), float("nan")]
+ payload = {
+ "nan_float": float("nan"),
+ "numpy_inf": np.float64("inf"),
+ "nested": nested,
+ "tuple": (np.int64(3), float("inf")),
+ }
+
+ sanitized = sanitize_export_payload_in_place(payload)
+
+ assert sanitized is payload
+ assert sanitized["nested"] is nested
+ assert sanitized == {
+ "nan_float": None,
+ "numpy_inf": None,
+ "nested": [1.0, 2.5, None],
+ "tuple": [3, None],
+ }
diff --git a/tests/test_export_size_budget.py b/tests/test_export_size_budget.py
index 857763d..77bf48f 100644
--- a/tests/test_export_size_budget.py
+++ b/tests/test_export_size_budget.py
@@ -2,41 +2,27 @@
from pathlib import Path
import sys
-from uuid import uuid4
+
+import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
-from dashboard.export.html import build_export_html_document
-from test_export_html import _full_summary_run, _write_config
-
# This baseline tracks the current representative export fixture on this branch.
# The runtime split added source files, but the generated runtime asset remains
# small; the larger size drift comes from the repository's current export
# payload/embedded dependency footprint rather than from a major new runtime
# bundle.
-EXPORT_HTML_BASELINE_BYTES = 8_833_840
+# Updated after calculation notes were ported to declarative page features.
+# Keep the growth allowance separate so future changes still surface clearly.
+EXPORT_HTML_BASELINE_BYTES = 11_502_295
EXPORT_HTML_GROWTH_BUDGET_BYTES = 350_000
-
-def _workspace_tmp_dir(label: str) -> Path:
- path = Path("tmp_export_test_artifacts") / f"{label}_{uuid4().hex}"
- path.mkdir(parents=True, exist_ok=True)
- return path
-
-
-def test_export_html_size_budget_for_representative_fixture() -> None:
- tmp_path = _workspace_tmp_dir("size_budget")
- config = _write_config(
- tmp_path,
- export_html_lines=[
- "dashboard:",
- " weighting: all",
- " values: all",
- ],
- )
-
- html = build_export_html_document([], config, summary_runs=[_full_summary_run()])
+@pytest.mark.full_export
+def test_export_html_size_budget_for_representative_fixture(
+ representative_full_export_html: str,
+) -> None:
+ html = representative_full_export_html
actual_size = len(html.encode("utf-8"))
max_size = EXPORT_HTML_BASELINE_BYTES + EXPORT_HTML_GROWTH_BUDGET_BYTES
diff --git a/tests/test_export_warnings.py b/tests/test_export_warnings.py
index 45e0094..deea02e 100644
--- a/tests/test_export_warnings.py
+++ b/tests/test_export_warnings.py
@@ -4,7 +4,6 @@
from pathlib import Path
import logging
import sys
-from uuid import uuid4
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -22,7 +21,7 @@
def _workspace_tmp_dir(label: str) -> Path:
- path = Path("tmp_export_test_artifacts") / f"{label}_{uuid4().hex}"
+ path = Path(".pytest_tmp") / "export_helpers" / label
path.mkdir(parents=True, exist_ok=True)
return path
@@ -31,6 +30,7 @@ def test_export_logs_selector_unavailable_warning_once_and_falls_back(caplog: py
tmp_path = _workspace_tmp_dir("warning_unavailable")
config = _write_config(
tmp_path,
+ dashboard_pages=[{"long_term_choices": ["shadow_pricing"]}],
export_html_lines=[
"dashboard:",
" weighting: all",
@@ -57,13 +57,13 @@ def test_export_logs_selector_unavailable_warning_once_and_falls_back(caplog: py
warning_messages = [
record.getMessage()
for record in caplog.records
- if "visualizer.export_html.pages.long_term_choices.shadow_pricing.student_type"
+ if "dashboard.export.pages.long_term_choices.shadow_pricing.student_type"
in record.getMessage()
]
assert payload["states"]["Weighted||Percent"]["shadow_pricing"]["kind"] == "page"
assert warning_messages == [
- "Warning: visualizer.export_html.pages.long_term_choices.shadow_pricing.student_type is configured, but no enabled export part uses this selector. Ignoring the configuration."
+ "Warning: dashboard.export.pages.long_term_choices.shadow_pricing.student_type is configured, but no enabled export part uses this selector. Ignoring the configuration."
]
@@ -83,7 +83,7 @@ def test_export_raises_readable_error_for_invalid_selector_values() -> None:
with pytest.raises(
ExportBuildError,
- match="Unsupported visualizer.export_html.pages.trip_summaries.trip_mode.tour_purpose values: 'invalid-purpose'",
+ match="Unsupported dashboard.export.pages.trip_summaries.trip_mode.tour_purpose values: 'invalid-purpose'",
):
build_export_html_document([], config, summary_runs=[_full_summary_run()])
@@ -109,13 +109,13 @@ def test_export_rejects_unknown_page_and_selector_configuration_entries() -> Non
)
with pytest.raises(
- ValueError, match="Unsupported visualizer.export_html.pages entries"
+ ValueError, match="Unsupported dashboard.export.pages entries"
):
build_export_html_document([], bad_page_config, summary_runs=[_full_summary_run()])
with pytest.raises(
ValueError,
- match="Unsupported visualizer.export_html.pages.trip_summaries.trip_mode entries",
+ match="Unsupported dashboard.export.pages.trip_summaries.trip_mode entries",
):
build_export_html_document(
[],
@@ -124,6 +124,7 @@ def test_export_rejects_unknown_page_and_selector_configuration_entries() -> Non
)
+@pytest.mark.full_export
def test_export_logs_total_payload_warning_for_large_default_export(
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
@@ -181,9 +182,9 @@ def test_export_logs_static_heavy_region_warning_with_disable_hint(
for message in messages
)
assert any(
- "overview is large because region body contributes about"
+ "overview is large because region overview_demographics contributes about"
in message
- and "visualizer.export_html.pages.overview.parts.body.enabled: false"
+ and "dashboard.export.pages.overview.parts.overview_demographics.enabled: false"
in message
for message in messages
)
@@ -196,7 +197,7 @@ def test_export_logs_selector_expansion_warning_with_disable_hint(
tmp_path = _workspace_tmp_dir("warning_selector_region")
config = _write_config(
tmp_path,
- dashboard_pages=["tour_summaries"],
+ dashboard_pages=[{"tour_summaries": ["tour_mode"]}],
export_html_lines=[
"pages:",
" tour_summaries:",
@@ -215,10 +216,10 @@ def test_export_logs_selector_expansion_warning_with_disable_hint(
messages = [record.getMessage() for record in caplog.records]
assert any(
- "tour_mode expands region tour_mode_modes to 8 selector combinations"
+ "tour_mode expands region tour_mode_modes to 4 selector combinations"
in message
- and "selectors: tour_purpose, auto_sufficiency" in message
- and "visualizer.export_html.pages.tour_summaries.tour_mode.parts.tour_mode_modes.enabled: false"
+ and "selectors: tour_purpose, hide_drive_alone" in message
+ and "dashboard.export.pages.tour_summaries.tour_mode.parts.tour_mode_modes.enabled: false"
in message
for message in messages
)
diff --git a/tests/test_figure_builders.py b/tests/test_figure_builders.py
new file mode 100644
index 0000000..a447a5f
--- /dev/null
+++ b/tests/test_figure_builders.py
@@ -0,0 +1,121 @@
+from pathlib import Path
+import sys
+
+import polars as pl
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from dashboard.rendering import Plotter, RenderContext
+
+
+def test_figure_first_bar_omits_undeclared_hover_columns() -> None:
+ figure = Plotter(RenderContext()).figure.bar(
+ [
+ (
+ "Base",
+ pl.DataFrame(
+ {"mode": ["Walk"], "trip_count": [5.0], "pct": [100.0]}
+ ),
+ )
+ ],
+ x="mode",
+ y="trip_count",
+ category_order=["Walk", "Bike"],
+ )
+
+ hover = list(figure.data[0].customdata)
+ assert "Pct:" not in hover[0]
+ assert "Pct:" not in hover[1]
+ assert hover[0] == "Base
mode: Walk
Count: 5.0"
+ assert figure.layout.hovermode != "x unified"
+
+
+def test_figure_builder_reports_run_and_missing_columns() -> None:
+ with pytest.raises(
+ ValueError,
+ match="bar chart for run 'Base' is missing columns: trip_count",
+ ):
+ Plotter(RenderContext()).figure.bar(
+ [("Base", pl.DataFrame({"mode": ["Walk"]}))],
+ x="mode",
+ y="trip_count",
+ )
+
+
+def test_bar_and_density_chart_hover_formatting_matches_units() -> None:
+ bar = Plotter(RenderContext(value_mode="share")).bar(
+ [("Base", pl.DataFrame({"mode": ["Walk", "Bike"], "trip_count": [25.0, 75.0]}))],
+ x="mode",
+ y="trip_count",
+ x_title="Mode",
+ y_title="Trips",
+ )
+ density_percent = Plotter(RenderContext(value_mode="share")).density(
+ [("Base", pl.DataFrame({"clock_time": ["03:00", "03:30"], "trip_count": [25.0, 75.0]}))],
+ x="clock_time",
+ y="trip_count",
+ x_title="Clock Time",
+ y_title="Trips",
+ )
+ density_count = Plotter(RenderContext(value_mode="count")).density(
+ [("Base", pl.DataFrame({"clock_time": ["03:00"], "trip_count": [1234.0]}))],
+ x="clock_time",
+ y="trip_count",
+ x_title="Clock Time",
+ y_title="Trips",
+ )
+
+ assert list(bar.object.data[0].customdata)[0] == (
+ "Base
Mode: Walk
Percent of Trips (%): 25.00%"
+ )
+ assert list(density_percent.object.data[0].customdata)[0] == (
+ "Base
Clock Time: 03:00
Percent of Trips (%): 25.00%"
+ )
+ assert list(density_count.object.data[0].customdata)[0] == (
+ "Base
Clock Time: 03:00
Trips: 1,234"
+ )
+
+
+def test_bar_chart_uses_configured_all_series_hover_mode() -> None:
+ data = [
+ ("Base", pl.DataFrame({"mode": ["Walk", "Bike"], "trip_count": [5.0, 1.0]})),
+ ("Build", pl.DataFrame({"mode": ["Walk", "Bike"], "trip_count": [7.0, 0.5]})),
+ ]
+
+ chart = Plotter(RenderContext(bar_hover_mode="all")).bar(
+ data, x="mode", y="trip_count"
+ )
+ assert chart.object.layout.hovermode == "x unified"
+
+
+def test_density_chart_uses_configured_all_series_hover_mode() -> None:
+ data = [
+ ("Base", pl.DataFrame({"bin": [1, 2], "count": [10.0, 12.0]})),
+ ("Build", pl.DataFrame({"bin": [1, 2], "count": [8.0, 15.0]})),
+ ]
+
+ default_chart = Plotter(RenderContext()).density(data, x="bin", y="count")
+ all_hover_chart = Plotter(RenderContext(density_hover_mode="all")).density(
+ data, x="bin", y="count"
+ )
+
+ assert default_chart.object.layout.hovermode != "x unified"
+ assert all_hover_chart.object.layout.hovermode == "x unified"
+
+
+def test_scatter_chart_can_add_one_to_one_reference_line() -> None:
+ chart = Plotter(RenderContext()).scatter(
+ [("Base", pl.DataFrame({"observed": [10.0, 20.0], "modeled": [12.0, 25.0]}))],
+ x="observed",
+ y="modeled",
+ one_to_one=True,
+ )
+
+ reference_line = chart.object.data[-1]
+ assert reference_line.name == "1:1 line"
+ assert list(reference_line.x) == [0.0, 25.0]
+ assert list(reference_line.y) == [0.0, 25.0]
+ assert reference_line.line.color == "#BDBDBD"
+ assert reference_line.line.dash == "dash"
+ assert reference_line.showlegend is False
diff --git a/tests/test_logging.py b/tests/test_logging.py
index a743026..c5fd0fd 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -11,11 +11,11 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
-from activitysim_viz_logging import configure_logging, get_logger, shutdown_logging
+from runtime.logging import configure_logging, get_logger, shutdown_logging
from dashboard.app import build_dashboard
from dashboard.page_base import DashboardPage
from dashboard.page_definitions import DashboardPageDefinition
-from processor.summarize.cache import create_summary_run
+from processor.summarize.cache_types import create_summary_run
from test_export_html import _full_summary_run, _write_config
@@ -70,7 +70,7 @@ def build_page(self):
return self.new_section(pn.pane.Markdown("Probe"))
def _refresh(self) -> None:
- self.require_prepared_runs()
+ self.data.prepared_runs()
_PreparedWarningProbePage.definition = DashboardPageDefinition(
@@ -97,3 +97,25 @@ def test_prepared_data_placeholder_warning_is_written_to_log_file(
assert "requires prepared run data" in log_path.read_text(encoding="utf-8")
shutdown_logging()
+
+
+def test_configure_logging_suppresses_known_bokeh_patch_warning(
+ tmp_path: Path,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ config = _write_config(tmp_path)
+ configure_logging(config)
+
+ noisy_message = (
+ "Dropping a patch because it contains a previously known reference "
+ "(id='p1854'). Most of the time this is harmless and usually a result "
+ "of updating a model on one side of a communications channel while it "
+ "was being removed on the other end."
+ )
+ with caplog.at_level(logging.WARNING):
+ logging.warning(noisy_message)
+ logging.warning("a different root warning")
+
+ assert noisy_message not in caplog.text
+ assert "a different root warning" in caplog.text
+ shutdown_logging()
diff --git a/tests/test_page_authoring.py b/tests/test_page_authoring.py
new file mode 100644
index 0000000..ed8e425
--- /dev/null
+++ b/tests/test_page_authoring.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import panel as pn
+import pytest
+
+from dashboard import DashboardPage, DashboardState
+from test_export_html import _write_config
+
+
+def test_provider_selector_repairs_stale_value_before_feature_render(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ state = DashboardState(summary_runs=[], weighting_modes=config.weighting_modes)
+
+ class DeclarativePage(DashboardPage):
+ def __init__(self) -> None:
+ self.available = ["A", "B"]
+ self.rendered_values: list[str] = []
+ super().__init__(state, config)
+
+ def build_page(self):
+ chart = self.feature("chart")
+ self.choice = chart.select(
+ "choice",
+ "Choice",
+ options=lambda: self.available,
+ default="last",
+ )
+ body = chart.section(
+ "body",
+ selectors=("choice",),
+ render=self.render_chart,
+ )
+ return pn.Column(self.choice, body)
+
+ def render_chart(self):
+ value = str(self.choice.value)
+ self.rendered_values.append(value)
+ return pn.pane.Markdown(value)
+
+ page = DeclarativePage()
+ page.refresh(force=True)
+ assert page.choice.value == "B"
+
+ page.available = ["C", "D"]
+ page.refresh(force=True)
+
+ assert page.choice.value == "D"
+ assert page.rendered_values[-1] == "D"
+ assert [feature.feature_id for feature in page.features] == ["chart"]
+ assert page.registered_sections[0].section_id == "chart.body"
+ assert page.registered_sections[0].selector_ids == ("chart.choice",)
+
+
+def test_query_identity_uses_declared_selector_state(tmp_path: Path) -> None:
+ config = _write_config(tmp_path)
+ state = DashboardState(summary_runs=[], weighting_modes=config.weighting_modes)
+ calls: list[str] = []
+
+ class QueryPage(DashboardPage):
+ def build_page(self):
+ self.choice = self.select("choice", "Choice", options=["A", "B"])
+ body = self.section(
+ "body",
+ selectors=("choice",),
+ render=self.render_chart,
+ )
+ return pn.Column(self.choice, body)
+
+ def render_chart(self):
+ value = str(self.choice.value)
+
+ def build(value=value):
+ calls.append(value)
+ return value
+
+ return pn.pane.Markdown(self.query(build))
+
+ page = QueryPage(state, config)
+ page.refresh(force=True)
+ page.refresh(force=True)
+ page.choice.value = "B"
+ page.refresh(force=True)
+
+ assert calls == ["A", "B"]
+ assert state.cache_stats["page_query"] == {"hits": 2, "misses": 2}
+
+
+def test_feature_ids_must_be_unique(tmp_path: Path) -> None:
+ config = _write_config(tmp_path)
+ state = DashboardState(summary_runs=[], weighting_modes=config.weighting_modes)
+
+ class DuplicateFeaturePage(DashboardPage):
+ def build_page(self):
+ self.feature("duplicate")
+ with pytest.raises(ValueError, match="duplicate feature id"):
+ self.feature("duplicate")
+ return pn.Column()
+
+ DuplicateFeaturePage(state, config)
diff --git a/tests/test_page_ordering.py b/tests/test_page_ordering.py
index b28381b..500c8bf 100644
--- a/tests/test_page_ordering.py
+++ b/tests/test_page_ordering.py
@@ -24,7 +24,7 @@ def test_live_and_export_page_resolution_share_default_order(tmp_path: Path) ->
assert [page.page_id for page in export_pages] == EXPECTED_DEFAULT_LEAF_PAGE_IDS
-def test_export_page_order_keeps_live_page_order_when_export_overrides_are_configured(
+def test_export_page_order_keeps_all_live_pages_when_overrides_are_configured(
tmp_path: Path,
) -> None:
config = _write_config(
@@ -73,3 +73,54 @@ def test_export_page_order_inherits_live_page_order_when_export_pages_are_unset(
"trip_stop_distance",
"overview",
]
+
+
+def test_export_pages_are_overrides_not_a_page_allow_list(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["overview", "trip_summaries"],
+ export_html_lines=[
+ "pages:",
+ " trip_summaries:",
+ " trip_mode: {}",
+ ],
+ )
+
+ assert [page.page_id for page in resolve_live_page_definitions(config)] == [
+ "overview",
+ "trip_stop_purpose",
+ "trip_mode",
+ "trip_stop_time",
+ "trip_stop_distance",
+ ]
+ assert [page.page_id for page in resolve_export_page_definitions(config)] == [
+ "overview",
+ "trip_stop_purpose",
+ "trip_mode",
+ "trip_stop_time",
+ "trip_stop_distance",
+ ]
+
+
+def test_export_page_override_can_explicitly_disable_a_live_page(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ dashboard_pages=["overview", "trip_summaries"],
+ export_html_lines=[
+ "pages:",
+ " trip_summaries:",
+ " trip_mode:",
+ " enabled: false",
+ ],
+ )
+
+ assert [page.page_id for page in resolve_export_page_definitions(config)] == [
+ "overview",
+ "trip_stop_purpose",
+ "trip_stop_time",
+ "trip_stop_distance",
+ ]
diff --git a/tests/test_page_registry_contract.py b/tests/test_page_registry_contract.py
index d239201..838d68c 100644
--- a/tests/test_page_registry_contract.py
+++ b/tests/test_page_registry_contract.py
@@ -8,9 +8,8 @@
from dashboard import DashboardState
from dashboard.export.protocols import validate_export_page
-from dashboard.export.payload import resolve_page_parts
+from dashboard.export.traversal import resolve_page_parts
from dashboard.page_registry import (
- all_page_definitions,
build_registered_live_pages,
build_registered_export_pages,
)
@@ -89,19 +88,20 @@ def test_representative_export_pages_keep_expected_runtime_sections(
}
expected_sections = {
- "overview": [("body", ())],
+ "overview": [("overview_kpis", ()), ("overview_demographics", ())],
"daily_activity_pattern": [("activity_pattern_body", ("person_type",))],
"escorted_tours": [
- ("escorted_tours_static_body", ()),
- ("escorted_tours_directional_body", ("direction",)),
+ ("school_escort.body", ()),
+ ("adult_escort.body", ()),
+ ("direction.body", ("direction.value",)),
+ ("distance.body", ("direction.value",)),
],
- "trip_mode": [("trip_summary_mode_body", ("tour_purpose",))],
+ "trip_mode": [("trip_summary_mode_body", ("tour_purpose", "hide_drive_alone"))],
"mandatory_location_choice": [
- ("worker_geography", ("geography_level", "geography")),
- ("commuting_flows", ("geography_level", "geography")),
- ("mandatory_distance_table", ("geography_level", "geography")),
- ("distance_distribution", ("geography_level", "geography")),
- ("remote_work", ("geography_level", "geography")),
+ ("remote_work.body", ("geography_level", "geography")),
+ ("distance.distribution", ("geography_level", "geography")),
+ ("flows.body", ("geography_level", "geography")),
+ ("geography_comparison.body", ("geography_level", "geography")),
],
}
@@ -113,9 +113,3 @@ def test_representative_export_pages_keep_expected_runtime_sections(
(part_def.part_id, tuple(part_def.selector_ids))
for part_def, _ in resolved_parts
] == expected
-
-
-def test_registered_page_definitions_keep_legacy_selector_ids_unique_per_page() -> None:
- for definition in all_page_definitions():
- selector_ids = [selector.selector_id for selector in definition.selectors]
- assert len(selector_ids) == len(set(selector_ids))
diff --git a/tests/test_prepare_cache.py b/tests/test_prepare_cache.py
index 5c7daeb..a66cf1a 100644
--- a/tests/test_prepare_cache.py
+++ b/tests/test_prepare_cache.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from pathlib import Path
import sys
@@ -33,7 +34,7 @@
def _write_config(
tmp_path: Path,
*,
- visualizer_lines: list[str] | None = None,
+ dashboard_lines: list[str] | None = None,
column_lines: list[str] | None = None,
extra_lines: list[str] | None = None,
) -> Config:
@@ -42,17 +43,16 @@ def _write_config(
lines = [
'name: "Prepared Cache Test"',
"runs: []",
- "processor:",
- " root: summary_cache",
- " summaries:",
- " weighting_modes:",
- " - weighted",
- " - unweighted",
- "visualizer:",
- ' dashboard_title: "Prepared Cache Test"',
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes:",
+ " - weighted",
+ " - unweighted",
+ "dashboard:",
+ ' title: "Prepared Cache Test"',
]
- if visualizer_lines:
- lines.extend(f" {line}" for line in visualizer_lines)
+ if dashboard_lines:
+ lines.extend(f" {line}" for line in dashboard_lines)
if column_lines:
lines.append("columns:")
lines.extend(f" {line}" for line in column_lines)
@@ -132,34 +132,6 @@ def _raw_run() -> RunData:
)
-def test_legacy_summaries_processor_keys_warn_but_still_load(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- config_path = tmp_path / "config.yaml"
- config_path.write_text(
- "\n".join(
- [
- 'name: "Legacy Processor Config"',
- "runs: []",
- "summaries:",
- " root: summary_cache",
- " weighting_modes:",
- " - weighted",
- "visualizer:",
- ' dashboard_title: "Legacy Processor Config"',
- ]
- ),
- encoding="utf-8",
- )
-
- config = Config.from_yaml(config_path)
-
- assert config.summary_root.endswith("summary_cache")
- assert config.weighting_modes == ["weighted"]
- assert "summaries.root" in caplog.text
- assert "summaries.weighting_modes" in caplog.text
-
def _prepared_run(config: Config) -> RunData:
return prepare_data(_raw_run(), config)
@@ -173,11 +145,21 @@ def _write_custom_prepared_tables(
root.mkdir(parents=True, exist_ok=True)
tables = {
"households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
- "persons": pl.DataFrame({"person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "day": pl.DataFrame({"day_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "tours": pl.DataFrame({"tour_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "trips": pl.DataFrame({"trip_id": [1000], "tour_id": [100], "person_id": [10], "finalweight": [1.0]}),
- "vehicles": pl.DataFrame({"vehicle_id": [1001], "household_id": [1], "finalweight": [1.0]}),
+ "persons": pl.DataFrame(
+ {"person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "day": pl.DataFrame(
+ {"day_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "tours": pl.DataFrame(
+ {"tour_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "trips": pl.DataFrame(
+ {"trip_id": [1000], "tour_id": [100], "person_id": [10], "finalweight": [1.0]}
+ ),
+ "vehicles": pl.DataFrame(
+ {"vehicle_id": [1001], "household_id": [1], "finalweight": [1.0]}
+ ),
"joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
"land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
}
@@ -202,12 +184,7 @@ def _prepared_run_with_orphan_trip() -> RunData:
{"person_id": [10], "household_id": [1], "finalweight": [1.0]}
),
tours=pl.DataFrame(
- {
- "tour_id": [100],
- "person_id": [10],
- "household_id": [1],
- "finalweight": [1.0],
- }
+ {"tour_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}
),
trips=pl.DataFrame(
{
@@ -411,27 +388,65 @@ def test_prepared_cache_round_trip_creates_default_layout(tmp_path: Path) -> Non
assert loaded.trip_weight_col == "trip_weight"
+def test_prepared_cache_round_trips_skimjoin_resolved_network_los(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ prepared = _prepared_run(config)
+ network_los = tmp_path / "network_los.yaml"
+ network_los.write_text("skim_time_periods: {}\n", encoding="utf-8")
+ prepared.skimjoin_manifest = {
+ "skimjoin_enabled": True,
+ "skimjoin_config_digest": "abc123",
+ "skimjoin_status": "applied",
+ "skimjoin_resolved_network_los_file": str(network_los),
+ }
+
+ entry = write_prepared_run_cache(prepared, config, run_key="base")
+ loaded = load_prepared_run_cache(entry.cache_dir, config)
+
+ assert entry.manifest["skimjoin_resolved_network_los_file"] == str(network_los)
+ assert loaded.skimjoin_manifest["skimjoin_resolved_network_los_file"] == str(
+ network_los
+ )
+
+ manifest_path = entry.cache_dir / "manifest.json"
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ manifest.pop("skimjoin_resolved_network_los_file")
+ manifest["run_fingerprint"] = {
+ "skimjoin": {"resolved_network_los_file": str(network_los)}
+ }
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+
+ loaded_from_nested = load_prepared_run_cache(entry.cache_dir, config)
+ assert loaded_from_nested.skimjoin_manifest[
+ "skimjoin_resolved_network_los_file"
+ ] == str(network_los)
+
+
def test_prepare_config_digest_ignores_presentation_only_changes(
tmp_path: Path,
) -> None:
config_a = _write_config(
tmp_path / "a",
- visualizer_lines=[
- 'dashboard_title: "Dashboard A"',
- "dashboard_pages:",
- " - overview",
- "export_html:",
+ dashboard_lines=[
+ 'title: "Dashboard A"',
+ "live:",
+ " pages:",
+ " - overview",
+ "export:",
" dashboard:",
" values: all",
],
)
config_b = _write_config(
tmp_path / "b",
- visualizer_lines=[
- 'dashboard_title: "Dashboard B"',
- "dashboard_pages:",
- " - destination",
- "export_html:",
+ dashboard_lines=[
+ 'title: "Dashboard B"',
+ "live:",
+ " pages:",
+ " - destination",
+ "export:",
" dashboard:",
" weighting: all",
],
@@ -454,10 +469,10 @@ def test_config_accepts_custom_prepared_table_map_and_csv_prepare_output(
"runs:",
' - label: "Prepared Run"',
" prepared_table_map:",
- f" households: {str(custom_dir / 'households.parquet').replace('\\', '/')}",
- f" persons: {str(custom_dir / 'persons.csv').replace('\\', '/')}",
- f" day: {str(custom_dir / 'day.csv').replace('\\', '/')}",
- f" vehicles: {str(custom_dir / 'vehicles.parquet').replace('\\', '/')}",
+ f" households: {(custom_dir / 'households.parquet').as_posix()}",
+ f" persons: {(custom_dir / 'persons.csv').as_posix()}",
+ f" day: {(custom_dir / 'day.csv').as_posix()}",
+ f" vehicles: {(custom_dir / 'vehicles.parquet').as_posix()}",
],
)
@@ -497,10 +512,9 @@ def test_config_rejects_invalid_custom_prepared_table_map_and_output_format(
' - label: "Prepared Run"',
" prepared_table_map:",
" households_alias: households.parquet",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Invalid Prepared Config"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Invalid Prepared Config"',
]
),
encoding="utf-8",
@@ -517,10 +531,9 @@ def test_config_rejects_invalid_custom_prepared_table_map_and_output_format(
"prepare:",
" output:",
" file_format: json",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Invalid Prepare Format"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Invalid Prepare Format"',
]
),
encoding="utf-8",
@@ -537,10 +550,9 @@ def test_config_rejects_invalid_custom_prepared_table_map_and_output_format(
"prepare:",
" validation:",
" relationship_checks: maybe",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Invalid Prepare Validation"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Invalid Prepare Validation"',
]
),
encoding="utf-8",
@@ -818,7 +830,7 @@ def test_prepared_cache_invalidates_when_student_type_config_changes(
config_b = _write_config(
tmp_path / "b",
column_lines=["total_employment: EMP_TOTAL"],
- visualizer_lines=None,
+ dashboard_lines=None,
)
config_path = Path(config_b.config_path)
config_path.write_text(
@@ -826,11 +838,12 @@ def test_prepared_cache_invalidates_when_student_type_config_changes(
+ "\n"
+ "\n".join(
[
- "student_types:",
- " - label: School",
- " land_use_columns: [ENROLLGRADEKto8, ENROLLGRADE9to12]",
- " - label: University",
- " land_use_columns: [COLLEGEENROLL]",
+ "prepare:",
+ " student_types:",
+ " - label: School",
+ " land_use_columns: [ENROLLGRADEKto8, ENROLLGRADE9to12]",
+ " - label: University",
+ " land_use_columns: [COLLEGEENROLL]",
]
),
encoding="utf-8",
diff --git a/tests/test_processor_prepare.py b/tests/test_processor_prepare.py
index 5f7d012..a2b783a 100644
--- a/tests/test_processor_prepare.py
+++ b/tests/test_processor_prepare.py
@@ -3,6 +3,7 @@
from pathlib import Path
import sys
+import numpy as np
import polars as pl
import pytest
@@ -24,8 +25,10 @@
resolve_skim_path as processor_resolve_skim_path,
)
from processor.summarize.cache_types import strip_weights
+from processor.summarize.contracts import missing_summary_inputs
from processor.summarize.summaries import tour, trip
-from processor.summarize.summaries.long_term import (
+from processor.summarize.summaries import tour_profiles, trip_distributions
+from processor.summarize.summaries.long_term_geography import (
external_workplace_loc,
internal_vs_external,
park_and_ride_location_residual_histogram,
@@ -33,16 +36,24 @@
school_shadow_pricing_residual_histogram,
school_shadow_pricing_residuals,
school_loc_vs_land_use_enrollment,
+ wfh,
+ workplace_shadow_pricing_residual_histogram,
+ workplace_shadow_pricing_residuals,
+ workplace_vs_land_use_employment,
+)
+from processor.summarize.summaries.long_term_distance import (
schl_tlfd,
- telecommute,
univ_tlfd,
+ work_tlfd,
+)
+from processor.summarize.summaries.long_term_person import telecommute
+from processor.summarize.summaries.long_term_vehicle import (
vehicle_char_age,
vehicle_char_body,
vehicle_char_fuel,
- work_tlfd,
- workplace_shadow_pricing_residual_histogram,
- workplace_shadow_pricing_residuals,
- workplace_vs_land_use_employment,
+)
+from processor.summarize.summaries.summary_helpers import (
+ _configured_geography_dimensions,
)
from processor.summarize.summaries.tour_geography import (
avg_mand_tour_distance,
@@ -63,10 +74,9 @@ def _write_config(
lines = [
'name: "Processor Prepare Test"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Processor Prepare Test"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Processor Prepare Test"',
]
if extra_lines:
lines.extend(extra_lines)
@@ -201,7 +211,9 @@ def _raw_run_with_atwork_subtour_frequency() -> ProcessorRunData:
{"tour_id": [], "person_id": []},
schema={"tour_id": pl.Int64, "person_id": pl.Int64},
),
- land_use=pl.DataFrame({"zone_id": [10, 20], "TAZ": [10, 20], "EMPLOY_TOT": [7, 8]}),
+ land_use=pl.DataFrame(
+ {"zone_id": [10, 20], "TAZ": [10, 20], "EMPLOY_TOT": [7, 8]}
+ ),
skim_matrix=None,
skim_zone_map=None,
)
@@ -315,7 +327,13 @@ def _raw_run_with_escort_event_inputs() -> ProcessorRunData:
"duration": [2, 2, 2, 2, 2],
"origin": [10, 20, 30, 10, 20],
"destination": [20, 30, 40, 20, 30],
- "stop_frequency": ["2out_2in", "2out_0in", "0out_1in", "0out_0in", "0out_0in"],
+ "stop_frequency": [
+ "2out_2in",
+ "2out_0in",
+ "0out_1in",
+ "0out_0in",
+ "0out_0in",
+ ],
}
),
trips=pl.DataFrame(
@@ -444,7 +462,11 @@ def _raw_run_with_escort_event_inputs() -> ProcessorRunData:
schema={"tour_id": pl.Int64, "person_id": pl.Int64},
),
land_use=pl.DataFrame(
- {"zone_id": [10, 20, 30, 40], "TAZ": [10, 20, 30, 40], "EMPLOY_TOT": [7, 8, 9, 10]}
+ {
+ "zone_id": [10, 20, 30, 40],
+ "TAZ": [10, 20, 30, 40],
+ "EMPLOY_TOT": [7, 8, 9, 10],
+ }
),
skim_matrix=None,
skim_zone_map=None,
@@ -770,7 +792,9 @@ def test_processor_prepare_data_uses_person_worker_flags_for_autosuff_when_confi
assert prepared.tours.sort("tour_id")["AUTOSUFF"].to_list() == [1, 2]
assert prepared.trips.sort("trip_id")["AUTOSUFF"].to_list() == [1, 2]
- summary = tour.tour_mode(prepared, config).sort(["tour_mode", "tour_purpose"])
+ summary = tour_profiles.tour_mode(prepared, config).sort(
+ ["tour_mode", "tour_purpose"]
+ )
assert summary.filter(pl.col("tour_purpose") == "work").to_dicts() == [
{
"tour_mode": "DRIVE",
@@ -1005,6 +1029,147 @@ def test_processor_prepare_data_uses_zone_id_as_maz_fallback_for_trip_skim_dista
assert prepared.trips["od_dist"].to_list() == [12.5]
+def test_processor_prepare_data_adds_non_motorized_distance_from_csv(
+ tmp_path: Path,
+) -> None:
+ csv_path = tmp_path / "maz_maz_walk.csv"
+ pl.DataFrame(
+ {
+ "OMAZ": [100, 200],
+ "DMAZ": [200, 100],
+ "DISTWALK": [0.5, 0.75],
+ }
+ ).write_csv(csv_path)
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "zones:",
+ " use_maz: true",
+ " maz_col: [MAZ, zone_id]",
+ " taz_col: [TAZ]",
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: maz_maz_walk.csv",
+ " matrix: null",
+ ],
+ )
+ raw = _raw_run()
+ raw = ProcessorRunData(
+ label=raw.label,
+ run_dir=raw.run_dir,
+ skim_file=raw.skim_file,
+ hh=raw.hh,
+ per=raw.per,
+ tours=raw.tours,
+ trips=pl.DataFrame(
+ {
+ "trip_id": [1, 2, 3],
+ "tour_id": [1001, 1001, 1001],
+ "person_id": [101, 101, 101],
+ "household_id": [1, 1, 1],
+ "trip_mode": ["WALK", "BIKE", "WALK"],
+ "origin": [100, 200, 999],
+ "destination": [200, 100, 100],
+ }
+ ),
+ joint_participants=raw.joint_participants,
+ land_use=pl.DataFrame(
+ {
+ "zone_id": [100, 200, 999],
+ "TAZ": [1, 2, 3],
+ "EMPLOY_TOT": [7, 8, 9],
+ }
+ ),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+ prepared = processor_prepare_data(raw, config)
+
+ assert prepared.trips["prepared_non_motorized_distance"].to_list() == [
+ 0.5,
+ 0.75,
+ None,
+ ]
+ diagnostics = prepared.prepare_diagnostics["trips.prepared_non_motorized_distance"]
+ assert diagnostics["source_type"] == "csv"
+ assert diagnostics["value_column"] == "DISTWALK"
+ assert diagnostics["unresolved"] == 1
+ assert diagnostics["eligible_non_motorized_unresolved"] == 1
+
+
+def test_processor_prepare_data_adds_non_motorized_distance_from_omx(
+ tmp_path: Path,
+) -> None:
+ import openmatrix as omx
+
+ omx_path = tmp_path / "walk.omx"
+ handle = omx.open_file(str(omx_path), "w")
+ handle["WLK_DIST"] = np.array([[0.0, 1.25], [1.5, 0.0]])
+ handle.close()
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "zones:",
+ " use_maz: true",
+ " maz_col: [MAZ, zone_id]",
+ " taz_col: [TAZ]",
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: walk.omx",
+ " matrix: WLK_DIST",
+ ],
+ )
+ raw = _raw_run()
+ raw = ProcessorRunData(
+ label=raw.label,
+ run_dir=raw.run_dir,
+ skim_file=raw.skim_file,
+ hh=raw.hh,
+ per=raw.per,
+ tours=raw.tours,
+ trips=pl.DataFrame(
+ {
+ "trip_id": [1, 2],
+ "tour_id": [1001, 1001],
+ "person_id": [101, 101],
+ "household_id": [1, 1],
+ "trip_mode": ["WALK", "BIKE"],
+ "origin": [100, 200],
+ "destination": [200, 100],
+ }
+ ),
+ joint_participants=raw.joint_participants,
+ land_use=pl.DataFrame(
+ {
+ "zone_id": [100, 200],
+ "TAZ": [1, 2],
+ "EMPLOY_TOT": [7, 8],
+ }
+ ),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+ prepared = processor_prepare_data(raw, config)
+
+ assert prepared.trips["prepared_non_motorized_distance"].to_list() == [
+ 1.25,
+ 1.5,
+ ]
+ diagnostics = prepared.prepare_diagnostics["trips.prepared_non_motorized_distance"]
+ assert diagnostics["source_type"] == "omx"
+ assert diagnostics["matrix"] == "WLK_DIST"
+
+
+def test_processor_prepare_data_leaves_non_motorized_distance_absent_without_config(
+ tmp_path: Path,
+) -> None:
+ prepared = processor_prepare_data(_raw_run(), _write_config(tmp_path))
+
+ assert "prepared_non_motorized_distance" not in prepared.trips.columns
+
+
def test_processor_prepare_data_surfaces_null_distance_fields_without_usable_zone_mapping(
tmp_path: Path,
) -> None:
@@ -1048,9 +1213,9 @@ def test_processor_prepare_data_surfaces_null_distance_fields_without_usable_zon
assert prepared.prepare_diagnostics["tours.SKIMDIST"]["unresolved"] == 1
assert prepared.prepare_diagnostics["trips.od_dist"]["unresolved"] == 1
assert prepared.prepare_diagnostics["trips.out_dir_dist"]["unresolved"] == 1
- assert tour.tour_distance(prepared, config).is_empty()
- assert trip.trip_distance(prepared, config).is_empty()
- assert trip.stop_ood_distance(prepared, config).is_empty()
+ assert tour_profiles.tour_distance(prepared, config).is_empty()
+ assert trip_distributions.trip_distance(prepared, config).is_empty()
+ assert trip_distributions.stop_ood_distance(prepared, config).is_empty()
def test_processor_prepare_data_keeps_legitimate_zero_skim_values(
@@ -1427,7 +1592,12 @@ def test_processor_prepare_normalizes_escort_fields_and_derives_num_escortees(
"primary_purpose": ["escort", "escort", "school", "school"],
"tour_type": ["escort", "escort", "school", "school"],
"tour_mode": ["DRIVE", "DRIVE", "WALK", "WALK"],
- "tour_category": ["non-mandatory", "non-mandatory", "mandatory", "mandatory"],
+ "tour_category": [
+ "non-mandatory",
+ "non-mandatory",
+ "mandatory",
+ "mandatory",
+ ],
"start": [8, 9, 10, 11],
"end": [9, 10, 11, 12],
"duration": [1, 1, 1, 1],
@@ -1483,7 +1653,9 @@ def test_processor_read_run_returns_partial_data_when_optional_tables_are_missin
assert loaded.hh["household_id"].to_list() == [1]
assert loaded.joint_participants.is_empty()
assert loaded.land_use.is_empty()
- assert processor_table_availability(loaded)["joint_tour_participants"] == "unavailable"
+ assert (
+ processor_table_availability(loaded)["joint_tour_participants"] == "unavailable"
+ )
assert processor_table_availability(loaded)["land_use"] == "unavailable"
assert "Cannot find" in processor_table_unavailable_reasons(loaded)["land_use"]
@@ -1498,10 +1670,9 @@ def test_processor_read_run_marks_misnamed_configured_table_as_unavailable(
[
'name: "Processor Prepare Test"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Processor Prepare Test"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Processor Prepare Test"',
"files:",
" trips: definitely_not_trips",
]
@@ -1529,7 +1700,9 @@ def test_processor_read_run_marks_misnamed_configured_table_as_unavailable(
assert loaded.trips.is_empty()
assert processor_table_availability(loaded)["trips"] == "unavailable"
- assert "definitely_not_trips" in processor_table_unavailable_reasons(loaded)["trips"]
+ assert (
+ "definitely_not_trips" in processor_table_unavailable_reasons(loaded)["trips"]
+ )
def test_config_normalizes_per_run_file_map_and_rejects_invalid_keys(
@@ -1566,10 +1739,9 @@ def test_config_normalizes_per_run_file_map_and_rejects_invalid_keys(
' label: "Run A"',
" file_map:",
" households_alias: final_hh",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Invalid File Map Config"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Invalid File Map Config"',
]
),
encoding="utf-8",
@@ -1706,8 +1878,7 @@ def test_processor_read_run_marks_misnamed_per_run_override_as_unavailable(
assert loaded.trips.is_empty()
assert processor_table_availability(loaded)["trips"] == "unavailable"
assert (
- "definitely_not_trips"
- in processor_table_unavailable_reasons(loaded)["trips"]
+ "definitely_not_trips" in processor_table_unavailable_reasons(loaded)["trips"]
)
@@ -1720,10 +1891,14 @@ def test_processor_prepare_derives_default_student_types_and_land_use_overlay(
assert prepared.per["student_type"].to_list() == [None, "School", "University"]
school_overlay = prepared.land_use.filter(pl.col("student_type") == "School")
- university_overlay = prepared.land_use.filter(pl.col("student_type") == "University")
+ university_overlay = prepared.land_use.filter(
+ pl.col("student_type") == "University"
+ )
assert school_overlay["enrollment_count"].to_list() == [0.0, 75.0, 0.0]
assert university_overlay["enrollment_count"].to_list() == [0.0, 0.0, 100.0]
- assert prepared.land_use.filter(pl.col("student_type").is_null())["EMPLOYMENT"].to_list() == [
+ assert prepared.land_use.filter(pl.col("student_type").is_null())[
+ "EMPLOYMENT"
+ ].to_list() == [
7.0,
8.0,
9.0,
@@ -1761,19 +1936,20 @@ def test_processor_prepare_adds_configured_geography_aggregation_columns(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
- " Rural: [20]",
- " district:",
- " source_zone_system: maz",
- f" file: {geography_csv.name}",
- " zone_id_col: MAZ",
- " geography_col: district",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
+ " Rural: [20]",
+ " district:",
+ " source_zone_system: maz",
+ f" file: {geography_csv.name}",
+ " zone_id_col: MAZ",
+ " geography_col: district",
],
)
@@ -1793,6 +1969,74 @@ def test_processor_prepare_adds_configured_geography_aggregation_columns(
assert prepared.trips["destination_geo__district"].to_list() == ["South"]
+def test_processor_prepare_copies_native_home_geographies_to_persons(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ raw = _raw_run()
+ raw.hh = raw.hh.with_columns(
+ pl.lit("Household County").alias("home_county"),
+ pl.lit("Household MPO").alias("home_mpo"),
+ )
+ raw.per = raw.per.with_columns(pl.lit("Person County").alias("home_county"))
+
+ prepared = processor_prepare_data(raw, config)
+
+ assert prepared.hh["home_county"].to_list() == ["Household County"]
+ assert prepared.hh["home_mpo"].to_list() == ["Household MPO"]
+ assert prepared.per["home_county"].to_list() == ["Person County"]
+ assert prepared.per["home_mpo"].to_list() == ["Household MPO"]
+
+
+def test_summary_geography_dimensions_include_native_home_geographies(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ ],
+ )
+ df = pl.DataFrame(
+ {
+ "home_zone_id": [10],
+ "home_taz": [10],
+ "home_county": ["A"],
+ "home_mpo": ["B"],
+ "home_geo__county": ["West"],
+ "work_geo__county": ["West"],
+ }
+ )
+
+ assert _configured_geography_dimensions(
+ df,
+ config=config,
+ base_type="maz",
+ base_col="home_zone_id",
+ role_prefix="home",
+ ) == [
+ ("maz", "home_zone_id"),
+ ("home_taz", "home_taz"),
+ ("home_county", "home_county"),
+ ("home_mpo", "home_mpo"),
+ ("county", "home_geo__county"),
+ ]
+ assert _configured_geography_dimensions(
+ df,
+ config=config,
+ base_type="work_taz",
+ base_col="work_taz",
+ role_prefix="work",
+ ) == [("county", "work_geo__county")]
+
+
def test_processor_prepare_skips_geography_aggregation_columns_when_geography_disabled(
tmp_path: Path,
) -> None:
@@ -1804,19 +2048,20 @@ def test_processor_prepare_skips_geography_aggregation_columns_when_geography_di
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: false",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
- " Rural: [20]",
- " district:",
- " source_zone_system: maz",
- f" file: {geography_csv.name}",
- " zone_id_col: MAZ",
- " geography_col: district",
+ "summarize:",
+ " geography:",
+ " enabled: false",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
+ " Rural: [20]",
+ " district:",
+ " source_zone_system: maz",
+ f" file: {geography_csv.name}",
+ " zone_id_col: MAZ",
+ " geography_col: district",
],
)
@@ -1836,15 +2081,16 @@ def test_long_term_comparison_summaries_emit_configured_geography_levels(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " Central: [20]",
- " East: [30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " Central: [20]",
+ " East: [30]",
],
)
prepared = processor_prepare_data(_raw_run_with_student_enrollment_inputs(), config)
@@ -1853,94 +2099,83 @@ def test_long_term_comparison_summaries_emit_configured_geography_levels(
school = school_loc_vs_land_use_enrollment(prepared, config)
assert "county" in workplace["geography_type"].to_list()
- assert (
- workplace.filter(pl.col("geography_type") == "county")
- .sort("geography_id")
- .select(["geography_id", "employment_count", "worker_count"])
- .to_dicts()
- == [
- {"geography_id": "Central", "employment_count": 8.0, "worker_count": 0.0},
- {"geography_id": "East", "employment_count": 9.0, "worker_count": 0.0},
- {"geography_id": "West", "employment_count": 7.0, "worker_count": 1.0},
- ]
- )
+ assert workplace.filter(pl.col("geography_type") == "county").sort(
+ "geography_id"
+ ).select(["geography_id", "employment_count", "worker_count"]).to_dicts() == [
+ {"geography_id": "Central", "employment_count": 8.0, "worker_count": 0.0},
+ {"geography_id": "East", "employment_count": 9.0, "worker_count": 0.0},
+ {"geography_id": "West", "employment_count": 7.0, "worker_count": 1.0},
+ ]
assert "county" in school["geography_type"].to_list()
- assert (
- school.filter(pl.col("geography_type") == "county")
- .sort(["geography_id", "student_type"])
- .select(["geography_id", "student_type", "enrollment_count", "student_count"])
- .to_dicts()
- == [
- {
- "geography_id": "Central",
- "student_type": "School",
- "enrollment_count": 75.0,
- "student_count": 1.0,
- },
- {
- "geography_id": "Central",
- "student_type": "University",
- "enrollment_count": 0.0,
- "student_count": 0.0,
- },
- {
- "geography_id": "East",
- "student_type": "School",
- "enrollment_count": 0.0,
- "student_count": 0.0,
- },
- {
- "geography_id": "East",
- "student_type": "University",
- "enrollment_count": 100.0,
- "student_count": 1.0,
- },
- {
- "geography_id": "West",
- "student_type": "School",
- "enrollment_count": 0.0,
- "student_count": 0.0,
- },
- {
- "geography_id": "West",
- "student_type": "University",
- "enrollment_count": 0.0,
- "student_count": 0.0,
- },
- ]
- )
- assert (
- workplace.filter(pl.col("geography_type") == "all_geographies")
- .select(["geography_id", "employment_count", "worker_count"])
- .to_dicts()
- == [
- {
- "geography_id": "all_geographies",
- "employment_count": 24.0,
- "worker_count": 1.0,
- }
- ]
- )
- assert (
- school.filter(pl.col("geography_type") == "all_geographies")
- .sort("student_type")
- .select(["geography_id", "student_type", "enrollment_count", "student_count"])
- .to_dicts()
- == [
- {
- "geography_id": "all_geographies",
- "student_type": "School",
- "enrollment_count": 75.0,
- "student_count": 1.0,
- },
- {
- "geography_id": "all_geographies",
- "student_type": "University",
- "enrollment_count": 100.0,
- "student_count": 1.0,
- },
- ]
- )
+ assert school.filter(pl.col("geography_type") == "county").sort(
+ ["geography_id", "student_type"]
+ ).select(
+ ["geography_id", "student_type", "enrollment_count", "student_count"]
+ ).to_dicts() == [
+ {
+ "geography_id": "Central",
+ "student_type": "School",
+ "enrollment_count": 75.0,
+ "student_count": 1.0,
+ },
+ {
+ "geography_id": "Central",
+ "student_type": "University",
+ "enrollment_count": 0.0,
+ "student_count": 0.0,
+ },
+ {
+ "geography_id": "East",
+ "student_type": "School",
+ "enrollment_count": 0.0,
+ "student_count": 0.0,
+ },
+ {
+ "geography_id": "East",
+ "student_type": "University",
+ "enrollment_count": 100.0,
+ "student_count": 1.0,
+ },
+ {
+ "geography_id": "West",
+ "student_type": "School",
+ "enrollment_count": 0.0,
+ "student_count": 0.0,
+ },
+ {
+ "geography_id": "West",
+ "student_type": "University",
+ "enrollment_count": 0.0,
+ "student_count": 0.0,
+ },
+ ]
+ assert workplace.filter(pl.col("geography_type") == "all_geographies").select(
+ ["geography_id", "employment_count", "worker_count"]
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "employment_count": 24.0,
+ "worker_count": 1.0,
+ }
+ ]
+ assert school.filter(pl.col("geography_type") == "all_geographies").sort(
+ "student_type"
+ ).select(
+ ["geography_id", "student_type", "enrollment_count", "student_count"]
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "student_type": "School",
+ "enrollment_count": 75.0,
+ "student_count": 1.0,
+ },
+ {
+ "geography_id": "all_geographies",
+ "student_type": "University",
+ "enrollment_count": 100.0,
+ "student_count": 1.0,
+ },
+ ]
def test_shadow_pricing_residual_summaries_emit_configured_geography_levels(
@@ -1949,15 +2184,16 @@ def test_shadow_pricing_residual_summaries_emit_configured_geography_levels(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " Central: [20]",
- " East: [30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " Central: [20]",
+ " East: [30]",
],
)
prepared = processor_prepare_data(_raw_run_with_student_enrollment_inputs(), config)
@@ -1965,119 +2201,111 @@ def test_shadow_pricing_residual_summaries_emit_configured_geography_levels(
workplace = workplace_shadow_pricing_residuals(prepared, config)
school = school_shadow_pricing_residuals(prepared, config)
- assert (
- workplace.filter(pl.col("geography_type") == "county")
- .sort("geography_id")
- .select(
- [
- "geography_id",
- "target_count",
- "modeled_count",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "Central",
- "target_count": 8.0,
- "modeled_count": 0.0,
- "residual_count": -8.0,
- "absolute_residual_count": 8.0,
- "percent_error": -100.0,
- },
- {
- "geography_id": "East",
- "target_count": 9.0,
- "modeled_count": 0.0,
- "residual_count": -9.0,
- "absolute_residual_count": 9.0,
- "percent_error": -100.0,
- },
- {
- "geography_id": "West",
- "target_count": 7.0,
- "modeled_count": 1.0,
- "residual_count": -6.0,
- "absolute_residual_count": 6.0,
- "percent_error": pytest.approx(-85.71428571428571),
- },
+ assert workplace.filter(pl.col("geography_type") == "county").sort(
+ "geography_id"
+ ).select(
+ [
+ "geography_id",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
]
- )
- assert (
- school.filter(pl.col("geography_type") == "county")
- .sort(["geography_id", "student_type"])
- .select(
- [
- "geography_id",
- "student_type",
- "target_count",
- "modeled_count",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "Central",
- "student_type": "School",
- "target_count": 75.0,
- "modeled_count": 1.0,
- "residual_count": -74.0,
- "absolute_residual_count": 74.0,
- "percent_error": pytest.approx(-98.66666666666667),
- },
- {
- "geography_id": "Central",
- "student_type": "University",
- "target_count": 0.0,
- "modeled_count": 0.0,
- "residual_count": 0.0,
- "absolute_residual_count": 0.0,
- "percent_error": None,
- },
- {
- "geography_id": "East",
- "student_type": "School",
- "target_count": 0.0,
- "modeled_count": 0.0,
- "residual_count": 0.0,
- "absolute_residual_count": 0.0,
- "percent_error": None,
- },
- {
- "geography_id": "East",
- "student_type": "University",
- "target_count": 100.0,
- "modeled_count": 1.0,
- "residual_count": -99.0,
- "absolute_residual_count": 99.0,
- "percent_error": -99.0,
- },
- {
- "geography_id": "West",
- "student_type": "School",
- "target_count": 0.0,
- "modeled_count": 0.0,
- "residual_count": 0.0,
- "absolute_residual_count": 0.0,
- "percent_error": None,
- },
- {
- "geography_id": "West",
- "student_type": "University",
- "target_count": 0.0,
- "modeled_count": 0.0,
- "residual_count": 0.0,
- "absolute_residual_count": 0.0,
- "percent_error": None,
- },
+ ).to_dicts() == [
+ {
+ "geography_id": "Central",
+ "target_count": 8.0,
+ "modeled_count": 0.0,
+ "residual_count": -8.0,
+ "absolute_residual_count": 8.0,
+ "percent_error": -100.0,
+ },
+ {
+ "geography_id": "East",
+ "target_count": 9.0,
+ "modeled_count": 0.0,
+ "residual_count": -9.0,
+ "absolute_residual_count": 9.0,
+ "percent_error": -100.0,
+ },
+ {
+ "geography_id": "West",
+ "target_count": 7.0,
+ "modeled_count": 1.0,
+ "residual_count": -6.0,
+ "absolute_residual_count": 6.0,
+ "percent_error": pytest.approx(-85.71428571428571),
+ },
+ ]
+ assert school.filter(pl.col("geography_type") == "county").sort(
+ ["geography_id", "student_type"]
+ ).select(
+ [
+ "geography_id",
+ "student_type",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
]
- )
+ ).to_dicts() == [
+ {
+ "geography_id": "Central",
+ "student_type": "School",
+ "target_count": 75.0,
+ "modeled_count": 1.0,
+ "residual_count": -74.0,
+ "absolute_residual_count": 74.0,
+ "percent_error": pytest.approx(-98.66666666666667),
+ },
+ {
+ "geography_id": "Central",
+ "student_type": "University",
+ "target_count": 0.0,
+ "modeled_count": 0.0,
+ "residual_count": 0.0,
+ "absolute_residual_count": 0.0,
+ "percent_error": None,
+ },
+ {
+ "geography_id": "East",
+ "student_type": "School",
+ "target_count": 0.0,
+ "modeled_count": 0.0,
+ "residual_count": 0.0,
+ "absolute_residual_count": 0.0,
+ "percent_error": None,
+ },
+ {
+ "geography_id": "East",
+ "student_type": "University",
+ "target_count": 100.0,
+ "modeled_count": 1.0,
+ "residual_count": -99.0,
+ "absolute_residual_count": 99.0,
+ "percent_error": -99.0,
+ },
+ {
+ "geography_id": "West",
+ "student_type": "School",
+ "target_count": 0.0,
+ "modeled_count": 0.0,
+ "residual_count": 0.0,
+ "absolute_residual_count": 0.0,
+ "percent_error": None,
+ },
+ {
+ "geography_id": "West",
+ "student_type": "University",
+ "target_count": 0.0,
+ "modeled_count": 0.0,
+ "residual_count": 0.0,
+ "absolute_residual_count": 0.0,
+ "percent_error": None,
+ },
+ ]
def test_workplace_shadow_pricing_residuals_preserve_modeled_only_rows(
@@ -2086,14 +2314,15 @@ def test_workplace_shadow_pricing_residuals_preserve_modeled_only_rows(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " North: [10]",
- " South: [20]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " North: [10]",
+ " South: [20]",
],
)
prepared = ProcessorRunData(
@@ -2128,38 +2357,33 @@ def test_workplace_shadow_pricing_residuals_preserve_modeled_only_rows(
["geography_type", "geography_id"]
)
- assert (
- summary.filter(pl.col("geography_type") == "county")
- .select(
- [
- "geography_id",
- "target_count",
- "modeled_count",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "North",
- "target_count": 0.0,
- "modeled_count": 2.0,
- "residual_count": 2.0,
- "absolute_residual_count": 2.0,
- "percent_error": None,
- },
- {
- "geography_id": "South",
- "target_count": 5.0,
- "modeled_count": 0.0,
- "residual_count": -5.0,
- "absolute_residual_count": 5.0,
- "percent_error": -100.0,
- },
+ assert summary.filter(pl.col("geography_type") == "county").select(
+ [
+ "geography_id",
+ "target_count",
+ "modeled_count",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
]
- )
+ ).to_dicts() == [
+ {
+ "geography_id": "North",
+ "target_count": 0.0,
+ "modeled_count": 2.0,
+ "residual_count": 2.0,
+ "absolute_residual_count": 2.0,
+ "percent_error": None,
+ },
+ {
+ "geography_id": "South",
+ "target_count": 5.0,
+ "modeled_count": 0.0,
+ "residual_count": -5.0,
+ "absolute_residual_count": 5.0,
+ "percent_error": -100.0,
+ },
+ ]
def test_shadow_pricing_histogram_summaries_include_dynamic_count_bins(
@@ -2168,15 +2392,16 @@ def test_shadow_pricing_histogram_summaries_include_dynamic_count_bins(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " Central: [20]",
- " East: [30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " Central: [20]",
+ " East: [30]",
],
)
prepared = processor_prepare_data(_raw_run_with_student_enrollment_inputs(), config)
@@ -2186,7 +2411,11 @@ def test_shadow_pricing_histogram_summaries_include_dynamic_count_bins(
assert "county" in workplace["geography_type"].to_list()
assert "county" in school["geography_type"].to_list()
- assert set(school["student_type"].unique().to_list()) == {"All", "School", "University"}
+ assert set(school["student_type"].unique().to_list()) == {
+ "All",
+ "School",
+ "University",
+ }
assert set(workplace.columns) == {
"geography_type",
"bin_start",
@@ -2213,8 +2442,7 @@ def test_shadow_pricing_histogram_summaries_include_dynamic_count_bins(
)
assert (
school.filter(
- (pl.col("geography_type") == "county")
- & (pl.col("student_type") == "All")
+ (pl.col("geography_type") == "county") & (pl.col("student_type") == "All")
)["geography_count"].sum()
== 6.0
)
@@ -2234,16 +2462,16 @@ def test_park_and_ride_location_residuals_roll_up_used_lots_only(
extra_lines=[
"columns:",
" pnr_lot_capacity: [PNR_CAP]",
- "modes:",
+ "summarize:",
" pnr_tour_modes: [PNR_TRANSIT, PNR_LOCAL, PNR_PREMIUM]",
- "geography:",
- " enabled: true",
- " aggregations:",
- " district:",
- " source_zone_system: maz",
- " mapping:",
- " North: [1, 2]",
- " South: [3, 4, 5]",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " district:",
+ " source_zone_system: maz",
+ " mapping:",
+ " North: [1, 2]",
+ " South: [3, 4, 5]",
],
)
prepared = ProcessorRunData(
@@ -2254,7 +2482,13 @@ def test_park_and_ride_location_residuals_roll_up_used_lots_only(
per=pl.DataFrame(),
tours=pl.DataFrame(
{
- "tour_mode": ["PNR_TRANSIT", "PNR_LOCAL", "WALK", "PNR_PREMIUM", "PNR_LOCAL"],
+ "tour_mode": [
+ "PNR_TRANSIT",
+ "PNR_LOCAL",
+ "WALK",
+ "PNR_PREMIUM",
+ "PNR_LOCAL",
+ ],
"pnr_zone_id": [1, 2, None, 3, 4],
"finalweight": [2.0, 3.0, 99.0, 5.0, 40.0],
}
@@ -2264,6 +2498,7 @@ def test_park_and_ride_location_residuals_roll_up_used_lots_only(
land_use=pl.DataFrame(
{
"MAZ": [1, 2, 3, 4, 5],
+ "TAZ": [100, 100, 200, 200, 200],
"PNR_CAP": [10.0, 20.0, 30.0, 40.0, 50.0],
"land_use_geo__district": ["North", "North", "South", "South", "South"],
}
@@ -2274,78 +2509,130 @@ def test_park_and_ride_location_residuals_roll_up_used_lots_only(
summary = park_and_ride_location_residuals(prepared, config)
- assert (
- summary.filter(pl.col("geography_type") == "maz")
- .sort("geography_id")
- .select(
- [
- "geography_id",
- "pnr_tour_count",
- "pnr_lot_capacity",
- "residual_count",
- "absolute_residual_count",
- "percent_error",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "1",
- "pnr_tour_count": 2.0,
- "pnr_lot_capacity": 10.0,
- "residual_count": -8.0,
- "absolute_residual_count": 8.0,
- "percent_error": -80.0,
- },
- {
- "geography_id": "2",
- "pnr_tour_count": 3.0,
- "pnr_lot_capacity": 20.0,
- "residual_count": -17.0,
- "absolute_residual_count": 17.0,
- "percent_error": -85.0,
- },
- {
- "geography_id": "3",
- "pnr_tour_count": 5.0,
- "pnr_lot_capacity": 30.0,
- "residual_count": -25.0,
- "absolute_residual_count": 25.0,
- "percent_error": pytest.approx(-83.33333333333334),
- },
- {
- "geography_id": "4",
- "pnr_tour_count": 40.0,
- "pnr_lot_capacity": 40.0,
- "residual_count": 0.0,
- "absolute_residual_count": 0.0,
- "percent_error": 0.0,
- },
- ]
- )
- assert (
- summary.filter(pl.col("geography_type") == "district")
- .sort("geography_id")
- .select(["geography_id", "pnr_tour_count", "pnr_lot_capacity"])
- .to_dicts()
- == [
- {"geography_id": "North", "pnr_tour_count": 5.0, "pnr_lot_capacity": 30.0},
- {"geography_id": "South", "pnr_tour_count": 45.0, "pnr_lot_capacity": 70.0},
+ assert summary.filter(pl.col("geography_type") == "maz").sort(
+ "geography_id"
+ ).select(
+ [
+ "geography_id",
+ "pnr_tour_count",
+ "pnr_lot_capacity",
+ "residual_count",
+ "absolute_residual_count",
+ "percent_error",
]
+ ).to_dicts() == [
+ {
+ "geography_id": "1",
+ "pnr_tour_count": 2.0,
+ "pnr_lot_capacity": 10.0,
+ "residual_count": -8.0,
+ "absolute_residual_count": 8.0,
+ "percent_error": -80.0,
+ },
+ {
+ "geography_id": "2",
+ "pnr_tour_count": 3.0,
+ "pnr_lot_capacity": 20.0,
+ "residual_count": -17.0,
+ "absolute_residual_count": 17.0,
+ "percent_error": -85.0,
+ },
+ {
+ "geography_id": "3",
+ "pnr_tour_count": 5.0,
+ "pnr_lot_capacity": 30.0,
+ "residual_count": -25.0,
+ "absolute_residual_count": 25.0,
+ "percent_error": pytest.approx(-83.33333333333334),
+ },
+ {
+ "geography_id": "4",
+ "pnr_tour_count": 40.0,
+ "pnr_lot_capacity": 40.0,
+ "residual_count": 0.0,
+ "absolute_residual_count": 0.0,
+ "percent_error": 0.0,
+ },
+ ]
+ assert summary.filter(pl.col("geography_type") == "district").sort(
+ "geography_id"
+ ).select(["geography_id", "pnr_tour_count", "pnr_lot_capacity"]).to_dicts() == [
+ {"geography_id": "North", "pnr_tour_count": 5.0, "pnr_lot_capacity": 30.0},
+ {"geography_id": "South", "pnr_tour_count": 45.0, "pnr_lot_capacity": 70.0},
+ ]
+ assert summary.filter(pl.col("geography_type") == "taz").sort(
+ "geography_id"
+ ).select(["geography_id", "pnr_tour_count", "pnr_lot_capacity"]).to_dicts() == [
+ {"geography_id": "100", "pnr_tour_count": 5.0, "pnr_lot_capacity": 30.0},
+ {"geography_id": "200", "pnr_tour_count": 45.0, "pnr_lot_capacity": 70.0},
+ ]
+ assert summary.filter(pl.col("geography_type") == "all_geographies").select(
+ ["geography_id", "pnr_tour_count", "pnr_lot_capacity"]
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "pnr_tour_count": 50.0,
+ "pnr_lot_capacity": 100.0,
+ }
+ ]
+
+
+def test_park_and_ride_location_residuals_support_taz_only_inputs(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "columns:",
+ " pnr_lot_capacity: [PNR_CAP]",
+ "summarize:",
+ " pnr_tour_modes: [PNR_TRANSIT]",
+ ],
)
- assert (
- summary.filter(pl.col("geography_type") == "all_geographies")
- .select(["geography_id", "pnr_tour_count", "pnr_lot_capacity"])
- .to_dicts()
- == [
+ prepared = ProcessorRunData(
+ label="Prepared",
+ run_dir="C:/runs/prepared",
+ skim_file=None,
+ hh=pl.DataFrame(),
+ per=pl.DataFrame(),
+ tours=pl.DataFrame(
{
- "geography_id": "all_geographies",
- "pnr_tour_count": 50.0,
- "pnr_lot_capacity": 100.0,
+ "tour_mode": ["PNR_TRANSIT", "PNR_TRANSIT", "WALK"],
+ "pnr_taz": [10, 20, 20],
+ "finalweight": [2.0, 3.0, 99.0],
}
- ]
+ ),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(
+ {
+ "TAZ": [10, 20, 30],
+ "PNR_CAP": [12.0, 30.0, 40.0],
+ }
+ ),
+ skim_matrix=None,
+ skim_zone_map=None,
)
+ assert missing_summary_inputs(park_and_ride_location_residuals, prepared) == {}
+ summary = park_and_ride_location_residuals(prepared, config)
+
+ assert summary.filter(pl.col("geography_type") == "taz").sort(
+ "geography_id"
+ ).select(["geography_id", "pnr_tour_count", "pnr_lot_capacity"]).to_dicts() == [
+ {"geography_id": "10", "pnr_tour_count": 2.0, "pnr_lot_capacity": 12.0},
+ {"geography_id": "20", "pnr_tour_count": 3.0, "pnr_lot_capacity": 30.0},
+ ]
+ assert summary.filter(pl.col("geography_type") == "all_geographies").select(
+ ["geography_id", "pnr_tour_count", "pnr_lot_capacity"]
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "pnr_tour_count": 5.0,
+ "pnr_lot_capacity": 42.0,
+ }
+ ]
+
def test_park_and_ride_location_residual_histogram_uses_zero_bin_and_configured_modes(
tmp_path: Path,
@@ -2355,7 +2642,7 @@ def test_park_and_ride_location_residual_histogram_uses_zero_bin_and_configured_
extra_lines=[
"columns:",
" pnr_lot_capacity: [PNR_CAP]",
- "modes:",
+ "summarize:",
" pnr_tour_modes: [PNR_TRANSIT, PNR_LOCAL]",
],
)
@@ -2398,15 +2685,19 @@ def test_park_and_ride_location_residual_histogram_uses_zero_bin_and_configured_
& (pl.col("bin_end") == 0.0)
)
assert zero_bin["geography_count"].to_list() == [1.0]
- assert histogram.filter(pl.col("geography_type") == "maz")["geography_count"].sum() == 2.0
+ assert (
+ histogram.filter(pl.col("geography_type") == "maz")["geography_count"].sum()
+ == 2.0
+ )
def test_geography_summaries_include_all_geographies_rollups(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
+ "summarize:",
+ " geography:",
+ " enabled: true",
],
)
prepared = ProcessorRunData(
@@ -2444,56 +2735,54 @@ def test_geography_summaries_include_all_geographies_rollups(tmp_path: Path) ->
nonmandatory_mix = int_vs_ext_non_mand_tour_freq(prepared, config)
external_tour_locations = ext_non_mand_tour_loc(prepared, config)
- assert (
- external_workplace.filter(pl.col("geography_type") == "all_geographies")
- .select(["geography_id", "external_worker_count", "all_worker_count"])
- .to_dicts()
- == [
- {
- "geography_id": "all_geographies",
- "external_worker_count": 1.0,
- "all_worker_count": 2.0,
- }
- ]
- )
- assert (
- nonmandatory_mix.filter(pl.col("geography_type") == "all_geographies")
- .select(
- [
- "geography_id",
- "internal_nonmandatory_tour_count",
- "external_nonmandatory_tour_count",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "all_geographies",
- "internal_nonmandatory_tour_count": 1.0,
- "external_nonmandatory_tour_count": 1.0,
- }
+ assert external_workplace.filter(
+ pl.col("geography_type") == "all_geographies"
+ ).select(
+ ["geography_id", "external_worker_count", "all_worker_count"]
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "external_worker_count": 1.0,
+ "all_worker_count": 2.0,
+ }
+ ]
+ assert nonmandatory_mix.filter(
+ pl.col("geography_type") == "all_geographies"
+ ).select(
+ [
+ "geography_id",
+ "internal_nonmandatory_tour_count",
+ "external_nonmandatory_tour_count",
]
- )
- assert (
- external_tour_locations.filter(pl.col("geography_type") == "all_geographies")
- .select(["geography_id", "external_nonmandatory_tour_count"])
- .to_dicts()
- == [{"geography_id": "all_geographies", "external_nonmandatory_tour_count": 1.0}]
- )
+ ).to_dicts() == [
+ {
+ "geography_id": "all_geographies",
+ "internal_nonmandatory_tour_count": 1.0,
+ "external_nonmandatory_tour_count": 1.0,
+ }
+ ]
+ assert external_tour_locations.filter(
+ pl.col("geography_type") == "all_geographies"
+ ).select(["geography_id", "external_nonmandatory_tour_count"]).to_dicts() == [
+ {"geography_id": "all_geographies", "external_nonmandatory_tour_count": 1.0}
+ ]
-def test_geography_summaries_include_configured_aggregation_levels(tmp_path: Path) -> None:
+def test_geography_summaries_include_configured_aggregation_levels(
+ tmp_path: Path,
+) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " East: [20, 30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " East: [20, 30]",
],
)
prepared = ProcessorRunData(
@@ -2534,36 +2823,29 @@ def test_geography_summaries_include_configured_aggregation_levels(tmp_path: Pat
tour_summary = int_vs_ext_non_mand_tour_freq(prepared, config)
assert "county" in worker_summary["geography_type"].to_list()
- assert (
- workplace_summary.filter(pl.col("geography_type") == "county")
- .select(["geography_id", "external_worker_count"])
- .to_dicts()
- == [{"geography_id": "East", "external_worker_count": 1.0}]
- )
- assert (
- tour_summary.filter(pl.col("geography_type") == "county")
- .sort("geography_id")
- .select(
- [
- "geography_id",
- "internal_nonmandatory_tour_count",
- "external_nonmandatory_tour_count",
- ]
- )
- .to_dicts()
- == [
- {
- "geography_id": "East",
- "internal_nonmandatory_tour_count": 1.0,
- "external_nonmandatory_tour_count": 0.0,
- },
- {
- "geography_id": "West",
- "internal_nonmandatory_tour_count": 0.0,
- "external_nonmandatory_tour_count": 1.0,
- },
+ assert workplace_summary.filter(pl.col("geography_type") == "county").select(
+ ["geography_id", "external_worker_count"]
+ ).to_dicts() == [{"geography_id": "East", "external_worker_count": 1.0}]
+ assert tour_summary.filter(pl.col("geography_type") == "county").sort(
+ "geography_id"
+ ).select(
+ [
+ "geography_id",
+ "internal_nonmandatory_tour_count",
+ "external_nonmandatory_tour_count",
]
- )
+ ).to_dicts() == [
+ {
+ "geography_id": "East",
+ "internal_nonmandatory_tour_count": 1.0,
+ "external_nonmandatory_tour_count": 0.0,
+ },
+ {
+ "geography_id": "West",
+ "internal_nonmandatory_tour_count": 0.0,
+ "external_nonmandatory_tour_count": 1.0,
+ },
+ ]
def test_mandatory_distance_summaries_include_configured_geography_levels(
@@ -2572,14 +2854,15 @@ def test_mandatory_distance_summaries_include_configured_geography_levels(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " East: [20, 30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " East: [20, 30]",
],
)
prepared = ProcessorRunData(
@@ -2598,7 +2881,7 @@ def test_mandatory_distance_summaries_include_configured_geography_levels(
"is_worker": [True, True, False],
"is_student": [False, True, True],
"person_type": ["1", "7", "3"],
- "distance_to_work": [10.0, 20.0, None],
+ "distance_to_work": [0.5, 20.0, None],
"distance_to_school": [None, 5.0, 15.0],
"finalweight": [1.0, 2.0, 3.0],
}
@@ -2616,73 +2899,62 @@ def test_mandatory_distance_summaries_include_configured_geography_levels(
university_distance = univ_tlfd(prepared, config)
mandatory_distance = avg_mand_tour_distance(prepared, config)
- assert (
- work_distance.filter(
- (pl.col("geography_type") == "county")
- & (pl.col("geography_id") == "East")
- & (pl.col("distance_bin") == 21)
- )["person_count"].to_list()
- == [2.0]
- )
- assert (
- school_distance.filter(
- (pl.col("geography_type") == "county")
- & (pl.col("geography_id") == "East")
- & (pl.col("distance_bin") == 6)
- )["person_count"].to_list()
- == [2.0]
- )
- assert (
- university_distance.filter(
- (pl.col("geography_type") == "county")
- & (pl.col("geography_id") == "East")
- & (pl.col("distance_bin") == 16)
- )["person_count"].to_list()
- == [3.0]
- )
- assert (
- mandatory_distance.filter(
- (pl.col("mandatory_tour_purpose") == "work")
- & (pl.col("geography_type") == "county")
- & (pl.col("geography_id") == "East")
- )
- .select(["average_tour_distance", "person_count"])
- .to_dicts()
- == [{"average_tour_distance": 20.0, "person_count": 2.0}]
- )
- assert (
- mandatory_distance.filter(pl.col("geography_type") == "all_geographies")
- .sort("mandatory_tour_purpose")
- .select(
- [
- "mandatory_tour_purpose",
- "geography_id",
- "average_tour_distance",
- "person_count",
- ]
- )
- .to_dicts()
- == [
- {
- "mandatory_tour_purpose": "school",
- "geography_id": "all_geographies",
- "average_tour_distance": 5.0,
- "person_count": 2.0,
- },
- {
- "mandatory_tour_purpose": "university",
- "geography_id": "all_geographies",
- "average_tour_distance": 15.0,
- "person_count": 3.0,
- },
- {
- "mandatory_tour_purpose": "work",
- "geography_id": "all_geographies",
- "average_tour_distance": 16.666666666666668,
- "person_count": 3.0,
- },
+ assert work_distance.filter(
+ (pl.col("geography_type") == "county")
+ & (pl.col("geography_id") == "West")
+ & (pl.col("distance_bin") == 0)
+ )["person_count"].to_list() == [1.0]
+ assert work_distance.filter(
+ (pl.col("geography_type") == "county")
+ & (pl.col("geography_id") == "East")
+ & (pl.col("distance_bin") == 20)
+ )["person_count"].to_list() == [2.0]
+ assert school_distance.filter(
+ (pl.col("geography_type") == "county")
+ & (pl.col("geography_id") == "East")
+ & (pl.col("distance_bin") == 5)
+ )["person_count"].to_list() == [2.0]
+ assert university_distance.filter(
+ (pl.col("geography_type") == "county")
+ & (pl.col("geography_id") == "East")
+ & (pl.col("distance_bin") == 15)
+ )["person_count"].to_list() == [3.0]
+ assert mandatory_distance.filter(
+ (pl.col("mandatory_tour_purpose") == "work")
+ & (pl.col("geography_type") == "county")
+ & (pl.col("geography_id") == "East")
+ ).select(["average_tour_distance", "person_count"]).to_dicts() == [
+ {"average_tour_distance": 20.0, "person_count": 2.0}
+ ]
+ assert mandatory_distance.filter(
+ pl.col("geography_type") == "all_geographies"
+ ).sort("mandatory_tour_purpose").select(
+ [
+ "mandatory_tour_purpose",
+ "geography_id",
+ "average_tour_distance",
+ "person_count",
]
- )
+ ).to_dicts() == [
+ {
+ "mandatory_tour_purpose": "school",
+ "geography_id": "all_geographies",
+ "average_tour_distance": 5.0,
+ "person_count": 2.0,
+ },
+ {
+ "mandatory_tour_purpose": "university",
+ "geography_id": "all_geographies",
+ "average_tour_distance": 15.0,
+ "person_count": 3.0,
+ },
+ {
+ "mandatory_tour_purpose": "work",
+ "geography_id": "all_geographies",
+ "average_tour_distance": 13.5,
+ "person_count": 3.0,
+ },
+ ]
def test_telecommute_summary_includes_configured_geography_levels(
@@ -2691,14 +2963,15 @@ def test_telecommute_summary_includes_configured_geography_levels(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " East: [20, 30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " East: [20, 30]",
],
)
prepared = ProcessorRunData(
@@ -2728,45 +3001,96 @@ def test_telecommute_summary_includes_configured_geography_levels(
summary = telecommute(prepared, config)
- assert (
- summary.filter(pl.col("geography_type") == "county")
- .sort(["geography_id", "telecommute_frequency"])
- .to_dicts()
- == [
- {
- "geography_type": "county",
- "geography_id": "East",
- "telecommute_frequency": "often",
- "person_count": 2.0,
- },
- {
- "geography_type": "county",
- "geography_id": "West",
- "telecommute_frequency": "never",
- "person_count": 1.0,
- },
- ]
- )
- assert (
- summary.filter(pl.col("geography_type") == "all_geographies")
- .sort("telecommute_frequency")
- .to_dicts()
- == [
- {
- "geography_type": "all_geographies",
- "geography_id": "all_geographies",
- "telecommute_frequency": "never",
- "person_count": 1.0,
- },
+ assert summary.filter(pl.col("geography_type") == "county").sort(
+ ["geography_id", "telecommute_frequency"]
+ ).to_dicts() == [
+ {
+ "geography_type": "county",
+ "geography_id": "East",
+ "telecommute_frequency": "often",
+ "person_count": 2.0,
+ },
+ {
+ "geography_type": "county",
+ "geography_id": "West",
+ "telecommute_frequency": "never",
+ "person_count": 1.0,
+ },
+ ]
+ assert summary.filter(pl.col("geography_type") == "all_geographies").sort(
+ "telecommute_frequency"
+ ).to_dicts() == [
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "telecommute_frequency": "never",
+ "person_count": 1.0,
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "telecommute_frequency": "often",
+ "person_count": 2.0,
+ },
+ ]
+
+
+def test_work_from_home_summary_includes_native_home_geographies(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ prepared = ProcessorRunData(
+ label="Prepared",
+ run_dir="C:/runs/prepared",
+ skim_file=None,
+ hh=pl.DataFrame({"household_id": [1, 2], "finalweight": [1.0, 1.0]}),
+ per=pl.DataFrame(
{
- "geography_type": "all_geographies",
- "geography_id": "all_geographies",
- "telecommute_frequency": "often",
- "person_count": 2.0,
- },
- ]
+ "person_id": [101, 201],
+ "household_id": [1, 2],
+ "home_zone_id": [10, 20],
+ "home_county": ["North", "South"],
+ "home_mpo": ["Metro", "Metro"],
+ "is_worker": [True, True],
+ "work_from_home": [True, False],
+ "finalweight": [2.0, 3.0],
+ }
+ ),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
)
+ summary = wfh(prepared, config)
+
+ assert summary.filter(pl.col("geography_type") == "home_county").sort(
+ "geography_id"
+ ).to_dicts() == [
+ {
+ "geography_type": "home_county",
+ "geography_id": "North",
+ "worker_count": 2.0,
+ "work_from_home_worker_count": 2.0,
+ },
+ {
+ "geography_type": "home_county",
+ "geography_id": "South",
+ "worker_count": 3.0,
+ "work_from_home_worker_count": 0.0,
+ },
+ ]
+ assert summary.filter(pl.col("geography_type") == "home_mpo").to_dicts() == [
+ {
+ "geography_type": "home_mpo",
+ "geography_id": "Metro",
+ "worker_count": 5.0,
+ "work_from_home_worker_count": 2.0,
+ }
+ ]
+
def test_nonmandatory_average_tour_distance_includes_configured_geography_levels(
tmp_path: Path,
@@ -2774,14 +3098,15 @@ def test_nonmandatory_average_tour_distance_includes_configured_geography_levels
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " West: [10]",
- " East: [20, 30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " West: [10]",
+ " East: [20, 30]",
],
)
prepared = ProcessorRunData(
@@ -2821,48 +3146,42 @@ def test_nonmandatory_average_tour_distance_includes_configured_geography_levels
summary = avg_non_mand_tour_distance(prepared, config)
- assert (
- summary.filter(pl.col("geography_type") == "county")
- .sort(["nonmandatory_tour_purpose", "geography_id"])
- .to_dicts()
- == [
- {
- "nonmandatory_tour_purpose": "eatout",
- "geography_type": "county",
- "geography_id": "East",
- "average_tour_distance": 10.0,
- "tour_count": 2.0,
- },
- {
- "nonmandatory_tour_purpose": "shopping",
- "geography_type": "county",
- "geography_id": "West",
- "average_tour_distance": 5.5,
- "tour_count": 4.0,
- },
- ]
- )
- assert (
- summary.filter(pl.col("geography_type") == "all_geographies")
- .sort("nonmandatory_tour_purpose")
- .to_dicts()
- == [
- {
- "nonmandatory_tour_purpose": "eatout",
- "geography_type": "all_geographies",
- "geography_id": "all_geographies",
- "average_tour_distance": 10.0,
- "tour_count": 2.0,
- },
- {
- "nonmandatory_tour_purpose": "shopping",
- "geography_type": "all_geographies",
- "geography_id": "all_geographies",
- "average_tour_distance": 5.5,
- "tour_count": 4.0,
- },
- ]
- )
+ assert summary.filter(pl.col("geography_type") == "county").sort(
+ ["nonmandatory_tour_purpose", "geography_id"]
+ ).to_dicts() == [
+ {
+ "nonmandatory_tour_purpose": "eatout",
+ "geography_type": "county",
+ "geography_id": "East",
+ "average_tour_distance": 10.0,
+ "tour_count": 2.0,
+ },
+ {
+ "nonmandatory_tour_purpose": "shopping",
+ "geography_type": "county",
+ "geography_id": "West",
+ "average_tour_distance": 5.5,
+ "tour_count": 4.0,
+ },
+ ]
+ assert summary.filter(pl.col("geography_type") == "all_geographies").sort(
+ "nonmandatory_tour_purpose"
+ ).to_dicts() == [
+ {
+ "nonmandatory_tour_purpose": "eatout",
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "average_tour_distance": 10.0,
+ "tour_count": 2.0,
+ },
+ {
+ "nonmandatory_tour_purpose": "shopping",
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "average_tour_distance": 5.5,
+ "tour_count": 4.0,
+ },
+ ]
def test_student_type_config_supports_custom_person_segmentation(
@@ -2871,19 +3190,20 @@ def test_student_type_config_supports_custom_person_segmentation(
config = _write_config(
tmp_path,
extra_lines=[
- "student_types:",
- " - label: Elementary/Middle School",
- " land_use_columns: [ENROLLGRADEKto8]",
- " person:",
- " school_segment: [1]",
- " - label: High School",
- " land_use_columns: [ENROLLGRADE9to12]",
- " person:",
- " SCHG: [2]",
- " - label: University",
- " land_use_columns: [COLLEGEENROLL]",
- " person:",
- " is_university: true",
+ "prepare:",
+ " student_types:",
+ " - label: Elementary/Middle School",
+ " land_use_columns: [ENROLLGRADEKto8]",
+ " person:",
+ " school_segment: [1]",
+ " - label: High School",
+ " land_use_columns: [ENROLLGRADE9to12]",
+ " person:",
+ " SCHG: [2]",
+ " - label: University",
+ " land_use_columns: [COLLEGEENROLL]",
+ " person:",
+ " is_university: true",
],
)
@@ -2917,11 +3237,12 @@ def test_student_type_config_supports_local_config_enrollment_columns(
config = _write_config(
tmp_path,
extra_lines=[
- "student_types:",
- " - label: School",
- " land_use_columns: [Elementary_Enrolment, Secondary_Enrolment]",
- " - label: University",
- " land_use_columns: [PostSecFTE]",
+ "prepare:",
+ " student_types:",
+ " - label: School",
+ " land_use_columns: [Elementary_Enrolment, Secondary_Enrolment]",
+ " - label: University",
+ " land_use_columns: [PostSecFTE]",
],
)
@@ -2968,13 +3289,14 @@ def test_student_type_config_rejects_custom_multi_school_segmentation_without_pe
_write_config(
tmp_path,
extra_lines=[
- "student_types:",
- " - label: Elementary/Middle School",
- " land_use_columns: [ENROLLGRADEKto8]",
- " - label: High School",
- " land_use_columns: [ENROLLGRADE9to12]",
- " - label: University",
- " land_use_columns: [COLLEGEENROLL]",
+ "prepare:",
+ " student_types:",
+ " - label: Elementary/Middle School",
+ " land_use_columns: [ENROLLGRADEKto8]",
+ " - label: High School",
+ " land_use_columns: [ENROLLGRADE9to12]",
+ " - label: University",
+ " land_use_columns: [COLLEGEENROLL]",
],
)
@@ -3169,17 +3491,18 @@ def test_processor_prepare_uses_canonical_student_aliases_for_student_type_deriv
" school_segment: school_segment_src",
" schg: schg_src",
" pstudent: pstudent_src",
- "student_types:",
- " - label: School",
- " land_use_columns: [ENROLLGRADEKto8]",
- " person:",
- " is_university: false",
- " school_segment: [K12]",
- " - label: University",
- " land_use_columns: [COLLEGEENROLL]",
- " person:",
- " is_university: true",
- " pstudent: ['2']",
+ "prepare:",
+ " student_types:",
+ " - label: School",
+ " land_use_columns: [ENROLLGRADEKto8]",
+ " person:",
+ " is_university: false",
+ " school_segment: [K12]",
+ " - label: University",
+ " land_use_columns: [COLLEGEENROLL]",
+ " person:",
+ " is_university: true",
+ " pstudent: ['2']",
],
)
raw = _raw_run()
@@ -3225,7 +3548,9 @@ def test_vehicle_long_term_summaries_use_prepared_vehicle_table_and_unweighted_r
}
)
prepared = processor_prepare_data(raw, config)
- prepared.vehicles = prepared.vehicles.with_columns(pl.Series("finalweight", [2.0, 3.0]))
+ prepared.vehicles = prepared.vehicles.with_columns(
+ pl.Series("finalweight", [2.0, 3.0])
+ )
age = vehicle_char_age(prepared, config)
fuel = vehicle_char_fuel(prepared, config)
diff --git a/tests/test_run_cli.py b/tests/test_run_cli.py
index 1e098a8..ef7b7c9 100644
--- a/tests/test_run_cli.py
+++ b/tests/test_run_cli.py
@@ -11,6 +11,7 @@
import run
import runtime.workflows as runtime_workflows
+from runtime.workflows import WorkflowPlan
from dashboard import app as dashboard_app
from dashboard.export.html import ExportBuildError
from processor.models import RunData
@@ -20,11 +21,12 @@
)
from runtime.config import Config
from processor.summarize import cache as summary_cache
+from processor.summarize import builder as summary_builder
from processor.summarize.cache import (
build_run_fingerprint,
- create_summary_run,
write_summary_run_cache,
)
+from processor.summarize.cache_types import create_summary_run
from runtime.workflows import prepare as prepare_workflow
def _write_cli_config(
@@ -37,20 +39,19 @@ def _write_cli_config(
) -> Config:
lines = [
'name: "CLI Test Config"',
- "processor:",
- " root: summary_cache",
- " summaries:",
- " weighting_modes:",
- " - weighted",
- " - unweighted",
- "visualizer:",
- ' dashboard_title: "CLI Test Dashboard"',
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes:",
+ " - weighted",
+ " - unweighted",
+ "dashboard:",
+ ' title: "CLI Test Dashboard"',
]
if dashboard_pages is not None:
- lines.append(" dashboard_pages:")
- lines.extend(f" - {page_id}" for page_id in dashboard_pages)
+ lines.extend([" live:", " pages:"])
+ lines.extend(f" - {page_id}" for page_id in dashboard_pages)
if export_html_lines:
- lines.append(" export_html:")
+ lines.append(" export:")
lines.extend(f" {line}" for line in export_html_lines)
lines.extend(
[
@@ -74,8 +75,14 @@ def _write_cli_config(
def _simple_summary_run(label: str, run_key: str) -> object:
- weighted = {"totals": pl.DataFrame({"population": [100.0]})}
- unweighted = {"totals": pl.DataFrame({"population": [50.0]})}
+ weighted = {
+ summary_id: pl.DataFrame({"value": [100.0]})
+ for summary_id in summary_builder.DEFAULT_SUMMARY_IDS
+ }
+ unweighted = {
+ summary_id: pl.DataFrame({"value": [50.0]})
+ for summary_id in summary_builder.DEFAULT_SUMMARY_IDS
+ }
return create_summary_run(
label=label,
run_key=run_key,
@@ -89,7 +96,7 @@ def _simple_summary_mode_build(label: str, run_key: str) -> tuple[dict, dict]:
return (
summary_run.summaries_by_mode,
{
- mode: {"totals": {"state": "available"}}
+ mode: {"population_totals": {"state": "available"}}
for mode in summary_run.summaries_by_mode
},
)
@@ -144,72 +151,6 @@ def _patch_prepare_pipeline(
monkeypatch.setattr(prepare_workflow, "prepare_data", prepare_data)
-def test_main_no_dashboard_overrides_config_default_and_skips_dashboard(
- tmp_path: Path,
- monkeypatch,
-) -> None:
- run_dir = tmp_path / "run_a"
- _write_cli_config(
- tmp_path,
- runs=[{"dir": str(run_dir), "label": "Run A"}],
- extra_lines=[
- "pipeline:",
- " steps:",
- " - summarize",
- " - dashboard",
- ],
- )
- read_calls: list[str] = []
- built_summaries: list[str] = []
-
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
- _patch_prepare_pipeline(
- monkeypatch,
- read_run=lambda run_dir, config, label=None, **kwargs: (
- read_calls.append(label or Path(run_dir).name),
- _fake_run_data(label or Path(run_dir).name, str(run_dir)),
- )[1],
- prepare_data=lambda rd, config: rd,
- )
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
- lambda rd, config: (
- built_summaries.append(rd.label),
- _simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
- )[1],
- )
- monkeypatch.setattr(
- dashboard_app,
- "build_dashboard",
- lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("dashboard should not be built")
- ),
- )
- monkeypatch.setattr(
- pn,
- "serve",
- lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("panel serve should not be called")
- ),
- )
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "activitysim-viz",
- "--config",
- str(tmp_path / "config.yaml"),
- "--no-dashboard",
- ],
- )
-
- run.main()
-
- assert read_calls == ["Run A"]
- assert built_summaries == ["Run A"]
-
-
def test_main_rejects_from_csvs_with_write_csvs(monkeypatch, capsys) -> None:
monkeypatch.setattr(
sys,
@@ -287,7 +228,7 @@ def test_main_dashboard_step_without_summarize_loads_cached_summaries_from_confi
)
dashboard_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -364,7 +305,7 @@ def test_main_uses_config_dashboard_step_for_live_dashboard_only_run(
dashboard_calls: list[dict[str, object]] = []
serve_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -458,7 +399,7 @@ def test_main_uses_config_dashboard_mode_export_without_cli_override(
)
export_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -540,7 +481,7 @@ def test_main_config_dashboard_mode_host_falls_back_to_live_dashboard(
dashboard_calls: list[dict[str, object]] = []
serve_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -613,7 +554,7 @@ def test_main_config_dashboard_mode_none_skips_dashboard_phase(
read_calls: list[str] = []
built_summaries: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -622,9 +563,7 @@ def test_main_config_dashboard_mode_none_skips_dashboard_phase(
)[1],
prepare_data=lambda rd, config: rd,
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config: (
built_summaries.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -673,8 +612,7 @@ def test_main_dashboard_only_respects_pipeline_without_segment_when_loading_summ
"pipeline:",
" steps:",
" - dashboard",
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" visibility: segments_only",
@@ -692,8 +630,7 @@ def test_main_dashboard_only_respects_pipeline_without_segment_when_loading_summ
)
effective_summary_config = runtime_workflows.effective_processor_config(
config,
- apply_skimjoin=False,
- apply_segmentation=False,
+ plan=WorkflowPlan.for_steps(config, ("prepare", "summarize", "dashboard")),
)
summary_run = _simple_summary_run("Run A", "run-a")
write_summary_run_cache(
@@ -716,7 +653,7 @@ def test_main_dashboard_only_respects_pipeline_without_segment_when_loading_summ
)
dashboard_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -764,7 +701,7 @@ def test_main_loads_dashboard_from_explicit_summary_cache_dirs_without_raw_reads
dashboard_calls: list[dict[str, object]] = []
serve_calls: list[object] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -832,9 +769,7 @@ def test_main_prepare_only_writes_prepared_cache_and_exits(
read_calls: list[str] = []
prepare_calls: list[str] = []
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("summaries should not be built during prepare-only runs")
),
@@ -884,7 +819,7 @@ def test_main_prepare_only_writes_prepared_cache_and_exits(
assert not (Path(config.summary_root) / "run-a" / "manifest.json").exists()
-def test_main_write_csvs_no_dashboard_writes_summary_cache_and_exits(
+def test_main_explicit_summarize_write_csvs_writes_summary_cache_and_exits(
tmp_path: Path,
monkeypatch,
) -> None:
@@ -896,7 +831,7 @@ def test_main_write_csvs_no_dashboard_writes_summary_cache_and_exits(
read_calls: list[str] = []
built_summaries: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -905,9 +840,7 @@ def test_main_write_csvs_no_dashboard_writes_summary_cache_and_exits(
)[1],
prepare_data=lambda rd, config: rd,
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config: (
built_summaries.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -934,8 +867,8 @@ def test_main_write_csvs_no_dashboard_writes_summary_cache_and_exits(
"activitysim-viz",
"--config",
str(tmp_path / "config.yaml"),
+ "--summarize",
"--write-csvs",
- "--no-dashboard",
],
)
@@ -945,7 +878,7 @@ def test_main_write_csvs_no_dashboard_writes_summary_cache_and_exits(
assert read_calls == ["Run A"]
assert built_summaries == ["Run A"]
assert (cache_dir / "manifest.json").exists()
- assert (cache_dir / "summary_tables" / "weighted" / "totals.csv").exists()
+ assert (cache_dir / "summary_tables" / "weighted" / "population_totals.csv").exists()
def test_main_explicit_prepare_and_summarize_runs_processor_without_dashboard(
@@ -961,7 +894,7 @@ def test_main_explicit_prepare_and_summarize_runs_processor_without_dashboard(
prepare_calls: list[str] = []
built_summaries: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -973,9 +906,7 @@ def test_main_explicit_prepare_and_summarize_runs_processor_without_dashboard(
rd,
)[1],
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config: (
built_summaries.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -1050,7 +981,7 @@ def test_main_refresh_summary_cache_rebuilds_and_rewrites_run_cache(
prepare_calls: list[str] = []
summary_build_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1062,9 +993,7 @@ def test_main_refresh_summary_cache_rebuilds_and_rewrites_run_cache(
rd,
)[1],
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config, **kwargs: (
summary_build_calls.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -1122,7 +1051,7 @@ def test_main_refresh_prepared_cache_rebuilds_prepared_tables_before_summarize(
prepare_calls: list[str] = []
summary_build_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1134,9 +1063,7 @@ def test_main_refresh_prepared_cache_rebuilds_prepared_tables_before_summarize(
rd,
)[1],
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config, **kwargs: (
summary_build_calls.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -1200,7 +1127,7 @@ def test_main_uses_cache_hit_for_one_run_and_raw_fallback_for_another(
read_calls: list[str] = []
built_summaries: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1209,9 +1136,7 @@ def test_main_uses_cache_hit_for_one_run_and_raw_fallback_for_another(
)[1],
prepare_data=lambda rd, config: rd,
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config: (
built_summaries.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -1304,7 +1229,7 @@ def test_main_refresh_caches_rebuilds_prepared_and_summary_caches(
prepare_calls: list[str] = []
summary_build_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1316,9 +1241,7 @@ def test_main_refresh_caches_rebuilds_prepared_and_summary_caches(
rd,
)[1],
)
- monkeypatch.setattr(
- summary_cache,
- "build_mode_summaries_with_metadata",
+ monkeypatch.setattr(summary_builder, "build_mode_summaries_with_metadata",
lambda rd, config, **kwargs: (
summary_build_calls.append(rd.label),
_simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
@@ -1402,7 +1325,7 @@ def test_main_loads_prepared_runs_for_enabled_live_prepared_data_page_even_on_ca
dashboard_calls: list[dict[str, object]] = []
read_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1478,7 +1401,7 @@ def test_main_from_csvs_loads_prepared_runs_for_enabled_live_prepared_data_page_
dashboard_calls: list[dict[str, object]] = []
read_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1532,7 +1455,7 @@ def test_main_from_csvs_keeps_prepared_data_page_unavailable_when_no_inputs_exis
cache_dir = write_summary_run_cache(summary_run, config)
dashboard_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -1608,7 +1531,7 @@ def test_main_export_does_not_load_prepared_runs_for_live_only_prepared_data_pag
)
export_calls: list[dict[str, object]] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -1701,7 +1624,7 @@ def test_main_surfaces_export_build_phase_failures_in_cli(
),
)
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
monkeypatch.setattr(
"dashboard.export.html.write_export_html_document", # type: ignore[arg-type]
lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -1782,12 +1705,13 @@ def test_main_dashboard_only_exits_with_friendly_message_for_stale_summary_cache
"summarize:",
" weighting_modes:",
" - weighted",
- "dashboard:",
- " title: \"CLI Test Dashboard\"",
- " pages:",
- " - overview",
+ "dashboard:",
+ " title: \"CLI Test Dashboard\"",
+ " live:",
+ " pages:",
+ " - overview",
"runs:",
- f" - dir: \"{str(run_dir).replace('\\', '/')}\"",
+ f" - dir: \"{run_dir.as_posix()}\"",
" label: \"Run A\"",
]
(tmp_path / "config.yaml").write_text(
@@ -1795,7 +1719,7 @@ def test_main_dashboard_only_exits_with_friendly_message_for_stale_summary_cache
encoding="utf-8",
)
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
monkeypatch.setattr(
sys,
"argv",
diff --git a/tests/test_runtime_canonical_columns.py b/tests/test_runtime_canonical_columns.py
index c619f3a..e7526f7 100644
--- a/tests/test_runtime_canonical_columns.py
+++ b/tests/test_runtime_canonical_columns.py
@@ -12,14 +12,12 @@
from processor.models import RunData
from processor.prepare.enrichment.pipeline import prepare_data
from processor.summarize.schema import SUMMARY_OUTPUT_COLUMNS
-from processor.summarize.summaries import (
- daily_travel,
- joint_travel,
- legacy,
- long_term,
- tour,
- trip,
-)
+from processor.summarize.summaries import joint_travel, tour, trip
+from processor.summarize.summaries import tour_profiles, trip_distributions
+from processor.summarize.summaries import daily_travel_activity
+from processor.summarize.summaries import daily_travel_escort_counts
+from processor.summarize.summaries import daily_travel_escort_distributions
+from processor.summarize.summaries import long_term_geography, long_term_person
from processor.tour_purpose import with_summary_tour_purpose
@@ -27,7 +25,7 @@ def _write_config(
tmp_path: Path,
*,
column_lines: list[str] | None = None,
- visualizer_lines: list[str] | None = None,
+ display_lines: list[str] | None = None,
extra_lines: list[str] | None = None,
) -> Config:
tmp_path.mkdir(parents=True, exist_ok=True)
@@ -35,13 +33,13 @@ def _write_config(
lines = [
'name: "Canonical Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Canonical Test Dashboard"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
]
- if visualizer_lines:
- lines.extend(f" {line}" for line in visualizer_lines)
+ if display_lines:
+ lines.append("display:")
+ lines.extend(f" {line}" for line in display_lines)
if column_lines:
lines.append("columns:")
lines.extend(f" {line}" for line in column_lines)
@@ -51,6 +49,21 @@ def _write_config(
return Config.from_yaml(config_path)
+def _write_network_los(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "\n".join(
+ [
+ "skim_time_periods:",
+ " periods: [0, 6, 12, 24, 32, 42, 48]",
+ " labels: [EA, AM, MD, PM, EV, EA]",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return path
+
+
def _raw_run_with_alternate_columns() -> RunData:
return RunData(
label="Base",
@@ -333,11 +346,11 @@ def test_config_rejects_empty_pnr_tour_mode_list(tmp_path: Path) -> None:
)
-def test_config_still_supports_legacy_modes_pnr_tour_modes(tmp_path: Path) -> None:
+def test_config_parses_pnr_tour_modes_from_summarize(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "modes:",
+ "summarize:",
" pnr_tour_modes: [PNR_LOCAL, PNR_PREMIUM]",
],
)
@@ -357,9 +370,10 @@ def test_tour_purpose_grouping_flags_parse_explicit_booleans(tmp_path: Path) ->
config = _write_config(
tmp_path,
extra_lines=[
- "group_joint_tour_purposes: true",
- "group_atwork_tour_purposes: false",
- "group_school_tour_purposes: true",
+ "summarize:",
+ " group_joint_tour_purposes: true",
+ " group_atwork_tour_purposes: false",
+ " group_school_tour_purposes: true",
],
)
@@ -368,13 +382,16 @@ def test_tour_purpose_grouping_flags_parse_explicit_booleans(tmp_path: Path) ->
assert config.group_school_tour_purposes is True
-def test_tour_purpose_grouping_flags_allow_explicit_false_overrides(tmp_path: Path) -> None:
+def test_tour_purpose_grouping_flags_allow_explicit_false_overrides(
+ tmp_path: Path,
+) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "group_joint_tour_purposes: false",
- "group_atwork_tour_purposes: false",
- "group_school_tour_purposes: false",
+ "summarize:",
+ " group_joint_tour_purposes: false",
+ " group_atwork_tour_purposes: false",
+ " group_school_tour_purposes: false",
],
)
@@ -404,7 +421,10 @@ def test_tour_purpose_grouping_flags_reject_invalid_values(tmp_path: Path) -> No
ValueError,
match="group_joint_tour_purposes must be true or false",
):
- _write_config(tmp_path, extra_lines=["group_joint_tour_purposes: maybe"])
+ _write_config(
+ tmp_path,
+ extra_lines=["summarize:", " group_joint_tour_purposes: maybe"],
+ )
with pytest.raises(
ValueError,
@@ -434,27 +454,34 @@ def test_config_summary_signature_changes_when_alias_lists_change(
assert config_a.summary_config_digest != config_b.summary_config_digest
-def test_config_summary_signature_changes_when_transit_subsidy_labels_change(
+def test_config_summary_signature_tracks_materialized_transit_subsidy_labels(
tmp_path: Path,
) -> None:
config_a = _write_config(
tmp_path / "a",
extra_lines=[
- "transit_subsidies:",
- " 0: No Subsidy",
- " 1: Employer Paid",
+ "display:",
+ " labels:",
+ " transit_subsidy:",
+ " mapping:",
+ " 0: No Subsidy",
+ " 1: Employer Paid",
],
)
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- "transit_subsidies:",
- " 0: No Subsidy",
- " 1: Universal Pass",
+ "display:",
+ " labels:",
+ " transit_subsidy:",
+ " mapping:",
+ " 0: No Subsidy",
+ " 1: Universal Pass",
],
)
assert config_a.summary_config_digest != config_b.summary_config_digest
+ assert config_a.presentation_config_digest != config_b.presentation_config_digest
def test_config_categories_preserve_mapping_order_and_fallback_order(
@@ -463,16 +490,17 @@ def test_config_categories_preserve_mapping_order_and_fallback_order(
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " mode:",
- " mapping:",
- " WALK: Walk",
- " DRIVEALONE: Drive Alone",
- " order: descending",
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " WALK: Walk",
+ " DRIVEALONE: Drive Alone",
+ " order: descending",
],
)
- spec = config.category_spec("mode")
+ spec = config.dashboard_label_spec("mode")
assert spec is not None
assert list(spec.mapping_items) == [
("WALK", "Walk"),
@@ -487,13 +515,14 @@ def test_category_specs_apply_ascending_descending_and_data_fallbacks(
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " alpha:",
- " order: ascending",
- " omega:",
- " order: descending",
- " seen:",
- " order: data",
+ "display:",
+ " labels:",
+ " alpha:",
+ " order: ascending",
+ " omega:",
+ " order: descending",
+ " seen:",
+ " order: data",
],
)
@@ -507,53 +536,6 @@ def test_category_specs_apply_ascending_descending_and_data_fallbacks(
]
-def test_categories_override_legacy_label_and_order_settings(
- tmp_path: Path,
-) -> None:
- config = _write_config(
- tmp_path,
- extra_lines=[
- "person_types:",
- " 1: Worker Legacy",
- "transit_subsidies:",
- " 1: Subsidy Legacy",
- "geography:",
- " enabled: true",
- " landuse_col: COUNTY",
- " mapping:",
- " 1: Legacy County",
- "modes:",
- " order:",
- " - LEGACY_MODE",
- "categories:",
- " person_type:",
- " mapping:",
- " 1: Worker New",
- " transit_subsidy:",
- " mapping:",
- " 1: Subsidy New",
- " geography:",
- " mapping:",
- " 1: New County",
- " mode:",
- " mapping:",
- " NEW_MODE: New Mode",
- ],
- )
-
- assert config.person_type_label("1") == "Worker New"
- assert config.transit_subsidy_label("1") == "Subsidy New"
- assert config.apply_geo_mapping(pl.Series(["1", "9"])).to_list() == [
- "New County",
- "9",
- ]
- assert config.ordered_modes(["LEGACY_MODE", "NEW_MODE", "OTHER"]) == [
- "NEW_MODE",
- "LEGACY_MODE",
- "OTHER",
- ]
-
-
def test_geography_aggregations_support_inline_and_file_mappings(
tmp_path: Path,
) -> None:
@@ -565,23 +547,26 @@ def test_geography_aggregations_support_inline_and_file_mappings(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
- " Rural: [20]",
- " district:",
- " source_zone_system: maz",
- f" file: {geography_csv.name}",
- " zone_id_col: MAZ",
- " geography_col: district",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
+ " Rural: [20]",
+ " district:",
+ " source_zone_system: maz",
+ f" file: {geography_csv.name}",
+ " zone_id_col: MAZ",
+ " geography_col: district",
],
)
- assert [aggregation.name for aggregation in config.geography_aggregations.aggregations] == [
+ assert [
+ aggregation.name for aggregation in config.geography_aggregations.aggregations
+ ] == [
"county",
"district",
]
@@ -598,25 +583,27 @@ def test_geography_aggregation_digest_changes_when_lookup_changes(
config_a = _write_config(
tmp_path / "a",
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
],
)
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Rural: [10]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Rural: [10]",
],
)
@@ -630,25 +617,27 @@ def test_disabled_geography_aggregations_are_ignored_and_do_not_change_digests(
config_a = _write_config(
tmp_path / "a",
extra_lines=[
- "geography:",
- " enabled: false",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
+ "summarize:",
+ " geography:",
+ " enabled: false",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
],
)
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- "geography:",
- " enabled: false",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Rural: [10]",
+ "summarize:",
+ " geography:",
+ " enabled: false",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Rural: [10]",
],
)
@@ -671,10 +660,9 @@ def test_enable_maz_geographies_defaults_off_and_only_changes_presentation_diges
[
'name: "Canonical Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Canonical Test Dashboard"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
" enable_maz_geographies: true",
]
),
@@ -689,27 +677,115 @@ def test_enable_maz_geographies_defaults_off_and_only_changes_presentation_diges
assert config_a.presentation_config_digest != config_b.presentation_config_digest
+def test_density_hover_mode_defaults_to_closest_and_accepts_all(tmp_path: Path) -> None:
+ default_config = _write_config(tmp_path / "default")
+ all_hover_config = _write_config(
+ tmp_path / "all",
+ extra_lines=[
+ "display:",
+ " density_hover_mode: all",
+ ],
+ )
+
+ assert default_config.density_hover_mode == "closest"
+ assert all_hover_config.density_hover_mode == "all"
+
+
+def test_bar_hover_mode_defaults_to_closest_and_accepts_all(tmp_path: Path) -> None:
+ default_config = _write_config(tmp_path / "default")
+ all_hover_config = _write_config(
+ tmp_path / "all",
+ extra_lines=[
+ "display:",
+ " bar_hover_mode: all",
+ ],
+ )
+
+ assert default_config.bar_hover_mode == "closest"
+ assert all_hover_config.bar_hover_mode == "all"
+
+
+def test_density_hover_mode_only_changes_presentation_digest(tmp_path: Path) -> None:
+ config_a = _write_config(tmp_path / "a")
+ config_b = _write_config(
+ tmp_path / "b",
+ extra_lines=[
+ "display:",
+ " density_hover_mode: all",
+ ],
+ )
+
+ assert config_a.prepare_config_digest == config_b.prepare_config_digest
+ assert config_a.summary_config_digest == config_b.summary_config_digest
+ assert config_a.presentation_config_digest != config_b.presentation_config_digest
+
+
+def test_bar_hover_mode_only_changes_presentation_digest(tmp_path: Path) -> None:
+ config_a = _write_config(tmp_path / "a")
+ config_b = _write_config(
+ tmp_path / "b",
+ extra_lines=[
+ "display:",
+ " bar_hover_mode: all",
+ ],
+ )
+
+ assert config_a.prepare_config_digest == config_b.prepare_config_digest
+ assert config_a.summary_config_digest == config_b.summary_config_digest
+ assert config_a.presentation_config_digest != config_b.presentation_config_digest
+
+
+def test_config_rejects_invalid_bar_hover_mode(tmp_path: Path) -> None:
+ with pytest.raises(
+ ValueError,
+ match="display.bar_hover_mode must be either 'closest' or 'all'",
+ ):
+ _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " bar_hover_mode: everything",
+ ],
+ )
+
+
+def test_config_rejects_invalid_density_hover_mode(tmp_path: Path) -> None:
+ with pytest.raises(
+ ValueError,
+ match="display.density_hover_mode must be either 'closest' or 'all'",
+ ):
+ _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " density_hover_mode: everything",
+ ],
+ )
+
+
def test_dashboard_labels_only_change_presentation_digest(
tmp_path: Path,
) -> None:
config_a = _write_config(
tmp_path / "a",
extra_lines=[
- "dashboard_labels:",
- " mode:",
- " mapping:",
- " WALK: Walk",
- " DRIVEALONE: Drive Alone",
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " WALK: Walk",
+ " DRIVEALONE: Drive Alone",
],
)
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- "dashboard_labels:",
- " mode:",
- " mapping:",
- " WALK: Walk Trips",
- " DRIVEALONE: Solo Drive",
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " WALK: Walk Trips",
+ " DRIVEALONE: Solo Drive",
],
)
@@ -724,19 +800,21 @@ def test_summary_categories_change_summary_digest_without_changing_presentation_
config_a = _write_config(
tmp_path / "a",
extra_lines=[
- "summary_categories:",
- " geography:",
- " mapping:",
- " 1: Urban",
+ "summarize:",
+ " category_normalization:",
+ " geography:",
+ " mapping:",
+ " 1: Urban",
],
)
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- "summary_categories:",
- " geography:",
- " mapping:",
- " 1: Core",
+ "summarize:",
+ " category_normalization:",
+ " geography:",
+ " mapping:",
+ " 1: Core",
],
)
@@ -751,14 +829,15 @@ def test_typed_geography_summaries_include_configured_aggregation_levels(
config = _write_config(
tmp_path,
extra_lines=[
- "geography:",
- " enabled: true",
- " aggregations:",
- " county:",
- " source_zone_system: taz",
- " mapping:",
- " Urban: [10]",
- " Rural: [20, 30]",
+ "summarize:",
+ " geography:",
+ " enabled: true",
+ " aggregations:",
+ " county:",
+ " source_zone_system: taz",
+ " mapping:",
+ " Urban: [10]",
+ " Rural: [20, 30]",
],
)
@@ -784,25 +863,18 @@ def test_typed_geography_summaries_include_configured_aggregation_levels(
skim_matrix=None,
skim_zone_map=None,
)
- wfh = long_term.wfh(prepared, config)
- flows = long_term.commuting_flows(prepared, config)
+ wfh = long_term_geography.wfh(prepared, config)
+ flows = long_term_geography.commuting_flows(prepared, config)
assert ("county" in wfh["geography_type"].to_list()) is True
- assert (
- wfh.filter(pl.col("geography_type") == "county")["geography_id"].to_list()
- == ["Urban"]
- )
- assert (
- flows.filter(pl.col("origin_geography_type") == "county")[
- "origin_geography_id"
- ].to_list()
- == ["Urban"]
- )
- assert (
- flows.filter(pl.col("destination_geography_type") == "county")[
- "destination_geography_id"
- ].to_list()
- == ["Rural"]
- )
+ assert wfh.filter(pl.col("geography_type") == "county")[
+ "geography_id"
+ ].to_list() == ["Urban"]
+ assert flows.filter(pl.col("origin_geography_type") == "county")[
+ "origin_geography_id"
+ ].to_list() == ["Urban"]
+ assert flows.filter(pl.col("destination_geography_type") == "county")[
+ "destination_geography_id"
+ ].to_list() == ["Rural"]
def test_config_summary_signature_changes_when_tour_purpose_grouping_changes(
@@ -811,7 +883,7 @@ def test_config_summary_signature_changes_when_tour_purpose_grouping_changes(
config_a = _write_config(tmp_path / "a")
config_b = _write_config(
tmp_path / "b",
- extra_lines=["group_joint_tour_purposes: false"],
+ extra_lines=["summarize:", " group_joint_tour_purposes: false"],
)
assert config_a.summary_config_digest != config_b.summary_config_digest
@@ -834,13 +906,232 @@ def test_config_prepare_signature_changes_when_auto_sufficiency_basis_changes(
assert config_a.presentation_config_digest == config_b.presentation_config_digest
+def test_config_loads_prepare_time_periods_and_includes_file_digest(
+ tmp_path: Path,
+) -> None:
+ network_los = _write_network_los(tmp_path / "network_los.yaml")
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "prepare:",
+ " time_periods:",
+ " network_los_file: network_los.yaml",
+ " trip_period_number_column: depart",
+ " tour_start_period_number_column: start",
+ " tour_end_period_number_column: end",
+ ],
+ )
+
+ assert config.prepare_time_periods.enabled is True
+ assert config.prepare_time_periods.network_los_file == str(network_los.resolve())
+ assert config.prepare_time_periods.network_los_digest is not None
+ assert config.prepare_time_periods.trip_period_number_column == "depart"
+
+
+def test_config_prepare_signature_changes_when_time_period_config_changes(
+ tmp_path: Path,
+) -> None:
+ network_los_a = _write_network_los(tmp_path / "a" / "network_los.yaml")
+ network_los_b = _write_network_los(tmp_path / "b" / "network_los.yaml")
+ network_los_b.write_text(
+ "\n".join(
+ [
+ "skim_time_periods:",
+ " periods: [0, 12, 24, 48]",
+ " labels: [EA, MD, EV]",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ config_a = _write_config(
+ tmp_path / "a",
+ extra_lines=[
+ "prepare:",
+ " time_periods:",
+ f" network_los_file: {network_los_a.name}",
+ " trip_period_number_column: depart",
+ ],
+ )
+ config_b = _write_config(
+ tmp_path / "b",
+ extra_lines=[
+ "prepare:",
+ " time_periods:",
+ f" network_los_file: {network_los_b.name}",
+ " trip_period_number_column: depart",
+ ],
+ )
+
+ assert config_a.prepare_config_digest != config_b.prepare_config_digest
+
+
+def test_config_loads_non_motorized_distance_skim_csv_defaults(
+ tmp_path: Path,
+) -> None:
+ csv_path = tmp_path / "maz_maz_walk.csv"
+ pl.DataFrame({"OMAZ": [1], "DMAZ": [2], "DISTWALK": [0.5]}).write_csv(csv_path)
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: maz_maz_walk.csv",
+ " matrix: null",
+ ],
+ )
+
+ settings = config.prepare_non_motorized_distance_skim
+ assert settings.enabled is True
+ assert settings.file == str(csv_path.resolve())
+ assert settings.source_type == "csv"
+ assert settings.matrix is None
+ assert settings.value_column == "DISTWALK"
+ assert settings.file_digest is not None
+
+
+def test_config_loads_non_motorized_distance_skim_csv_inventory_matrix_name(
+ tmp_path: Path,
+) -> None:
+ csv_path = tmp_path / "maz_maz_walk.csv"
+ pl.DataFrame({"OMAZ": [1], "DMAZ": [2], "DISTWALK": [0.5]}).write_csv(csv_path)
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: maz_maz_walk.csv",
+ " matrix: maz_maz_walk__DISTWALK",
+ ],
+ )
+
+ assert config.prepare_non_motorized_distance_skim.matrix == (
+ "maz_maz_walk__DISTWALK"
+ )
+ assert config.prepare_non_motorized_distance_skim.value_column == "DISTWALK"
+
+
+def test_config_non_motorized_distance_skim_omx_requires_matrix(
+ tmp_path: Path,
+) -> None:
+ (tmp_path / "walk.omx").write_bytes(b"placeholder")
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ 'name: "Invalid Non-Motorized Config"',
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: walk.omx",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="prepare.non_motorized_distance_skim.matrix is required",
+ ):
+ Config.from_yaml(config_path)
+
+
+def test_config_prepare_signature_changes_when_non_motorized_skim_changes(
+ tmp_path: Path,
+) -> None:
+ csv_a = tmp_path / "a" / "maz_maz_walk.csv"
+ csv_b = tmp_path / "b" / "maz_maz_walk.csv"
+ csv_a.parent.mkdir(parents=True)
+ csv_b.parent.mkdir(parents=True)
+ pl.DataFrame({"OMAZ": [1], "DMAZ": [2], "DISTWALK": [0.5]}).write_csv(csv_a)
+ pl.DataFrame({"OMAZ": [1], "DMAZ": [2], "DISTWALK": [0.75]}).write_csv(csv_b)
+ config_a = _write_config(
+ tmp_path / "a",
+ extra_lines=[
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: maz_maz_walk.csv",
+ " matrix: null",
+ ],
+ )
+ config_b = _write_config(
+ tmp_path / "b",
+ extra_lines=[
+ "prepare:",
+ " non_motorized_distance_skim:",
+ " file: maz_maz_walk.csv",
+ " matrix: null",
+ ],
+ )
+
+ assert config_a.prepare_config_digest != config_b.prepare_config_digest
+
+
+def test_config_rejects_missing_prepare_time_period_network_los(
+ tmp_path: Path,
+) -> None:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ 'name: "Canonical Test Config"',
+ "runs: []",
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
+ "prepare:",
+ " time_periods:",
+ " network_los_file: missing_network_los.yaml",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="prepare.time_periods.network_los_file"):
+ Config.from_yaml(config_path)
+
+
+def test_config_loads_create_hypothetical_skim_tables(tmp_path: Path) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "skimjoin:",
+ " create_hypothetical_skim_tables: true",
+ ],
+ )
+
+ assert config.skimjoin.create_hypothetical_skim_tables is True
+
+
+def test_config_rejects_renamed_hypothetical_sidecar_key(tmp_path: Path) -> None:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ 'name: "Canonical Test Config"',
+ "runs: []",
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
+ "skimjoin:",
+ " generate_hypothetical_sidecars: true",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="create_hypothetical_skim_tables",
+ ):
+ Config.from_yaml(config_path)
+
+
def test_config_presentation_signature_changes_when_log_level_changes(
tmp_path: Path,
) -> None:
config_a = _write_config(tmp_path / "a")
config_b = _write_config(
tmp_path / "b",
- visualizer_lines=[
+ extra_lines=[
"log_level: warning",
],
)
@@ -856,9 +1147,10 @@ def test_config_presentation_signature_changes_when_export_output_path_changes(
config_a = _write_config(tmp_path / "a")
config_b = _write_config(
tmp_path / "b",
- visualizer_lines=[
- "export_html:",
- " output_path: exports/dashboard.html",
+ extra_lines=[
+ "dashboard:",
+ " export:",
+ " output_path: exports/dashboard.html",
],
)
@@ -895,10 +1187,9 @@ def test_config_rejects_invalid_auto_sufficiency_basis(tmp_path: Path) -> None:
[
'name: "Canonical Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Canonical Test Dashboard"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
"prepare:",
" auto_sufficiency_basis: bicycles",
]
@@ -910,7 +1201,7 @@ def test_config_rejects_invalid_auto_sufficiency_basis(tmp_path: Path) -> None:
Config.from_yaml(config_path)
-def test_config_rejects_invalid_visualizer_log_level(tmp_path: Path) -> None:
+def test_config_rejects_invalid_log_level(tmp_path: Path) -> None:
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "config.yaml"
config_path.write_text(
@@ -918,17 +1209,16 @@ def test_config_rejects_invalid_visualizer_log_level(tmp_path: Path) -> None:
[
'name: "Canonical Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Canonical Test Dashboard"',
- " log_level: verbose",
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Canonical Test Dashboard"',
+ "log_level: verbose",
]
),
encoding="utf-8",
)
- with pytest.raises(ValueError, match="visualizer.log_level"):
+ with pytest.raises(ValueError, match="log_level"):
Config.from_yaml(config_path)
@@ -938,13 +1228,17 @@ def test_transit_subsidy_summary_uses_raw_categories_and_label_overrides(
config = _write_config(
tmp_path,
extra_lines=[
- "person_types:",
- " 1: Full-time worker",
- " 3: University student",
- "transit_subsidies:",
- " 0: No Subsidy",
- " 1: Employer Paid",
- " 2: Student Discount",
+ "display:",
+ " labels:",
+ " person_type:",
+ " mapping:",
+ " 1: Full-time worker",
+ " 3: University student",
+ " transit_subsidy:",
+ " mapping:",
+ " 0: No Subsidy",
+ " 1: Employer Paid",
+ " 2: Student Discount",
],
)
run = RunData(
@@ -969,7 +1263,7 @@ def test_transit_subsidy_summary_uses_raw_categories_and_label_overrides(
skim_zone_map=None,
)
- result = long_term.transit_subsidy(run, config).sort(
+ result = long_term_person.transit_subsidy(run, config).sort(
["person_type", "transit_subsidy_status"]
)
@@ -1109,9 +1403,10 @@ def test_tour_purpose_grouping_preserves_current_behavior_when_disabled(
config = _write_config(
tmp_path,
extra_lines=[
- "group_joint_tour_purposes: false",
- "group_atwork_tour_purposes: false",
- "group_school_tour_purposes: false",
+ "summarize:",
+ " group_joint_tour_purposes: false",
+ " group_atwork_tour_purposes: false",
+ " group_school_tour_purposes: false",
],
)
prepared = _prepared_run_with_groupable_tour_purposes()
@@ -1132,9 +1427,9 @@ def test_tour_purpose_grouping_preserves_current_behavior_when_disabled(
trip_weight_col=prepared.trip_weight_col,
)
- tour_tod_profiles = tour.tour_tod(prepared, config)
+ tour_tod_profiles = tour_profiles.tour_tod(prepared, config)
trip_mode_profile = trip.trip_mode(prepared, config)
- person_tour_rates = daily_travel.tour_rate_per_person(prepared, config)
+ person_tour_rates = daily_travel_activity.tour_rate_per_person(prepared, config)
tour_purposes = set(tour_tod_profiles["tour_purpose"].unique().to_list())
assert "joint_eatout" in tour_purposes
@@ -1165,9 +1460,9 @@ def test_tour_purpose_grouping_rolls_up_joint_atwork_and_school_across_summaries
trip_weight_col=prepared.trip_weight_col,
)
- tour_tod_profiles = tour.tour_tod(prepared, config)
+ tour_tod_profiles = tour_profiles.tour_tod(prepared, config)
trip_mode_profile = trip.trip_mode(prepared, config)
- person_tour_rates = daily_travel.tour_rate_per_person(prepared, config)
+ person_tour_rates = daily_travel_activity.tour_rate_per_person(prepared, config)
tour_purposes = set(tour_tod_profiles["tour_purpose"].unique().to_list())
assert "joint" in tour_purposes
@@ -1189,7 +1484,9 @@ def test_tour_purpose_grouping_rolls_up_joint_atwork_and_school_across_summaries
non_total = tour_tod_profiles.filter(pl.col("tour_purpose") != "all_tour_purposes")
total = tour_tod_profiles.filter(pl.col("tour_purpose") == "all_tour_purposes")
- assert total["departure_tour_count"].sum() == non_total["departure_tour_count"].sum()
+ assert (
+ total["departure_tour_count"].sum() == non_total["departure_tour_count"].sum()
+ )
def test_tour_rate_per_person_includes_all_person_types_total(tmp_path: Path) -> None:
@@ -1220,7 +1517,7 @@ def test_tour_rate_per_person_includes_all_person_types_total(tmp_path: Path) ->
skim_zone_map=None,
)
- summary = daily_travel.tour_rate_per_person(rd, config).sort(
+ summary = daily_travel_activity.tour_rate_per_person(rd, config).sort(
["person_type", "tour_purpose"]
)
@@ -1239,9 +1536,10 @@ def test_atwork_grouping_does_not_relabel_parent_mandatory_work_tours(
config = _write_config(
tmp_path,
extra_lines=[
- "group_joint_tour_purposes: true",
- "group_atwork_tour_purposes: true",
- "group_school_tour_purposes: false",
+ "summarize:",
+ " group_joint_tour_purposes: true",
+ " group_atwork_tour_purposes: true",
+ " group_school_tour_purposes: false",
],
)
tours = pl.DataFrame(
@@ -1283,9 +1581,11 @@ def test_atwork_subtour_frequency_summary_counts_parent_work_tours_only(
skim_zone_map=None,
)
- summary = tour.at_work_sub_tour_freq(rd, config)
+ summary = tour_profiles.at_work_sub_tour_freq(rd, config)
- assert summary.sort("atwork_subtour_frequency_category").to_dict(as_series=False) == {
+ assert summary.sort("atwork_subtour_frequency_category").to_dict(
+ as_series=False
+ ) == {
"atwork_subtour_frequency_category": ["eat", "no_subtours"],
"atwork_subtour_count": [3.0, 2.0],
}
@@ -1311,7 +1611,12 @@ def test_escorted_tour_summaries_exclude_child_person_types(tmp_path: Path) -> N
"person_id": [101, 101, 103, 102],
"person_type": [4, 4, 2, 7],
"tour_purpose": ["escort", "shopping", "escort", "school"],
- "school_esc_outbound": ["ride_share", None, "pure_escort", "pure_escort"],
+ "school_esc_outbound": [
+ "ride_share",
+ None,
+ "pure_escort",
+ "pure_escort",
+ ],
"school_esc_inbound": [None, "ride_share", "pure_escort", "ride_share"],
"SKIMDIST": [12.2, 7.6, 44.4, 9.1],
"num_ob_stops": [1, 5, 4, 2],
@@ -1334,28 +1639,33 @@ def test_escorted_tour_summaries_exclude_child_person_types(tmp_path: Path) -> N
skim_zone_map=None,
)
- total = daily_travel.total_escorted_tours(rd, config)
- school = daily_travel.escorted_tours_to_from_school(rd, config).sort(
+ total = daily_travel_escort_counts.total_escorted_tours(rd, config)
+ school = daily_travel_escort_counts.escorted_tours_to_from_school(rd, config).sort(
["escort_type", "direction"]
)
- purposes = daily_travel.adult_escorted_tour_purposes_by_direction(rd, config).sort(
- ["tour_purpose", "direction"]
- )
- person_types = daily_travel.adult_escorted_tours_by_person_type_and_direction(
+ purposes = daily_travel_escort_counts.adult_escorted_tour_purposes_by_direction(
rd, config
- ).sort(["person_type", "direction"])
- tour_distance = (
- daily_travel.adult_escorted_tour_distance_distribution_by_direction(
+ ).sort(["tour_purpose", "direction"])
+ person_types = (
+ daily_travel_escort_counts.adult_escorted_tours_by_person_type_and_direction(
rd, config
- ).sort(["direction", "distance_bin"])
+ ).sort(["person_type", "direction"])
)
- trip_distance = (
- daily_travel.adult_escorted_trip_distance_distribution_by_direction(
- rd, config
- ).sort(["direction", "distance_bin"])
- )
- stop_frequency = daily_travel.adult_escort_trip_stop_frequency(rd, config).sort(
- ["tour_purpose", "outbound_stop_count", "inbound_stop_count", "total_stop_count"]
+ tour_distance = daily_travel_escort_distributions.adult_escorted_tour_distance_distribution_by_direction(
+ rd, config
+ ).sort(["direction", "distance_bin"])
+ trip_distance = daily_travel_escort_distributions.adult_escorted_trip_distance_distribution_by_direction(
+ rd, config
+ ).sort(["direction", "distance_bin"])
+ stop_frequency = daily_travel_escort_distributions.adult_escort_trip_stop_frequency(
+ rd, config
+ ).sort(
+ [
+ "tour_purpose",
+ "outbound_stop_count",
+ "inbound_stop_count",
+ "total_stop_count",
+ ]
)
assert total.to_dict(as_series=False) == {"tour_count": [5.0]}
@@ -1475,9 +1785,9 @@ def test_adult_escort_event_stop_distribution_filters_to_explicit_escort_types(
skim_zone_map=None,
)
- summary = daily_travel.adult_escort_event_stop_distribution(rd, config).sort(
- ["segment", "stop_count"]
- )
+ summary = daily_travel_escort_distributions.adult_escort_event_stop_distribution(
+ rd, config
+ ).sort(["segment", "stop_count"])
assert summary.to_dict(as_series=False) == {
"segment": [
@@ -1539,16 +1849,12 @@ def test_adult_escort_distance_distributions_filter_to_explicit_escort_types(
skim_zone_map=None,
)
- tour_distance = (
- daily_travel.adult_escorted_tour_distance_distribution_by_direction(
- rd, config
- ).sort(["direction", "distance_bin"])
- )
- trip_distance = (
- daily_travel.adult_escorted_trip_distance_distribution_by_direction(
- rd, config
- ).sort(["direction", "distance_bin"])
- )
+ tour_distance = daily_travel_escort_distributions.adult_escorted_tour_distance_distribution_by_direction(
+ rd, config
+ ).sort(["direction", "distance_bin"])
+ trip_distance = daily_travel_escort_distributions.adult_escorted_trip_distance_distribution_by_direction(
+ rd, config
+ ).sort(["direction", "distance_bin"])
assert tour_distance.to_dict(as_series=False) == {
"distance_bin": ["40+", "19", "40+", "12", "40+"],
@@ -1589,9 +1895,30 @@ def test_student_school_escort_status_by_direction_summarizes_student_school_tou
"tour_id": [2001, 2002, 2003, 2004, 2005, 2006],
"person_id": [201, 202, 202, 203, 204, 204],
"person_type": [6, 7, 7, 4, 8, 8],
- "tour_purpose": ["school", "school", "school", "school", "shopping", "school"],
- "school_esc_outbound": ["none", "pure_escort", "ride_share", "pure_escort", "ride_share", "pure_escort"],
- "school_esc_inbound": ["none", "ride_share", "none", "pure_escort", "ride_share", "pure_escort"],
+ "tour_purpose": [
+ "school",
+ "school",
+ "school",
+ "school",
+ "shopping",
+ "school",
+ ],
+ "school_esc_outbound": [
+ "none",
+ "pure_escort",
+ "ride_share",
+ "pure_escort",
+ "ride_share",
+ "pure_escort",
+ ],
+ "school_esc_inbound": [
+ "none",
+ "ride_share",
+ "none",
+ "pure_escort",
+ "ride_share",
+ "pure_escort",
+ ],
"finalweight": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
}
),
@@ -1602,9 +1929,9 @@ def test_student_school_escort_status_by_direction_summarizes_student_school_tou
skim_zone_map=None,
)
- summary = daily_travel.student_school_escort_status_by_direction(rd, config).sort(
- ["direction", "escort_type"]
- )
+ summary = daily_travel_escort_counts.student_school_escort_status_by_direction(
+ rd, config
+ ).sort(["direction", "escort_type"])
assert summary.to_dict(as_series=False) == {
"direction": [
@@ -1665,9 +1992,9 @@ def test_student_school_escort_status_treats_blank_labels_as_not_escorted(
skim_zone_map=None,
)
- summary = daily_travel.student_school_escort_status_by_direction(rd, config).sort(
- ["direction", "escort_type"]
- )
+ summary = daily_travel_escort_counts.student_school_escort_status_by_direction(
+ rd, config
+ ).sort(["direction", "escort_type"])
assert summary.to_dict(as_series=False) == {
"direction": [
@@ -1733,14 +2060,12 @@ def test_households_with_school_escorting_by_student_count_and_direction_summari
skim_zone_map=None,
)
- denominator = daily_travel.student_households_by_student_count(rd, config).sort(
- "student_count"
- )
- summary = (
- daily_travel.households_with_school_escorting_by_student_count_and_direction(
- rd, config
- ).sort(["direction", "student_count"])
- )
+ denominator = daily_travel_escort_counts.student_households_by_student_count(
+ rd, config
+ ).sort("student_count")
+ summary = daily_travel_escort_counts.households_with_school_escorting_by_student_count_and_direction(
+ rd, config
+ ).sort(["direction", "student_count"])
assert denominator.to_dict(as_series=False) == {
"student_count": [1, 2],
@@ -1786,7 +2111,14 @@ def test_schoolkids_per_escorted_tour_by_student_count_and_direction_summarizes_
{
"tour_id": [2001, 2002, 2003, 2004, 2005, 2006],
"person_id": [101, 101, 103, 104, 104, 106],
- "tour_purpose": ["escort", "escort", "escort", "escort", "shopping", "escort"],
+ "tour_purpose": [
+ "escort",
+ "escort",
+ "escort",
+ "escort",
+ "shopping",
+ "escort",
+ ],
"school_esc_outbound": [
"pure_escort",
"ride_share",
@@ -1803,7 +2135,7 @@ def test_schoolkids_per_escorted_tour_by_student_count_and_direction_summarizes_
"pure_escort",
"ride_share",
],
- "num_escorted": [1.0, 2.0, 3.0, 4.0, 9.0, None],
+ "num_escortees": [1.0, 2.0, 3.0, 4.0, 9.0, None],
"finalweight": [2.0, 1.0, 3.0, 4.0, 5.0, 6.0],
}
),
@@ -1814,11 +2146,9 @@ def test_schoolkids_per_escorted_tour_by_student_count_and_direction_summarizes_
skim_zone_map=None,
)
- summary = (
- daily_travel.schoolkids_per_escorted_tour_by_student_count_and_direction(
- rd, config
- ).sort(["direction", "student_count"])
- )
+ summary = daily_travel_escort_counts.schoolkids_per_escorted_tour_by_student_count_and_direction(
+ rd, config
+ ).sort(["direction", "student_count"])
assert summary.to_dict(as_series=False) == {
"student_count": [1, 1, 1],
@@ -1857,6 +2187,111 @@ def test_prepare_data_uses_default_fallbacks_for_purpose_timing_and_employment(
assert prepared.land_use["EMPLOYMENT"].to_list() == [7, 8, 9]
+def test_prepare_data_derives_trip_and_tour_period_labels(
+ tmp_path: Path,
+) -> None:
+ _write_network_los(tmp_path / "network_los.yaml")
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "prepare:",
+ " time_periods:",
+ " network_los_file: network_los.yaml",
+ " trip_period_number_column: depart",
+ " tour_start_period_number_column: start",
+ " tour_end_period_number_column: end",
+ ],
+ )
+ raw = RunData(
+ label="Base",
+ run_dir="C:/runs/base",
+ skim_file=None,
+ hh=pl.DataFrame({"household_id": [1], "home_zone_id": [10]}),
+ per=pl.DataFrame({"person_id": [101], "household_id": [1], "ptype": [1]}),
+ tours=pl.DataFrame(
+ {
+ "tour_id": [1001],
+ "person_id": [101],
+ "household_id": [1],
+ "tour_mode": ["DRIVE"],
+ "start": [7],
+ "end": [33],
+ "origin": [10],
+ "destination": [20],
+ }
+ ),
+ trips=pl.DataFrame(
+ {
+ "trip_id": list(range(1, 9)),
+ "tour_id": [1001] * 8,
+ "person_id": [101] * 8,
+ "household_id": [1] * 8,
+ "trip_mode": ["DRIVEALONE"] * 8,
+ "depart": [1, 7, 13, 25, 33, 44, 49, None],
+ "outbound": [True, True, True, False, False, False, False, False],
+ "trip_num": list(range(1, 9)),
+ "origin": [10] * 8,
+ "destination": [20] * 8,
+ },
+ schema_overrides={"depart": pl.Int64},
+ ),
+ joint_participants=pl.DataFrame(
+ {"tour_id": [], "person_id": []},
+ schema={"tour_id": pl.Int64, "person_id": pl.Int64},
+ ),
+ land_use=pl.DataFrame({"zone_id": [10, 20], "TAZ": [10, 20]}),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+ prepared = prepare_data(raw, config)
+
+ assert prepared.trips["trip_period"].to_list() == [
+ "EA",
+ "AM",
+ "MD",
+ "PM",
+ "EV",
+ "EA",
+ None,
+ None,
+ ]
+ assert prepared.tours["start_period"].to_list() == ["AM"]
+ assert prepared.tours["end_period"].to_list() == ["EV"]
+ assert prepared.tours["first_inbound_trip_period"].to_list() == ["PM"]
+ assert (
+ prepared.prepare_diagnostics["time_periods.trips.trip_period"]["unresolved"]
+ == 2
+ )
+
+
+def test_prepare_data_preserves_raw_trip_period_when_source_missing(
+ tmp_path: Path,
+) -> None:
+ _write_network_los(tmp_path / "network_los.yaml")
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "prepare:",
+ " time_periods:",
+ " network_los_file: network_los.yaml",
+ " trip_period_number_column: missing_depart",
+ ],
+ )
+ raw = _raw_run_with_default_fallback_columns()
+ raw.trips = raw.trips.drop("depart").with_columns(
+ pl.Series("trip_period", ["AM", "PM"])
+ )
+
+ prepared = prepare_data(raw, config)
+
+ assert prepared.trips["trip_period"].to_list() == ["AM", "PM"]
+ assert (
+ prepared.prepare_diagnostics["time_periods.trips.trip_period"]["status"]
+ == "source_column_missing"
+ )
+
+
def test_prepare_data_resolves_shared_alias_lists_independently_per_run(
tmp_path: Path,
) -> None:
@@ -1885,6 +2320,19 @@ def test_prepare_data_prefers_non_numeric_purpose_alias_when_multiple_candidates
assert prepared.tours["tour_purpose"].to_list() == ["eatout"]
+def test_prepare_data_falls_back_from_configured_numeric_purpose_to_tour_type(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ column_lines=["tour_purpose: primary_purpose"],
+ )
+
+ prepared = prepare_data(_raw_run_with_default_fallback_columns(), config)
+
+ assert prepared.tours["tour_purpose"].to_list() == ["eatout"]
+
+
def test_prepare_data_overwrites_numeric_raw_tour_purpose_with_readable_alias(
tmp_path: Path,
) -> None:
@@ -2028,12 +2476,12 @@ def test_summaries_use_canonical_runtime_columns_and_preserve_output_shapes(
assert stop_purpose["tour_purpose"].to_list() == ["eatout"]
assert stop_purpose["stop_destination_purpose"].to_list() == ["shop"]
- stop_freq = tour.stop_freq(prepared, config)
+ stop_freq = tour_profiles.stop_freq(prepared, config)
assert stop_freq.columns == list(
SUMMARY_OUTPUT_COLUMNS["tour_stop_frequency_by_tour_purpose"]
)
- stop_location = trip.stop_ood_distance(prepared, config)
+ stop_location = trip_distributions.stop_ood_distance(prepared, config)
assert stop_location.columns == list(
SUMMARY_OUTPUT_COLUMNS["stop_out_of_direction_distance_by_tour_purpose"]
)
@@ -2041,24 +2489,14 @@ def test_summaries_use_canonical_runtime_columns_and_preserve_output_shapes(
assert "all_tour_purposes" in stop_location["tour_purpose"].unique().to_list()
assert "eatout" in stop_location["tour_purpose"].unique().to_list()
- stop_timing = trip.trip_stop_tod(prepared, config)
+ stop_timing = trip_distributions.trip_stop_tod(prepared, config)
assert stop_timing.columns == list(
SUMMARY_OUTPUT_COLUMNS["trip_departure_time_by_purpose"]
)
assert "all_tour_purposes" in stop_timing["tour_purpose"].unique().to_list()
assert "eatout" in stop_timing["tour_purpose"].unique().to_list()
- tour_mode_profile = legacy.tour_mode_profile(prepared, config)
- assert tour_mode_profile.columns == [
- "tour_mode",
- "purpose",
- "freq_as0",
- "freq_as1",
- "freq_as2",
- "freq_all",
- ]
-
- tour_tod_profiles = tour.tour_tod(prepared, config)
+ tour_tod_profiles = tour_profiles.tour_tod(prepared, config)
assert tour_tod_profiles.columns == [
"time_bin",
"tour_purpose",
@@ -2069,15 +2507,6 @@ def test_summaries_use_canonical_runtime_columns_and_preserve_output_shapes(
assert "all_tour_purposes" in tour_tod_profiles["tour_purpose"].unique().to_list()
assert "eatout" in tour_tod_profiles["tour_purpose"].unique().to_list()
- if "od_dist" in prepared.trips.columns:
- totals_df = legacy.system_totals(prepared, config)
- assert totals_df["employment"].to_list() == [24.0]
-
- distance_df = legacy.distance_distribution(prepared, config)
- assert "purpose" in distance_df.columns
- assert "All NM" in distance_df["purpose"].to_list()
-
-
def test_summaries_return_empty_tables_when_canonical_columns_are_missing(
tmp_path: Path,
) -> None:
@@ -2091,7 +2520,9 @@ def test_summaries_return_empty_tables_when_canonical_columns_are_missing(
hh=prepared.hh,
per=prepared.per,
tours=prepared.tours.drop(["tour_purpose", "summary_tour_purpose"]),
- trips=prepared.trips.drop(["tour_purpose", "summary_tour_purpose", "trip_purpose"]),
+ trips=prepared.trips.drop(
+ ["tour_purpose", "summary_tour_purpose", "trip_purpose"]
+ ),
joint_participants=prepared.joint_participants,
land_use=prepared.land_use,
skim_matrix=prepared.skim_matrix,
@@ -2103,12 +2534,10 @@ def test_summaries_return_empty_tables_when_canonical_columns_are_missing(
assert trip.trip_mode(prepared, config).is_empty()
assert trip.stop_purpose_by_tour_purpose(prepared, config).is_empty()
- assert trip.trip_stop_tod(prepared, config).is_empty()
- assert trip.stop_ood_distance(prepared, config).is_empty()
- assert tour.stop_freq(prepared, config).is_empty()
- assert tour.tour_tod(prepared, config).is_empty()
- assert legacy.distance_distribution(prepared, config).is_empty()
-
+ assert trip_distributions.trip_stop_tod(prepared, config).is_empty()
+ assert trip_distributions.stop_ood_distance(prepared, config).is_empty()
+ assert tour_profiles.stop_freq(prepared, config).is_empty()
+ assert tour_profiles.tour_tod(prepared, config).is_empty()
def test_prepare_data_skips_fragile_joins_when_dependency_keys_are_missing(
tmp_path: Path,
diff --git a/tests/test_runtime_config_package.py b/tests/test_runtime_config_package.py
index 438ab86..a547fab 100644
--- a/tests/test_runtime_config_package.py
+++ b/tests/test_runtime_config_package.py
@@ -17,6 +17,16 @@
config_for_run,
resolve_run_skimjoin_settings,
)
+from processor.skimjoin.config.io import (
+ load_config_file as load_skimjoin_config_file,
+)
+from processor.skimjoin.config.normalize import normalize_config as normalize_skimjoin
+from processor.skimjoin.config.validation import (
+ load_config as validate_skimjoin_config,
+)
+
+
+ROOT = Path(__file__).resolve().parents[1]
def test_runtime_config_public_import_surface_and_package_resolution(tmp_path: Path) -> None:
@@ -26,10 +36,9 @@ def test_runtime_config_public_import_surface_and_package_resolution(tmp_path: P
[
'name: "Package Surface Test"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Package Surface Test"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Package Surface Test"',
]
),
encoding="utf-8",
@@ -48,3 +57,41 @@ def test_runtime_config_public_import_surface_and_package_resolution(tmp_path: P
assert PreparedColumnSegmentationSource is not None
assert CsvLookupSegmentationSource is not None
assert SegmentationDefinition is not None
+
+
+def test_repository_example_configs_match_current_schemas() -> None:
+ config = Config.from_yaml(ROOT / "config.yaml")
+
+ assert config.pipeline.steps == ("summarize", "dashboard")
+ assert config.pipeline.dashboard_mode == "live"
+ assert config.skimjoin.enabled is False
+ assert config.skimjoin.config_path == str(
+ (ROOT / "example_skimjoin_config.yaml").resolve()
+ )
+ assert config.include_notes is True
+ assert config.missing_data_display == "card"
+
+ skimjoin_raw = load_skimjoin_config_file(
+ ROOT / "example_skimjoin_config.yaml"
+ )
+ # The example intentionally points at a placeholder network_los.yaml.
+ # Supply a small explicit period map in memory so every mode/component can
+ # still pass the full normalizer without requiring user data.
+ skimjoin_raw["dimensions"]["PERIOD"]["values_from_network_los"] = False
+ skimjoin_raw["dimensions"]["PERIOD"]["values"] = {"1": "EA"}
+ skimjoin = validate_skimjoin_config(
+ skimjoin_raw,
+ require_activitysim_tables=False,
+ )
+ normalized_skimjoin = normalize_skimjoin(skimjoin)
+
+ assert tuple(skimjoin.modes) == (
+ "SOV",
+ "HOV2",
+ "HOV3",
+ "WALK",
+ "WALK_TRANSIT",
+ "PNR_TRANSIT",
+ )
+ assert normalized_skimjoin.trip_lookups
+ assert normalized_skimjoin.tour_lookups
diff --git a/tests/test_runtime_workflows.py b/tests/test_runtime_workflows.py
index d2864fd..7d0903b 100644
--- a/tests/test_runtime_workflows.py
+++ b/tests/test_runtime_workflows.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
+from dataclasses import replace
from pathlib import Path
import sys
@@ -11,7 +12,7 @@
import runtime.workflows as runtime_workflows
import run as cli_run
-from processor.models import ProcessorWorkflowResult
+from runtime.workflows import PreparedRunsArtifact, SummaryRunsArtifact, WorkflowPlan
from processor.models import RunData
from processor.prepare.availability import attach_table_availability
from processor.prepare.cache import (
@@ -20,14 +21,19 @@
)
from runtime.config import Config
from runtime.config.models import SkimjoinSettings
-from processor.summarize.contracts import summary_contract
+from processor.summarize.contracts import (
+ empty_summary_frame,
+ get_summary_contract,
+ summary,
+)
from processor.summarize import cache as summary_cache
+from processor.summarize import catalog as summary_catalog
+from processor.summarize import builder as summary_builder
from processor.summarize.cache import (
build_run_fingerprint,
- create_summary_run,
write_summary_run_cache,
)
-from processor.summarize.summary_specs import SummarySpec
+from processor.summarize.cache_types import create_summary_run
from runtime.workflows import prepare as prepare_workflow
@@ -37,29 +43,29 @@ def _write_config(
runs: list[dict],
dashboard_pages: list[str] | None = None,
export_html_lines: list[str] | None = None,
- visualizer_lines: list[str] | None = None,
+ display_lines: list[str] | None = None,
extra_lines: list[str] | None = None,
) -> Config:
tmp_path.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "config.yaml"
lines = [
'name: "Workflow Test Config"',
- "processor:",
- " root: summary_cache",
- " summaries:",
- " weighting_modes:",
- " - weighted",
- " - unweighted",
- "visualizer:",
- ' dashboard_title: "Workflow Test Dashboard"',
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes:",
+ " - weighted",
+ " - unweighted",
+ "dashboard:",
+ ' title: "Workflow Test Dashboard"',
]
- if visualizer_lines:
- lines.extend(f" {line}" for line in visualizer_lines)
+ if display_lines:
+ lines.append("display:")
+ lines.extend(f" {line}" for line in display_lines)
if dashboard_pages is not None:
- lines.append(" dashboard_pages:")
- lines.extend(f" - {page_id}" for page_id in dashboard_pages)
+ lines.extend([" live:", " pages:"])
+ lines.extend(f" - {page_id}" for page_id in dashboard_pages)
if export_html_lines:
- lines.append(" export_html:")
+ lines.append(" export:")
lines.extend(f" {line}" for line in export_html_lines)
lines.append("runs:")
for run_entry in runs:
@@ -72,140 +78,1033 @@ def _write_config(
if prepared_table_map:
lines.append(" prepared_table_map:")
for table_id, path in prepared_table_map.items():
- normalized_path = str(path).replace("\\", "/")
+ normalized_path = str(Path(path).resolve()).replace("\\", "/")
lines.append(f' {table_id}: "{normalized_path}"')
+ summary_table_map = run_entry.get("summary_table_map")
+ if summary_table_map:
+ lines.append(" summary_table_map:")
+ for summary_id, path in summary_table_map.items():
+ normalized_path = str(Path(path).resolve()).replace("\\", "/")
+ lines.append(f' {summary_id}: "{normalized_path}"')
if extra_lines:
lines.extend(extra_lines)
config_path.write_text("\n".join(lines), encoding="utf-8")
return Config.from_yaml(config_path)
-def _simple_summary_run(label: str, run_key: str) -> object:
- return create_summary_run(
- label=label,
- run_key=run_key,
- summaries_by_mode={
- "weighted": {"totals": pl.DataFrame({"population": [100.0]})},
- "unweighted": {"totals": pl.DataFrame({"population": [50.0]})},
- },
- source_run_dir=f"C:/runs/{run_key}",
+def _workflow_plan(
+ config: Config,
+ *,
+ skimjoin: bool | None = None,
+ segment: bool | None = None,
+) -> WorkflowPlan:
+ steps = list(config.pipeline.steps)
+ for step, enabled, dependency in (
+ ("skimjoin", skimjoin, "prepare"),
+ ("segment", segment, "summarize"),
+ ):
+ if enabled is None:
+ continue
+ steps = [candidate for candidate in steps if candidate != step]
+ if enabled:
+ insert_at = steps.index(dependency) + 1
+ steps.insert(insert_at, step)
+ return WorkflowPlan.for_steps(config, steps)
+
+
+def _simple_summary_run(label: str, run_key: str) -> object:
+ return create_summary_run(
+ label=label,
+ run_key=run_key,
+ summaries_by_mode={
+ "weighted": {"population_totals": pl.DataFrame({"person_count": [100.0]})},
+ "unweighted": {"population_totals": pl.DataFrame({"person_count": [50.0]})},
+ },
+ source_run_dir=f"C:/runs/{run_key}",
+ )
+
+
+def _simple_summary_mode_build(label: str, run_key: str) -> tuple[dict, dict]:
+ summary_run = _simple_summary_run(label, run_key)
+ return (
+ summary_run.summaries_by_mode,
+ {
+ mode: {"population_totals": {"state": "available"}}
+ for mode in summary_run.summaries_by_mode
+ },
+ )
+
+
+def _fake_run_data(label: str, run_dir: str) -> RunData:
+ return RunData(
+ label=label,
+ run_dir=run_dir,
+ skim_file=None,
+ hh=pl.DataFrame(),
+ per=pl.DataFrame(),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+
+def _write_custom_prepared_tables(
+ root: Path,
+ *,
+ file_format: str = "parquet",
+) -> dict[str, str]:
+ root.mkdir(parents=True, exist_ok=True)
+ tables = {
+ "households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
+ "persons": pl.DataFrame(
+ {"person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "tours": pl.DataFrame(
+ {
+ "tour_id": [100],
+ "person_id": [10],
+ "household_id": [1],
+ "finalweight": [1.0],
+ }
+ ),
+ "trips": pl.DataFrame(
+ {
+ "trip_id": [1000],
+ "tour_id": [100],
+ "person_id": [10],
+ "finalweight": [1.0],
+ }
+ ),
+ "joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
+ "land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
+ }
+ paths: dict[str, str] = {}
+ for table_id, table in tables.items():
+ path = root / f"{table_id}.{file_format}"
+ if file_format == "parquet":
+ table.write_parquet(path)
+ else:
+ table.write_csv(path)
+ paths[table_id] = str(path.resolve())
+ return paths
+
+
+def _write_inconsistent_custom_prepared_tables(
+ root: Path,
+ *,
+ file_format: str = "parquet",
+) -> dict[str, str]:
+ root.mkdir(parents=True, exist_ok=True)
+ tables = {
+ "households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
+ "persons": pl.DataFrame(
+ {"person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "tours": pl.DataFrame(
+ {
+ "tour_id": [100],
+ "person_id": [10],
+ "household_id": [1],
+ "finalweight": [1.0],
+ }
+ ),
+ "trips": pl.DataFrame(
+ {
+ "trip_id": [1000, 1001],
+ "tour_id": [100, 999],
+ "person_id": [10, 999],
+ "household_id": [1, 999],
+ "finalweight": [1.0, 1.0],
+ }
+ ),
+ "joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
+ "land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
+ }
+ paths: dict[str, str] = {}
+ for table_id, table in tables.items():
+ path = root / f"{table_id}.{file_format}"
+ if file_format == "parquet":
+ table.write_parquet(path)
+ else:
+ table.write_csv(path)
+ paths[table_id] = str(path.resolve())
+ return paths
+
+
+def _write_summary_table(path: Path, value: float) -> str:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ pl.DataFrame(
+ {
+ "person_count": [value],
+ "household_count": [value],
+ "tour_count": [value],
+ "trip_count": [value],
+ "stop_count": [value],
+ }
+ ).write_csv(path)
+ return str(path.resolve())
+
+
+def _write_auto_vmt_validation_summary(path: Path, value: float) -> str:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ pl.DataFrame(
+ {
+ "TOD": ["Daily"],
+ "SOV": [value],
+ "HOV2": [0.0],
+ "HOV3": [0.0],
+ "Truck": [0.0],
+ "Total": [value],
+ }
+ ).write_csv(path)
+ return str(path.resolve())
+
+
+def _prepared_identity(
+ *,
+ config: Config,
+ run_key: str,
+ label: str,
+ run_dir: str,
+) -> dict[str, object]:
+ return build_prepared_manifest_identity(
+ run_key=run_key,
+ config=config,
+ run_fingerprint=build_run_fingerprint(
+ label=label,
+ run_dir=run_dir,
+ skim_file=None,
+ hh_weight_col=None,
+ person_weight_col=None,
+ trip_weight_col=None,
+ ),
+ )
+
+
+def test_load_runtime_config_rejects_unknown_summary_table_map_id(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "unknown.csv"
+ _write_summary_table(summary_path, 1.0)
+ _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"unknown_summary": summary_path},
+ }
+ ],
+ )
+
+ with pytest.raises(ValueError, match="unsupported summary ids"):
+ runtime_workflows.load_runtime_config(tmp_path / "config.yaml")
+
+
+def test_load_runtime_config_rejects_old_demo_validation_summary_ids(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ _write_auto_vmt_validation_summary(summary_path, 1.0)
+ _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"demo_auto_vmt_summary": summary_path},
+ }
+ ],
+ )
+
+ with pytest.raises(ValueError, match="unsupported summary ids"):
+ runtime_workflows.load_runtime_config(tmp_path / "config.yaml")
+
+
+def test_non_default_summary_specs_remain_registered_but_not_default_built(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path, runs=[{"label": "External"}])
+
+ spec = summary_catalog.SUMMARY_BY_ID["auto_vmt_validation_summary"]
+
+ assert spec.build_by_default is False
+ assert (
+ summary_catalog.SUMMARY_FILENAME_BY_ID["auto_vmt_validation_summary"]
+ == "auto_vmt_validation_summary.csv"
+ )
+ assert "auto_vmt_validation_summary" not in summary_builder.DEFAULT_SUMMARY_IDS
+ assert "auto_vmt_validation_summary" not in summary_builder.DEFAULT_SUMMARY_IDS
+
+
+def test_validation_scaffold_summaries_are_registered_with_empty_contracts(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path, runs=[{"label": "External"}])
+ expected_ids = {
+ "link_validation_summary",
+ "count_location_counts_validation_summary",
+ "count_location_volumes_validation_summary",
+ "count_location_scatter_validation_summary",
+ "count_location_fit_validation_summary",
+ "county_flows_validation_summary",
+ "county_flows_joja_validation_summary",
+ "commercial_vehicle_validation_summary",
+ "commercial_vehicle_vmt_validation_summary",
+ "external_trip_validation_summary",
+ "external_vmt_validation_summary",
+ "auto_vmt_validation_summary",
+ "work_from_home_validation_summary",
+ }
+
+ for summary_id in expected_ids:
+ spec = summary_catalog.SUMMARY_BY_ID[summary_id]
+ contract = get_summary_contract(spec.builder)
+
+ assert spec.build_by_default is False
+ assert contract is not None
+ assert empty_summary_frame(spec.builder).schema == dict(contract.schema)
+ assert summary_catalog.SUMMARY_FILENAME_BY_ID[summary_id] == f"{summary_id}.csv"
+ assert summary_id not in summary_builder.DEFAULT_SUMMARY_IDS
+ assert summary_id not in summary_builder.DEFAULT_SUMMARY_IDS
+
+
+def test_run_summary_workflow_loads_summary_only_run_without_prepared_inputs(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "totals.csv"
+ _write_summary_table(summary_path, 11.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"population_totals": summary_path},
+ }
+ ],
+ )
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=True,
+ )
+
+ assert result.prepared.runs == []
+ assert [run.label for run in result.runs] == ["External"]
+ assert (
+ result.runs[0].summaries_by_mode["weighted"]["population_totals"]["person_count"][0] == 11.0
+ )
+ assert (
+ result.runs[0].summaries_by_mode["unweighted"]["population_totals"]["person_count"][0]
+ == 11.0
+ )
+
+
+def test_summary_only_run_loads_non_default_summary_table_map_id(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ _write_auto_vmt_validation_summary(summary_path, 42.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {
+ "auto_vmt_validation_summary": summary_path,
+ },
+ }
+ ],
+ )
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=True,
+ )
+
+ weighted = result.runs[0].summaries_by_mode["weighted"]
+ assert weighted["auto_vmt_validation_summary"].to_dicts() == [
+ {
+ "TOD": "Daily",
+ "SOV": 42.0,
+ "HOV2": 0.0,
+ "HOV3": 0.0,
+ "Truck": 0.0,
+ "Total": 42.0,
+ }
+ ]
+ assert not (
+ Path(config.summary_root)
+ / "external"
+ / "summary_tables"
+ / "weighted"
+ / "totals.csv"
+ ).exists()
+
+
+def test_summary_only_run_bypasses_skimjoin_when_pipeline_enables_it(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "totals.csv"
+ _write_summary_table(summary_path, 41.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"population_totals": summary_path},
+ }
+ ],
+ extra_lines=[
+ "pipeline:",
+ " steps:",
+ " - prepare",
+ " - skimjoin",
+ " - summarize",
+ "skimjoin:",
+ " create_hypothetical_skim_tables: true",
+ " defaults:",
+ ],
+ )
+
+ prepare_result = runtime_workflows.run_prepare_workflow(
+ config=config,
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=True,
+ write_cache=True,
+ plan=_workflow_plan(config, skimjoin=True),
+ )
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=True,
+ prepared=prepare_result,
+ plan=_workflow_plan(config, skimjoin=True),
+ )
+
+ assert prepare_result.runs == []
+ assert [run.label for run in result.runs] == ["External"]
+ assert (
+ result.runs[0].summaries_by_mode["weighted"]["population_totals"]["person_count"][0] == 41.0
+ )
+ loaded = runtime_workflows.load_summary_runs_from_cache(
+ config=config,
+ cache_root=Path(config.summary_root),
+ explicit_cache_dirs=None,
+ run_entries=config.runs,
+ required_summary_ids=("population_totals",),
+ )
+ assert loaded[0].summaries_by_mode["weighted"]["population_totals"]["person_count"][0] == 41.0
+
+
+def test_run_summary_workflow_overlays_summary_table_map_on_generated_summaries(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ run_dir = tmp_path / "run_a"
+ summary_path = tmp_path / "outside" / "totals.csv"
+ _write_summary_table(summary_path, 99.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "dir": str(run_dir),
+ "label": "Run A",
+ "summary_table_map": {"population_totals": summary_path},
+ }
+ ],
+ )
+ monkeypatch.setattr(
+ summary_builder,
+ "DEFAULT_SUMMARY_IDS",
+ ["population_totals", "auto_vmt_totals"],
+ )
+ monkeypatch.setattr(
+ summary_builder,
+ "build_mode_summaries_with_metadata",
+ lambda rd, config, summary_ids=None: (
+ {
+ "weighted": {
+ summary_id: pl.DataFrame({"metric": [summary_id], "value": [1.0]})
+ for summary_id in summary_ids
+ },
+ "unweighted": {
+ summary_id: pl.DataFrame({"metric": [summary_id], "value": [2.0]})
+ for summary_id in summary_ids
+ },
+ },
+ {
+ mode: {summary_id: {"state": "available"} for summary_id in summary_ids}
+ for mode in ("weighted", "unweighted")
+ },
+ ),
+ )
+ monkeypatch.setattr(
+ prepare_workflow,
+ "read_run",
+ lambda run_dir, config, label=None, **kwargs: _fake_run_data(
+ label or "Run A",
+ str(run_dir),
+ ),
+ )
+ monkeypatch.setattr(prepare_workflow, "prepare_data", lambda rd, config: rd)
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=False,
+ )
+
+ weighted = result.runs[0].summaries_by_mode["weighted"]
+ assert weighted["population_totals"]["person_count"][0] == 99.0
+ assert weighted["auto_vmt_totals"].to_dicts() == [
+ {"metric": "auto_vmt_totals", "value": 1.0}
+ ]
+
+
+def test_run_summary_workflow_reuses_all_existing_prepared_runs_when_cache_disabled(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ run_a_dir = tmp_path / "run_a"
+ run_b_dir = tmp_path / "run_b"
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {"dir": str(run_a_dir), "label": "Run A"},
+ {"dir": str(run_b_dir), "label": "Run B"},
+ ],
+ )
+ prepared_a = _fake_run_data("Run A", str(run_a_dir))
+ prepared_b = _fake_run_data("Run B", str(run_b_dir))
+ existing_result = PreparedRunsArtifact(
+ runs=[("Run A", prepared_a), ("Run B", prepared_b)],
+ by_key={
+ "run-a": ("Run A", prepared_a),
+ "run-b": ("Run B", prepared_b),
+ },
+ run_keys=["run-a", "run-b"],
+ )
+ summary_build_labels: list[str] = []
+
+ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
+ summary_build_labels.append(rd.label)
+ requested = list(summary_ids or [])
+ return (
+ {
+ mode: {
+ summary_id: pl.DataFrame({"run": [rd.label]})
+ for summary_id in requested
+ }
+ for mode in config.weighting_modes
+ },
+ {
+ mode: {summary_id: {"state": "available"} for summary_id in requested}
+ for mode in config.weighting_modes
+ },
+ )
+
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
+ monkeypatch.setattr(
+ summary_builder,
+ "build_mode_summaries_with_metadata",
+ fake_build_mode_summaries_with_metadata,
+ )
+ _patch_prepare_pipeline(
+ monkeypatch,
+ read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("existing prepared runs should be reused")
+ ),
+ prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("existing prepared runs should be reused")
+ ),
+ )
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ prepared_prefer_cache=False,
+ write_cache=False,
+ prepared=existing_result,
+ )
+
+ assert summary_build_labels == ["Run A", "Run B"]
+ assert result.prepared.by_key["run-a"][1] is prepared_a
+ assert result.prepared.by_key["run-b"][1] is prepared_b
+ assert result.prepared.run_keys == ["run-a", "run-b"]
+
+
+def test_prepare_then_summary_does_not_rerun_skimjoin_for_existing_prepared_runs(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ run_a_dir = tmp_path / "run_a"
+ run_b_dir = tmp_path / "run_b"
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {"dir": str(run_a_dir), "label": "Run A"},
+ {"dir": str(run_b_dir), "label": "Run B"},
+ ],
+ extra_lines=[
+ "pipeline:",
+ " steps:",
+ " - prepare",
+ " - skimjoin",
+ " - summarize",
+ ],
+ )
+ read_labels: list[str] = []
+ skimjoin_labels: list[str] = []
+
+ def fake_read_run(run_dir, config, label=None, **kwargs):
+ run_label = label or Path(run_dir).name
+ read_labels.append(run_label)
+ return _fake_run_data(run_label, str(run_dir))
+
+ def fake_apply_skimjoin(rd, config):
+ skimjoin_labels.append(rd.label)
+ return rd
+
+ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
+ requested = list(summary_ids or [])
+ return (
+ {
+ mode: {
+ summary_id: pl.DataFrame({"run": [rd.label]})
+ for summary_id in requested
+ }
+ for mode in config.weighting_modes
+ },
+ {
+ mode: {summary_id: {"state": "available"} for summary_id in requested}
+ for mode in config.weighting_modes
+ },
+ )
+
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
+ monkeypatch.setattr(
+ summary_builder,
+ "build_mode_summaries_with_metadata",
+ fake_build_mode_summaries_with_metadata,
+ )
+ _patch_prepare_pipeline(
+ monkeypatch,
+ read_run=fake_read_run,
+ prepare_data=lambda rd, config: rd,
+ )
+
+ def fake_resolve_skimjoin(config, entry):
+ return SkimjoinSettings(
+ enabled=True,
+ config_path="mock_skimjoin.yaml",
+ config_digest="mock-digest",
+ )
+
+ monkeypatch.setattr(
+ "runtime.config.resolve_run_skimjoin_settings",
+ fake_resolve_skimjoin,
+ )
+ monkeypatch.setattr(
+ "runtime.config.normalize_prepare.resolve_run_skimjoin_settings",
+ fake_resolve_skimjoin,
+ )
+ monkeypatch.setattr(prepare_workflow, "apply_skimjoin", fake_apply_skimjoin)
+
+ prepare_result = runtime_workflows.run_prepare_workflow(
+ config=config,
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=False,
+ plan=_workflow_plan(config, skimjoin=True),
+ )
+ runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ prepared_prefer_cache=False,
+ write_cache=False,
+ prepared=prepare_result,
+ plan=_workflow_plan(config, skimjoin=True),
+ )
+
+ assert read_labels == ["Run A", "Run B"]
+ assert skimjoin_labels == ["Run A", "Run B"]
+
+
+def test_run_summary_workflow_does_not_build_non_default_registered_summaries(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ run_dir = tmp_path / "run_a"
+ config = _write_config(
+ tmp_path,
+ runs=[{"dir": str(run_dir), "label": "Run A"}],
+ )
+ build_calls: list[list[str] | None] = []
+
+ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
+ build_calls.append(list(summary_ids) if summary_ids is not None else None)
+ requested = list(summary_ids or [])
+ return (
+ {
+ mode: {
+ summary_id: pl.DataFrame({"value": [1.0]})
+ for summary_id in requested
+ }
+ for mode in config.weighting_modes
+ },
+ {
+ mode: {summary_id: {"state": "available"} for summary_id in requested}
+ for mode in config.weighting_modes
+ },
+ )
+
+ monkeypatch.setattr(
+ summary_builder,
+ "build_mode_summaries_with_metadata",
+ fake_build_mode_summaries_with_metadata,
+ )
+ monkeypatch.setattr(
+ prepare_workflow,
+ "read_run",
+ lambda run_dir, config, label=None, **kwargs: _fake_run_data(
+ label or "Run A",
+ str(run_dir),
+ ),
+ )
+ monkeypatch.setattr(prepare_workflow, "prepare_data", lambda rd, config: rd)
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=True,
+ )
+
+ assert build_calls
+ assert all(
+ "auto_vmt_validation_summary" not in call for call in build_calls if call
+ )
+ assert (
+ "auto_vmt_validation_summary"
+ not in result.runs[0].summaries_by_mode["weighted"]
+ )
+ assert not (
+ Path(config.summary_root)
+ / "run-a"
+ / "summary_tables"
+ / "weighted"
+ / "auto_vmt_validation_summary.csv"
+ ).exists()
+
+
+def test_mixed_run_preserves_generated_defaults_and_overlays_non_default_summary(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ run_dir = tmp_path / "run_a"
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ _write_auto_vmt_validation_summary(summary_path, 88.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "dir": str(run_dir),
+ "label": "Run A",
+ "summary_table_map": {
+ "auto_vmt_validation_summary": summary_path,
+ },
+ }
+ ],
+ )
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
+ monkeypatch.setattr(
+ summary_builder,
+ "build_mode_summaries_with_metadata",
+ lambda rd, config, summary_ids=None: (
+ {
+ mode: {
+ "population_totals": pl.DataFrame({"person_count": [1.0]}),
+ }
+ for mode in config.weighting_modes
+ },
+ {
+ mode: {"population_totals": {"state": "available"}}
+ for mode in config.weighting_modes
+ },
+ ),
+ )
+ monkeypatch.setattr(
+ prepare_workflow,
+ "read_run",
+ lambda run_dir, config, label=None, **kwargs: _fake_run_data(
+ label or "Run A",
+ str(run_dir),
+ ),
+ )
+ monkeypatch.setattr(prepare_workflow, "prepare_data", lambda rd, config: rd)
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=False,
+ )
+
+ weighted = result.runs[0].summaries_by_mode["weighted"]
+ assert weighted["population_totals"].to_dicts() == [{"person_count": 1.0}]
+ assert weighted["auto_vmt_validation_summary"].to_dicts() == [
+ {
+ "TOD": "Daily",
+ "SOV": 88.0,
+ "HOV2": 0.0,
+ "HOV3": 0.0,
+ "Truck": 0.0,
+ "Total": 88.0,
+ }
+ ]
+
+
+def test_summary_table_map_file_identity_invalidates_summary_cache(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "totals.csv"
+ _write_summary_table(summary_path, 11.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"population_totals": summary_path},
+ }
+ ],
+ )
+ runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=True,
+ )
+ _write_summary_table(summary_path, 12.0)
+
+ result = runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=True,
+ write_cache=False,
+ )
+
+ assert (
+ result.runs[0].summaries_by_mode["weighted"]["population_totals"]["person_count"][0] == 12.0
)
-def _simple_summary_mode_build(label: str, run_key: str) -> tuple[dict, dict]:
- summary_run = _simple_summary_run(label, run_key)
- return (
- summary_run.summaries_by_mode,
- {
- mode: {"totals": {"state": "available"}}
- for mode in summary_run.summaries_by_mode
- },
+def test_dashboard_only_loads_summary_table_map_without_cache(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "totals.csv"
+ _write_summary_table(summary_path, 21.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {"population_totals": summary_path},
+ }
+ ],
+ )
+
+ loaded = runtime_workflows.load_summary_runs_from_cache(
+ config=config,
+ cache_root=Path(config.summary_root),
+ explicit_cache_dirs=None,
+ run_entries=config.runs,
+ required_summary_ids=("population_totals",),
)
+ assert [run.label for run in loaded] == ["External"]
+ assert loaded[0].summaries_by_mode["weighted"]["population_totals"]["person_count"][0] == 21.0
-def _fake_run_data(label: str, run_dir: str) -> RunData:
- return RunData(
- label=label,
- run_dir=run_dir,
- skim_file=None,
- hh=pl.DataFrame(),
- per=pl.DataFrame(),
- tours=pl.DataFrame(),
- trips=pl.DataFrame(),
- joint_participants=pl.DataFrame(),
- land_use=pl.DataFrame(),
- skim_matrix=None,
- skim_zone_map=None,
+
+def test_dashboard_only_loads_non_default_summary_table_map_id(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ _write_auto_vmt_validation_summary(summary_path, 31.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
+ {
+ "label": "External",
+ "summary_table_map": {
+ "auto_vmt_validation_summary": summary_path,
+ },
+ }
+ ],
)
+ loaded = runtime_workflows.load_summary_runs_from_cache(
+ config=config,
+ cache_root=Path(config.summary_root),
+ explicit_cache_dirs=None,
+ run_entries=config.runs,
+ required_summary_ids=("auto_vmt_validation_summary",),
+ )
-def _write_custom_prepared_tables(
- root: Path,
- *,
- file_format: str = "parquet",
-) -> dict[str, str]:
- root.mkdir(parents=True, exist_ok=True)
- tables = {
- "households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
- "persons": pl.DataFrame({"person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "tours": pl.DataFrame({"tour_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "trips": pl.DataFrame({"trip_id": [1000], "tour_id": [100], "person_id": [10], "finalweight": [1.0]}),
- "joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
- "land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
- }
- paths: dict[str, str] = {}
- for table_id, table in tables.items():
- path = root / f"{table_id}.{file_format}"
- if file_format == "parquet":
- table.write_parquet(path)
- else:
- table.write_csv(path)
- paths[table_id] = str(path.resolve())
- return paths
+ assert [run.label for run in loaded] == ["External"]
+ loaded_table = loaded[0].summaries_by_mode["weighted"][
+ "auto_vmt_validation_summary"
+ ]
+ assert loaded_table.to_dicts() == [
+ {
+ "TOD": "Daily",
+ "SOV": 31.0,
+ "HOV2": 0.0,
+ "HOV3": 0.0,
+ "Truck": 0.0,
+ "Total": 31.0,
+ }
+ ]
-def _write_inconsistent_custom_prepared_tables(
- root: Path,
- *,
- file_format: str = "parquet",
-) -> dict[str, str]:
- root.mkdir(parents=True, exist_ok=True)
- tables = {
- "households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
- "persons": pl.DataFrame(
- {"person_id": [10], "household_id": [1], "finalweight": [1.0]}
- ),
- "tours": pl.DataFrame(
+def test_dashboard_only_respects_empty_required_summary_ids_for_optional_only_page(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ _write_auto_vmt_validation_summary(summary_path, 17.0)
+ config = _write_config(
+ tmp_path,
+ runs=[
{
- "tour_id": [100],
- "person_id": [10],
- "household_id": [1],
- "finalweight": [1.0],
+ "label": "External",
+ "summary_table_map": {
+ "auto_vmt_validation_summary": summary_path,
+ },
}
- ),
- "trips": pl.DataFrame(
+ ],
+ )
+
+ loaded = runtime_workflows.load_summary_runs_from_cache(
+ config=config,
+ cache_root=Path(config.summary_root),
+ explicit_cache_dirs=None,
+ run_entries=config.runs,
+ required_summary_ids=(),
+ )
+
+ assert (
+ loaded[0].summaries_by_mode["weighted"]["auto_vmt_validation_summary"]["SOV"][0]
+ == 17.0
+ )
+
+
+def test_summary_table_map_contract_rejects_missing_external_columns(
+ tmp_path: Path,
+) -> None:
+ summary_path = tmp_path / "outside" / "vmtSummary.csv"
+ summary_path.parent.mkdir(parents=True, exist_ok=True)
+ pl.DataFrame({"TOD": ["Daily"], "SOV": [1.0]}).write_csv(summary_path)
+ config = _write_config(
+ tmp_path,
+ runs=[
{
- "trip_id": [1000, 1001],
- "tour_id": [100, 999],
- "person_id": [10, 999],
- "household_id": [1, 999],
- "finalweight": [1.0, 1.0],
+ "label": "External",
+ "summary_table_map": {
+ "auto_vmt_validation_summary": summary_path,
+ },
}
- ),
- "joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
- "land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
- }
- paths: dict[str, str] = {}
- for table_id, table in tables.items():
- path = root / f"{table_id}.{file_format}"
- if file_format == "parquet":
- table.write_parquet(path)
- else:
- table.write_csv(path)
- paths[table_id] = str(path.resolve())
- return paths
+ ],
+ )
+ with pytest.raises(
+ ValueError,
+ match=r"summary_table_map\['auto_vmt_validation_summary'\] is missing required columns",
+ ):
+ runtime_workflows.run_summary_workflow(
+ config=config,
+ cache_root=Path(config.summary_root),
+ prepared_root=Path(config.summary_root),
+ run_entries=config.runs,
+ prefer_cache=False,
+ write_cache=False,
+ )
-def _prepared_identity(
- *,
- config: Config,
- run_key: str,
- label: str,
- run_dir: str,
-) -> dict[str, object]:
- return build_prepared_manifest_identity(
- run_key=run_key,
- config=config,
- run_fingerprint=build_run_fingerprint(
- label=label,
- run_dir=run_dir,
- skim_file=None,
- hh_weight_col=None,
- person_weight_col=None,
- trip_weight_col=None,
- ),
+
+def test_prune_summary_runs_keeps_optional_summary_ids_when_requested() -> None:
+ summary_run = create_summary_run(
+ label="External",
+ run_key="external",
+ summaries_by_mode={
+ "weighted": {
+ "population_totals": pl.DataFrame({"person_count": [1.0]}),
+ "auto_vmt_validation_summary": pl.DataFrame(
+ {
+ "TOD": ["Daily"],
+ "SOV": [2.0],
+ "HOV2": [0.0],
+ "HOV3": [0.0],
+ "Truck": [0.0],
+ "Total": [2.0],
+ }
+ ),
+ },
+ "unweighted": {
+ "population_totals": pl.DataFrame({"person_count": [1.0]}),
+ "auto_vmt_validation_summary": pl.DataFrame(
+ {
+ "TOD": ["Daily"],
+ "SOV": [2.0],
+ "HOV2": [0.0],
+ "HOV3": [0.0],
+ "Truck": [0.0],
+ "Total": [2.0],
+ }
+ ),
+ },
+ },
)
+ pruned = runtime_workflows.prune_summary_runs(
+ [summary_run],
+ ("population_totals", "auto_vmt_validation_summary"),
+ )
+
+ assert set(pruned[0].summaries_by_mode["weighted"]) == {
+ "population_totals",
+ "auto_vmt_validation_summary",
+ }
+
def test_run_prepare_workflow_uses_cache_hit_without_raw_read_or_prepare_rebuild(
tmp_path: Path,
@@ -286,18 +1185,6 @@ def _segmented_run_data(label: str, run_dir: str) -> RunData:
skim_zone_map=None,
)
- result = runtime_workflows.run_prepare_workflow(
- config=config,
- prepared_root=prepared_root,
- run_entries=config.runs,
- prefer_cache=True,
- write_cache=True,
- )
-
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
- assert result.run_keys == ["run-a"]
-
-
def test_run_prepare_workflow_rebuilds_and_writes_prepared_cache_on_cache_miss(
tmp_path: Path,
monkeypatch,
@@ -365,7 +1252,9 @@ def test_run_prepare_workflow_skips_integrated_skimjoin_when_apply_skimjoin_is_f
prepare_workflow,
"apply_skimjoin",
lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("apply_skimjoin should not be called when skimjoin is disabled for the effective workflow")
+ AssertionError(
+ "apply_skimjoin should not be called when skimjoin is disabled for the effective workflow"
+ )
),
)
@@ -375,10 +1264,10 @@ def test_run_prepare_workflow_skips_integrated_skimjoin_when_apply_skimjoin_is_f
run_entries=config.runs,
prefer_cache=False,
write_cache=False,
- apply_skimjoin=False,
+ plan=_workflow_plan(config, skimjoin=False),
)
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
+ assert [label for label, _ in result.runs] == ["Run A"]
def test_run_prepare_workflow_applies_integrated_skimjoin_when_enabled_for_effective_workflow(
@@ -396,6 +1285,10 @@ def test_run_prepare_workflow_applies_integrated_skimjoin_when_enabled_for_effec
config_digest="digest-123",
normalized_config=object(),
)
+ config.pipeline = replace(
+ config.pipeline,
+ steps=("prepare", "skimjoin", "summarize", "dashboard"),
+ )
skimjoin_calls: list[str] = []
_patch_prepare_pipeline(
@@ -421,10 +1314,10 @@ def test_run_prepare_workflow_applies_integrated_skimjoin_when_enabled_for_effec
run_entries=config.runs,
prefer_cache=False,
write_cache=False,
- apply_skimjoin=True,
+ plan=_workflow_plan(config, skimjoin=True),
)
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
+ assert [label for label, _ in result.runs] == ["Run A"]
assert skimjoin_calls == ["Run A"]
@@ -437,8 +1330,7 @@ def test_run_summary_workflow_without_segment_step_builds_only_full_summary_runs
tmp_path,
runs=[{"dir": str(run_dir), "label": "Run A"}],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: market",
" definitions:",
@@ -466,7 +1358,7 @@ def test_run_summary_workflow_without_segment_step_builds_only_full_summary_runs
prepare_data=lambda rd, config: rd,
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda rd, config: _simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
)
@@ -477,10 +1369,10 @@ def test_run_summary_workflow_without_segment_step_builds_only_full_summary_runs
run_entries=config.runs,
prefer_cache=False,
write_cache=False,
- apply_segmentation=False,
+ plan=_workflow_plan(config, segment=False),
)
- assert [(run.segmentation_type, run.segment_id) for run in result.summary_runs] == [
+ assert [(run.segmentation_type, run.segment_id) for run in result.runs] == [
("full", "full")
]
@@ -494,8 +1386,9 @@ def test_run_summary_workflow_with_segment_step_builds_full_and_segmented_summar
tmp_path,
runs=[{"dir": str(run_dir), "label": "Run A"}],
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "pipeline:",
+ " steps: [segment, summarize, dashboard]",
+ "segment:",
" dashboard:",
" segmentation_type: market",
" definitions:",
@@ -523,7 +1416,7 @@ def test_run_summary_workflow_with_segment_step_builds_full_and_segmented_summar
prepare_data=lambda rd, config: rd,
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda rd, config: _simple_summary_mode_build(rd.label, Path(rd.run_dir).name),
)
@@ -534,10 +1427,10 @@ def test_run_summary_workflow_with_segment_step_builds_full_and_segmented_summar
run_entries=config.runs,
prefer_cache=False,
write_cache=False,
- apply_segmentation=True,
+ plan=_workflow_plan(config, segment=True),
)
- assert [(run.segmentation_type, run.segment_id) for run in result.summary_runs] == [
+ assert [(run.segmentation_type, run.segment_id) for run in result.runs] == [
("full", "full"),
("market", "urban"),
("market", "rural"),
@@ -577,8 +1470,8 @@ def test_run_prepare_workflow_loads_custom_prepared_tables_without_raw_prepare(
write_cache=True,
)
- assert [label for label, _ in result.prepared_runs] == ["Prepared Run"]
- assert result.prepared_runs[0][1].hh["household_id"].to_list() == [1]
+ assert [label for label, _ in result.runs] == ["Prepared Run"]
+ assert result.runs[0][1].hh["household_id"].to_list() == [1]
def test_run_prepare_workflow_warns_on_inconsistent_custom_prepared_tables(
@@ -602,11 +1495,17 @@ def test_run_prepare_workflow_warns_on_inconsistent_custom_prepared_tables(
write_cache=True,
)
- assert [label for label, _ in result.prepared_runs] == ["Prepared Run"]
+ assert [label for label, _ in result.runs] == ["Prepared Run"]
captured = capsys.readouterr()
combined_output = caplog.text + captured.err + captured.out
- assert 'Prepared relationship validation found 3 failed checks for run "Prepared Run".' in combined_output
- assert "trips rows reference person_id values not present in persons.person_id" in combined_output
+ assert (
+ 'Prepared relationship validation found 3 failed checks for run "Prepared Run".'
+ in combined_output
+ )
+ assert (
+ "trips rows reference person_id values not present in persons.person_id"
+ in combined_output
+ )
def test_run_prepare_workflow_errors_on_inconsistent_custom_prepared_tables_when_configured(
@@ -625,7 +1524,10 @@ def test_run_prepare_workflow_errors_on_inconsistent_custom_prepared_tables_when
],
)
- with pytest.raises(RuntimeError, match='Prepared relationship validation failed for run "Prepared Run"'):
+ with pytest.raises(
+ RuntimeError,
+ match='Prepared relationship validation failed for run "Prepared Run"',
+ ):
runtime_workflows.run_prepare_workflow(
config=config,
prepared_root=runtime_workflows.prepared_cache_root(config, create=True),
@@ -655,7 +1557,9 @@ def test_run_prepare_workflow_skips_relationship_validation_when_disabled(
monkeypatch.setattr(
"runtime.workflows.prepare.validate_prepared_relationships",
lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("validate_prepared_relationships should not be called when disabled")
+ AssertionError(
+ "validate_prepared_relationships should not be called when disabled"
+ )
),
)
@@ -667,7 +1571,7 @@ def test_run_prepare_workflow_skips_relationship_validation_when_disabled(
write_cache=True,
)
- assert [label for label, _ in result.prepared_runs] == ["Prepared Run"]
+ assert [label for label, _ in result.runs] == ["Prepared Run"]
def test_run_prepare_workflow_skips_run_when_no_raw_tables_are_available(
@@ -683,17 +1587,19 @@ def test_run_prepare_workflow_skips_run_when_no_raw_tables_are_available(
_patch_prepare_pipeline(
monkeypatch,
- read_run=lambda run_dir, config, label=None, **kwargs: attach_table_availability(
- _fake_run_data(label or Path(run_dir).name, str(run_dir)),
- table_states={
- "households": "unavailable",
- "persons": "unavailable",
- "tours": "unavailable",
- "trips": "unavailable",
- "joint_tour_participants": "unavailable",
- "land_use": "unavailable",
- },
- table_reasons={"households": "missing"},
+ read_run=lambda run_dir, config, label=None, **kwargs: (
+ attach_table_availability(
+ _fake_run_data(label or Path(run_dir).name, str(run_dir)),
+ table_states={
+ "households": "unavailable",
+ "persons": "unavailable",
+ "tours": "unavailable",
+ "trips": "unavailable",
+ "joint_tour_participants": "unavailable",
+ "land_use": "unavailable",
+ },
+ table_reasons={"households": "missing"},
+ )
),
prepare_data=lambda rd, config: rd,
)
@@ -706,8 +1612,8 @@ def test_run_prepare_workflow_skips_run_when_no_raw_tables_are_available(
write_cache=True,
)
- assert result.prepared_runs == []
- assert result.prepared_runs_by_key == {}
+ assert result.runs == []
+ assert result.by_key == {}
def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_unavailable(
@@ -723,29 +1629,31 @@ def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_unavailable
_patch_prepare_pipeline(
monkeypatch,
- read_run=lambda run_dir, config, label=None, **kwargs: attach_table_availability(
- RunData(
- label=label or Path(run_dir).name,
- run_dir=str(run_dir),
- skim_file=None,
- hh=pl.DataFrame({"household_id": [1]}),
- per=pl.DataFrame(),
- tours=pl.DataFrame(),
- trips=pl.DataFrame(),
- joint_participants=pl.DataFrame(),
- land_use=pl.DataFrame(),
- skim_matrix=None,
- skim_zone_map=None,
- ),
- table_states={
- "households": "available",
- "persons": "unavailable",
- "tours": "unavailable",
- "trips": "unavailable",
- "joint_tour_participants": "unavailable",
- "land_use": "unavailable",
- },
- table_reasons={"persons": "missing"},
+ read_run=lambda run_dir, config, label=None, **kwargs: (
+ attach_table_availability(
+ RunData(
+ label=label or Path(run_dir).name,
+ run_dir=str(run_dir),
+ skim_file=None,
+ hh=pl.DataFrame({"household_id": [1]}),
+ per=pl.DataFrame(),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
+ ),
+ table_states={
+ "households": "available",
+ "persons": "unavailable",
+ "tours": "unavailable",
+ "trips": "unavailable",
+ "joint_tour_participants": "unavailable",
+ "land_use": "unavailable",
+ },
+ table_reasons={"persons": "missing"},
+ )
),
prepare_data=lambda rd, config: rd,
)
@@ -758,8 +1666,8 @@ def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_unavailable
write_cache=False,
)
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
- assert list(result.prepared_runs_by_key) == ["run-a"]
+ assert [label for label, _ in result.runs] == ["Run A"]
+ assert list(result.by_key) == ["run-a"]
def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_failed(
@@ -777,32 +1685,34 @@ def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_failed(
_patch_prepare_pipeline(
monkeypatch,
- read_run=lambda run_dir, config, label=None, **kwargs: attach_table_availability(
- RunData(
- label=label or Path(run_dir).name,
- run_dir=str(run_dir),
- skim_file=None,
- hh=pl.DataFrame({"household_id": [1]}),
- per=pl.DataFrame({"person_id": [1]}),
- tours=pl.DataFrame(),
- trips=pl.DataFrame(),
- joint_participants=pl.DataFrame(),
- land_use=pl.DataFrame(),
- skim_matrix=None,
- skim_zone_map=None,
- ),
- table_states={
- "households": "available",
- "persons": "available",
- "tours": "failed",
- "trips": "unavailable",
- "joint_tour_participants": "unavailable",
- "land_use": "unavailable",
- },
- table_reasons={
- "tours": "tour enrichment failed",
- "trips": "missing",
- },
+ read_run=lambda run_dir, config, label=None, **kwargs: (
+ attach_table_availability(
+ RunData(
+ label=label or Path(run_dir).name,
+ run_dir=str(run_dir),
+ skim_file=None,
+ hh=pl.DataFrame({"household_id": [1]}),
+ per=pl.DataFrame({"person_id": [1]}),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
+ ),
+ table_states={
+ "households": "available",
+ "persons": "available",
+ "tours": "failed",
+ "trips": "unavailable",
+ "joint_tour_participants": "unavailable",
+ "land_use": "unavailable",
+ },
+ table_reasons={
+ "tours": "tour enrichment failed",
+ "trips": "missing",
+ },
+ )
),
prepare_data=lambda rd, config: rd,
)
@@ -815,8 +1725,8 @@ def test_run_prepare_workflow_keeps_partial_run_when_some_tables_are_failed(
write_cache=False,
)
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
- assert list(result.prepared_runs_by_key) == ["run-a"]
+ assert [label for label, _ in result.runs] == ["Run A"]
+ assert list(result.by_key) == ["run-a"]
captured = capsys.readouterr()
assert "recorded failed tables" in (caplog.text + captured.err + captured.out)
@@ -885,10 +1795,13 @@ def test_run_prepare_workflow_validates_prepared_cache_loads(
write_cache=True,
)
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
+ assert [label for label, _ in result.runs] == ["Run A"]
captured = capsys.readouterr()
combined_output = caplog.text + captured.err + captured.out
- assert 'Prepared relationship validation found 3 failed checks for run "Run A".' in combined_output
+ assert (
+ 'Prepared relationship validation found 3 failed checks for run "Run A".'
+ in combined_output
+ )
def test_run_summary_workflow_uses_cache_hit_without_raw_read_or_summary_rebuild(
@@ -920,7 +1833,7 @@ def test_run_summary_workflow_uses_cache_hit_without_raw_read_or_summary_rebuild
),
)
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -931,7 +1844,7 @@ def test_run_summary_workflow_uses_cache_hit_without_raw_read_or_summary_rebuild
),
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError(
@@ -948,9 +1861,9 @@ def test_run_summary_workflow_uses_cache_hit_without_raw_read_or_summary_rebuild
write_cache=False,
)
- assert [summary_run.label for summary_run in result.summary_runs] == ["Run A"]
- assert result.prepared_runs == []
- assert result.prepared_runs_by_key == {}
+ assert [summary_run.label for summary_run in result.runs] == ["Run A"]
+ assert result.prepared.runs == []
+ assert result.prepared.by_key == {}
def test_run_summary_workflow_cache_hit_keeps_existing_prepared_run_by_key(
@@ -984,7 +1897,7 @@ def test_run_summary_workflow_cache_hit_keeps_existing_prepared_run_by_key(
),
)
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
@@ -995,7 +1908,7 @@ def test_run_summary_workflow_cache_hit_keeps_existing_prepared_run_by_key(
),
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError(
@@ -1004,11 +1917,11 @@ def test_run_summary_workflow_cache_hit_keeps_existing_prepared_run_by_key(
),
)
- existing_result = ProcessorWorkflowResult(
- prepared_runs=[("Run A", prepared_run)],
- prepared_runs_by_key={"run-a": ("Run A", prepared_run)},
+ existing_result = PreparedRunsArtifact(
+ runs=[("Run A", prepared_run)],
+ by_key={"run-a": ("Run A", prepared_run)},
run_keys=["run-a"],
- run_fingerprints_by_key={"run-a": fingerprint},
+ fingerprints_by_key={"run-a": fingerprint},
)
result = runtime_workflows.run_summary_workflow(
@@ -1017,12 +1930,12 @@ def test_run_summary_workflow_cache_hit_keeps_existing_prepared_run_by_key(
run_entries=config.runs,
prefer_cache=True,
write_cache=False,
- existing_result=existing_result,
+ prepared=existing_result,
)
- assert [summary_run.label for summary_run in result.summary_runs] == ["Run A"]
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
- assert result.prepared_runs_by_key["run-a"][1] is prepared_run
+ assert [summary_run.label for summary_run in result.runs] == ["Run A"]
+ assert [label for label, _ in result.prepared.runs] == ["Run A"]
+ assert result.prepared.by_key["run-a"][1] is prepared_run
def test_run_summary_workflow_rebuilds_and_writes_cache_on_cache_miss(
@@ -1038,7 +1951,7 @@ def test_run_summary_workflow_rebuilds_and_writes_cache_on_cache_miss(
prepare_calls: list[str] = []
summary_build_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1051,7 +1964,7 @@ def test_run_summary_workflow_rebuilds_and_writes_cache_on_cache_miss(
)[1],
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda rd, config: (
summary_build_calls.append(rd.label),
@@ -1070,9 +1983,9 @@ def test_run_summary_workflow_rebuilds_and_writes_cache_on_cache_miss(
assert read_calls == ["Run A"]
assert prepare_calls == ["Run A"]
assert summary_build_calls == ["Run A"]
- assert [summary_run.label for summary_run in result.summary_runs] == ["Run A"]
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
- assert list(result.prepared_runs_by_key) == ["run-a"]
+ assert [summary_run.label for summary_run in result.runs] == ["Run A"]
+ assert [label for label, _ in result.prepared.runs] == ["Run A"]
+ assert list(result.prepared.by_key) == ["run-a"]
assert (Path(config.summary_root) / "run-a" / "manifest.json").exists()
assert (
Path(config.summary_root) / "run-a" / "prepared_tables" / "manifest.json"
@@ -1110,7 +2023,7 @@ def test_run_summary_workflow_uses_prepared_cache_before_raw_rebuild(
run_fingerprint=processor_prepare_fingerprint,
)
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: (
@@ -1123,7 +2036,7 @@ def test_run_summary_workflow_uses_prepared_cache_before_raw_rebuild(
)[1],
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda rd, config: (
summary_build_calls.append(rd.label),
@@ -1143,7 +2056,7 @@ def test_run_summary_workflow_uses_prepared_cache_before_raw_rebuild(
assert read_calls == []
assert prepare_calls == []
assert summary_build_calls == ["Run A"]
- assert [label for label, _ in result.prepared_runs] == ["Run A"]
+ assert [label for label, _ in result.prepared.runs] == ["Run A"]
def test_run_summary_workflow_reuses_in_memory_prepared_runs_without_reload(
@@ -1158,25 +2071,22 @@ def test_run_summary_workflow_reuses_in_memory_prepared_runs_without_reload(
prepared_run = _fake_run_data("Run A", str(run_dir))
summary_build_calls: list[str] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["totals"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["population_totals"])
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("read_run should not be called when prepared runs already exist in memory")
+ AssertionError(
+ "read_run should not be called when prepared runs already exist in memory"
+ )
),
prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("prepare_data should not be called when prepared runs already exist in memory")
- ),
- )
- monkeypatch.setattr(
- runtime_workflows,
- "load_prepared_run_cache",
- lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("load_prepared_run_cache should not be called when prepared runs already exist in memory")
+ AssertionError(
+ "prepare_data should not be called when prepared runs already exist in memory"
+ )
),
)
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
lambda rd, config: (
summary_build_calls.append(rd.label),
@@ -1184,11 +2094,11 @@ def test_run_summary_workflow_reuses_in_memory_prepared_runs_without_reload(
)[1],
)
- existing_result = ProcessorWorkflowResult(
- prepared_runs=[("Run A", prepared_run)],
- prepared_runs_by_key={"run-a": ("Run A", prepared_run)},
+ existing_result = PreparedRunsArtifact(
+ runs=[("Run A", prepared_run)],
+ by_key={"run-a": ("Run A", prepared_run)},
run_keys=["run-a"],
- run_fingerprints_by_key={
+ fingerprints_by_key={
"run-a": build_run_fingerprint(
label="Run A",
run_dir=config.runs[0]["dir"],
@@ -1206,11 +2116,11 @@ def test_run_summary_workflow_reuses_in_memory_prepared_runs_without_reload(
run_entries=config.runs,
prefer_cache=False,
write_cache=False,
- existing_result=existing_result,
+ prepared=existing_result,
)
assert summary_build_calls == ["Run A"]
- assert result.prepared_runs_by_key["run-a"][1] is prepared_run
+ assert result.prepared.by_key["run-a"][1] is prepared_run
def _patch_prepare_pipeline(
@@ -1229,11 +2139,11 @@ def test_run_summary_workflow_backfills_only_missing_summary_tables(
tmp_path: Path,
monkeypatch,
) -> None:
- @summary_contract(schema={"value": pl.Float64})
+ @summary(id="good", schema={"value": pl.Float64})
def good_summary(rd: RunData, config: Config) -> pl.DataFrame:
return pl.DataFrame({"value": [1.0]})
- @summary_contract(schema={"value": pl.Float64})
+ @summary(id="new", schema={"value": pl.Float64})
def new_summary(rd: RunData, config: Config) -> pl.DataFrame:
return pl.DataFrame({"value": [2.0]})
@@ -1273,17 +2183,17 @@ def new_summary(rd: RunData, config: Config) -> pl.DataFrame:
source_run_dir=str(run_dir),
)
monkeypatch.setitem(
- summary_cache.SUMMARY_SPEC_BY_ID,
+ summary_catalog.SUMMARY_BY_ID,
"good",
- SummarySpec("good", "good", good_summary),
+ good_summary.summary_definition,
)
monkeypatch.setitem(
- summary_cache.SUMMARY_SPEC_BY_ID,
+ summary_catalog.SUMMARY_BY_ID,
"new",
- SummarySpec("new", "new", new_summary),
+ new_summary.summary_definition,
)
- monkeypatch.setitem(summary_cache.SUMMARY_FILENAME_BY_ID, "good", "good.csv")
- monkeypatch.setitem(summary_cache.SUMMARY_FILENAME_BY_ID, "new", "new.csv")
+ monkeypatch.setitem(summary_catalog.SUMMARY_FILENAME_BY_ID, "good", "good.csv")
+ monkeypatch.setitem(summary_catalog.SUMMARY_FILENAME_BY_ID, "new", "new.csv")
summary_cache.write_summary_run_bundle(
[cached_only_good],
config,
@@ -1298,7 +2208,7 @@ def new_summary(rd: RunData, config: Config) -> pl.DataFrame:
)
build_calls: list[list[str] | None] = []
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["good", "new"])
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["good", "new"])
def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
build_calls.append(list(summary_ids) if summary_ids is not None else None)
@@ -1318,7 +2228,7 @@ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
return tables, metadata
monkeypatch.setattr(
- summary_cache,
+ summary_builder,
"build_mode_summaries_with_metadata",
fake_build_mode_summaries_with_metadata,
)
@@ -1328,7 +2238,9 @@ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
AssertionError("read_run should not be called when prepared cache is valid")
),
prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("prepare_data should not be called when prepared cache is valid")
+ AssertionError(
+ "prepare_data should not be called when prepared cache is valid"
+ )
),
)
@@ -1342,7 +2254,7 @@ def fake_build_mode_summaries_with_metadata(rd, config, summary_ids=None):
)
assert build_calls == [["new"]]
- weighted_tables = result.summary_runs[0].summaries_by_mode["weighted"]
+ weighted_tables = result.runs[0].summaries_by_mode["weighted"]
assert sorted(weighted_tables) == ["good", "new"]
@@ -1386,7 +2298,7 @@ def test_run_cli_uses_configured_terminal_log_level(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
runs=[],
- visualizer_lines=[
+ extra_lines=[
"log_level: error",
],
)
@@ -1413,7 +2325,6 @@ def test_resolve_requested_steps_uses_config_pipeline_defaults(tmp_path: Path) -
dashboard=False,
prepare_only=False,
write_csvs=False,
- no_dashboard=False,
from_csvs=None,
skip_summary_cache_write=False,
refresh_caches=False,
@@ -1429,7 +2340,9 @@ def test_resolve_requested_steps_uses_config_pipeline_defaults(tmp_path: Path) -
]
-def test_resolve_effective_plan_uses_pipeline_dashboard_mode_and_overwrite(tmp_path: Path) -> None:
+def test_resolve_effective_plan_uses_pipeline_dashboard_mode_and_overwrite(
+ tmp_path: Path,
+) -> None:
config = _write_config(
tmp_path,
runs=[],
@@ -1449,7 +2362,6 @@ def test_resolve_effective_plan_uses_pipeline_dashboard_mode_and_overwrite(tmp_p
dashboard=False,
prepare_only=False,
write_csvs=False,
- no_dashboard=False,
from_csvs=None,
skip_summary_cache_write=False,
refresh_caches=False,
@@ -1487,7 +2399,6 @@ def test_resolve_effective_plan_drops_dashboard_when_config_dashboard_mode_is_no
dashboard=False,
prepare_only=False,
write_csvs=False,
- no_dashboard=False,
from_csvs=None,
skip_summary_cache_write=False,
refresh_caches=False,
@@ -1505,11 +2416,17 @@ def test_resolve_effective_plan_drops_dashboard_when_config_dashboard_mode_is_no
def test_resolve_dashboard_execution_mode_maps_host_to_live_with_warning(
caplog: pytest.LogCaptureFixture,
+ capsys: pytest.CaptureFixture[str],
) -> None:
execution_mode = cli_run.resolve_dashboard_execution_mode("host")
assert execution_mode == "live"
- assert "pipeline.dashboard_mode 'host' is not implemented yet; using live mode." in caplog.text
+ captured = capsys.readouterr()
+ combined_output = caplog.text + captured.err + captured.out
+ assert (
+ "pipeline.dashboard_mode 'host' is not implemented yet; using live mode."
+ in combined_output
+ )
def test_resolve_effective_plan_preserves_logical_skimjoin_step_for_prepare_only_defaults(
@@ -1532,7 +2449,6 @@ def test_resolve_effective_plan_preserves_logical_skimjoin_step_for_prepare_only
dashboard=False,
prepare_only=False,
write_csvs=False,
- no_dashboard=False,
from_csvs=None,
skip_summary_cache_write=False,
refresh_caches=False,
@@ -1595,19 +2511,23 @@ def test_run_summary_workflow_continues_when_one_summary_fails(
runs=[{"dir": str(run_dir), "label": "Run A"}],
)
- @summary_contract(schema={"value": pl.Float64})
+ @summary(id="good", schema={"value": pl.Float64})
def good_summary(rd: RunData, config: Config) -> pl.DataFrame:
return pl.DataFrame({"value": [1.0]})
- @summary_contract(schema={"value": pl.Float64})
+ @summary(id="bad", schema={"value": pl.Float64})
def bad_summary(rd: RunData, config: Config) -> pl.DataFrame:
raise RuntimeError("boom")
- monkeypatch.setattr(summary_cache, "DEFAULT_SUMMARY_IDS", ["good", "bad"])
- monkeypatch.setitem(summary_cache.SUMMARY_SPEC_BY_ID, "good", SummarySpec("good", "good", good_summary))
- monkeypatch.setitem(summary_cache.SUMMARY_SPEC_BY_ID, "bad", SummarySpec("bad", "bad", bad_summary))
- monkeypatch.setitem(summary_cache.SUMMARY_FILENAME_BY_ID, "good", "good.csv")
- monkeypatch.setitem(summary_cache.SUMMARY_FILENAME_BY_ID, "bad", "bad.csv")
+ monkeypatch.setattr(summary_builder, "DEFAULT_SUMMARY_IDS", ["good", "bad"])
+ monkeypatch.setitem(
+ summary_catalog.SUMMARY_BY_ID, "good", good_summary.summary_definition
+ )
+ monkeypatch.setitem(
+ summary_catalog.SUMMARY_BY_ID, "bad", bad_summary.summary_definition
+ )
+ monkeypatch.setitem(summary_catalog.SUMMARY_FILENAME_BY_ID, "good", "good.csv")
+ monkeypatch.setitem(summary_catalog.SUMMARY_FILENAME_BY_ID, "bad", "bad.csv")
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda run_dir, config, label=None, **kwargs: _fake_run_data(
@@ -1625,8 +2545,8 @@ def bad_summary(rd: RunData, config: Config) -> pl.DataFrame:
write_cache=False,
)
- weighted = result.summary_runs[0].summaries_by_mode["weighted"]
- metadata = result.summary_runs[0].summary_metadata_by_mode["weighted"]
+ weighted = result.runs[0].summaries_by_mode["weighted"]
+ metadata = result.runs[0].summary_metadata_by_mode["weighted"]
assert weighted["good"].to_dicts() == [{"value": 1.0}]
assert weighted["bad"].is_empty()
assert metadata["good"]["state"] == "available"
@@ -1688,10 +2608,14 @@ def test_load_prepared_runs_for_dashboard_returns_empty_when_required_runs_are_m
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("read_run should not be called when required runs are unresolved")
+ AssertionError(
+ "read_run should not be called when required runs are unresolved"
+ )
),
prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("prepare_data should not be called when required runs are unresolved")
+ AssertionError(
+ "prepare_data should not be called when required runs are unresolved"
+ )
),
)
@@ -1708,7 +2632,9 @@ def test_load_prepared_runs_for_dashboard_supports_custom_prepared_runs(
tmp_path: Path,
monkeypatch,
) -> None:
- prepared_map = _write_custom_prepared_tables(tmp_path / "custom_prepared", file_format="csv")
+ prepared_map = _write_custom_prepared_tables(
+ tmp_path / "custom_prepared", file_format="csv"
+ )
config = _write_config(
tmp_path,
runs=[{"label": "Prepared Run", "prepared_table_map": prepared_map}],
@@ -1717,10 +2643,14 @@ def test_load_prepared_runs_for_dashboard_supports_custom_prepared_runs(
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("read_run should not be called for custom prepared dashboard loads")
+ AssertionError(
+ "read_run should not be called for custom prepared dashboard loads"
+ )
),
prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("prepare_data should not be called for custom prepared dashboard loads")
+ AssertionError(
+ "prepare_data should not be called for custom prepared dashboard loads"
+ )
),
)
@@ -1734,7 +2664,7 @@ def test_load_prepared_runs_for_dashboard_supports_custom_prepared_runs(
assert ordered_runs[0][1].trips["trip_id"].to_list() == [1000]
-def test_prune_processor_result_keeps_only_required_dashboard_data() -> None:
+def test_prune_summary_artifact_keeps_only_required_dashboard_data() -> None:
prepared_run = RunData(
label="Run A",
run_dir="C:/runs/run_a",
@@ -1763,28 +2693,28 @@ def test_prune_processor_result_keeps_only_required_dashboard_data() -> None:
},
source_run_dir="C:/runs/run_a",
)
- result = ProcessorWorkflowResult(
- summary_runs=[summary_run],
- prepared_runs=[("Run A", prepared_run)],
- prepared_runs_by_key={"run-a": ("Run A", prepared_run)},
- run_keys=["run-a"],
+ result = SummaryRunsArtifact(
+ runs=[summary_run],
+ prepared=PreparedRunsArtifact(
+ runs=[("Run A", prepared_run)],
+ by_key={"run-a": ("Run A", prepared_run)},
+ run_keys=["run-a"],
+ ),
)
- pruned = runtime_workflows.prune_processor_result(
+ pruned = runtime_workflows.prune_summary_artifact(
result,
required_summary_ids=("population_totals",),
required_prepared_tables=("trips",),
)
assert pruned is not None
- assert list(pruned.summary_runs[0].summaries_by_mode["weighted"]) == [
- "population_totals"
- ]
- assert pruned.prepared_runs_by_key["run-a"][1].hh.is_empty()
- assert pruned.prepared_runs_by_key["run-a"][1].per.is_empty()
- assert pruned.prepared_runs_by_key["run-a"][1].trips is prepared_run.trips
- assert pruned.prepared_runs_by_key["run-a"][1].trips["trip_id"].to_list() == [100]
- assert pruned.prepared_runs_by_key["run-a"][1].skim_file is None
+ assert list(pruned.runs[0].summaries_by_mode["weighted"]) == ["population_totals"]
+ assert pruned.prepared.by_key["run-a"][1].hh.is_empty()
+ assert pruned.prepared.by_key["run-a"][1].per.is_empty()
+ assert pruned.prepared.by_key["run-a"][1].trips is prepared_run.trips
+ assert pruned.prepared.by_key["run-a"][1].trips["trip_id"].to_list() == [100]
+ assert pruned.prepared.by_key["run-a"][1].skim_file is None
def test_load_prepared_runs_for_dashboard_prunes_existing_runs_to_required_tables(
@@ -1813,10 +2743,14 @@ def test_load_prepared_runs_for_dashboard_prunes_existing_runs_to_required_table
_patch_prepare_pipeline(
monkeypatch,
read_run=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("read_run should not be called when the run is already loaded")
+ AssertionError(
+ "read_run should not be called when the run is already loaded"
+ )
),
prepare_data=lambda *args, **kwargs: (_ for _ in ()).throw(
- AssertionError("prepare_data should not be called when the run is already loaded")
+ AssertionError(
+ "prepare_data should not be called when the run is already loaded"
+ )
),
)
@@ -1866,13 +2800,15 @@ def fake_run_prepare_workflow(**kwargs):
kwargs["run_entries"]
)
]
- return ProcessorWorkflowResult(
- prepared_runs=[("Run A", prepared_run)],
- prepared_runs_by_key={"run-a": ("Run A", prepared_run)},
+ return PreparedRunsArtifact(
+ runs=[("Run A", prepared_run)],
+ by_key={"run-a": ("Run A", prepared_run)},
run_keys=["run-a"],
)
- monkeypatch.setattr(prepare_workflow, "run_prepare_workflow", fake_run_prepare_workflow)
+ monkeypatch.setattr(
+ prepare_workflow, "run_prepare_workflow", fake_run_prepare_workflow
+ )
ordered_runs = runtime_workflows.load_prepared_runs_for_dashboard(
config=config,
diff --git a/tests/test_segmentation_feature.py b/tests/test_segmentation_feature.py
index 4a2c429..7af02bb 100644
--- a/tests/test_segmentation_feature.py
+++ b/tests/test_segmentation_feature.py
@@ -12,10 +12,10 @@
from processor.models import RunData
from processor.segmentation import build_analysis_units_for_run
from processor.summarize.cache import (
- create_summary_run,
load_summary_run_bundle,
write_summary_run_bundle,
)
+from processor.summarize.cache_types import create_summary_run
from runtime.config import Config
@@ -26,10 +26,11 @@ def _write_config(tmp_path: Path, extra_lines: list[str]) -> Config:
"\n".join(
[
'name: "Segmentation Test"',
- "processor:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Segmentation Dashboard"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Segmentation Dashboard"',
+ "pipeline:",
+ " steps: [segment, summarize, dashboard]",
"runs: []",
*extra_lines,
]
@@ -55,9 +56,19 @@ def _prepared_run() -> RunData:
),
per=pl.DataFrame(
{
- "person_id": [101, 201],
- "household_id": [1, 2],
- "finalweight": [1.0, 1.0],
+ "person_id": [101, 102, 201, 202],
+ "household_id": [1, 1, 2, 2],
+ "person_group": ["adult", "child", "adult", "child"],
+ "finalweight": [1.0, 1.0, 1.0, 1.0],
+ }
+ ),
+ day=pl.DataFrame(
+ {
+ "day_id": [10001, 10002, 20001, 20002],
+ "person_id": [101, 102, 201, 202],
+ "household_id": [1, 1, 2, 2],
+ "day_segment": ["target", "other", "other", "other"],
+ "finalweight": [1.0, 1.0, 1.0, 1.0],
}
),
tours=pl.DataFrame(
@@ -77,8 +88,20 @@ def _prepared_run() -> RunData:
"finalweight": [1.0, 1.0],
}
),
+ vehicles=pl.DataFrame(
+ {
+ "vehicle_id": [11, 12, 21],
+ "household_id": [1, 1, 2],
+ "fuel_type": ["gas", "ev", "gas"],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ ),
joint_participants=pl.DataFrame(
- {"tour_id": [1001, 2001], "person_id": [101, 201]}
+ {
+ "tour_id": [1001, 1001, 2001, 2001],
+ "person_id": [101, 102, 201, 202],
+ "participant_role": ["owner", "guest", "owner", "other"],
+ }
),
land_use=pl.DataFrame(
{
@@ -98,8 +121,7 @@ def test_config_parses_multiple_segmentation_definitions(tmp_path: Path) -> None
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" visibility: segments_only",
@@ -136,14 +158,104 @@ def test_config_parses_multiple_segmentation_definitions(tmp_path: Path) -> None
assert config.segmentation.definition_by_name("county") is not None
+def test_config_normalizes_segmentation_source_tables(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " households:",
+ " source:",
+ " source_table: households",
+ " column: market",
+ " segments:",
+ " - id: urban",
+ " label: Urban",
+ " values: [Urban]",
+ " persons:",
+ " source:",
+ " source_table: persons",
+ " column: person_group",
+ " segments:",
+ " - id: adult",
+ " label: Adult",
+ " values: [adult]",
+ " vehicles:",
+ " source:",
+ " source_table: vehicles",
+ " column: fuel_type",
+ " segments:",
+ " - id: ev",
+ " label: EV",
+ " values: [ev]",
+ " day:",
+ " source:",
+ " source_table: day",
+ " column: day_segment",
+ " segments:",
+ " - id: target",
+ " label: Target",
+ " values: [target]",
+ " joint:",
+ " source:",
+ " source_table: joint_tour_participants",
+ " column: participant_role",
+ " segments:",
+ " - id: guest",
+ " label: Guest",
+ " values: [guest]",
+ " old_hh:",
+ " source:",
+ " source_table: hh",
+ " column: market",
+ " segments:",
+ " - id: rural",
+ " label: Rural",
+ " values: [Rural]",
+ " old_per:",
+ " source:",
+ " source_table: per",
+ " column: person_group",
+ " segments:",
+ " - id: child",
+ " label: Child",
+ " values: [child]",
+ " old_joint:",
+ " source:",
+ " source_table: joint_participants",
+ " column: participant_role",
+ " segments:",
+ " - id: other",
+ " label: Other",
+ " values: [other]",
+ ],
+ )
+
+ assert {
+ definition.name: definition.source.source_table
+ for definition in config.segmentation.definitions
+ if hasattr(definition.source, "source_table")
+ } == {
+ "households": "hh",
+ "persons": "per",
+ "vehicles": "vehicles",
+ "day": "day",
+ "joint": "joint_participants",
+ "old_hh": "hh",
+ "old_per": "per",
+ "old_joint": "joint_participants",
+ }
+
+
def test_config_requires_dashboard_segmentation_type_to_exist(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="dashboard.segmentation_type"):
_write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
- " dashboard:",
+ "segment:",
+ " dashboard:",
" segmentation_type: county",
" definitions:",
" vot:",
@@ -163,8 +275,7 @@ def test_summary_digest_changes_for_definition_but_not_dashboard_selection(
tmp_path: Path,
) -> None:
base_lines = [
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" visibility: full_and_segments",
@@ -192,10 +303,10 @@ def test_summary_digest_changes_for_definition_but_not_dashboard_selection(
config_b = _write_config(
tmp_path / "b",
extra_lines=[
- *base_lines[:4],
+ *base_lines[:2],
" segmentation_type: vot",
" visibility: segments_only",
- *base_lines[5:],
+ *base_lines[4:],
],
)
config_c = _write_config(
@@ -222,8 +333,7 @@ def test_build_analysis_units_supports_multiple_segmentation_types(tmp_path: Pat
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" definitions:",
@@ -286,8 +396,7 @@ def test_build_analysis_units_supports_land_use_anchor_in_multi_type_config(
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" definitions:",
@@ -317,6 +426,208 @@ def test_build_analysis_units_supports_land_use_anchor_in_multi_type_config(
assert units[1].prepared_run.hh["household_id"].to_list() == [1]
+def test_build_analysis_units_supports_vehicle_anchor(tmp_path: Path) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " fuel:",
+ " source:",
+ " type: prepared_column",
+ " source_table: vehicles",
+ " column: fuel_type",
+ " segments:",
+ " - id: ev",
+ " label: EV",
+ " values: [ev]",
+ ],
+ )
+
+ units = build_analysis_units_for_run(
+ run_key="run-a",
+ run_name="Run A",
+ prepared_run=_prepared_run(),
+ config=config,
+ )
+
+ ev = units[1].prepared_run
+ assert units[1].segment_metadata.source_table == "vehicles"
+ assert units[1].segment_metadata.source_key_column == "vehicle_id"
+ assert ev.vehicles["vehicle_id"].to_list() == [12]
+ assert ev.hh["household_id"].to_list() == [1]
+ assert ev.per["person_id"].to_list() == [101, 102]
+ assert ev.day["day_id"].to_list() == [10001, 10002]
+ assert ev.tours["tour_id"].to_list() == [1001]
+ assert ev.trips["trip_id"].to_list() == [5001]
+ assert ev.joint_participants["person_id"].to_list() == [101, 102]
+
+
+def test_build_analysis_units_supports_day_anchor(tmp_path: Path) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " day_segment:",
+ " source:",
+ " type: prepared_column",
+ " source_table: day",
+ " column: day_segment",
+ " segments:",
+ " - id: target",
+ " label: Target",
+ " values: [target]",
+ ],
+ )
+
+ units = build_analysis_units_for_run(
+ run_key="run-a",
+ run_name="Run A",
+ prepared_run=_prepared_run(),
+ config=config,
+ )
+
+ target = units[1].prepared_run
+ assert units[1].segment_metadata.source_table == "day"
+ assert units[1].segment_metadata.source_key_column == "day_id"
+ assert target.day["day_id"].to_list() == [10001]
+ assert target.hh["household_id"].to_list() == [1]
+ assert target.per["person_id"].to_list() == [101]
+ assert target.vehicles["vehicle_id"].to_list() == [11, 12]
+ assert target.tours["tour_id"].to_list() == [1001]
+ assert target.trips["trip_id"].to_list() == [5001]
+ assert target.joint_participants["person_id"].to_list() == [101, 102]
+
+
+def test_build_analysis_units_supports_joint_participants_anchor(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " joint_role:",
+ " source:",
+ " type: prepared_column",
+ " source_table: joint_tour_participants",
+ " column: participant_role",
+ " segments:",
+ " - id: guest",
+ " label: Guest",
+ " values: [guest]",
+ ],
+ )
+
+ units = build_analysis_units_for_run(
+ run_key="run-a",
+ run_name="Run A",
+ prepared_run=_prepared_run(),
+ config=config,
+ )
+
+ guest = units[1].prepared_run
+ assert units[1].segment_metadata.source_table == "joint_participants"
+ assert units[1].segment_metadata.source_key_column is None
+ assert guest.joint_participants["person_id"].to_list() == [102]
+ assert guest.tours["tour_id"].to_list() == [1001]
+ assert guest.trips["trip_id"].to_list() == [5001]
+ assert guest.per["person_id"].to_list() == [101, 102]
+ assert guest.hh["household_id"].to_list() == [1]
+ assert guest.day["day_id"].to_list() == [10001, 10002]
+ assert guest.vehicles["vehicle_id"].to_list() == [11, 12]
+
+
+def test_csv_lookup_segmentation_supports_vehicle_anchor(tmp_path: Path) -> None:
+ csv_path = tmp_path / "vehicle_lookup.csv"
+ csv_path.write_text("vehicle_id,vehicle_segment\n12,clean\n", encoding="utf-8")
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " vehicle_segment:",
+ " source:",
+ " type: csv_lookup",
+ f" file: {csv_path.resolve().as_posix()}",
+ " join:",
+ " source_table: vehicles",
+ " source_key_column: vehicle_id",
+ " csv_key_column: vehicle_id",
+ " segment_value_column: vehicle_segment",
+ " segments:",
+ " - id: clean",
+ " label: Clean",
+ " values: [clean]",
+ ],
+ )
+
+ units = build_analysis_units_for_run(
+ run_key="run-a",
+ run_name="Run A",
+ prepared_run=_prepared_run(),
+ config=config,
+ )
+
+ assert config.segmentation.definitions[0].source.join_source_table == "vehicles"
+ assert units[1].segment_metadata.source_key_column == "vehicle_id"
+ assert units[1].prepared_run.vehicles["vehicle_id"].to_list() == [12]
+
+
+def test_new_segmentation_anchors_require_relationship_keys(tmp_path: Path) -> None:
+ cases = [
+ (
+ "vehicles",
+ "fuel_type",
+ pl.DataFrame({"vehicle_id": [12], "fuel_type": ["ev"]}),
+ "vehicles",
+ "vehicles segmentation requires a household_id column",
+ ),
+ (
+ "day",
+ "day_segment",
+ pl.DataFrame({"day_id": [10001], "day_segment": ["target"]}),
+ "day",
+ "day segmentation requires a person_id or household_id column",
+ ),
+ (
+ "joint_tour_participants",
+ "participant_role",
+ pl.DataFrame({"tour_id": [1001], "participant_role": ["guest"]}),
+ "joint_participants",
+ "joint_tour_participants segmentation requires tour_id and person_id columns",
+ ),
+ ]
+ for config_table, column, broken_table, run_attr, message in cases:
+ config = _write_config(
+ tmp_path / config_table,
+ extra_lines=[
+ "segment:",
+ " definitions:",
+ " broken:",
+ " source:",
+ " type: prepared_column",
+ f" source_table: {config_table}",
+ f" column: {column}",
+ " segments:",
+ " - id: broken",
+ " label: Broken",
+ " values: [ev, target, guest]",
+ ],
+ )
+ prepared_run = _prepared_run()
+ setattr(prepared_run, run_attr, broken_table)
+
+ with pytest.raises(ValueError, match=message):
+ build_analysis_units_for_run(
+ run_key="run-a",
+ run_name="Run A",
+ prepared_run=prepared_run,
+ config=config,
+ )
+
+
def test_csv_lookup_segmentation_rejects_one_to_many_membership(tmp_path: Path) -> None:
csv_path = tmp_path / "county_lookup.csv"
csv_path.write_text("HH_ID,county\n1,North\n1,South\n", encoding="utf-8")
@@ -325,9 +636,8 @@ def test_csv_lookup_segmentation_rejects_one_to_many_membership(tmp_path: Path)
_write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
- " dashboard:",
+ "segment:",
+ " dashboard:",
" segmentation_type: county",
" definitions:",
" county:",
@@ -352,12 +662,12 @@ def test_dashboard_state_filters_to_configured_segmentation_type_and_shows_all_v
full_run = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [1]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [1]})}},
)
north_county = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [2]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [2]})}},
segmentation_type="county",
segment_id="north",
segment_label="North",
@@ -366,7 +676,7 @@ def test_dashboard_state_filters_to_configured_segmentation_type_and_shows_all_v
south_county = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [3]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [3]})}},
segmentation_type="county",
segment_id="south",
segment_label="South",
@@ -375,7 +685,7 @@ def test_dashboard_state_filters_to_configured_segmentation_type_and_shows_all_v
low_vot = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [4]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [4]})}},
segmentation_type="vot",
segment_id="low",
segment_label="Low VOT",
@@ -390,7 +700,7 @@ def test_dashboard_state_filters_to_configured_segmentation_type_and_shows_all_v
)
assert state.run_labels == ["Run A (Full)", "Run A (North)", "Run A (South)"]
- summary_set = state.get_summary_table_set("totals", weighting_key="weighted")
+ summary_set = state.get_summary_table_set("population_totals", weighting_key="weighted")
assert summary_set is not None
assert [label for label, _ in summary_set] == [
"Run A (Full)",
@@ -403,12 +713,12 @@ def test_dashboard_state_segments_only_hides_full_for_selected_type() -> None:
full_run = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [1]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [1]})}},
)
north_county = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [2]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [2]})}},
segmentation_type="county",
segment_id="north",
segment_label="North",
@@ -430,7 +740,7 @@ def test_dashboard_state_segments_only_keeps_full_runs_when_no_segmented_series_
full_run = create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [1]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [1]})}},
)
state = DashboardState(
@@ -442,7 +752,7 @@ def test_dashboard_state_segments_only_keeps_full_runs_when_no_segmented_series_
assert state.has_segmented_summary_series is False
assert state.run_labels == ["Run A"]
- summary_set = state.get_summary_table_set("totals", weighting_key="weighted")
+ summary_set = state.get_summary_table_set("population_totals", weighting_key="weighted")
assert summary_set is not None
assert [label for label, _ in summary_set] == ["Run A"]
@@ -453,8 +763,7 @@ def test_summary_cache_round_trip_persists_multiple_segmentation_types(
config = _write_config(
tmp_path,
extra_lines=[
- "segmentation:",
- " enabled: true",
+ "segment:",
" dashboard:",
" segmentation_type: county",
" definitions:",
@@ -482,12 +791,12 @@ def test_summary_cache_round_trip_persists_multiple_segmentation_types(
create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [1]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [1]})}},
),
create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [2]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [2]})}},
segmentation_type="county",
segment_id="north",
segment_label="North",
@@ -496,7 +805,7 @@ def test_summary_cache_round_trip_persists_multiple_segmentation_types(
create_summary_run(
label="Run A",
run_key="run-a",
- summaries_by_mode={"weighted": {"totals": pl.DataFrame({"x": [3]})}},
+ summaries_by_mode={"weighted": {"population_totals": pl.DataFrame({"x": [3]})}},
segmentation_type="vot",
segment_id="low",
segment_label="Low VOT",
diff --git a/tests/test_skimjoin_integration.py b/tests/test_skimjoin_integration.py
index d4c03f4..cd504bf 100644
--- a/tests/test_skimjoin_integration.py
+++ b/tests/test_skimjoin_integration.py
@@ -22,7 +22,8 @@
from processor.skimjoin.inventory import inventory_skim_files
from processor.skimjoin.pipeline import apply_skimjoin
from processor.skimjoin.skimstore.omx import OmxSkimStore
-from processor.summarize import cache as summary_cache
+from processor.summarize import cache_types as summary_cache_types
+from processor.summarize import builder as summary_builder
from processor.summarize.contracts import empty_summary_frame
from processor.summarize.summaries import skimjoin as skimjoin_summaries
from runtime.config import Config, config_for_run
@@ -41,27 +42,32 @@ def _write_main_config(
run_dir = run_dir or (tmp_path / "run")
lines = [
'name: "Skimjoin Integration Test"',
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Skimjoin Integration Test"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Skimjoin Integration Test"',
+ "pipeline:",
+ (
+ " steps: [prepare, skimjoin, summarize, dashboard]"
+ if skimjoin_enabled
+ else " steps: [summarize, dashboard]"
+ ),
"zones:",
f" use_maz: {'true' if use_maz else 'false'}",
"runs:",
- f' - dir: "{str(run_dir).replace("\\", "/")}"',
+ f' - dir: "{run_dir.as_posix()}"',
' label: "Run A"',
]
if run_skimjoin_lines:
lines.append(" skimjoin:")
lines.extend(f" {line}" for line in run_skimjoin_lines)
- lines.extend(
- [
- "skimjoin:",
- f" enabled: {'true' if skimjoin_enabled else 'false'}",
- ]
- )
+ lines.append("skimjoin:")
if skimjoin_config_name is not None:
- lines.append(f" config_path: {skimjoin_config_name}")
+ lines.extend(
+ [
+ " defaults:",
+ f" config_path: {skimjoin_config_name}",
+ ]
+ )
config_path.write_text("\n".join(lines), encoding="utf-8")
return Config.from_yaml(config_path)
@@ -286,12 +292,10 @@ def test_config_loads_separate_skimjoin_config_and_digest(tmp_path: Path) -> Non
config = _write_main_config(tmp_path, skimjoin_enabled=True)
- assert config.skimjoin.enabled is True
+ assert config.skimjoin_step_enabled() is True
assert config.skimjoin.config_path == str((tmp_path / "skimjoin.yaml").resolve())
assert config.skimjoin.config_digest
- assert "skimjoin_trip_component_stats" in summary_cache.requested_summary_ids(
- config
- )
+ assert "skimjoin_trip_component_stats" in summary_builder.DEFAULT_SUMMARY_IDS
def test_config_loads_integrated_skimjoin_without_activitysim_table_paths(
@@ -321,7 +325,7 @@ def test_config_loads_integrated_skimjoin_without_activitysim_table_paths(
config = _write_main_config(tmp_path, skimjoin_enabled=True)
- assert config.skimjoin.enabled is True
+ assert config.skimjoin_step_enabled() is True
assert config.skimjoin.normalized_config is not None
@@ -396,7 +400,7 @@ def test_config_allows_run_level_skimjoin_config_without_global_path(
run_skimjoin_lines=[f"config_path: {run_config_path.name}"],
)
- assert config.skimjoin.enabled is True
+ assert config.skimjoin_step_enabled() is True
assert config.skimjoin.config_path is None
resolved = config_for_run(config, config.runs[0])
assert resolved.skimjoin.config_path == str(run_config_path.resolve())
@@ -484,7 +488,7 @@ def test_run_level_skim_files_override_config_level_project_skim_files(
skimjoin_enabled=True,
run_skimjoin_lines=[
"skim_files:",
- f' - "{str(override_skim_path.resolve()).replace("\\", "/")}"',
+ f' - "{override_skim_path.resolve().as_posix()}"',
],
)
@@ -845,23 +849,24 @@ def test_prepare_workflow_supports_two_runs_with_different_skimjoin_config_files
"\n".join(
[
'name: "Two Run Skimjoin Test"',
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Two Run Skimjoin Test"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Two Run Skimjoin Test"',
"zones:",
" use_maz: false",
"runs:",
- f' - dir: "{str(run_a_dir).replace("\\", "/")}"',
+ f' - dir: "{run_a_dir.as_posix()}"',
' label: "Run A"',
" skimjoin:",
" config_path: skimjoin_a.yaml",
- f' - dir: "{str(run_b_dir).replace("\\", "/")}"',
+ f' - dir: "{run_b_dir.as_posix()}"',
' label: "Run B"',
" skimjoin:",
" config_path: skimjoin_b.yaml",
"skimjoin:",
- " enabled: true",
+ " defaults: {}",
+ "pipeline:",
+ " steps: [prepare, skimjoin]",
]
),
encoding="utf-8",
@@ -877,7 +882,7 @@ def test_prepare_workflow_supports_two_runs_with_different_skimjoin_config_files
write_cache=False,
)
- outputs = {label: prepared for label, prepared in result.prepared_runs}
+ outputs = {label: prepared for label, prepared in result.runs}
assert outputs["Run A"].trips["skim_time"].to_list() == [11.0]
assert outputs["Run B"].trips["skim_time"].to_list() == [101.0]
@@ -910,26 +915,27 @@ def test_prepare_workflow_supports_two_runs_sharing_one_skimjoin_config_with_dif
"\n".join(
[
'name: "Shared Config Different Skims"',
- "summaries:",
- " root: summary_cache",
- "visualizer:",
- ' dashboard_title: "Shared Config Different Skims"',
+ "root: summary_cache",
+ "dashboard:",
+ ' title: "Shared Config Different Skims"',
"zones:",
" use_maz: false",
"runs:",
- f' - dir: "{str(run_a_dir).replace("\\", "/")}"',
+ f' - dir: "{run_a_dir.as_posix()}"',
' label: "Run A"',
" skimjoin:",
" skim_files:",
- f' - "{str(skim_a.resolve()).replace("\\", "/")}"',
- f' - dir: "{str(run_b_dir).replace("\\", "/")}"',
+ f' - "{skim_a.resolve().as_posix()}"',
+ f' - dir: "{run_b_dir.as_posix()}"',
' label: "Run B"',
" skimjoin:",
" skim_files:",
- f' - "{str(skim_b.resolve()).replace("\\", "/")}"',
+ f' - "{skim_b.resolve().as_posix()}"',
"skimjoin:",
- " enabled: true",
- " config_path: skimjoin.yaml",
+ " defaults:",
+ " config_path: skimjoin.yaml",
+ "pipeline:",
+ " steps: [prepare, skimjoin]",
]
),
encoding="utf-8",
@@ -945,7 +951,7 @@ def test_prepare_workflow_supports_two_runs_sharing_one_skimjoin_config_with_dif
write_cache=False,
)
- outputs = {label: prepared for label, prepared in result.prepared_runs}
+ outputs = {label: prepared for label, prepared in result.runs}
assert outputs["Run A"].trips["skim_time"].to_list() == [12.0]
assert outputs["Run B"].trips["skim_time"].to_list() == [102.0]
@@ -1480,7 +1486,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
write_cache=True,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert prepared.trips["skim_time"].to_list() == [2.0]
assert prepared.tours["skim_time_outbound"].to_list() == [2.0]
assert prepared.tours["skim_time_inbound"].to_list() == [3.0]
@@ -1490,7 +1496,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
)
assert manifest["skimjoin_enabled"] is True
- summaries = summary_cache.build_summaries(
+ summaries = summary_builder.build_summaries(
prepared,
config,
summary_ids=[
@@ -1503,6 +1509,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
assert trip_stats.to_dicts() == [
{
+ "skim_scenario": "chosen_mode",
"trip_mode": "All Modes",
"component": "skim_time",
"n_total": 1.0,
@@ -1517,6 +1524,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
"missing_share": 0.0,
},
{
+ "skim_scenario": "chosen_mode",
"trip_mode": "SOV",
"component": "skim_time",
"n_total": 1.0,
@@ -1533,6 +1541,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
]
assert tour_stats.to_dicts() == [
{
+ "skim_scenario": "chosen_mode",
"tour_mode": "All Modes",
"component": "skim_time_inbound",
"n_total": 1.0,
@@ -1547,6 +1556,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
"missing_share": 0.0,
},
{
+ "skim_scenario": "chosen_mode",
"tour_mode": "All Modes",
"component": "skim_time_outbound",
"n_total": 1.0,
@@ -1561,6 +1571,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
"missing_share": 0.0,
},
{
+ "skim_scenario": "chosen_mode",
"tour_mode": "SOV",
"component": "skim_time_inbound",
"n_total": 1.0,
@@ -1575,6 +1586,7 @@ def test_run_prepare_workflow_applies_mapping_aware_skimjoin(tmp_path: Path) ->
"missing_share": 0.0,
},
{
+ "skim_scenario": "chosen_mode",
"tour_mode": "SOV",
"component": "skim_time_outbound",
"n_total": 1.0,
@@ -1631,7 +1643,7 @@ def test_run_prepare_workflow_supports_file_specific_zone_lookup_overrides(
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert prepared.skimjoin_manifest["skimjoin_status"] == "applied"
assert prepared.trips["skim_fare"].to_list() == [7.5]
assert prepared.tours["skim_fare_outbound"].to_list() == [7.5]
@@ -1673,7 +1685,7 @@ def test_apply_skimjoin_skips_missing_prepare_columns_gracefully(tmp_path: Path)
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
skipped = prepared.skimjoin_reports["skipped_rule_report"]
assert "skim_time" not in prepared.trips.columns
assert prepared.skimjoin_manifest["skimjoin_status"] == "no_outputs"
@@ -1702,7 +1714,7 @@ def test_run_prepare_workflow_handles_trips_without_canonical_trip_id(tmp_path:
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert "trip_id" not in prepared.trips.columns
assert prepared.trips["skim_time"].to_list() == [2.0]
assert prepared.tours["skim_time_outbound"].to_list() == [2.0]
@@ -2538,7 +2550,7 @@ def test_run_prepare_workflow_supports_keyed_csv_skims_in_integrated_runtime(
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert prepared.skimjoin_manifest["skimjoin_status"] == "applied"
assert prepared.trips["skim_transit_maz_stop_walk"].to_list() == [0.25]
assert prepared.tours["skim_transit_maz_stop_walk_outbound"].to_list() == [0.25]
@@ -2582,7 +2594,7 @@ def test_run_prepare_workflow_records_fallback_manifest_and_report(
write_cache=True,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert prepared.trips["skim_time"].to_list() == [2.0]
assert prepared.skimjoin_manifest["skimjoin_fallback_count"] == 1
assert prepared.skimjoin_manifest["skimjoin_fallback_outputs"] == ["skim_time"]
@@ -2655,7 +2667,7 @@ def test_run_prepare_workflow_supports_csv_od_skims_in_integrated_runtime(
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
+ prepared = result.runs[0][1]
assert prepared.skimjoin_manifest["skimjoin_status"] == "applied"
assert prepared.trips["skim_walk_maz_distance"].to_list() == [0.5]
assert prepared.tours["skim_walk_maz_distance_outbound"].to_list() == [0.5]
@@ -2754,6 +2766,70 @@ def test_annotate_trips_nullifies_configured_keyed_csv_sentinel_values(tmp_path:
assert missing["reason"].to_list() == ["sentinel_value"]
+def test_annotate_trips_uses_prepared_trip_period_dimension(
+ tmp_path: Path,
+) -> None:
+ skim_path = tmp_path / "skims.omx"
+ _write_omx(skim_path, matrix_name="SOV_TIME__EA")
+ handle = omx.open_file(str(skim_path), "a")
+ handle["SOV_TIME__AM"] = np.array([[5.0, 6.0], [7.0, 8.0]])
+ handle.close()
+ (tmp_path / "skimjoin.yaml").write_text(
+ "\n".join(
+ [
+ "project:",
+ " skim_files:",
+ f" - {skim_path.name}",
+ "activitysim:",
+ " trip_mode_column: trip_mode",
+ " trip_id_column: trip_id",
+ " tour_id_column: tour_id",
+ " outbound_column: outbound",
+ "defaults:",
+ " origin: OTAZ",
+ " destination: DTAZ",
+ "zone_mapping:",
+ " lookup_name: taz",
+ "dimensions:",
+ " PERIOD:",
+ " source_columns:",
+ " trip_source_column: trip_period",
+ " outbound_tour_source_column: start_period",
+ " inbound_tour_source_column: first_inbound_trip_period",
+ "modes:",
+ " SOV:",
+ " time: SOV_TIME__{PERIOD}",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ config = _write_main_config(tmp_path, skimjoin_enabled=True)
+ normalized = config.skimjoin.normalized_config
+ assert normalized is not None
+ trips = pl.DataFrame(
+ {
+ "trip_id": [1, 2],
+ "trip_mode": ["SOV", "SOV"],
+ "OTAZ": [101, 101],
+ "DTAZ": [102, 102],
+ "trip_period": ["EA", "AM"],
+ }
+ )
+ inventory = inventory_skim_files(normalized.skim_files)
+
+ annotated, lookup_summary, missing = annotate_trips(
+ trips,
+ normalized,
+ inventory,
+ skim_store=OmxSkimStore(),
+ )
+
+ assert annotated["trip_period"].to_list() == ["EA", "AM"]
+ assert annotated["skim_time"].to_list() == [2.0, 6.0]
+ assert lookup_summary.height == 2
+ assert missing.is_empty()
+
+
def test_annotate_trips_handles_late_matrix_names_in_missing_report_without_schema_failure(
tmp_path: Path,
) -> None:
@@ -2856,6 +2932,38 @@ def _boom(*args, **kwargs):
]
+def test_apply_skimjoin_can_fail_fast_when_annotation_raises(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ skim_path = tmp_path / "skims.omx"
+ _write_omx(skim_path)
+ _write_skimjoin_config(tmp_path, skim_file=skim_path)
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ "pipeline:",
+ " steps: [prepare, skimjoin]",
+ "skimjoin:",
+ " failure_policy: error",
+ " defaults:",
+ " config_path: skimjoin.yaml",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ config = Config.from_yaml(config_path)
+
+ def _boom(*args, **kwargs):
+ raise ValueError("annotation exploded")
+
+ monkeypatch.setattr("processor.skimjoin.pipeline.annotate_trips", _boom)
+
+ with pytest.raises(ValueError, match="annotation exploded"):
+ apply_skimjoin(_skimjoin_ready_run_data(), config)
+
+
def test_apply_skimjoin_disabled_resets_manifest_and_reports(tmp_path: Path) -> None:
config = _write_main_config(tmp_path, skimjoin_enabled=False)
prepared = _skimjoin_ready_run_data()
@@ -2872,11 +2980,15 @@ def test_apply_skimjoin_disabled_resets_manifest_and_reports(tmp_path: Path) ->
"skimjoin_enabled": False,
"skimjoin_status": "disabled",
"skimjoin_config_digest": None,
+ "skimjoin_resolved_network_los_file": None,
"skimjoin_applied_outputs": [],
"skimjoin_skipped_rules": [],
"skimjoin_warning_count": 0,
"skimjoin_fallback_count": 0,
"skimjoin_fallback_outputs": [],
+ "skimjoin_hypothetical_sidecars_enabled": False,
+ "skimjoin_trip_hypothetical_rows": 0,
+ "skimjoin_tour_hypothetical_rows": 0,
"skimjoin_failure_detail": None,
}
assert result.skimjoin_reports == {}
@@ -3048,6 +3160,7 @@ def test_trip_skim_component_stats_follow_weighted_contract(tmp_path: Path) -> N
).to_dicts()[0]
assert drive_time == {
+ "skim_scenario": "chosen_mode",
"trip_mode": "DRIVE",
"component": "skim_time",
"n_total": 6.0,
@@ -3062,6 +3175,7 @@ def test_trip_skim_component_stats_follow_weighted_contract(tmp_path: Path) -> N
"missing_share": 0.5,
}
assert drive_cost == {
+ "skim_scenario": "chosen_mode",
"trip_mode": "DRIVE",
"component": "skim_cost",
"n_total": 6.0,
@@ -3076,6 +3190,7 @@ def test_trip_skim_component_stats_follow_weighted_contract(tmp_path: Path) -> N
"missing_share": 0.0,
}
assert all_modes_time == {
+ "skim_scenario": "chosen_mode",
"trip_mode": "All Modes",
"component": "skim_time",
"n_total": 8.0,
@@ -3117,8 +3232,8 @@ def _boom(*args, **kwargs):
write_cache=False,
)
- prepared = result.prepared_runs[0][1]
- summaries = summary_cache.build_summaries(
+ prepared = result.runs[0][1]
+ summaries = summary_builder.build_summaries(
prepared,
config,
summary_ids=["population_totals", "skimjoin_trip_component_stats"],
@@ -3131,7 +3246,7 @@ def _boom(*args, **kwargs):
def test_tour_skim_component_summaries_follow_unweighted_mode(tmp_path: Path) -> None:
config = _write_main_config(tmp_path, skimjoin_enabled=False)
- prepared = summary_cache.strip_weights(_skim_summary_run_data())
+ prepared = summary_cache_types.strip_weights(_skim_summary_run_data())
stats = skimjoin_summaries.tour_skim_component_stats(prepared, config)
@@ -3223,12 +3338,9 @@ def test_tour_annotation_uses_directional_period_source_columns(
"dimensions:",
" PERIOD:",
" source_columns:",
- " trip_source_column: depart",
- " outbound_tour_source_column: start",
- " inbound_tour_source_column: first_inbound_trip_depart",
- " values:",
- " 8: AM",
- " 17: PM",
+ " trip_source_column: trip_period",
+ " outbound_tour_source_column: start_period",
+ " inbound_tour_source_column: first_inbound_trip_period",
"defaults:",
" origin: OTAZ",
" destination: DTAZ",
@@ -3249,8 +3361,8 @@ def test_tour_annotation_uses_directional_period_source_columns(
"tour_mode": ["SOV"],
"origin": [1],
"destination": [2],
- "start": [8],
- "first_inbound_trip_depart": [17],
+ "start_period": ["AM"],
+ "first_inbound_trip_period": ["PM"],
"OTAZ": [1],
"DTAZ": [2],
}
diff --git a/tests/test_summary_cache.py b/tests/test_summary_cache.py
index 74def5d..3cc3e2d 100644
--- a/tests/test_summary_cache.py
+++ b/tests/test_summary_cache.py
@@ -11,7 +11,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-from dashboard.components import bar_chart
+from dashboard.rendering import Plotter, RenderContext
from dashboard.pages.long_term_choices.individual_choices import (
IndividualChoicesPage,
)
@@ -35,8 +35,8 @@
TourModePage as TourSummariesTourModePage,
)
from dashboard.pages.tour_summaries.tour_mode import (
- _auto_sufficiency_definitions_markdown,
- _filter_col,
+ auto_sufficiency_definitions_markdown,
+ vehicle_attribute_data,
)
from dashboard.pages.tour_summaries.internal_external_tours import (
InternalExternalToursPage,
@@ -48,6 +48,7 @@
from dashboard.pages.tour_summaries.tour_purpose import TourPurposePage
from dashboard.pages.tour_summaries.tour_stop_frequency import (
TourStopFrequencyPage,
+ stop_frequency_chart_data,
)
from dashboard.pages.tour_summaries.tour_time import TourTimePage
from dashboard.pages.trip_summaries.trip_mode import TripModePage
@@ -62,23 +63,20 @@
from processor.models import RunData
from processor.prepare.cache import build_prepared_manifest_identity
from processor.prepare.enrichment.pipeline import prepare_data
-from processor.summarize import cache as summary_cache_module
-from processor.summarize.contracts import empty_summary_frame, summary_contract
+from processor.summarize import builder as summary_builder_module
+from processor.summarize.contracts import empty_summary_frame, summary
from processor.summarize.cache import (
- SummaryCacheError,
build_run_fingerprint,
- build_summaries_with_metadata,
build_run_keys,
- create_summary_run,
load_summary_run_cache,
write_summary_run_cache,
)
+from processor.summarize.cache_types import SummaryCacheError, create_summary_run
+from processor.summarize.builder import build_summaries_with_metadata
from processor.summarize.schema import SUMMARY_OUTPUT_COLUMNS
-from processor.summarize.summary_specs import SUMMARY_SPECS, SummarySpec
-from processor.summarize.summary_specs import SUMMARY_SPEC_BY_ID
+from processor.summarize.catalog import SUMMARY_BY_ID, SUMMARY_DEFINITIONS
from runtime.config import Config
from runtime.config.models import SkimjoinSettings
-from processor.summarize.summaries import legacy
def _collect_cards(viewable) -> list[pn.Card]:
@@ -111,10 +109,20 @@ def _collect_tabulators(viewable) -> list[pn.widgets.Tabulator]:
return tables
+def _collect_tabs(viewable) -> list[pn.Tabs]:
+ tabs: list[pn.Tabs] = []
+ if isinstance(viewable, pn.Tabs):
+ tabs.append(viewable)
+ if hasattr(viewable, "objects"):
+ for child in viewable.objects:
+ tabs.extend(_collect_tabs(child))
+ return tabs
+
+
def _write_config(
tmp_path: Path,
*,
- visualizer_lines: list[str] | None = None,
+ dashboard_lines: list[str] | None = None,
extra_lines: list[str] | None = None,
) -> Config:
tmp_path.mkdir(parents=True, exist_ok=True)
@@ -124,29 +132,27 @@ def _write_config(
[
'name: "Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
+ "root: summary_cache",
+ "summarize:",
" weighting_modes:",
" - weighted",
" - unweighted",
- "visualizer:",
- ' dashboard_title: "Test Dashboard"',
+ "dashboard:",
+ ' title: "Test Dashboard"',
]
),
encoding="utf-8",
)
if extra_lines:
config_path.write_text(
- config_path.read_text(encoding="utf-8")
- + "\n"
- + "\n".join(extra_lines),
+ config_path.read_text(encoding="utf-8") + "\n" + "\n".join(extra_lines),
encoding="utf-8",
)
- if visualizer_lines:
+ if dashboard_lines:
config_path.write_text(
config_path.read_text(encoding="utf-8")
+ "\n"
- + "\n".join(f" {line}" for line in visualizer_lines),
+ + "\n".join(f" {line}" for line in dashboard_lines),
encoding="utf-8",
)
return Config.from_yaml(config_path)
@@ -154,36 +160,42 @@ def _write_config(
def _sample_summary_run() -> object:
weighted = {
- "destination_distance": pl.DataFrame(
+ "tour_distance_by_tour_purpose": pl.DataFrame(
{
- "purpose": ["All NM", "All NM", "shopping", "shopping"],
- "distbin": [0, 1, 0, 1],
- "freq": [5.0, 7.5, 2.0, 4.0],
+ "distance_bin": ["0", "1", "0", "1"],
+ "tour_purpose": ["all", "all", "shopping", "shopping"],
+ "tour_count": [5.0, 7.5, 2.0, 4.0],
}
),
- "destination_average_distance": pl.DataFrame(
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(
{
- "purpose": ["shopping"],
- "avg_distance": [3.25],
+ "nonmandatory_tour_purpose": ["shopping"],
+ "geography_type": ["all_geographies"],
+ "geography_id": ["all_geographies"],
+ "average_tour_distance": [3.25],
+ "tour_count": [6.0],
}
),
- "geo_flows": pl.DataFrame(),
+ "commuting_flows": pl.DataFrame(),
}
unweighted = {
- "destination_distance": pl.DataFrame(
+ "tour_distance_by_tour_purpose": pl.DataFrame(
{
- "purpose": ["All NM", "All NM", "shopping", "shopping"],
- "distbin": [0, 1, 0, 1],
- "freq": [2.0, 3.0, 1.0, 2.0],
+ "distance_bin": ["0", "1", "0", "1"],
+ "tour_purpose": ["all", "all", "shopping", "shopping"],
+ "tour_count": [2.0, 3.0, 1.0, 2.0],
}
),
- "destination_average_distance": pl.DataFrame(
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(
{
- "purpose": ["shopping"],
- "avg_distance": [2.5],
+ "nonmandatory_tour_purpose": ["shopping"],
+ "geography_type": ["all_geographies"],
+ "geography_id": ["all_geographies"],
+ "average_tour_distance": [2.5],
+ "tour_count": [3.0],
}
),
- "geo_flows": pl.DataFrame(),
+ "commuting_flows": pl.DataFrame(),
}
return create_summary_run(
label="Base",
@@ -245,16 +257,146 @@ def _skim_summary_tables() -> tuple[dict[str, pl.DataFrame], dict[str, pl.DataFr
"HOV3",
"SCHOOLBUS",
],
- "n_total": [10.0, 12.0, 10.0, 7.0, 7.0, 7.0, 7.0, 6.0, 8.0, 9.0, 11.0, 5.0],
- "n_valid": [9.0, 11.0, 10.0, 7.0, 7.0, 7.0, 7.0, 6.0, 8.0, 9.0, 10.0, 5.0],
- "mean": [15.126, 3.452, 99.111, 1.827, 12.233, 1.604, 28.06, 8.887, 34.221, 28.781, 7.004, 18.5],
- "std": [1.554, 0.882, 9.001, 0.214, 2.104, 0.187, 3.109, 1.443, 5.115, 4.201, 0.993, 1.2],
- "min": [11.0, 1.2, 88.0, 1.4, 8.1, 1.2, 22.0, 5.5, 20.0, 18.0, 5.0, 17.0],
- "max": [18.5, 5.4, 110.0, 2.1, 16.8, 1.9, 35.0, 12.7, 44.0, 37.0, 8.9, 20.0],
- "median": [15.0, 3.5, 100.0, 1.8, 12.0, 1.6, 28.0, 8.8, 34.0, 28.5, 7.0, 18.0],
- "mode": [14.0, 3.0, 98.0, 1.7, 11.5, 1.5, 27.0, 8.4, 33.0, 27.0, 7.0, 18.0],
- "zero_share": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
- "missing_share": [0.1, 0.08, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.09, 0.0],
+ "n_total": [
+ 10.0,
+ 12.0,
+ 10.0,
+ 7.0,
+ 7.0,
+ 7.0,
+ 7.0,
+ 6.0,
+ 8.0,
+ 9.0,
+ 11.0,
+ 5.0,
+ ],
+ "n_valid": [
+ 9.0,
+ 11.0,
+ 10.0,
+ 7.0,
+ 7.0,
+ 7.0,
+ 7.0,
+ 6.0,
+ 8.0,
+ 9.0,
+ 10.0,
+ 5.0,
+ ],
+ "mean": [
+ 15.126,
+ 3.452,
+ 99.111,
+ 1.827,
+ 12.233,
+ 1.604,
+ 28.06,
+ 8.887,
+ 34.221,
+ 28.781,
+ 7.004,
+ 18.5,
+ ],
+ "std": [
+ 1.554,
+ 0.882,
+ 9.001,
+ 0.214,
+ 2.104,
+ 0.187,
+ 3.109,
+ 1.443,
+ 5.115,
+ 4.201,
+ 0.993,
+ 1.2,
+ ],
+ "min": [
+ 11.0,
+ 1.2,
+ 88.0,
+ 1.4,
+ 8.1,
+ 1.2,
+ 22.0,
+ 5.5,
+ 20.0,
+ 18.0,
+ 5.0,
+ 17.0,
+ ],
+ "max": [
+ 18.5,
+ 5.4,
+ 110.0,
+ 2.1,
+ 16.8,
+ 1.9,
+ 35.0,
+ 12.7,
+ 44.0,
+ 37.0,
+ 8.9,
+ 20.0,
+ ],
+ "median": [
+ 15.0,
+ 3.5,
+ 100.0,
+ 1.8,
+ 12.0,
+ 1.6,
+ 28.0,
+ 8.8,
+ 34.0,
+ 28.5,
+ 7.0,
+ 18.0,
+ ],
+ "mode": [
+ 14.0,
+ 3.0,
+ 98.0,
+ 1.7,
+ 11.5,
+ 1.5,
+ 27.0,
+ 8.4,
+ 33.0,
+ 27.0,
+ 7.0,
+ 18.0,
+ ],
+ "zero_share": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ ],
+ "missing_share": [
+ 0.1,
+ 0.08,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.09,
+ 0.0,
+ ],
}
),
"skimjoin_tour_component_stats": pl.DataFrame(
@@ -300,10 +442,15 @@ def _skim_summary_tables() -> tuple[dict[str, pl.DataFrame], dict[str, pl.DataFr
return weighted, unweighted
-def _attach_test_skimjoin_config(config: Config) -> None:
+def _attach_test_skimjoin_config(
+ config: Config,
+ *,
+ ignore_modes: list[str] | None = None,
+) -> None:
config.skimjoin = SkimjoinSettings(
enabled=True,
normalized_config=SimpleNamespace(
+ ignore_modes=ignore_modes or [],
trip_lookups=[
SimpleNamespace(mode="SOV", output="skim_auto_time"),
SimpleNamespace(mode="SOV", output="skim_auto_distance"),
@@ -335,7 +482,9 @@ def _attach_test_skimjoin_config(config: Config) -> None:
SimpleNamespace(mode="HOV2", output="skim_auto_time_inbound"),
SimpleNamespace(mode="HOV2", output="skim_auto_cost_outbound"),
SimpleNamespace(mode="HOV2", output="skim_auto_cost_inbound"),
- SimpleNamespace(mode="WALK_TRANSIT", output="skim_transit_tiv_outbound"),
+ SimpleNamespace(
+ mode="WALK_TRANSIT", output="skim_transit_tiv_outbound"
+ ),
SimpleNamespace(mode="KNR_TRANSIT", output="skim_transit_tiv_inbound"),
SimpleNamespace(mode="WALK", output="skim_walk_time_outbound"),
SimpleNamespace(mode="WALK", output="skim_walk_time_inbound"),
@@ -457,11 +606,36 @@ def _write_custom_prepared_tables(
root.mkdir(parents=True, exist_ok=True)
tables = {
"households": pl.DataFrame({"household_id": [1], "finalweight": [1.0]}),
- "persons": pl.DataFrame({"person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "day": pl.DataFrame({"day_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "tours": pl.DataFrame({"tour_id": [100], "person_id": [10], "household_id": [1], "finalweight": [1.0]}),
- "trips": pl.DataFrame({"trip_id": [1000], "tour_id": [100], "person_id": [10], "finalweight": [1.0]}),
- "vehicles": pl.DataFrame({"vehicle_id": [1001], "household_id": [1], "finalweight": [1.0]}),
+ "persons": pl.DataFrame(
+ {"person_id": [10], "household_id": [1], "finalweight": [1.0]}
+ ),
+ "day": pl.DataFrame(
+ {
+ "day_id": [100],
+ "person_id": [10],
+ "household_id": [1],
+ "finalweight": [1.0],
+ }
+ ),
+ "tours": pl.DataFrame(
+ {
+ "tour_id": [100],
+ "person_id": [10],
+ "household_id": [1],
+ "finalweight": [1.0],
+ }
+ ),
+ "trips": pl.DataFrame(
+ {
+ "trip_id": [1000],
+ "tour_id": [100],
+ "person_id": [10],
+ "finalweight": [1.0],
+ }
+ ),
+ "vehicles": pl.DataFrame(
+ {"vehicle_id": [1001], "household_id": [1], "finalweight": [1.0]}
+ ),
"joint_tour_participants": pl.DataFrame({"tour_id": [], "person_id": []}),
"land_use": pl.DataFrame({"zone_id": [1], "TAZ": [1]}),
}
@@ -499,17 +673,21 @@ def test_summary_cache_round_trip_creates_configured_layout(tmp_path: Path) -> N
assert cache_dir == Path(config.summary_root) / "base"
assert cache_dir.exists()
assert (cache_dir / "manifest.json").exists()
- assert (cache_dir / "weighted" / "destinationDistByPurpose.csv").exists()
- assert (cache_dir / "unweighted" / "destinationAvgDistance.csv").exists()
+ assert (cache_dir / "weighted" / "tour_distance_by_tour_purpose.csv").exists()
+ assert (
+ cache_dir
+ / "unweighted"
+ / "average_nonmandatory_tour_distance_by_purpose_and_geography.csv"
+ ).exists()
loaded = load_summary_run_cache(
cache_dir,
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -524,13 +702,20 @@ def test_summary_cache_round_trip_creates_configured_layout(tmp_path: Path) -> N
assert loaded.label == "Base"
assert loaded.run_key == "base"
- assert loaded.summaries_by_mode["weighted"]["destination_distance"].to_dicts() == [
- {"purpose": "All NM", "distbin": 0, "freq": 5.0},
- {"purpose": "All NM", "distbin": 1, "freq": 7.5},
- {"purpose": "shopping", "distbin": 0, "freq": 2.0},
- {"purpose": "shopping", "distbin": 1, "freq": 4.0},
+ assert loaded.summaries_by_mode["weighted"]["tour_distance_by_tour_purpose"].to_dicts() == [
+ {"distance_bin": "0", "tour_purpose": "all", "tour_count": 5.0},
+ {"distance_bin": "1", "tour_purpose": "all", "tour_count": 7.5},
+ {"distance_bin": "0", "tour_purpose": "shopping", "tour_count": 2.0},
+ {"distance_bin": "1", "tour_purpose": "shopping", "tour_count": 4.0},
+ ]
+ assert loaded.summaries_by_mode["weighted"]["commuting_flows"].is_empty()
+ assert loaded.summaries_by_mode["weighted"]["commuting_flows"].columns == [
+ "origin_geography_type",
+ "origin_geography_id",
+ "destination_geography_type",
+ "destination_geography_id",
+ "commuter_count",
]
- assert loaded.summaries_by_mode["weighted"]["geo_flows"].width == 0
def test_summary_cache_detects_file_map_only_run_fingerprint_mismatch(
@@ -565,9 +750,9 @@ def test_summary_cache_detects_file_map_only_run_fingerprint_mismatch(
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=build_run_fingerprint(
@@ -622,9 +807,9 @@ def test_summary_cache_detects_fallback_file_map_only_run_fingerprint_mismatch(
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=build_run_fingerprint(
@@ -681,17 +866,15 @@ def test_summary_cache_detects_custom_prepared_table_path_or_mtime_change(
changed_path_map = dict(prepared_map)
changed_path_map["households"] = str(moved_households)
- with pytest.raises(
- SummaryCacheError, match="prepared manifest identity mismatch"
- ):
+ with pytest.raises(SummaryCacheError, match="prepared manifest identity mismatch"):
load_summary_run_cache(
cache_dir,
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -728,17 +911,15 @@ def test_summary_cache_detects_custom_prepared_table_path_or_mtime_change(
os.utime(trips_path, ns=(updated_ns, updated_ns))
- with pytest.raises(
- SummaryCacheError, match="prepared manifest identity mismatch"
- ):
+ with pytest.raises(SummaryCacheError, match="prepared manifest identity mismatch"):
load_summary_run_cache(
cache_dir,
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -764,21 +945,18 @@ def test_summary_cache_ignores_presentation_only_config_changes(
[
'name: "Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- " weighting_modes:",
- " - weighted",
- " - unweighted",
- "visualizer:",
- ' dashboard_title: "Dashboard A"',
- " dashboard_pages:",
- " - overview",
- " - destination",
- " export_html:",
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes: [weighted, unweighted]",
+ "dashboard:",
+ ' title: "Dashboard A"',
+ " live:",
+ " pages: [overview, trip_mode]",
+ " export:",
" dashboard:",
" weighting: all",
" pages:",
- " destination: {}",
+ " trip_mode: {}",
" overview: {}",
]
),
@@ -791,22 +969,19 @@ def test_summary_cache_ignores_presentation_only_config_changes(
[
'name: "Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- " weighting_modes:",
- " - weighted",
- " - unweighted",
- "visualizer:",
- ' dashboard_title: "Dashboard B"',
- " dashboard_pages:",
- " - destination",
- " - overview",
- " export_html:",
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes: [weighted, unweighted]",
+ "dashboard:",
+ ' title: "Dashboard B"',
+ " live:",
+ " pages: [trip_mode, overview]",
+ " export:",
" dashboard:",
" values: all",
" pages:",
" overview: {}",
- " destination:",
+ " trip_mode:",
" purpose: all",
]
),
@@ -833,9 +1008,9 @@ def test_summary_cache_ignores_presentation_only_config_changes(
config_b,
expected_modes=config_b.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=config_b.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -862,12 +1037,11 @@ def test_summary_cache_invalidates_when_summary_affecting_config_changes(
[
'name: "Test Config"',
"runs: []",
- "summaries:",
- " root: summary_cache",
- " weighting_modes:",
- " - weighted",
- "visualizer:",
- ' dashboard_title: "Test Dashboard"',
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes: [weighted]",
+ "dashboard:",
+ ' title: "Test Dashboard"',
]
),
encoding="utf-8",
@@ -896,9 +1070,9 @@ def test_summary_cache_invalidates_when_summary_affecting_config_changes(
changed_config,
expected_modes=changed_config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
- "geo_flows",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
+ "commuting_flows",
],
expected_summary_config_digest=changed_config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -912,31 +1086,6 @@ def test_summary_cache_invalidates_when_summary_affecting_config_changes(
)
-def test_destination_legacy_summaries_prefer_readable_purpose_aliases(
- tmp_path: Path,
-) -> None:
- config = _write_config(tmp_path)
- prepared = prepare_data(_prepared_destination_raw_run(), config)
-
- distance_df = legacy.distance_distribution(prepared, config)
- average_df = legacy.average_distance(prepared, config)
-
- assert sorted(distance_df["purpose"].unique().to_list()) == ["All NM", "eatout"]
- assert average_df["purpose"].to_list() == ["eatout"]
-
-
-def test_destination_legacy_summaries_return_empty_without_canonical_purpose(
- tmp_path: Path,
-) -> None:
- config = _write_config(tmp_path)
-
- distance_df = legacy.distance_distribution(_destination_raw_run(), config)
- average_df = legacy.average_distance(_destination_raw_run(), config)
-
- assert distance_df.is_empty()
- assert average_df.is_empty()
-
-
def test_prepare_data_overwrites_numeric_tour_purpose_before_destination_summaries(
tmp_path: Path,
) -> None:
@@ -945,28 +1094,9 @@ def test_prepare_data_overwrites_numeric_tour_purpose_before_destination_summari
assert prepared.tours["tour_purpose"].to_list() == ["eatout"]
- distance_df = legacy.distance_distribution(prepared, config)
- average_df = legacy.average_distance(prepared, config)
-
- assert sorted(distance_df["purpose"].unique().to_list()) == ["All NM", "eatout"]
- assert average_df["purpose"].to_list() == ["eatout"]
-
-
def test_registered_summary_builders_expose_contract_metadata() -> None:
- allowed_missing = {
- "traffic_count_comparisons",
- "screenline_flow_comparisons",
- "transit_boardings_by_operator_and_technology",
- "transit_transfer_rate",
- "commercial_vmt_totals",
- "bicycle_vmt_by_facility_type",
- }
- missing = [
- spec.summary_id
- for spec in SUMMARY_SPECS
- if not hasattr(spec.builder, "_summary_contract")
- ]
- assert set(missing) == allowed_missing
+ assert SUMMARY_DEFINITIONS
+ assert all(definition.contract.schema for definition in SUMMARY_DEFINITIONS)
def test_summary_output_columns_are_derived_from_builder_contracts() -> None:
@@ -976,10 +1106,10 @@ def test_summary_output_columns_are_derived_from_builder_contracts() -> None:
"trip_mode",
"trip_count",
)
- assert SUMMARY_OUTPUT_COLUMNS["destination_distance"] == (
- "purpose",
- "distbin",
- "freq",
+ assert SUMMARY_OUTPUT_COLUMNS["tour_distance_by_tour_purpose"] == (
+ "distance_bin",
+ "tour_purpose",
+ "tour_count",
)
@@ -989,7 +1119,8 @@ def test_build_summaries_with_metadata_marks_missing_inputs_unavailable(
) -> None:
config = _write_config(tmp_path)
- @summary_contract(
+ @summary(
+ id="probe_unavailable",
schema={"value": pl.Float64},
required_columns={"trips": ("needed",)},
)
@@ -998,13 +1129,11 @@ def unavailable_summary(rd: RunData, config: Config) -> pl.DataFrame:
"builder should not be called when prerequisites are missing"
)
- spec = SummarySpec("probe_unavailable", "probe_unavailable", unavailable_summary)
+ spec = unavailable_summary.summary_definition
monkeypatch.setattr(
- summary_cache_module, "DEFAULT_SUMMARY_IDS", ["probe_unavailable"]
- )
- monkeypatch.setitem(
- summary_cache_module.SUMMARY_SPEC_BY_ID, "probe_unavailable", spec
+ summary_builder_module, "DEFAULT_SUMMARY_IDS", ["probe_unavailable"]
)
+ monkeypatch.setitem(SUMMARY_BY_ID, "probe_unavailable", spec)
tables, metadata = build_summaries_with_metadata(_destination_raw_run(), config)
@@ -1016,6 +1145,28 @@ def unavailable_summary(rd: RunData, config: Config) -> pl.DataFrame:
assert "missing required columns" in metadata["probe_unavailable"]["detail"]
+def test_build_summaries_with_metadata_can_fail_fast_on_builder_errors(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ config = _write_config(tmp_path)
+
+ @summary(id="probe_failure", schema={"value": pl.Float64})
+ def failing_summary(rd: RunData, config: Config) -> pl.DataFrame:
+ raise RuntimeError("summary probe failed")
+
+ spec = failing_summary.summary_definition
+ monkeypatch.setattr(summary_builder_module, "DEFAULT_SUMMARY_IDS", ["probe_failure"])
+ monkeypatch.setitem(SUMMARY_BY_ID, "probe_failure", spec)
+
+ with pytest.raises(RuntimeError, match="summary probe failed"):
+ build_summaries_with_metadata(
+ _destination_raw_run(),
+ config,
+ raise_on_error=True,
+ )
+
+
def test_summary_cache_round_trip_preserves_summary_states_and_diagnostics(
tmp_path: Path,
) -> None:
@@ -1025,31 +1176,31 @@ def test_summary_cache_round_trip_preserves_summary_states_and_diagnostics(
run_key="base",
summaries_by_mode={
"weighted": {
- "destination_distance": pl.DataFrame(),
- "destination_average_distance": pl.DataFrame(),
+ "tour_distance_by_tour_purpose": pl.DataFrame(),
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(),
},
"unweighted": {
- "destination_distance": pl.DataFrame(),
- "destination_average_distance": pl.DataFrame(),
+ "tour_distance_by_tour_purpose": pl.DataFrame(),
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(),
},
},
summary_metadata_by_mode={
"weighted": {
- "destination_distance": {
+ "tour_distance_by_tour_purpose": {
"state": "unavailable",
"detail": "tours (missing required columns: SKIMDIST)",
},
- "destination_average_distance": {
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": {
"state": "failed",
"detail": "boom",
},
},
"unweighted": {
- "destination_distance": {
+ "tour_distance_by_tour_purpose": {
"state": "unavailable",
"detail": "tours (missing required columns: SKIMDIST)",
},
- "destination_average_distance": {
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": {
"state": "failed",
"detail": "boom",
},
@@ -1075,8 +1226,8 @@ def test_summary_cache_round_trip_preserves_summary_states_and_diagnostics(
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -1090,17 +1241,17 @@ def test_summary_cache_round_trip_preserves_summary_states_and_diagnostics(
)
assert (
- loaded.summary_metadata_by_mode["weighted"]["destination_distance"]["state"]
+ loaded.summary_metadata_by_mode["weighted"]["tour_distance_by_tour_purpose"]["state"]
== "unavailable"
)
assert (
- loaded.summary_metadata_by_mode["weighted"]["destination_average_distance"][
+ loaded.summary_metadata_by_mode["weighted"]["average_nonmandatory_tour_distance_by_purpose_and_geography"][
"state"
]
== "failed"
)
assert loaded.manifest["failed_summaries"]["weighted"] == [
- "destination_average_distance"
+ "average_nonmandatory_tour_distance_by_purpose_and_geography"
]
@@ -1113,30 +1264,30 @@ def test_summary_cache_writes_sentinel_csvs_for_empty_unavailable_and_failed_sum
run_key="base",
summaries_by_mode={
"weighted": {
- "destination_distance": pl.DataFrame(),
- "destination_average_distance": pl.DataFrame(),
+ "tour_distance_by_tour_purpose": pl.DataFrame(),
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(),
},
"unweighted": {
- "destination_distance": pl.DataFrame(),
- "destination_average_distance": pl.DataFrame(),
+ "tour_distance_by_tour_purpose": pl.DataFrame(),
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": pl.DataFrame(),
},
},
summary_metadata_by_mode={
"weighted": {
- "destination_distance": {
+ "tour_distance_by_tour_purpose": {
"state": "unavailable",
"detail": "tours (missing required columns: SKIMDIST)",
},
- "destination_average_distance": {
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": {
"state": "failed",
"detail": "boom",
},
},
"unweighted": {
- "destination_distance": {
+ "tour_distance_by_tour_purpose": {
"state": "empty",
},
- "destination_average_distance": {
+ "average_nonmandatory_tour_distance_by_purpose_and_geography": {
"state": "failed",
"detail": "boom",
},
@@ -1157,11 +1308,13 @@ def test_summary_cache_writes_sentinel_csvs_for_empty_unavailable_and_failed_sum
),
)
+ assert (cache_dir / "weighted" / "tour_distance_by_tour_purpose.csv").read_text(
+ encoding="utf-8"
+ ) == "__empty__\n"
assert (
- cache_dir / "weighted" / "destinationDistByPurpose.csv"
- ).read_text(encoding="utf-8") == "__empty__\n"
- assert (
- cache_dir / "weighted" / "destinationAvgDistance.csv"
+ cache_dir
+ / "weighted"
+ / "average_nonmandatory_tour_distance_by_purpose_and_geography.csv"
).read_text(encoding="utf-8") == "__empty__\n"
loaded = load_summary_run_cache(
@@ -1169,8 +1322,8 @@ def test_summary_cache_writes_sentinel_csvs_for_empty_unavailable_and_failed_sum
config,
expected_modes=config.weighting_modes,
expected_summary_ids=[
- "destination_distance",
- "destination_average_distance",
+ "tour_distance_by_tour_purpose",
+ "average_nonmandatory_tour_distance_by_purpose_and_geography",
],
expected_summary_config_digest=config.summary_config_digest,
expected_run_fingerprint=fingerprint,
@@ -1184,12 +1337,18 @@ def test_summary_cache_writes_sentinel_csvs_for_empty_unavailable_and_failed_sum
)
assert (
- loaded.summaries_by_mode["weighted"]["destination_distance"].schema
- == empty_summary_frame(legacy.distance_distribution).schema
+ loaded.summaries_by_mode["weighted"]["tour_distance_by_tour_purpose"].schema
+ == empty_summary_frame(
+ SUMMARY_BY_ID["tour_distance_by_tour_purpose"].builder
+ ).schema
)
assert (
- loaded.summaries_by_mode["weighted"]["destination_average_distance"].schema
- == empty_summary_frame(legacy.average_distance).schema
+ loaded.summaries_by_mode["weighted"]["average_nonmandatory_tour_distance_by_purpose_and_geography"].schema
+ == empty_summary_frame(
+ SUMMARY_BY_ID[
+ "average_nonmandatory_tour_distance_by_purpose_and_geography"
+ ].builder
+ ).schema
)
@@ -1228,7 +1387,23 @@ def test_tour_stop_frequency_live_page_uses_shared_summary_helpers(
assert list(page.purpose_sel.options) == ["All Tour Purposes", "eatout", "social"]
page.purpose_sel.value = "social"
page.refresh(force=True)
- assert page._body.objects
+ assert page.view.objects
+ directional_rows = [
+ obj
+ for obj in page.render_body()
+ if isinstance(obj, pn.Row)
+ and sum(len(_collect_plotly_panes(child)) for child in obj.objects) == 2
+ ]
+ assert len(directional_rows) == 1
+ directional_titles = {
+ str(plot.object.layout.title.text)
+ for child in directional_rows[0].objects
+ for plot in _collect_plotly_panes(child)
+ }
+ assert {
+ "Tour Stop Frequency - Purpose: social, Direction: Outbound",
+ "Tour Stop Frequency - Purpose: social, Direction: Inbound",
+ } == directional_titles
def test_trip_mode_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None:
@@ -1284,10 +1459,24 @@ def test_trip_mode_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None
page = TripModePage(state, config)
page.refresh(force=True)
- assert list(page.tour_purpose_sel.options) == ["All Tour Purposes", "eatout", "social"]
+ assert list(page.tour_purpose_sel.options) == [
+ "All Tour Purposes",
+ "eatout",
+ "social",
+ ]
+ plots = _collect_plotly_panes(page._body)
+ all_titles = {str(plot.object.layout.title.text) for plot in plots}
+ assert "Trip Mode Distribution for All Tours" in all_titles
+ assert "Trip Mode Distribution for All DRIVE Tours" in all_titles
+ assert "Trip Mode Distribution for All WALK Tours" in all_titles
page.tour_purpose_sel.value = "social"
page.refresh(force=True)
assert page._body.objects
+ plots = _collect_plotly_panes(page._body)
+ social_titles = {str(plot.object.layout.title.text) for plot in plots}
+ assert "Trip Mode Distribution for social Tours" in social_titles
+ assert "Trip Mode Distribution for DRIVE social Tours" in social_titles
+ assert "Trip Mode Distribution for WALK social Tours" in social_titles
def test_trip_mode_selector_uses_union_across_runs_and_zero_fills_missing_modes(
@@ -1333,10 +1522,14 @@ def test_trip_mode_selector_uses_union_across_runs_and_zero_fills_missing_modes(
page = TripModePage(state, config)
page.refresh(force=True)
- assert list(page.tour_purpose_sel.options) == ["All Tour Purposes", "eatout", "social"]
+ assert list(page.tour_purpose_sel.options) == [
+ "All Tour Purposes",
+ "eatout",
+ "social",
+ ]
page.tour_purpose_sel.value = "social"
charts = page.render_body()
- overall_chart = charts[0]
+ overall_chart = _collect_plotly_panes(charts[0])[0]
traces = {trace.name: trace for trace in overall_chart.object.data}
assert set(traces) == {"Base", "Build"}
@@ -1346,17 +1539,21 @@ def test_trip_mode_selector_uses_union_across_runs_and_zero_fills_missing_modes(
assert list(traces["Build"].y) == [1.0, 5.0]
-def test_trip_mode_page_uses_configured_mode_labels_on_plot_axes(tmp_path: Path) -> None:
+def test_trip_mode_page_uses_configured_mode_labels_on_plot_axes(
+ tmp_path: Path,
+) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " mode:",
- " mapping:",
- " WALK: Walk",
- " SHARED2: Shared Ride 2",
- " DRIVEALONE: Drive Alone",
- " DRIVE: Drive",
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " WALK: Walk",
+ " SHARED2: Shared Ride 2",
+ " SHARED3: Shared Ride 3+",
+ " DRIVEALONE: Drive Alone",
+ " DRIVE: Drive",
],
)
summary_run = _summary_run_with_tables(
@@ -1364,10 +1561,20 @@ def test_trip_mode_page_uses_configured_mode_labels_on_plot_axes(tmp_path: Path)
weighted={
"trip_mode_by_tour_purpose_and_tour_mode": pl.DataFrame(
{
- "tour_purpose": ["all_tour_purposes", "all_tour_purposes"],
- "tour_mode": ["all_tour_modes", "all_tour_modes"],
- "trip_mode": ["WALK", "SHARED2"],
- "trip_count": [2.0, 5.0],
+ "tour_purpose": [
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "all_tour_purposes",
+ ],
+ "tour_mode": [
+ "all_tour_modes",
+ "all_tour_modes",
+ "all_tour_modes",
+ "all_tour_modes",
+ ],
+ "trip_mode": ["DRIVEALONE", "WALK", "SHARED2", "SHARED3"],
+ "trip_count": [3.0, 2.0, 5.0, 10.0],
}
),
},
@@ -1380,14 +1587,30 @@ def test_trip_mode_page_uses_configured_mode_labels_on_plot_axes(tmp_path: Path)
page = TripModePage(state, config)
page.refresh(force=True)
- overall_chart = page.render_body()[0]
+ overall_chart = _collect_plotly_panes(page.render_body()[0])[0]
trace = overall_chart.object.data[0]
- assert list(trace.x) == ["Walk", "Shared Ride 2"]
+ assert page.hide_drive_alone.value is False
+ assert page.hide_drive_alone.name == "Hide Auto Modes"
+ assert list(trace.x) == [
+ "Walk",
+ "Shared Ride 2",
+ "Shared Ride 3+",
+ "Drive Alone",
+ ]
assert list(overall_chart.object.layout.xaxis.categoryarray) == [
"Walk",
"Shared Ride 2",
+ "Shared Ride 3+",
+ "Drive Alone",
]
+ page.hide_drive_alone.value = True
+ checked_chart = _collect_plotly_panes(page.render_body()[0])[0]
+ checked_trace = checked_chart.object.data[0]
+
+ assert list(checked_trace.x) == ["Walk"]
+ assert list(checked_chart.object.layout.xaxis.categoryarray) == ["Walk"]
+ assert list(checked_trace.y) == pytest.approx([10.0])
def test_daily_activity_pattern_page_uses_configured_mandatory_tour_labels_on_plot_axes(
@@ -1396,12 +1619,13 @@ def test_daily_activity_pattern_page_uses_configured_mandatory_tour_labels_on_pl
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " mandatory_tour_frequency:",
- " mapping:",
- " 1: Work",
- " 2: 2 Work",
- " 5: Work + School",
+ "display:",
+ " labels:",
+ " mandatory_tour_frequency:",
+ " mapping:",
+ " 1: Work",
+ " 2: 2 Work",
+ " 5: Work + School",
],
)
summary_run = _summary_run_with_tables(
@@ -1452,7 +1676,7 @@ def test_daily_activity_pattern_page_uses_configured_mandatory_tour_labels_on_pl
page = DailyActivityPatternPage(state, config)
page.refresh(force=True)
- mandatory_chart = page.render_body()[1].objects[0]
+ mandatory_chart = _collect_plotly_panes(page.render_body()[1])[0]
trace = mandatory_chart.object.data[0]
assert list(trace.x) == ["Work", "Work + School"]
@@ -1466,12 +1690,13 @@ def test_tour_purpose_selectors_use_category_labels_from_config(tmp_path: Path)
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " tour_purpose:",
- " mapping:",
- " all_tour_purposes: All Tour Purposes",
- " eatout: Eat Out",
- " social: Social Time",
+ "display:",
+ " labels:",
+ " tour_purpose:",
+ " mapping:",
+ " all_tour_purposes: All Tour Purposes",
+ " eatout: Eat Out",
+ " social: Social Time",
],
)
summary_run = _summary_run_with_tables(
@@ -1543,12 +1768,13 @@ def test_trip_stop_time_live_page_uses_shared_summary_helpers(
"eatout",
"social",
"social",
+ "social",
"all_tour_purposes",
"all_tour_purposes",
],
- "time_bin": [1, 2, 1, 2, 1, 2],
- "departure_trip_count": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0],
- "departure_stop_count": [3.0, 4.0, 5.0, 6.0, 8.0, 10.0],
+ "time_bin": [1, 2, 1, 2, 48, 1, 2],
+ "departure_trip_count": [2.0, 3.0, 4.0, 5.0, 0.0, 6.0, 8.0],
+ "departure_stop_count": [3.0, 4.0, 5.0, 6.0, 0.0, 8.0, 10.0],
}
),
},
@@ -1561,10 +1787,21 @@ def test_trip_stop_time_live_page_uses_shared_summary_helpers(
page = TripStopTimePage(state, config)
page.refresh(force=True)
- assert list(page.tour_purpose_sel.options) == ["All Tour Purposes", "eatout", "social"]
+ assert list(page.tour_purpose_sel.options) == [
+ "All Tour Purposes",
+ "eatout",
+ "social",
+ ]
page.tour_purpose_sel.value = "social"
page.refresh(force=True)
assert page._body.objects
+ trip_chart = _collect_plotly_panes(page._body)[0]
+ assert list(trip_chart.object.data[0].x)[:2] == ["03:00", "03:30"]
+ assert list(trip_chart.object.layout.xaxis.tickvals) == ["03:00"]
+ assert list(trip_chart.object.layout.xaxis.ticktext) == ["3:00"]
+ trip_hover = str(trip_chart.object.data[0].customdata[0])
+ assert "Clock Time: 03:00" in trip_hover
+ assert "start at 03:00" not in trip_hover
def test_dashboard_pages_apply_configured_dashboard_labels_to_category_plots(
@@ -1573,31 +1810,32 @@ def test_dashboard_pages_apply_configured_dashboard_labels_to_category_plots(
config = _write_config(
tmp_path,
extra_lines=[
- "dashboard_labels:",
- " license_holding_status:",
- " mapping:",
- " has_license: Has License",
- " no_license: No License",
- " transit_pass_ownership_status:",
- " mapping:",
- " has_transit_pass: Has Transit Pass",
- " no_transit_pass: No Transit Pass",
- " telecommute_frequency:",
- " mapping:",
- " No_Telecommute: No Telecommute",
- " 1_day_week: 1 Day per Week",
- " tour_composition:",
- " mapping:",
- " adults: Adults Only",
- " mixed: Mixed Group",
- " tour_category:",
- " mapping:",
- " mandatory: Mandatory",
- " non_mandatory: Non-Mandatory",
- " atwork_subtour_frequency_category:",
- " mapping:",
- " no_subtours: None",
- " eat: 1 Eating Out",
+ "display:",
+ " labels:",
+ " license_holding_status:",
+ " mapping:",
+ " has_license: Has License",
+ " no_license: No License",
+ " transit_pass_ownership_status:",
+ " mapping:",
+ " has_transit_pass: Has Transit Pass",
+ " no_transit_pass: No Transit Pass",
+ " telecommute_frequency:",
+ " mapping:",
+ " No_Telecommute: No Telecommute",
+ " 1_day_week: 1 Day per Week",
+ " tour_composition:",
+ " mapping:",
+ " adults: Adults Only",
+ " mixed: Mixed Group",
+ " tour_category:",
+ " mapping:",
+ " mandatory: Mandatory",
+ " non_mandatory: Non-Mandatory",
+ " atwork_subtour_frequency_category:",
+ " mapping:",
+ " no_subtours: None",
+ " eat: 1 Eating Out",
],
)
summary_run = _summary_run_with_tables(
@@ -1773,6 +2011,7 @@ def test_trip_stop_distance_live_page_uses_shared_summary_helpers(
"stop_out_of_direction_distance_by_tour_purpose": pl.DataFrame(
{
"tour_purpose": [
+ "all_tour_purposes",
"all_tour_purposes",
"all_tour_purposes",
"eatout",
@@ -1780,8 +2019,8 @@ def test_trip_stop_distance_live_page_uses_shared_summary_helpers(
"social",
"social",
],
- "distance_bin": [0, 1, 0, 1, 0, 1],
- "stop_count": [13.0, 11.0, 8.0, 4.0, 5.0, 7.0],
+ "distance_bin": [0, 1, 40, 0, 1, 0, 1],
+ "stop_count": [13.0, 6.0, 5.0, 8.0, 4.0, 5.0, 7.0],
}
),
},
@@ -1794,14 +2033,103 @@ def test_trip_stop_distance_live_page_uses_shared_summary_helpers(
page = TripStopDistancePage(state, config)
page.refresh(force=True)
- assert list(page.tour_purpose_sel.options) == ["All Tour Purposes", "eatout", "social"]
+ assert list(page.tour_purpose_sel.options) == [
+ "All Tour Purposes",
+ "eatout",
+ "social",
+ ]
assert page.tour_purpose_sel.value == "All Tour Purposes"
- assert len(page._body.objects) == 2
+ assert page.view.objects
+ all_titles = [
+ plot.object.layout.title.text for plot in _collect_plotly_panes(page._body)
+ ]
+ assert "Trip Distance Distribution for All Tours" in all_titles
+ assert "Stop Out-of-Direction Distance Distribution for All Tours" in all_titles
+ stop_ood_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._body)
+ if str(plot.object.layout.title.text)
+ == "Stop Out-of-Direction Distance Distribution for All Tours"
+ )
+ assert page.trip_stop_distance_range.current_range() == (0.0, 40.0)
+ assert list(stop_ood_plot.object.data[0].x) == [0.0, 1.0, 40.0]
+ assert list(stop_ood_plot.object.layout.xaxis.ticktext) == [
+ *[str(value) for value in range(0, 40, 2)],
+ "40+",
+ ]
+ assert list(stop_ood_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ assert list(stop_ood_plot.object.data[0].y) == pytest.approx(
+ [54.166666666666664, 25.0, 20.833333333333336]
+ )
+ page.trip_stop_distance_range.min_widget.value = 0.25
+ page.trip_stop_distance_range.max_widget.value = "2"
+ page.refresh(force=True)
+ ranged_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._body)
+ if str(plot.object.layout.title.text)
+ == "Stop Out-of-Direction Distance Distribution for All Tours"
+ )
+ assert list(ranged_plot.object.layout.xaxis.range) == [0.25, 2.0]
+ page.trip_stop_distance_range.reset()
+ page.refresh(force=True)
+ reset_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._body)
+ if str(plot.object.layout.title.text)
+ == "Stop Out-of-Direction Distance Distribution for All Tours"
+ )
+ assert list(reset_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ page.trip_stop_distance_range.min_widget.value = 2.0
+ page.trip_stop_distance_range.max_widget.value = "1"
+ page.refresh(force=True)
+ assert any(
+ card.title == "Trip and Stop Distance Data Not Available"
+ for card in _collect_cards(page._body)
+ )
+ page.trip_stop_distance_range.reset()
+ page.refresh(force=True)
page.tour_purpose_sel.value = "social"
page.refresh(force=True)
- assert len(page._body.objects) == 2
+ assert page.view.objects
+ social_titles = [
+ plot.object.layout.title.text for plot in _collect_plotly_panes(page._body)
+ ]
+ assert "Trip Distance Distribution for social Tours" in social_titles
+ assert (
+ "Stop Out-of-Direction Distance Distribution for social Tours" in social_titles
+ )
+
+
+def test_tour_stop_frequency_chart_data_caps_directional_stop_counts() -> None:
+ data = [
+ (
+ "Base",
+ pl.DataFrame(
+ {
+ "tour_purpose": ["all_tour_purposes"] * 6,
+ "total_stop_count": [0, 6, 7, 1, 0, 6],
+ "outbound_stop_count": [0, 3, 4, 1, 0, 3],
+ "inbound_stop_count": [0, 2, 3, 4, 1, 3],
+ "tour_count": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+ }
+ ),
+ )
+ ]
+
+ both = stop_frequency_chart_data(data, None, "Both")[0][1]
+ outbound = stop_frequency_chart_data(data, None, "Outbound")[0][1]
+
+ assert both.to_dict(as_series=False) == {
+ "stop_frequency": ["0", "1", "6+"],
+ "tour_count": [6.0, 4.0, 11.0],
+ }
+ assert outbound.to_dict(as_series=False) == {
+ "stop_frequency": ["0", "1", "3+"],
+ "tour_count": [6.0, 4.0, 11.0],
+ }
def test_daily_activity_pattern_live_page_uses_shared_summary_helpers(
@@ -1885,7 +2213,7 @@ def test_daily_activity_pattern_live_page_uses_shared_summary_helpers(
assert list(page.person_type_sel.options) == ["All Person Types", "worker"]
page.person_type_sel.value = "worker"
page.refresh(force=True)
- assert page._body.objects
+ assert page.view.objects
def test_daily_activity_pattern_page_renders_available_charts_when_one_summary_is_missing(
@@ -1957,9 +2285,7 @@ def test_daily_activity_pattern_page_renders_available_charts_when_one_summary_i
assert len(plots) == 4
card_markdown = [
- str(card.objects[0].object)
- for card in cards
- if getattr(card, "objects", None)
+ str(card.objects[0].object) for card in cards if getattr(card, "objects", None)
]
assert any(
getattr(card, "title", "") == "Data Not Available"
@@ -2046,10 +2372,10 @@ def test_joint_travel_participation_page_uses_counts_and_runtime_percent_mode(
if str(plot.object.layout.title.text)
== "Households Taking Part in a Joint Tour - All"
)
- assert list(people_plot.object.layout.xaxis.categoryarray) == ["2", "3"]
+ assert list(people_plot.object.layout.xaxis.categoryarray) == ["2", "3", "4", "5+"]
assert list(household_plot.object.layout.xaxis.categoryarray) == ["0", "1"]
assert list(household_plot.object.data[0].x) == ["0", "1"]
- assert list(people_plot.object.data[0].y) == [50.0, 100.0]
+ assert list(people_plot.object.data[0].y) == [50.0, 100.0, 0.0, 0.0]
state.value_mode = "Count"
page.refresh(force=True)
@@ -2060,8 +2386,85 @@ def test_joint_travel_participation_page_uses_counts_and_runtime_percent_mode(
if str(plot.object.layout.title.text)
== "People Taking Part in a Joint Tour by Household Size"
)
- assert list(people_plot.object.layout.xaxis.categoryarray) == ["2", "3"]
- assert list(people_plot.object.data[0].y) == [2.0, 3.0]
+ assert list(people_plot.object.layout.xaxis.categoryarray) == ["2", "3", "4", "5+"]
+ assert list(people_plot.object.data[0].y) == [2.0, 3.0, 0.0, 0.0]
+
+
+def test_joint_travel_frequency_can_hide_no_joint_tours_without_renormalizing(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ summary_run = _summary_run_with_tables(
+ label="Base",
+ weighted={
+ "jtf_distribution": pl.DataFrame(
+ {
+ "jtf_code": [0, 1],
+ "jtf_label": ["No Joint Tours", "One Joint Tour"],
+ "household_count": [5.0, 3.0],
+ }
+ ),
+ "joint_tours_by_household_size": pl.DataFrame(
+ {
+ "household_size": [2],
+ "household_count": [6.0],
+ "joint_tour_hh_count": [3.0],
+ }
+ ),
+ "joint_tour_party_size_distribution": pl.DataFrame(
+ {
+ "party_size": [2],
+ "joint_tour_count": [3.0],
+ }
+ ),
+ "joint_tour_composition_by_party_size": pl.DataFrame(
+ {
+ "tour_composition": ["adults"],
+ "party_size": [2],
+ "joint_tour_count": [3.0],
+ }
+ ),
+ "person_jtp_by_household_size": pl.DataFrame(
+ {
+ "household_size": [2],
+ "joint_tour_person_count": [2.0],
+ "total_person_count": [4.0],
+ }
+ ),
+ "household_jtp_by_household_size_and_jtf": pl.DataFrame(
+ {
+ "jtf": ["0", "1"],
+ "household_size": ["2", "2"],
+ "household_percent": [50.0, 50.0],
+ }
+ ),
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = JointTravelPage(state, config)
+ page.refresh(force=True)
+
+ assert any(
+ isinstance(obj, pn.pane.Markdown)
+ and obj.object == "### Joint Tour Frequency"
+ for obj in page.view.objects
+ )
+ assert page._frequency_section.objects[0].objects == [page.hide_no_joint_tours]
+ frequency_plot = _collect_plotly_panes(page._frequency_section)[0]
+ trace = frequency_plot.object.data[0]
+ assert list(trace.x) == ["No Joint Tours", "One Joint Tour"]
+ assert list(trace.y) == pytest.approx([62.5, 37.5])
+
+ page.hide_no_joint_tours.value = True
+ checked_plot = _collect_plotly_panes(page.render_frequency()[-1])[0]
+ checked_trace = checked_plot.object.data[0]
+
+ assert list(checked_trace.x) == ["One Joint Tour"]
+ assert list(checked_trace.y) == pytest.approx([37.5])
def test_joint_travel_composition_plot_keeps_category_axis_when_party_size_filters_out_bars(
@@ -2121,6 +2524,13 @@ def test_joint_travel_composition_plot_keeps_category_axis_when_party_size_filte
page = JointTravelPage(state, config)
page.refresh(force=True)
+ assert list(page.party_size_sel.options) == [
+ "All Party Sizes",
+ "2",
+ "3",
+ "4",
+ "5+",
+ ]
page.party_size_sel.value = "2"
page.refresh(force=True)
@@ -2130,15 +2540,18 @@ def test_joint_travel_composition_plot_keeps_category_axis_when_party_size_filte
if str(plot.object.layout.title.text)
== "Joint Tour Composition by Party Size - 2"
)
- assert list(composition_plot.object.layout.xaxis.categoryarray) == ["adults", "mixed"]
+ assert list(composition_plot.object.layout.xaxis.categoryarray) == [
+ "adults",
+ "mixed",
+ ]
assert list(composition_plot.object.data[0].x) == ["adults", "mixed"]
assert len(composition_plot.object.data[0].y) == 2
assert composition_plot.object.data[0].y[0] > 0
assert composition_plot.object.data[0].y[1] == 0.0
-def test_skims_group_lists_tour_skims_before_trip_skims() -> None:
- definitions = page_definitions_for_group("skims")
+def test_skim_summaries_group_lists_tour_skims_before_trip_skims() -> None:
+ definitions = page_definitions_for_group("skim_summaries")
assert [definition.page_id for definition in definitions] == [
"tour_skims",
@@ -2203,13 +2616,66 @@ def test_trip_walk_skims_use_explicit_walk_distance_and_time_labels(
table = _collect_tabulators(page._summary_section)[0]
assert table.value["skim_name"].tolist() == [
- "MAZ Actual Walk Time",
- "MAZ Network Walk Distance",
- "TAZ Skim Walk Distance",
- "Total Walk Access/Egress Time",
+ "MAZ Actual Walk Time (min)",
+ "MAZ Network Walk Distance (mi)",
+ "TAZ Skim Walk Distance (mi)",
+ "Total Walk Access/Egress Time (min)",
]
+def test_trip_skim_family_respects_skimjoin_ignored_modes(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ ignored_modes = ["EBIKE", "ESCOOTER", "BIKE_TRANSIT"]
+ _attach_test_skimjoin_config(config, ignore_modes=ignored_modes)
+ weighted, unweighted = _skim_summary_tables()
+ ignored_mode_rows = pl.DataFrame(
+ {
+ "component": ["skim_walk_time", "skim_walk_time", "skim_walk_time"],
+ "trip_mode": ignored_modes,
+ "n_total": [4.0, 3.0, 2.0],
+ "n_valid": [4.0, 3.0, 2.0],
+ "mean": [6.0, 7.0, 8.0],
+ "std": [1.0, 1.0, 1.0],
+ "min": [5.0, 6.0, 7.0],
+ "max": [7.0, 8.0, 9.0],
+ "median": [6.0, 7.0, 8.0],
+ "mode": [6.0, 7.0, 8.0],
+ "zero_share": [0.0, 0.0, 0.0],
+ "missing_share": [0.0, 0.0, 0.0],
+ }
+ )
+ weighted["skimjoin_trip_component_stats"] = pl.concat(
+ [weighted["skimjoin_trip_component_stats"], ignored_mode_rows],
+ how="vertical",
+ )
+ unweighted["skimjoin_trip_component_stats"] = pl.concat(
+ [unweighted["skimjoin_trip_component_stats"], ignored_mode_rows],
+ how="vertical",
+ )
+ state = DashboardState(
+ summary_runs=[
+ _summary_run_with_tables(
+ label="Base",
+ weighted=weighted,
+ unweighted=unweighted,
+ )
+ ],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = TripSkimsPage(state, config)
+ page.refresh(force=True)
+ page.trip_family_sel.value = "Bike Skims"
+ page.refresh(force=True)
+
+ table = _collect_tabulators(page._summary_section)[0]
+ assert set(table.value["trip_mode"].tolist()) == {"BIKE"}
+ for ignored_mode in ignored_modes:
+ assert ignored_mode not in table.value["trip_mode"].tolist()
+
+
def test_tour_skims_page_uses_family_and_direction_selectors_for_summary_table(
tmp_path: Path,
) -> None:
@@ -2238,7 +2704,7 @@ def test_tour_skims_page_uses_family_and_direction_selectors_for_summary_table(
table = tables[0]
assert list(table.value.columns[:2]) == ["skim_name", "tour_mode"]
assert set(table.value["tour_mode"].tolist()) == {"SOV", "HOV2"}
- assert set(table.value["skim_name"].tolist()) == {"Cost", "Time"}
+ assert set(table.value["skim_name"].tolist()) == {"Cost ($)", "Time (min)"}
assert "Outbound" == page.tour_direction_sel.value
page.tour_family_sel.value = "Transit Skims"
@@ -2246,21 +2712,22 @@ def test_tour_skims_page_uses_family_and_direction_selectors_for_summary_table(
page.refresh(force=True)
inbound_table = _collect_tabulators(page._summary_section)[0]
assert set(inbound_table.value["tour_mode"].tolist()) == {"KNR_TRANSIT"}
- assert "Transit In-Vehicle Time" in inbound_table.value["skim_name"].tolist()
+ assert "Transit In-Vehicle Time (min)" in inbound_table.value["skim_name"].tolist()
def test_tour_purpose_labels_render_consistently_across_pages(tmp_path: Path) -> None:
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " tour_purpose:",
- " mapping:",
- " all_tour_purposes: All Tour Purposes",
- " work: Work Trips",
- " shop: Shopping",
- " eatout: Eat Out",
- " social: Social Time",
+ "display:",
+ " labels:",
+ " tour_purpose:",
+ " mapping:",
+ " all_tour_purposes: All Tour Purposes",
+ " work: Work Trips",
+ " shop: Shopping",
+ " eatout: Eat Out",
+ " social: Social Time",
],
)
summary_run = _summary_run_with_tables(
@@ -2375,7 +2842,9 @@ def test_tour_purpose_labels_render_consistently_across_pages(tmp_path: Path) ->
tour_purpose_page.refresh(force=True)
tour_purpose_plots = _collect_plotly_panes(tour_purpose_page._body)
purpose_chart = next(
- plot for plot in tour_purpose_plots if plot.object.layout.title.text == "Tour Purpose"
+ plot
+ for plot in tour_purpose_plots
+ if plot.object.layout.title.text == "Tour Purpose"
)
assert list(purpose_chart.object.layout.xaxis.categoryarray) == [
"Work Trips",
@@ -2385,14 +2854,12 @@ def test_tour_purpose_labels_render_consistently_across_pages(tmp_path: Path) ->
tour_distance_page = TourDistancePage(state, config)
tour_distance_page.refresh(force=True)
- assert list(tour_distance_page.nonmand_purpose_sel.options) == [
- "All Tour Purposes",
- "Eat Out",
- "Social Time",
- ]
+ assert not hasattr(tour_distance_page, "nonmandatory_purpose_sel")
+ tour_distance_page.geo_level_sel.value = "Region"
+ tour_distance_page.refresh(force=True)
tabulators = _collect_tabulators(tour_distance_page._average_section)
nonmandatory_table = tabulators[0].value
- assert nonmandatory_table["nonmandatory_tour_purpose"].tolist() == [
+ assert nonmandatory_table["Non-Mandatory Tour Purpose"].tolist() == [
"Eat Out",
"Social Time",
]
@@ -2404,19 +2871,20 @@ def test_trip_stop_purpose_page_uses_trip_and_stop_purpose_dashboard_labels(
config = _write_config(
tmp_path,
extra_lines=[
- "dashboard_labels:",
- " tour_purpose:",
- " mapping:",
- " work: Work Tours",
- " shop: Shopping Tours",
- " trip_purpose:",
- " mapping:",
- " work: Work Trips",
- " shop: Shopping Trips",
- " stop_purpose:",
- " mapping:",
- " work: Work Stops",
- " shop: Shopping Stops",
+ "display:",
+ " labels:",
+ " tour_purpose:",
+ " mapping:",
+ " work: Work Tours",
+ " shop: Shopping Tours",
+ " trip_purpose:",
+ " mapping:",
+ " work: Work Trips",
+ " shop: Shopping Trips",
+ " stop_purpose:",
+ " mapping:",
+ " work: Work Stops",
+ " shop: Shopping Stops",
],
)
summary_run = _summary_run_with_tables(
@@ -2461,13 +2929,14 @@ def test_trip_stop_purpose_page_uses_trip_and_stop_purpose_dashboard_labels(
plots = _collect_plotly_panes(page._body)
trip_chart = next(
- plot for plot in plots if plot.object.layout.title.text == "Trip Purpose"
+ plot
+ for plot in plots
+ if plot.object.layout.title.text == "Trip Purpose for All Tours"
)
stop_chart = next(
plot
for plot in plots
- if plot.object.layout.title.text
- == "Stop Destination Purpose by Tour Purpose - All Tour Purposes"
+ if plot.object.layout.title.text == "Stop Destination Purpose for All Tours"
)
assert list(trip_chart.object.layout.xaxis.categoryarray) == [
"Work Trips",
@@ -2478,18 +2947,28 @@ def test_trip_stop_purpose_page_uses_trip_and_stop_purpose_dashboard_labels(
page.refresh(force=True)
plots = _collect_plotly_panes(page._body)
filtered_trip_chart = next(
- plot for plot in plots if plot.object.layout.title.text == "Trip Purpose"
+ plot
+ for plot in plots
+ if plot.object.layout.title.text == "Trip Purpose for Work Tours"
+ )
+ filtered_stop_chart = next(
+ plot
+ for plot in plots
+ if plot.object.layout.title.text == "Stop Destination Purpose for Work Tours"
)
assert list(filtered_trip_chart.object.data[0].x) == [
"Work Trips",
"Shopping Trips",
]
assert list(filtered_trip_chart.object.data[0].y) == [62.5, 37.5]
- assert list(stop_chart.object.layout.xaxis.categoryarray) == [
+ assert list(filtered_stop_chart.object.layout.xaxis.categoryarray) == [
+ "Work Stops",
+ "Shopping Stops",
+ ]
+ assert list(filtered_stop_chart.object.data[0].x) == [
"Work Stops",
"Shopping Stops",
]
- assert list(stop_chart.object.data[0].x) == ["Work Stops", "Shopping Stops"]
def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
@@ -2510,13 +2989,14 @@ def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
"adult_escort_event_stop_distribution": pl.DataFrame(
{
"segment": [
+ "outbound_before_dropoff",
"outbound_before_dropoff",
"outbound_after_dropoff",
"inbound_before_pickup",
"inbound_after_pickup",
],
- "stop_count": [1, 0, 0, 1],
- "tour_count": [2.0, 3.0, 4.0, 1.0],
+ "stop_count": [1, 4, 0, 0, 1],
+ "tour_count": [2.0, 5.0, 3.0, 4.0, 1.0],
}
),
"adult_escorted_tours_by_person_type_and_direction": pl.DataFrame(
@@ -2545,14 +3025,15 @@ def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
),
"student_households_by_student_count": pl.DataFrame(
{
- "student_count": [1, 2],
- "household_count": [10.0, 5.0],
+ "student_count": [1, 2, 6, 7],
+ "household_count": [10.0, 5.0, 3.0, 2.0],
}
),
"households_with_school_escorting_by_student_count_and_direction": pl.DataFrame(
{
- "student_count": [1, 2, 1, 2, 1, 2],
+ "student_count": [1, 2, 7, 1, 2, 1, 2],
"direction": [
+ "outbound",
"outbound",
"outbound",
"inbound",
@@ -2560,13 +3041,14 @@ def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
"both",
"both",
],
- "household_count": [4.0, 1.0, 3.0, 0.0, 2.0, 1.0],
+ "household_count": [4.0, 1.0, 2.0, 3.0, 0.0, 2.0, 1.0],
}
),
"schoolkids_per_escorted_tour_by_student_count_and_direction": pl.DataFrame(
{
- "student_count": [1, 2, 1, 2, 1, 2],
+ "student_count": [1, 2, 7, 1, 2, 1, 2],
"direction": [
+ "outbound",
"outbound",
"outbound",
"inbound",
@@ -2574,8 +3056,8 @@ def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
"both",
"both",
],
- "avg_schoolkids_per_tour": [1.5, 2.0, 1.0, 2.5, 1.0, 2.0],
- "tour_count": [4.0, 2.0, 3.0, 1.0, 2.0, 1.0],
+ "avg_schoolkids_per_tour": [1.5, 2.0, 4.0, 1.0, 2.5, 1.0, 2.0],
+ "tour_count": [4.0, 2.0, 3.0, 3.0, 1.0, 2.0, 1.0],
}
),
"adult_escorted_tour_distance_distribution_by_direction": pl.DataFrame(
@@ -2603,30 +3085,28 @@ def test_escorted_tours_live_page_renders_stop_distribution_controls_and_charts(
page.refresh(force=True)
assert list(page.direction_sel.options) == ["Both Directions", "Outbound"]
- assert len(page._body.objects) == 2
- render_calls = {"static": 0, "directional": 0}
- static_section = page._registered_sections["escorted_tours_static_body"]
- directional_section = page._registered_sections["escorted_tours_directional_body"]
- original_static_render = static_section.render
- original_directional_render = directional_section.render
-
- def counted_static_render():
- render_calls["static"] += 1
- return original_static_render()
-
- def counted_directional_render():
- render_calls["directional"] += 1
- return original_directional_render()
-
- static_section.render = counted_static_render
- directional_section.render = counted_directional_render
+ assert page.view.objects
+ render_calls = {section_id: 0 for section_id in page._registered_sections}
+ for section_id, section in page._registered_sections.items():
+ original_render = section.render
+
+ def counted_render(section_id=section_id, original_render=original_render):
+ render_calls[section_id] += 1
+ return original_render()
+
+ section.render = counted_render
page.direction_sel.value = "Both Directions"
page.refresh(force=False)
- assert page._body.objects
- assert render_calls == {"static": 0, "directional": 1}
+ assert page.view.objects
+ assert render_calls == {
+ "school_escort.body": 0,
+ "adult_escort.body": 0,
+ "direction.body": 1,
+ "distance.body": 1,
+ }
student_titles = [
str(plot.object.layout.title.text)
- for plot in _collect_plotly_panes(page._body)
+ for plot in _collect_plotly_panes(page.view)
if "Student School Escort Status" in str(plot.object.layout.title.text)
]
assert sorted(student_titles) == [
@@ -2636,7 +3116,7 @@ def counted_directional_render():
]
household_titles = [
str(plot.object.layout.title.text)
- for plot in _collect_plotly_panes(page._body)
+ for plot in _collect_plotly_panes(page.view)
if "Households With School Escorting" in str(plot.object.layout.title.text)
]
assert sorted(household_titles) == [
@@ -2646,17 +3126,17 @@ def counted_directional_render():
]
schoolkids_titles = [
str(plot.object.layout.title.text)
- for plot in _collect_plotly_panes(page._body)
- if "Schoolkids Per Escorted Tour" in str(plot.object.layout.title.text)
+ for plot in _collect_plotly_panes(page.view)
+ if "Schoolkids Per Adult Chauffeur Tour" in str(plot.object.layout.title.text)
]
assert sorted(schoolkids_titles) == [
- "Schoolkids Per Escorted Tour - Both Directions",
- "Schoolkids Per Escorted Tour - Inbound",
- "Schoolkids Per Escorted Tour - Outbound",
+ "Schoolkids Per Adult Chauffeur Tour - Both Directions",
+ "Schoolkids Per Adult Chauffeur Tour - Inbound",
+ "Schoolkids Per Adult Chauffeur Tour - Outbound",
]
stop_titles = [
str(plot.object.layout.title.text)
- for plot in _collect_plotly_panes(page._body)
+ for plot in _collect_plotly_panes(page.view)
if "Adult Escort Stops" in str(plot.object.layout.title.text)
]
assert sorted(stop_titles) == [
@@ -2665,6 +3145,69 @@ def counted_directional_render():
"Adult Escort Stops Before Dropoff - Outbound",
"Adult Escort Stops Before Pickup - Inbound",
]
+ household_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Households With School Escorting - Outbound"
+ )
+ assert list(household_plot.object.layout.xaxis.categoryarray) == ["1", "2", "6+"]
+ schoolkids_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Schoolkids Per Adult Chauffeur Tour - Outbound"
+ )
+ assert list(schoolkids_plot.object.layout.xaxis.categoryarray) == ["1", "2", "6+"]
+ assert list(schoolkids_plot.object.data[0].y) == [1.5, 2.0, 4.0]
+ stop_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Adult Escort Stops Before Dropoff - Outbound"
+ )
+ assert list(stop_plot.object.layout.xaxis.categoryarray) == ["0", "1", "3+"]
+ assert list(stop_plot.object.data[0].y) == pytest.approx(
+ [0.0, 28.57142857142857, 71.42857142857143]
+ )
+ distance_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Chauffeur Tour Distance Distribution - Both Directions"
+ )
+ assert page.escort_distance_range.current_range() == (0.0, 40.0)
+ assert list(distance_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ assert list(distance_plot.object.layout.xaxis.ticktext) == [
+ *[str(value) for value in range(0, 40, 2)],
+ "40+",
+ ]
+ page.escort_distance_range.min_widget.value = 10.0
+ page.escort_distance_range.max_widget.value = "20"
+ page.refresh(force=True)
+ ranged_distance_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Chauffeur Tour Distance Distribution - Both Directions"
+ )
+ assert list(ranged_distance_plot.object.layout.xaxis.range) == [10.0, 20.0]
+ page.escort_distance_range.reset()
+ page.refresh(force=True)
+ reset_distance_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page.view)
+ if str(plot.object.layout.title.text)
+ == "Chauffeur Tour Distance Distribution - Both Directions"
+ )
+ assert list(reset_distance_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ page.escort_distance_range.min_widget.value = 20.0
+ page.escort_distance_range.max_widget.value = "10"
+ page.refresh(force=True)
+ assert any(
+ card.title == "Chauffeur Distance Data Not Available"
+ for card in _collect_cards(page.view)
+ )
def test_escorted_tours_page_renders_core_charts_when_optional_summaries_missing(
@@ -2720,14 +3263,13 @@ def test_escorted_tours_page_renders_core_charts_when_optional_summaries_missing
page = EscortedToursPage(state, config)
page.refresh(force=True)
- assert page._body.objects
+ assert page.view.objects
titles = [
- str(plot.object.layout.title.text)
- for plot in _collect_plotly_panes(page._body)
+ str(plot.object.layout.title.text) for plot in _collect_plotly_panes(page.view)
]
- assert "Chauffer Escorting Tours by Person Type - Both Directions" in titles
- assert "Chauffer Escorting Tour Distance Distribution - Both Directions" in titles
- assert "Chauffer Escorting Trip Distance Distribution - Both Directions" in titles
+ assert "Chauffeur Tours by Person Type - Both Directions" in titles
+ assert "Chauffeur Tour Distance Distribution - Both Directions" in titles
+ assert "Chauffeur Trip Distance Distribution - Both Directions" in titles
assert "Adult Escort Stops Before Dropoff - Outbound" in titles
assert "Adult Escort Trip Stop Frequency - Both Directions" not in titles
assert all("Schoolkids Per Escorted Tour" not in title for title in titles)
@@ -2739,12 +3281,13 @@ def test_escorted_tours_page_uses_configured_escort_labels_for_student_status(
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " escort:",
- " mapping:",
- " not_escorted: Unescorted",
- " pure_escort: Driven Solo",
- " ride_share: Shared Ride",
+ "display:",
+ " labels:",
+ " escort:",
+ " mapping:",
+ " not_escorted: Unescorted",
+ " pure_escort: Driven Solo",
+ " ride_share: Shared Ride",
],
)
escorted_summary_run = _summary_run_with_tables(
@@ -2802,12 +3345,13 @@ def test_escorted_tours_page_uses_configured_escort_labels_for_student_status(
)
page = EscortedToursPage(state, config)
page.refresh(force=True)
- plots = _collect_plotly_panes(page._body)
+ plots = _collect_plotly_panes(page.view)
student_plot = next(
plot
for plot in plots
- if str(plot.object.layout.title.text) == "Student School Escort Status - Outbound"
+ if str(plot.object.layout.title.text)
+ == "Student School Escort Status - Outbound"
)
assert list(student_plot.object.layout.xaxis.categoryarray) == [
"Unescorted",
@@ -2914,8 +3458,8 @@ def test_overview_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None:
),
"household_size_distribution": pl.DataFrame(
{
- "household_size": [1, 2],
- "household_count": [15.0, 25.0],
+ "household_size": [1, 5, 6],
+ "household_count": [15.0, 10.0, 15.0],
}
),
"auto_vmt_totals": pl.DataFrame({"auto_vmt": [180.0]}),
@@ -2929,7 +3473,14 @@ def test_overview_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None:
page = OverviewPage(state, config)
page.refresh(force=True)
- assert len(page._body.objects) == 8
+ assert page.view.objects
+ household_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._demographics_section)
+ if str(plot.object.layout.title.text) == "Household Size Distribution"
+ )
+ assert list(household_plot.object.data[0].x) == ["1", "5+"]
+ assert list(household_plot.object.data[0].y) == [37.5, 62.5]
def test_overview_page_skips_bad_run_for_one_visualization_but_keeps_rendering(
@@ -2999,11 +3550,11 @@ def test_overview_page_skips_bad_run_for_one_visualization_but_keeps_rendering(
page = OverviewPage(state, config)
page.refresh(force=True)
- assert any(isinstance(obj, pn.Row) for obj in page._body.objects)
+ assert _collect_plotly_panes(page.view)
person_type_diag = next(
diagnostic
for diagnostic in page.visualization_diagnostics
- if diagnostic.visualization_id == "overview_person_type_distribution"
+ if diagnostic.visualization_id == "person_type_distribution"
)
assert person_type_diag.render_state == "partial"
assert person_type_diag.usable_run_labels == ("Base",)
@@ -3019,10 +3570,10 @@ def test_individual_choices_page_renders_partial_content_when_some_summaries_mis
label="Base",
weighted={
"license_holding_status_distribution": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["license_holding_status_distribution"].builder
+ SUMMARY_BY_ID["license_holding_status_distribution"].builder
),
"bicycle_comfort_level_distribution": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["bicycle_comfort_level_distribution"].builder
+ SUMMARY_BY_ID["bicycle_comfort_level_distribution"].builder
),
"transit_pass_ownership_by_person_type": pl.DataFrame(
{
@@ -3086,13 +3637,13 @@ def test_tour_summaries_tour_mode_page_renders_main_chart_without_vehicle_summar
}
),
"allocated_vehicle_age_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_age_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_age_by_occupancy"].builder
),
"allocated_vehicle_fuel_type_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_fuel_type_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_fuel_type_by_occupancy"].builder
),
"allocated_vehicle_body_type_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_body_type_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_body_type_by_occupancy"].builder
),
},
)
@@ -3105,23 +3656,18 @@ def test_tour_summaries_tour_mode_page_renders_main_chart_without_vehicle_summar
page.refresh(force=True)
assert list(page.purpose_sel.options) == ["All Tour Purposes", "work"]
- assert len(page._mode_section.objects) == 6
+ assert len(page._mode_section.objects) == 5
chart_titles = [
- obj.object.layout.title.text
- for obj in page._mode_section.objects
- if isinstance(obj, pn.pane.Plotly)
+ plot.object.layout.title.text
+ for plot in _collect_plotly_panes(page._mode_section)
]
assert chart_titles == [
"Tour Mode - All",
"Tour Mode - Zero Auto",
- "Tour Mode - Auto Deficient",
- "Tour Mode - Auto Sufficient",
- ]
- vehicle_cards = [
- obj
- for obj in page._vehicle_section.objects[-1].objects
- if isinstance(obj, pn.Card)
+ "Tour Mode - Fewer Vehicles Than Drivers",
+ "Tour Mode - At Least As Many Vehicles as Drivers",
]
+ vehicle_cards = _collect_cards(page._vehicle_section)
assert len(vehicle_cards) == 3
@@ -3131,11 +3677,14 @@ def test_tour_summaries_tour_mode_page_uses_configured_mode_labels_on_plot_axes(
config = _write_config(
tmp_path,
extra_lines=[
- "categories:",
- " mode:",
- " mapping:",
- " DRIVE: Drive Alone",
- " WALK: Walk",
+ "display:",
+ " labels:",
+ " mode:",
+ " mapping:",
+ " DRIVE: Drive Alone",
+ " HOV2: Shared Ride 2",
+ " HOV3: Shared Ride 3+",
+ " WALK: Walk",
],
)
summary_run = _summary_run_with_tables(
@@ -3143,22 +3692,27 @@ def test_tour_summaries_tour_mode_page_uses_configured_mode_labels_on_plot_axes(
weighted={
"tour_mode_by_tour_purpose_and_auto_sufficiency": pl.DataFrame(
{
- "tour_purpose": ["all_tour_purposes", "all_tour_purposes"],
- "tour_mode": ["DRIVE", "WALK"],
- "tour_count_all_households": [10.0, 5.0],
- "tour_count_zero_auto": [2.0, 4.0],
- "tour_count_auto_deficient": [3.0, 1.0],
- "tour_count_auto_sufficient": [5.0, 0.0],
+ "tour_purpose": [
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "all_tour_purposes",
+ "all_tour_purposes",
+ ],
+ "tour_mode": ["DRIVE", "WALK", "HOV2", "HOV3"],
+ "tour_count_all_households": [10.0, 5.0, 3.0, 2.0],
+ "tour_count_zero_auto": [2.0, 4.0, 1.0, 1.0],
+ "tour_count_auto_deficient": [3.0, 1.0, 1.0, 1.0],
+ "tour_count_auto_sufficient": [5.0, 0.0, 1.0, 0.0],
}
),
"allocated_vehicle_age_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_age_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_age_by_occupancy"].builder
),
"allocated_vehicle_fuel_type_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_fuel_type_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_fuel_type_by_occupancy"].builder
),
"allocated_vehicle_body_type_by_occupancy": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["allocated_vehicle_body_type_by_occupancy"].builder
+ SUMMARY_BY_ID["allocated_vehicle_body_type_by_occupancy"].builder
),
},
)
@@ -3170,14 +3724,27 @@ def test_tour_summaries_tour_mode_page_uses_configured_mode_labels_on_plot_axes(
page = TourSummariesTourModePage(state, config)
page.refresh(force=True)
- mode_chart = page.render_modes()[-1]
+ mode_chart = _collect_plotly_panes(page._mode_section)[0]
trace = mode_chart.object.data[0]
- assert list(trace.x) == ["Drive Alone", "Walk"]
+ assert page.hide_drive_alone.value is False
+ assert page.hide_drive_alone.name == "Hide Auto Modes"
+ assert list(trace.x) == ["Drive Alone", "Shared Ride 2", "Shared Ride 3+", "Walk"]
assert list(mode_chart.object.layout.xaxis.categoryarray) == [
"Drive Alone",
+ "Shared Ride 2",
+ "Shared Ride 3+",
"Walk",
]
+ page.hide_drive_alone.value = True
+ checked_chart = _collect_plotly_panes(
+ pn.Column(*page.render_modes_section())
+ )[0]
+ checked_trace = checked_chart.object.data[0]
+
+ assert list(checked_trace.x) == ["Walk"]
+ assert list(checked_chart.object.layout.xaxis.categoryarray) == ["Walk"]
+ assert list(checked_trace.y) == pytest.approx([25.0])
def test_tour_mode_auto_sufficiency_definitions_follow_configured_basis(
@@ -3191,8 +3758,10 @@ def test_tour_mode_auto_sufficiency_definitions_follow_configured_basis(
],
)
- markdown = _auto_sufficiency_definitions_markdown(config)
+ markdown = auto_sufficiency_definitions_markdown(config)
+ assert "**Fewer Vehicles Than Workers**" in markdown
+ assert "**At Least As Many Vehicles as Workers**" in markdown
assert "household has fewer vehicles than workers." in markdown
assert "household has at least as many vehicles as workers." in markdown
@@ -3205,15 +3774,14 @@ def test_mandatory_location_choice_uses_union_of_available_geographies(
label="Base",
weighted={
"internal_external_worker_by_geography": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["internal_external_worker_by_geography"].builder
+ SUMMARY_BY_ID["internal_external_worker_by_geography"].builder
),
- "commuting_flows": pl.DataFrame(
+ "work_location_distance_distribution_by_geography": pl.DataFrame(
{
- "origin_geography_type": ["all_geographies", "district", "maz"],
- "origin_geography_id": ["all_geographies", "A", "10"],
- "destination_geography_type": ["all_geographies", "district", "maz"],
- "destination_geography_id": ["all_geographies", "B", "30"],
- "commuter_count": [12.0, 5.0, 7.0],
+ "distance_bin": [1, 1, 1],
+ "geography_type": ["all_geographies", "district", "maz"],
+ "geography_id": ["all_geographies", "A", "10"],
+ "person_count": [12.0, 5.0, 7.0],
}
),
},
@@ -3226,9 +3794,10 @@ def test_mandatory_location_choice_uses_union_of_available_geographies(
page = MandatoryLocationChoicePage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "district"]
- commuting_widget = page._commuting_flows_section.objects[0]
- assert not isinstance(commuting_widget, pn.Card)
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "District",
+ ]
def test_mandatory_location_choice_can_show_maz_when_enabled(
@@ -3236,21 +3805,20 @@ def test_mandatory_location_choice_can_show_maz_when_enabled(
) -> None:
config = _write_config(
tmp_path,
- visualizer_lines=["enable_maz_geographies: true"],
+ dashboard_lines=["enable_maz_geographies: true"],
)
summary_run = _summary_run_with_tables(
label="Base",
weighted={
"internal_external_worker_by_geography": empty_summary_frame(
- SUMMARY_SPEC_BY_ID["internal_external_worker_by_geography"].builder
+ SUMMARY_BY_ID["internal_external_worker_by_geography"].builder
),
- "commuting_flows": pl.DataFrame(
+ "work_location_distance_distribution_by_geography": pl.DataFrame(
{
- "origin_geography_type": ["all_geographies", "maz", "maz"],
- "origin_geography_id": ["all_geographies", "10", "20"],
- "destination_geography_type": ["all_geographies", "maz", "maz"],
- "destination_geography_id": ["all_geographies", "30", "40"],
- "commuter_count": [12.0, 5.0, 7.0],
+ "distance_bin": [1, 1, 1],
+ "geography_type": ["all_geographies", "maz", "maz"],
+ "geography_id": ["all_geographies", "10", "20"],
+ "person_count": [12.0, 5.0, 7.0],
}
),
},
@@ -3263,11 +3831,14 @@ def test_mandatory_location_choice_can_show_maz_when_enabled(
page = MandatoryLocationChoicePage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "maz"]
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "MAZ",
+ ]
def test_tour_mode_vehicle_filters_sort_categories_stably() -> None:
- filtered = _filter_col(
+ filtered = vehicle_attribute_data(
[
(
"Base",
@@ -3280,8 +3851,8 @@ def test_tour_mode_vehicle_filters_sort_categories_stably() -> None:
),
)
],
- "occupancy",
"All",
+ category="fuel_type",
)
assert filtered[0][1]["fuel_type"].to_list() == [
@@ -3345,7 +3916,7 @@ def test_tour_mode_occupancy_selector_uses_common_values_across_vehicle_summarie
def test_internal_external_tours_geo_selector_uses_union_levels_across_tables(
tmp_path: Path,
) -> None:
- config = _write_config(tmp_path, visualizer_lines=["enable_maz_geographies: true"])
+ config = _write_config(tmp_path, dashboard_lines=["enable_maz_geographies: true"])
summary_run = _summary_run_with_tables(
label="Base",
weighted={
@@ -3374,7 +3945,32 @@ def test_internal_external_tours_geo_selector_uses_union_levels_across_tables(
page = InternalExternalToursPage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "district", "maz"]
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "District",
+ "MAZ",
+ ]
+ tables = _collect_tabulators(page._body)
+ assert len(tables) == 2
+ frequency_table = tables[0].value
+ location_table = tables[1].value
+ assert frequency_table.columns[:2].tolist() == ["Geography Type", "Geography Name"]
+ assert location_table.columns[:2].tolist() == ["Geography Type", "Geography Name"]
+ assert frequency_table["Geography Type"].tolist() == ["All Geographies"]
+ assert frequency_table["Geography Name"].tolist() == ["All Geographies"]
+ assert location_table["Geography Type"].tolist() == ["All Geographies"]
+ assert location_table["Geography Name"].tolist() == ["All Geographies"]
+
+ page.geo_level_sel.value = "District"
+ page.refresh(force=True)
+ tables = _collect_tabulators(page._body)
+ assert len(tables) == 1
+ frequency_table = tables[0].value
+ assert frequency_table.columns[:2].tolist() == ["Geography Type", "Geography Name"]
+ assert frequency_table["Geography Type"].tolist() == ["District"]
+ assert frequency_table["Geography Name"].tolist() == ["A"]
+ cards = _collect_cards(page._body)
+ assert any(card.title == "Data Not Available" for card in cards)
def test_internal_external_tours_geo_selector_hides_only_maz_when_disabled(
@@ -3409,7 +4005,7 @@ def test_internal_external_tours_geo_selector_hides_only_maz_when_disabled(
page = InternalExternalToursPage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "district"]
+ assert list(page.geo_level_sel.options) == ["All Geography Types", "District"]
def test_shadow_pricing_geo_selector_keeps_maz_available_when_disabled(
@@ -3469,13 +4065,17 @@ def test_shadow_pricing_geo_selector_keeps_maz_available_when_disabled(
page = ShadowPricingPage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "district", "maz"]
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "District",
+ "MAZ",
+ ]
def test_shadow_pricing_geo_selector_shows_detailed_levels_when_enabled(
tmp_path: Path,
) -> None:
- config = _write_config(tmp_path, visualizer_lines=["enable_maz_geographies: true"])
+ config = _write_config(tmp_path, dashboard_lines=["enable_maz_geographies: true"])
summary_run = _summary_run_with_tables(
label="Base",
weighted={
@@ -3529,7 +4129,11 @@ def test_shadow_pricing_geo_selector_shows_detailed_levels_when_enabled(
page = ShadowPricingPage(state, config)
page.refresh(force=True)
- assert list(page.geo_level_sel.options) == ["All Geographies", "district", "maz"]
+ assert list(page.geo_level_sel.options) == [
+ "All Geography Types",
+ "District",
+ "MAZ",
+ ]
def test_shadow_pricing_page_uses_residual_histograms_and_filters_school_student_type(
@@ -3664,19 +4268,19 @@ def test_shadow_pricing_page_uses_residual_histograms_and_filters_school_student
page = ShadowPricingPage(state, config)
page.refresh(force=True)
- page.geo_level_sel.value = "district"
+ page.geo_level_sel.value = "District"
page.refresh(force=True)
workplace_plot = next(
plot
- for plot in _collect_plotly_panes(page._workplace_section)
+ for plot in _collect_plotly_panes(page._workplace_plot_section)
if plot.object.layout.title.text == "Workplace Residual Distribution"
)
initial_x = [list(trace.x) for trace in workplace_plot.object.data]
workplace_plot = next(
plot
- for plot in _collect_plotly_panes(page._workplace_section)
+ for plot in _collect_plotly_panes(page._workplace_plot_section)
if plot.object.layout.title.text == "Workplace Residual Distribution"
)
district_x = [list(trace.x) for trace in workplace_plot.object.data]
@@ -3687,17 +4291,19 @@ def test_shadow_pricing_page_uses_residual_histograms_and_filters_school_student
workplace_plot = next(
plot
- for plot in _collect_plotly_panes(page._workplace_section)
+ for plot in _collect_plotly_panes(page._workplace_plot_section)
if plot.object.layout.title.text == "Workplace Residual Distribution"
)
- assert workplace_plot.object.layout.xaxis.title.text == "Residual (Modeled - Target)"
+ assert (
+ workplace_plot.object.layout.xaxis.title.text == "Residual (Modeled - Target)"
+ )
assert [list(trace.x) for trace in workplace_plot.object.data] == district_x
assert workplace_plot.object.layout.yaxis.title.text == "Percent of Geographies (%)"
page.student_type_sel.value = "University"
page.refresh(force=True)
- school_tables = _collect_tabulators(page._school_section)
+ school_tables = _collect_tabulators(page._school_table_section)
school_df = pl.from_pandas(school_tables[0].value)
assert set(school_df["student_type"].to_list()) == {"University"}
assert school_df["percent_error"].to_list()[0].endswith("%")
@@ -3743,8 +4349,22 @@ def test_shadow_pricing_school_all_student_type_uses_upstream_rollup_histogram(
),
"school_shadow_pricing_residual_histogram": pl.DataFrame(
{
- "geography_type": ["district", "district", "district", "district", "district", "district"],
- "student_type": ["School", "School", "University", "University", "All", "All"],
+ "geography_type": [
+ "district",
+ "district",
+ "district",
+ "district",
+ "district",
+ "district",
+ ],
+ "student_type": [
+ "School",
+ "School",
+ "University",
+ "University",
+ "All",
+ "All",
+ ],
"bin_start": [-2.0, 0.0, -2.0, 0.0, -2.0, 0.0],
"bin_end": [0.0, 2.0, 0.0, 2.0, 0.0, 2.0],
"geography_count": [3.0, 1.0, 2.0, 4.0, 5.0, 5.0],
@@ -3760,19 +4380,90 @@ def test_shadow_pricing_school_all_student_type_uses_upstream_rollup_histogram(
page = ShadowPricingPage(state, config)
page.refresh(force=True)
- page.geo_level_sel.value = "district"
+ page.geo_level_sel.value = "District"
page.student_type_sel.value = "All"
page.refresh(force=True)
school_plot = next(
plot
- for plot in _collect_plotly_panes(page._school_section)
+ for plot in _collect_plotly_panes(page._school_plot_section)
if plot.object.layout.title.text == "School Residual Distribution"
)
assert list(school_plot.object.data[0].x) == [-2.0, 0.0]
assert list(school_plot.object.data[0].y) == [5.0, 5.0]
+def test_shadow_pricing_tables_display_friendly_geography_columns(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ summary_run = _summary_run_with_tables(
+ label="Base",
+ weighted={
+ "workplace_shadow_pricing_residuals": pl.DataFrame(
+ {
+ "geography_type": ["all_geographies", "district"],
+ "geography_id": ["all_geographies", "north_zone"],
+ "target_count": [10.0, 4.0],
+ "modeled_count": [9.0, 5.0],
+ "residual_count": [-1.0, 1.0],
+ "absolute_residual_count": [1.0, 1.0],
+ "percent_error": [-10.0, 25.0],
+ }
+ ),
+ "workplace_shadow_pricing_residual_histogram": pl.DataFrame(
+ {
+ "geography_type": ["district"],
+ "bin_start": [-2.0],
+ "bin_end": [0.0],
+ "geography_count": [1.0],
+ }
+ ),
+ "school_shadow_pricing_residuals": pl.DataFrame(
+ {
+ "geography_type": ["district"],
+ "geography_id": ["school_zone"],
+ "student_type": ["School"],
+ "target_count": [8.0],
+ "modeled_count": [7.0],
+ "residual_count": [-1.0],
+ "absolute_residual_count": [1.0],
+ "percent_error": [-12.5],
+ }
+ ),
+ "school_shadow_pricing_residual_histogram": pl.DataFrame(
+ {
+ "geography_type": ["district"],
+ "student_type": ["School"],
+ "bin_start": [-2.0],
+ "bin_end": [0.0],
+ "geography_count": [1.0],
+ }
+ ),
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+ page = ShadowPricingPage(state, config)
+ page.refresh(force=True)
+
+ workplace = page.render_workplace_table(
+ summary_run.summaries_by_mode["weighted"]["workplace_shadow_pricing_residuals"]
+ )
+ school = page.render_school_table(
+ summary_run.summaries_by_mode["weighted"]["school_shadow_pricing_residuals"]
+ )
+
+ assert workplace.columns[:2] == ["Geography Type", "Geography Name"]
+ assert workplace["Geography Type"].to_list() == ["All Geographies", "District"]
+ assert workplace["Geography Name"].to_list() == ["All Geographies", "North Zone"]
+ assert school.columns[:2] == ["Geography Type", "Geography Name"]
+ assert school["Geography Type"].to_list() == ["District"]
+ assert school["Geography Name"].to_list() == ["School Zone"]
+
+
def test_shadow_pricing_all_geographies_shows_point_mass_cards_instead_of_plots(
tmp_path: Path,
) -> None:
@@ -3830,8 +4521,8 @@ def test_shadow_pricing_all_geographies_shows_point_mass_cards_instead_of_plots(
page = ShadowPricingPage(state, config)
page.refresh(force=True)
- workplace_cards = _collect_cards(page._workplace_section)
- school_cards = _collect_cards(page._school_section)
+ workplace_cards = _collect_cards(page._workplace_plot_section)
+ school_cards = _collect_cards(page._school_plot_section)
assert any(
getattr(card, "title", "") == "Workplace Residual Distribution Unavailable"
and "point mass" in str(card.objects[0].object)
@@ -3844,10 +4535,10 @@ def test_shadow_pricing_all_geographies_shows_point_mass_cards_instead_of_plots(
for card in school_cards
if getattr(card, "objects", None)
)
- assert _collect_plotly_panes(page._workplace_section) == []
- assert _collect_plotly_panes(page._school_section) == []
- assert _collect_tabulators(page._workplace_section) != []
- assert _collect_tabulators(page._school_section) != []
+ assert _collect_plotly_panes(page._workplace_plot_section) == []
+ assert _collect_plotly_panes(page._school_plot_section) == []
+ assert _collect_tabulators(page._workplace_table_section) != []
+ assert _collect_tabulators(page._school_table_section) != []
def test_park_and_ride_location_page_uses_residual_plot_and_table(
@@ -3886,7 +4577,7 @@ def test_park_and_ride_location_page_uses_residual_plot_and_table(
page = ParkAndRideLocationPage(state, config)
page.refresh(force=True)
- page.geo_level_sel.value = "district"
+ page.geo_level_sel.value = "District"
page.refresh(force=True)
plot = next(
@@ -3899,6 +4590,9 @@ def test_park_and_ride_location_page_uses_residual_plot_and_table(
tables = _collect_tabulators(page._table_section)
assert tables != []
table_df = pl.from_pandas(tables[0].value)
+ assert table_df.columns[:2] == ["Geography Type", "Geography Name"]
+ assert table_df["Geography Type"].to_list() == ["District", "District"]
+ assert table_df["Geography Name"].to_list() == ["North", "South"]
assert table_df["percent_error"].to_list()[0].endswith("%")
@@ -3948,7 +4642,7 @@ def test_park_and_ride_location_all_geographies_and_maz_table_behavior(
assert _collect_plotly_panes(page._plot_section) == []
assert _collect_tabulators(page._table_section) != []
- page.geo_level_sel.value = "maz"
+ page.geo_level_sel.value = "MAZ"
page.refresh(force=True)
cards = _collect_cards(page._table_section)
@@ -4005,6 +4699,7 @@ def test_mandatory_location_choice_external_workplace_aggregate_percent_uses_all
for plot in plots
if plot.object.layout.title.text == "External Worker Workplace Location"
)
+ assert list(external_plot.object.data[0].x) == ["All Geographies"]
assert list(external_plot.object.data[0].y) == [25.0]
@@ -4031,21 +4726,20 @@ def test_mandatory_location_choice_reorders_sections_and_shows_all_distance_plot
"all_worker_count": [4.0],
}
),
- "commuting_flows": pl.DataFrame(
- {
- "origin_geography_type": ["all_geographies"],
- "origin_geography_id": ["all_geographies"],
- "destination_geography_type": ["all_geographies"],
- "destination_geography_id": ["all_geographies"],
- "commuter_count": [4.0],
- }
- ),
"work_location_distance_distribution_by_geography": pl.DataFrame(
{
- "distance_bin": [1, 2],
- "geography_type": ["all_geographies", "all_geographies"],
- "geography_id": ["all_geographies", "all_geographies"],
- "person_count": [6.0, 4.0],
+ "distance_bin": [1, 40, 41],
+ "geography_type": [
+ "all_geographies",
+ "all_geographies",
+ "all_geographies",
+ ],
+ "geography_id": [
+ "all_geographies",
+ "all_geographies",
+ "all_geographies",
+ ],
+ "person_count": [6.0, 1.0, 3.0],
}
),
"school_location_distance_distribution_by_geography": pl.DataFrame(
@@ -4117,9 +4811,6 @@ def test_mandatory_location_choice_reorders_sections_and_shows_all_distance_plot
page._worker_section
)
assert page.view.objects.index(page._worker_section) < page.view.objects.index(
- page._commuting_flows_section
- )
- assert page.view.objects.index(page._commuting_flows_section) < page.view.objects.index(
page._mandatory_distance_table_section
)
@@ -4129,14 +4820,87 @@ def test_mandatory_location_choice_reorders_sections_and_shows_all_distance_plot
"School Location Distance Distribution",
"University Location Distance Distribution",
]
- comparison_table = _collect_tabulators(page._mandatory_distance_table_section)[0].value
- assert comparison_table.columns.tolist() == ["Mandatory Tour Purpose", "Base"]
+ worker_table = _collect_tabulators(page._worker_section)[0].value
+ assert worker_table.columns.tolist()[:2] == [
+ "Geography Type",
+ "Geography Name",
+ ]
+ assert worker_table["Geography Type"].tolist() == ["All Geographies"]
+ assert worker_table["Geography Name"].tolist() == ["All Geographies"]
+ assert page.mandatory_distance_range.min_widget.disabled is False
+ assert page.mandatory_distance_range.max_widget.disabled is False
+ assert page.mandatory_distance_range.current_range() == (0.0, 40.0)
+ assert list(distance_plots[0].object.data[0].x) == [1.0, 40.0]
+ assert list(distance_plots[0].object.layout.xaxis.ticktext) == [
+ "0",
+ "2",
+ "4",
+ "6",
+ "8",
+ "10",
+ "12",
+ "14",
+ "16",
+ "18",
+ "20",
+ "22",
+ "24",
+ "26",
+ "28",
+ "30",
+ "32",
+ "34",
+ "36",
+ "38",
+ "40+",
+ ]
+ assert list(distance_plots[0].object.layout.xaxis.range) == [0.0, 40.0]
+ assert list(distance_plots[0].object.data[0].y) == [60.0, 40.0]
+ page.mandatory_distance_range.min_widget.value = 2.0
+ page.mandatory_distance_range.max_widget.value = "10"
+ page.refresh(force=True)
+ ranged_plot = _collect_plotly_panes(page._distance_section)[0]
+ assert list(ranged_plot.object.layout.xaxis.range) == [2.0, 10.0]
+ page.mandatory_distance_range.reset()
+ page.refresh(force=True)
+ reset_plot = _collect_plotly_panes(page._distance_section)[0]
+ assert list(reset_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ page.mandatory_distance_range.min_widget.value = 10.0
+ page.mandatory_distance_range.max_widget.value = "2"
+ page.refresh(force=True)
+ assert any(
+ card.title == "Mandatory Location Distance Data Not Available"
+ for card in _collect_cards(page._distance_section)
+ )
+ comparison_table = _collect_tabulators(page._mandatory_distance_table_section)[
+ 0
+ ].value
+ comparison_tabs = _collect_tabs(page._mandatory_distance_table_section)[0]
+ assert list(comparison_tabs._names) == ["Base"]
+ assert comparison_table.columns.tolist() == [
+ "Mandatory Tour Purpose",
+ "Average Mandatory Tour Distance",
+ "Base Run Average Mandatory Tour Distance",
+ "Difference",
+ "% Difference",
+ ]
assert comparison_table["Mandatory Tour Purpose"].tolist() == [
"work",
"school",
"university",
]
- assert comparison_table["Base"].tolist() == ["0.00%", "0.00%", "0.00%"]
+ assert comparison_table["Average Mandatory Tour Distance"].tolist() == [
+ "8",
+ "4",
+ "10",
+ ]
+ assert comparison_table["Base Run Average Mandatory Tour Distance"].tolist() == [
+ "8",
+ "4",
+ "10",
+ ]
+ assert comparison_table["Difference"].tolist() == ["0", "0", "0"]
+ assert comparison_table["% Difference"].tolist() == ["0.00%", "0.00%", "0.00%"]
def test_mandatory_location_choice_supports_configured_geography_levels_for_distance_sections(
@@ -4162,15 +4926,6 @@ def test_mandatory_location_choice_supports_configured_geography_levels_for_dist
"all_worker_count": [4.0, 3.0],
}
),
- "commuting_flows": pl.DataFrame(
- {
- "origin_geography_type": ["all_geographies", "school_district"],
- "origin_geography_id": ["all_geographies", "North"],
- "destination_geography_type": ["all_geographies", "school_district"],
- "destination_geography_id": ["all_geographies", "North"],
- "commuter_count": [4.0, 3.0],
- }
- ),
"work_location_distance_distribution_by_geography": pl.DataFrame(
{
"distance_bin": [1, 1, 2, 2],
@@ -4244,11 +4999,15 @@ def test_mandatory_location_choice_supports_configured_geography_levels_for_dist
page = MandatoryLocationChoicePage(state, config)
page.refresh(force=True)
- assert "school_district" in list(page.geo_level_sel.options)
+ assert "School District" in list(page.geo_level_sel.options)
assert list(page.geography_sel.options) == ["All Geographies"]
- page.geo_level_sel.value = "school_district"
+ page.geo_level_sel.value = "School District"
page.refresh(force=True)
- assert list(page.geography_sel.options) == ["All", "North", "South"]
+ assert list(page.geography_sel.options) == [
+ "All School Districts",
+ "North",
+ "South",
+ ]
page.geography_sel.value = "North"
page.refresh(force=True)
@@ -4261,13 +5020,14 @@ def test_mandatory_location_choice_supports_configured_geography_levels_for_dist
for plot in distance_plots
if plot.object.layout.title.text == "Workplace Location Distance Distribution"
)
- assert list(work_distance_plot.object.data[0].x) == [1, 2]
+ assert list(work_distance_plot.object.data[0].x) == [1.0, 2.0]
assert list(work_distance_plot.object.data[0].y) == pytest.approx(
[66.66666666666666, 33.33333333333333]
)
worker_table = _collect_tabulators(page._worker_section)[0].value
- assert worker_table["geography"].tolist() == ["North"]
+ assert worker_table["Geography Type"].tolist() == ["School District"]
+ assert worker_table["Geography Name"].tolist() == ["North"]
worker_plots = _collect_plotly_panes(page._worker_section)
external_workplace_plot = next(
@@ -4277,14 +5037,12 @@ def test_mandatory_location_choice_supports_configured_geography_levels_for_dist
)
assert list(external_workplace_plot.object.data[0].x) == ["North"]
- commuting_table = _collect_tabulators(page._commuting_flows_section)[0].value
- assert commuting_table["origin_geography_id"].tolist() == ["North"]
-
remote_work_plots = _collect_plotly_panes(page._remote_work_section)
wfh_plot = next(
plot
for plot in remote_work_plots
- if plot.object.layout.title.text in {
+ if plot.object.layout.title.text
+ in {
"Work From Home Rate by Geography",
"Workers Working From Home by Geography",
}
@@ -4297,9 +5055,30 @@ def test_mandatory_location_choice_supports_configured_geography_levels_for_dist
)
assert list(telecommute_plot.object.data[0].x) == ["never", "often"]
- comparison_table = _collect_tabulators(page._mandatory_distance_table_section)[0].value
- assert comparison_table.columns.tolist() == ["Mandatory Tour Purpose", "Base"]
- assert comparison_table["Base"].tolist() == ["0.00%", "0.00%", "0.00%"]
+ comparison_table = _collect_tabulators(page._mandatory_distance_table_section)[
+ 0
+ ].value
+ comparison_tabs = _collect_tabs(page._mandatory_distance_table_section)[0]
+ assert list(comparison_tabs._names) == ["Base"]
+ assert comparison_table.columns.tolist() == [
+ "Mandatory Tour Purpose",
+ "Average Mandatory Tour Distance",
+ "Base Run Average Mandatory Tour Distance",
+ "Difference",
+ "% Difference",
+ ]
+ assert comparison_table["Average Mandatory Tour Distance"].tolist() == [
+ "8",
+ "4",
+ "10",
+ ]
+ assert comparison_table["Base Run Average Mandatory Tour Distance"].tolist() == [
+ "8",
+ "4",
+ "10",
+ ]
+ assert comparison_table["Difference"].tolist() == ["0", "0", "0"]
+ assert comparison_table["% Difference"].tolist() == ["0.00%", "0.00%", "0.00%"]
page.geography_sel.value = "South"
page.refresh(force=True)
@@ -4340,7 +5119,10 @@ def test_mandatory_location_choice_reuses_collected_data_on_selector_changes(
{
"origin_geography_type": ["all_geographies", "school_district"],
"origin_geography_id": ["all_geographies", "North"],
- "destination_geography_type": ["all_geographies", "school_district"],
+ "destination_geography_type": [
+ "all_geographies",
+ "school_district",
+ ],
"destination_geography_id": ["all_geographies", "North"],
"commuter_count": [4.0, 3.0],
}
@@ -4415,14 +5197,14 @@ def _counted_collect_data():
page.refresh(force=True)
assert call_count == 1
- page.geo_level_sel.value = "school_district"
+ page.geo_level_sel.value = "School District"
assert call_count == 1
page.geography_sel.value = "North"
assert call_count == 1
-def test_traffic_validation_shared_selectors_use_common_summary_options(
+def test_traffic_validation_removes_direction_period_selectors_and_count_card(
tmp_path: Path,
) -> None:
config = _write_config(tmp_path)
@@ -4457,8 +5239,243 @@ def test_traffic_validation_shared_selectors_use_common_summary_options(
page = TrafficValidationPage(state, config)
page.refresh(force=True)
- assert list(page.direction_sel.options) == ["All", "outbound"]
- assert list(page.count_period_sel.options) == ["All", "AM"]
+ assert [selector.selector_id for selector in page.registered_selectors] == [
+ "demo_period",
+ "demo_facility_type",
+ "demo_top_period",
+ "demo_top_n",
+ ]
+ assert page.demo_period_sel.name == "Period"
+ assert page.demo_top_period_sel.name == "Period"
+ assert not hasattr(page, "direction_sel")
+ assert not hasattr(page, "count_period_sel")
+ assert page.view.objects[2].object == "### Traffic Volume Summaries"
+ assert list(page.view.objects[3].objects) == [
+ page.demo_period_sel,
+ page.demo_facility_sel,
+ ]
+ sections = {section.section_id: section for section in page.registered_sections}
+ assert sections["facility_summaries.body"].selector_ids == ()
+ assert sections["observed_model_fit.body"].selector_ids == (
+ "demo_period",
+ "demo_facility_type",
+ )
+ assert sections["link_tables.volume"].selector_ids == ("demo_period",)
+ assert page.view.objects[-2].object == "### Screenline Flow Summaries"
+ plot_titles = [
+ plot.object.layout.title.text
+ for plot in _collect_plotly_panes(page._screenline_body)
+ ]
+ assert plot_titles == ["Screenline Flow Comparisons"]
+
+
+def test_traffic_validation_external_volume_table_compares_observed_and_modeled(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(
+ tmp_path,
+ extra_lines=[
+ "display:",
+ " labels:",
+ " facility_type:",
+ " mapping:",
+ " 4: Minor Arterial",
+ " 3: Principal Arterial",
+ ],
+ )
+ summary_run = _summary_run_with_tables(
+ label="Base",
+ weighted={
+ "traffic_count_comparisons": pl.DataFrame(
+ {
+ "direction": ["outbound"],
+ "count_period": ["AM"],
+ "count_location_id": ["1"],
+ "observed_volume": [10.0],
+ "modeled_volume": [11.0],
+ }
+ ),
+ "screenline_flow_comparisons": pl.DataFrame(
+ {
+ "direction": ["outbound"],
+ "count_period": ["AM"],
+ "screenline_id": ["A"],
+ "observed_volume": [15.0],
+ "modeled_volume": [14.0],
+ }
+ ),
+ "link_validation_summary": pl.DataFrame(
+ {
+ "id": [1, 2],
+ "From_Node": [100, 101],
+ "To_Node": [200, 201],
+ "FACTYPE": [3, 4],
+ "am_vol": [10.0, 20.0],
+ "md_vol": [0.0, 0.0],
+ "pm_vol": [0.0, 0.0],
+ "day_vol": [100.0, 200.0],
+ }
+ ),
+ "count_location_counts_validation_summary": pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [3, 4],
+ "am_vol": [10.0, 20.0],
+ "md_vol": [0.0, 0.0],
+ "pm_vol": [0.0, 0.0],
+ "day_vol": [100.0, 200.0],
+ }
+ ),
+ "count_location_volumes_validation_summary": pl.DataFrame(
+ {
+ "id": [1, 2],
+ "FACTYPE": [3, 4],
+ "am_vol": [11.0, 21.0],
+ "md_vol": [0.0, 0.0],
+ "pm_vol": [0.0, 0.0],
+ "day_vol": [110.0, 210.0],
+ }
+ ),
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = TrafficValidationPage(state, config)
+ page.refresh(force=True)
+ assert list(page.demo_facility_sel.options) == [
+ "All",
+ "Minor Arterial",
+ "Principal Arterial",
+ ]
+ assert page.demo_top_n_sel.name == "Top N by Modeled Volume"
+ page.demo_period_sel.value = "AM"
+ page.demo_facility_sel.value = "Principal Arterial"
+ page.refresh(force=True)
+
+ tables = _collect_tabulators(page._external_top_body)
+ tabs = _collect_tabs(page._external_top_body)
+ assert len(tables) == 1
+ assert list(tabs[-1]._names) == ["Base"]
+ table = tables[0].value
+ assert table.columns.tolist() == [
+ "link_id",
+ "facility_type",
+ "From_Node",
+ "To_Node",
+ "Observed Link Volume",
+ "Modeled Link Volume",
+ "Difference",
+ "% Difference",
+ ]
+ assert tables[0].titles["link_id"] == "Link ID"
+ assert table.to_dict("records") == [
+ {
+ "link_id": "1",
+ "facility_type": "Principal Arterial",
+ "From_Node": "100",
+ "To_Node": "200",
+ "Observed Link Volume": "100",
+ "Modeled Link Volume": "110",
+ "Difference": "10",
+ "% Difference": "10.00%",
+ }
+ ]
+ assert tables[0]._configuration == {
+ "columns": [{"field": "Difference", "sorter": "number"}]
+ }
+ assert page.view.objects[2].object == "### Traffic Volume Summaries"
+ assert list(page.view.objects[3].objects) == [
+ page.demo_period_sel,
+ page.demo_facility_sel,
+ ]
+ facility_tables = _collect_tabulators(page._facility_summary_body)
+ assert len(facility_tables) == 1
+ facility_table = facility_tables[0].value
+ assert facility_table.columns.tolist() == [
+ "Facility Type",
+ "n",
+ "Total Observed Count",
+ "Total Modeled Count",
+ "% Difference",
+ "RMSE",
+ "R^2",
+ ]
+ assert facility_table.to_dict("records") == [
+ {
+ "Facility Type": "Minor Arterial",
+ "n": "1",
+ "Total Observed Count": "200",
+ "Total Modeled Count": "210",
+ "% Difference": "5.00%",
+ "RMSE": "10",
+ "R^2": None,
+ },
+ {
+ "Facility Type": "Principal Arterial",
+ "n": "1",
+ "Total Observed Count": "100",
+ "Total Modeled Count": "110",
+ "% Difference": "10.00%",
+ "RMSE": "10",
+ "R^2": None,
+ },
+ ]
+ assert facility_tables[0]._configuration == {
+ "columns": [
+ {"field": "n", "sorter": "number"},
+ {"field": "RMSE", "sorter": "number"},
+ {"field": "R^2", "sorter": "number"},
+ ]
+ }
+ assert any(
+ isinstance(obj, pn.pane.Markdown)
+ and obj.object == "### Top Count Locations by Modeled Volume"
+ for obj in page.view.objects
+ )
+ top_count_section = page._external_top_body
+ assert (
+ top_count_section.objects[0].object
+ == "#### Observed vs Modeled Volumes - Day (Top 25 by Modeled Volume)"
+ )
+ plot_titles = [
+ plot.object.layout.title.text
+ for plot in _collect_plotly_panes(page._external_volume_body)
+ + _collect_plotly_panes(page._link_volume_body)
+ + _collect_plotly_panes(page._screenline_body)
+ ]
+ bar_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._link_volume_body)
+ if plot.object.layout.title.text == "Link Volume by Facility Type - AM"
+ )
+ count_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._external_volume_body)
+ if plot.object.layout.title.text == "Count Location Observed vs Modeled - AM"
+ )
+ reference_line = count_plot.object.data[-1]
+
+ assert reference_line.name == "1:1 line"
+ assert list(reference_line.x) == [0.0, 11.0]
+ assert list(reference_line.y) == [0.0, 11.0]
+ assert reference_line.line.color == "#BDBDBD"
+ assert reference_line.line.dash == "dash"
+ assert reference_line.showlegend is False
+ assert count_plot.sizing_mode == "scale_width"
+ assert count_plot.aspect_ratio == 1.0
+ assert list(bar_plot.object.data[0].x) == [
+ "Minor Arterial",
+ "Principal Arterial",
+ ]
+ assert bar_plot.object.layout.showlegend is True
+ assert bar_plot.object.data[0].name == "Base"
+ assert plot_titles[-1] == "Screenline Flow Comparisons"
+ assert "Traffic Count Comparisons" not in plot_titles
+ assert "Demo Link Volume by Facility Type - Day" not in plot_titles
+ assert "Link Volume by Facility Type - AM" in plot_titles
def test_transit_validation_technology_selector_uses_common_summary_options(
@@ -4560,10 +5577,37 @@ def test_tour_distance_chart_casts_distance_bins_consistently_across_runs(
page = TourDistancePage(state, config)
page.refresh(force=True)
- plot = next(obj for obj in page._distance_section.objects if isinstance(obj, pn.pane.Plotly))
+ plot = next(
+ obj for obj in page._distance_section.objects if isinstance(obj, pn.pane.Plotly)
+ )
traces = {trace.name: list(trace.x) for trace in plot.object.data}
- assert traces["A"] == ["0", "1"]
- assert traces["B"] == ["0"]
+ assert traces["A"] == [0.0, 1.0]
+ assert traces["B"] == [0.0]
+ assert list(plot.object.layout.xaxis.ticktext) == [
+ *[str(value) for value in range(0, 40, 2)],
+ "40+",
+ ]
+ assert list(plot.object.layout.xaxis.range) == [0.0, 40.0]
+ page.tour_distance_range.min_widget.value = 0.25
+ page.tour_distance_range.max_widget.value = "1"
+ page.refresh(force=True)
+ ranged_plot = next(
+ obj for obj in page._distance_section.objects if isinstance(obj, pn.pane.Plotly)
+ )
+ assert list(ranged_plot.object.layout.xaxis.range) == [0.25, 1.0]
+ page.tour_distance_range.reset()
+ page.refresh(force=True)
+ reset_plot = next(
+ obj for obj in page._distance_section.objects if isinstance(obj, pn.pane.Plotly)
+ )
+ assert list(reset_plot.object.layout.xaxis.range) == [0.0, 40.0]
+ page.tour_distance_range.min_widget.value = 1.0
+ page.tour_distance_range.max_widget.value = "1"
+ page.refresh(force=True)
+ assert any(
+ card.title == "Tour Distance Data Not Available"
+ for card in _collect_cards(page._distance_section)
+ )
def test_tour_distance_nonmandatory_average_table_compares_to_base_run(
@@ -4634,25 +5678,49 @@ def test_tour_distance_nonmandatory_average_table_compares_to_base_run(
page = TourDistancePage(state, config)
page.refresh(force=True)
- page.geo_level_sel.value = "district"
+ page.geo_level_sel.value = "District"
page.refresh(force=True)
- assert list(page.geography_sel.options) == ["All", "North"]
+ assert list(page.geography_sel.options) == ["All Districts", "North"]
page.geography_sel.value = "North"
page.refresh(force=True)
tables = _collect_tabulators(page._average_section)
- comparison_table = tables[0].value
- assert comparison_table.columns.tolist() == [
+ comparison_tabs = _collect_tabs(page._average_section)[0]
+ assert list(comparison_tabs._names) == ["Base", "Build"]
+ assert len(tables) == 2
+ base_table = tables[0].value
+ build_table = tables[1].value
+ expected_columns = [
"Non-Mandatory Tour Purpose",
- "Base",
- "Build",
+ "Average Non-Mandatory Tour Distance",
+ "Base Run Average Non-Mandatory Tour Distance",
+ "Difference",
+ "% Difference",
+ ]
+ assert base_table.columns.tolist() == expected_columns
+ assert build_table.columns.tolist() == expected_columns
+ assert base_table["Non-Mandatory Tour Purpose"].tolist() == [
+ "shopping",
+ "eatout",
]
- assert comparison_table["Non-Mandatory Tour Purpose"].tolist() == [
+ assert build_table["Non-Mandatory Tour Purpose"].tolist() == [
"shopping",
"eatout",
]
- assert comparison_table["Base"].tolist() == ["0.00%", "0.00%"]
- assert comparison_table["Build"].tolist() == ["25.00%", "-25.00%"]
+ assert base_table["Average Non-Mandatory Tour Distance"].tolist() == ["4", "8"]
+ assert base_table["Base Run Average Non-Mandatory Tour Distance"].tolist() == [
+ "4",
+ "8",
+ ]
+ assert base_table["Difference"].tolist() == ["0", "0"]
+ assert base_table["% Difference"].tolist() == ["0.00%", "0.00%"]
+ assert build_table["Average Non-Mandatory Tour Distance"].tolist() == ["5", "6"]
+ assert build_table["Base Run Average Non-Mandatory Tour Distance"].tolist() == [
+ "4",
+ "8",
+ ]
+ assert build_table["Difference"].tolist() == ["1", "2"]
+ assert build_table["% Difference"].tolist() == ["25.00%", "-25.00%"]
def test_tour_distance_nonmandatory_average_table_filters_to_selected_geography(
@@ -4722,21 +5790,32 @@ def test_tour_distance_nonmandatory_average_table_filters_to_selected_geography(
page = TourDistancePage(state, config)
page.refresh(force=True)
- page.geo_level_sel.value = "district"
+ page.geo_level_sel.value = "District"
page.refresh(force=True)
- assert list(page.geography_sel.options) == ["All", "North", "South"]
+ assert list(page.geography_sel.options) == ["All Districts", "North", "South"]
page.geography_sel.value = "South"
page.refresh(force=True)
- comparison_table = _collect_tabulators(page._average_section)[0].value
- assert comparison_table["Non-Mandatory Tour Purpose"].tolist() == ["shopping"]
- assert comparison_table["Base"].tolist() == ["0.00%"]
- assert comparison_table["Build"].tolist() == ["50.00%"]
+ comparison_tabs = _collect_tabs(page._average_section)[0]
+ assert list(comparison_tabs._names) == ["Base", "Build"]
+ tables = _collect_tabulators(page._average_section)
+ base_table = tables[0].value
+ build_table = tables[1].value
+ assert base_table["Non-Mandatory Tour Purpose"].tolist() == ["shopping"]
+ assert build_table["Non-Mandatory Tour Purpose"].tolist() == ["shopping"]
+ assert base_table["Average Non-Mandatory Tour Distance"].tolist() == ["8"]
+ assert base_table["Base Run Average Non-Mandatory Tour Distance"].tolist() == ["8"]
+ assert base_table["Difference"].tolist() == ["0"]
+ assert base_table["% Difference"].tolist() == ["0.00%"]
+ assert build_table["Average Non-Mandatory Tour Distance"].tolist() == ["12"]
+ assert build_table["Base Run Average Non-Mandatory Tour Distance"].tolist() == ["8"]
+ assert build_table["Difference"].tolist() == ["4"]
+ assert build_table["% Difference"].tolist() == ["50.00%"]
def test_bar_chart_pins_category_order_from_input_sequence() -> None:
- chart = bar_chart(
+ chart = Plotter(RenderContext()).bar(
[
(
"Base",
@@ -4748,8 +5827,8 @@ def test_bar_chart_pins_category_order_from_input_sequence() -> None:
),
)
],
- x_col="fuel_type",
- y_col="vehicle_count",
+ x="fuel_type",
+ y="vehicle_count",
)
category_array = list(chart.object.layout.xaxis.categoryarray)
@@ -4768,11 +5847,12 @@ def test_tour_time_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None
"all_tour_purposes",
"work",
"work",
+ "work",
],
- "time_bin": [1, 2, 1, 2],
- "departure_tour_count": [5.0, 6.0, 3.0, 4.0],
- "arrival_tour_count": [4.0, 5.0, 2.0, 3.0],
- "duration_tour_count": [2.0, 3.0, 1.0, 2.0],
+ "time_bin": [1, 2, 1, 2, 48],
+ "departure_tour_count": [5.0, 6.0, 3.0, 4.0, 0.0],
+ "arrival_tour_count": [4.0, 5.0, 2.0, 3.0, 0.0],
+ "duration_tour_count": [2.0, 3.0, 1.0, 2.0, 0.0],
}
),
},
@@ -4789,6 +5869,13 @@ def test_tour_time_live_page_uses_shared_summary_helpers(tmp_path: Path) -> None
page.purpose_sel.value = "work"
page.refresh(force=True)
assert page._body.objects
+ departure_chart = _collect_plotly_panes(page._body)[0]
+ assert list(departure_chart.object.data[0].x)[:2] == ["03:00", "03:30"]
+ assert list(departure_chart.object.layout.xaxis.tickvals) == ["03:00"]
+ assert list(departure_chart.object.layout.xaxis.ticktext) == ["3:00"]
+ departure_hover = str(departure_chart.object.data[0].customdata[0])
+ assert "Clock Time: 03:00" in departure_hover
+ assert "start at 03:00" not in departure_hover
def test_vehicle_ownership_type_live_page_uses_shared_summary_helpers(
@@ -4800,8 +5887,9 @@ def test_vehicle_ownership_type_live_page_uses_shared_summary_helpers(
weighted={
"auto_ownership_distribution": pl.DataFrame(
{
- "household_vehicle_count": [0, 1],
- "household_count": [12.0, 18.0],
+ "household_size": ["1", "1", "5+", "5+"],
+ "household_vehicle_count": [0, 5, 4, 5],
+ "household_count": [12.0, 6.0, 8.0, 10.0],
}
),
"autonomous_vehicle_ownership_totals": pl.DataFrame(
@@ -4874,8 +5962,60 @@ def test_vehicle_ownership_type_live_page_uses_shared_summary_helpers(
summary_runs=[long_term_summary_run],
weighting_modes=config.weighting_modes,
)
+ state.value_mode = "Count"
page = VehicleOwnershipTypePage(state, config)
page.refresh(force=True)
- assert page._body.objects
+ assert page.view.objects
+ assert list(page.hhsize_sel.options) == ["All", "1", "2", "3", "4", "5+"]
+ auto_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._ownership_section)
+ if str(plot.object.layout.title.text)
+ == "Auto Ownership by Household Size - All"
+ )
+ assert list(auto_plot.object.data[0].x) == ["0", "4+"]
+ assert list(auto_plot.object.data[0].y) == [12.0, 24.0]
+
+ page.hhsize_sel.value = "5+"
+ page.refresh(force=True)
+ filtered_plot = next(
+ plot
+ for plot in _collect_plotly_panes(page._ownership_section)
+ if str(plot.object.layout.title.text) == "Auto Ownership by Household Size - 5+"
+ )
+ assert list(filtered_plot.object.data[0].x) == ["4+"]
+ assert list(filtered_plot.object.data[0].y) == [18.0]
+
+
+def test_vehicle_ownership_type_renders_cards_for_empty_attribute_summaries(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ summary_run = _summary_run_with_tables(
+ label="Base",
+ weighted={
+ "auto_ownership_distribution": pl.DataFrame(
+ {
+ "household_vehicle_count": [1],
+ "household_count": [12.0],
+ }
+ ),
+ },
+ )
+ state = DashboardState(
+ summary_runs=[summary_run],
+ weighting_modes=config.weighting_modes,
+ )
+
+ page = VehicleOwnershipTypePage(state, config)
+ page.refresh(force=True)
+
+ assert not _collect_plotly_panes(page._vehicle_mix_section)
+ cards = _collect_cards(page._vehicle_mix_section)
+ assert len(cards) == 3
+ card_text = "\n".join(str(card.objects[0].object) for card in cards if card.objects)
+ assert "vehicle_age_distribution" in card_text
+ assert "vehicle_fuel_type_distribution" in card_text
+ assert "vehicle_body_type_distribution" in card_text
diff --git a/tests/test_summary_csv_export.py b/tests/test_summary_csv_export.py
new file mode 100644
index 0000000..4240716
--- /dev/null
+++ b/tests/test_summary_csv_export.py
@@ -0,0 +1,36 @@
+from pathlib import Path
+import sys
+
+import polars as pl
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from processor.summarize.csv_export import write_summary_csvs
+
+
+def test_write_summary_csvs_round_trips_named_tables(tmp_path: Path) -> None:
+ table = pl.DataFrame(
+ {
+ "tour_purpose": ["work", "school"],
+ "tour_count": [12.5, 7.0],
+ }
+ )
+
+ write_summary_csvs({"tour_counts": table}, tmp_path)
+
+ assert pl.read_csv(tmp_path / "tour_counts.csv").equals(table)
+
+
+@pytest.mark.parametrize("name", ["", "nested/table", "table.csv"])
+def test_write_summary_csvs_rejects_non_stem_names(
+ tmp_path: Path,
+ name: str,
+) -> None:
+ with pytest.raises(ValueError, match="Summary CSV name"):
+ write_summary_csvs({name: pl.DataFrame({"value": [1]})}, tmp_path)
+
+
+def test_write_summary_csvs_requires_polars_tables(tmp_path: Path) -> None:
+ with pytest.raises(TypeError, match="Polars DataFrame"):
+ write_summary_csvs({"table": object()}, tmp_path) # type: ignore[dict-item]
diff --git a/tests/test_summary_declarations.py b/tests/test_summary_declarations.py
new file mode 100644
index 0000000..7fba1c0
--- /dev/null
+++ b/tests/test_summary_declarations.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+from types import ModuleType
+
+import polars as pl
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from processor.models import RunData
+from processor.summarize.catalog import build_summary_catalog
+from processor.summarize.contracts import SummaryResultError, summary
+
+
+def _run(**tables) -> RunData:
+ defaults = {
+ "hh": pl.DataFrame(),
+ "per": pl.DataFrame(),
+ "tours": pl.DataFrame(),
+ "trips": pl.DataFrame(),
+ "joint_participants": pl.DataFrame(),
+ "land_use": pl.DataFrame(),
+ }
+ defaults.update(tables)
+ return RunData(
+ label="Test",
+ run_dir="C:/runs/test",
+ skim_file=None,
+ skim_matrix=None,
+ skim_zone_map=None,
+ **defaults,
+ )
+
+
+def test_summary_declaration_supplies_identity_filename_and_typed_empty_preflight() -> (
+ None
+):
+ calls = []
+
+ @summary(
+ id="probe",
+ filename="probe_external_name",
+ schema={"value": pl.Float64},
+ required_columns={"trips": ("needed",)},
+ )
+ def probe(run: RunData, config) -> pl.DataFrame:
+ calls.append(run.label)
+ return pl.DataFrame({"value": [1.0]})
+
+ result = probe(_run(), None)
+
+ assert result.schema == {"value": pl.Float64}
+ assert result.is_empty()
+ assert calls == []
+ assert probe.summary_definition.summary_id == "probe"
+ assert probe.summary_definition.filename == "probe_external_name"
+
+
+@pytest.mark.parametrize(
+ ("result", "message"),
+ [
+ (pl.DataFrame({"extra": [1.0]}), "missing columns: value"),
+ (pl.DataFrame({"value": [1]}), "expected Float64, got Int64"),
+ (
+ pl.DataFrame({"other": [1.0], "value": [2.0]}),
+ "columns in the wrong order",
+ ),
+ ],
+)
+def test_summary_declaration_rejects_invalid_successful_results(
+ result, message
+) -> None:
+ schema = {"value": pl.Float64}
+ if "other" in result.columns:
+ schema = {"value": pl.Float64, "other": pl.Float64}
+
+ @summary(id="invalid_result", schema=schema)
+ def invalid_result(run: RunData, config) -> pl.DataFrame:
+ return result
+
+ with pytest.raises(SummaryResultError, match=message):
+ invalid_result(_run(), None)
+
+
+def test_explicit_catalog_rejects_duplicate_summary_ids() -> None:
+ module = ModuleType("tests.duplicate_summaries")
+
+ def first(run, config):
+ return pl.DataFrame({"value": [1.0]})
+
+ def second(run, config):
+ return pl.DataFrame({"value": [2.0]})
+
+ first.__module__ = module.__name__
+ second.__module__ = module.__name__
+ module.first = summary(id="duplicate", schema={"value": pl.Float64})(first)
+ module.second = summary(id="duplicate", schema={"value": pl.Float64})(second)
+
+ with pytest.raises(ValueError, match="Duplicate summary id 'duplicate'"):
+ build_summary_catalog((module,))
diff --git a/tests/test_summary_regressions.py b/tests/test_summary_regressions.py
new file mode 100644
index 0000000..9ff3f7b
--- /dev/null
+++ b/tests/test_summary_regressions.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import polars as pl
+
+from processor.models import RunData
+from processor.summarize.summaries.demographics import hh_size, person_type
+from processor.summarize.summaries.long_term_geography import free_parking
+
+
+def _run(
+ *,
+ hh: pl.DataFrame | None = None,
+ per: pl.DataFrame | None = None,
+) -> RunData:
+ return RunData(
+ label="Test",
+ run_dir="C:/runs/test",
+ skim_file=None,
+ hh=hh if hh is not None else pl.DataFrame(),
+ per=per if per is not None else pl.DataFrame(),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skim_zone_map=None,
+ )
+
+
+def _config():
+ return SimpleNamespace(
+ use_maz=False,
+ geography_aggregations=SimpleNamespace(aggregations=()),
+ person_type_label=lambda value: {"1": "Full-Time Worker"}.get(
+ str(value), str(value)
+ ),
+ )
+
+
+def test_household_size_summary_normalizes_integer_width_to_contract() -> None:
+ result = hh_size(
+ _run(
+ hh=pl.DataFrame(
+ {
+ "HHSIZE": pl.Series([1, 2, 2], dtype=pl.Int32),
+ "finalweight": [1.0, 2.0, 3.0],
+ }
+ )
+ )
+ )
+
+ assert result.schema == {
+ "household_size": pl.Int64,
+ "household_count": pl.Float64,
+ }
+ assert result.to_dicts() == [
+ {"household_size": 1, "household_count": 1.0},
+ {"household_size": 2, "household_count": 5.0},
+ ]
+
+
+def test_person_type_summary_returns_declared_column_order() -> None:
+ result = person_type(
+ _run(
+ per=pl.DataFrame(
+ {
+ "person_type": [1, 1],
+ "finalweight": [1.0, 2.0],
+ }
+ )
+ ),
+ _config(),
+ )
+
+ assert result.columns == ["person_type", "person_type_label", "person_count"]
+ assert result.to_dicts() == [
+ {
+ "person_type": "1",
+ "person_type_label": "Full-Time Worker",
+ "person_count": 3.0,
+ }
+ ]
+
+
+def test_free_parking_summary_accepts_boolean_model_output() -> None:
+ result = free_parking(
+ _run(
+ per=pl.DataFrame(
+ {
+ "is_worker": [True, True, True],
+ "free_parking_at_work": [False, True, False],
+ "workplace_zone_id": [10, 10, 20],
+ "finalweight": [1.0, 2.0, 3.0],
+ }
+ )
+ ),
+ _config(),
+ )
+
+ assert result.to_dicts() == [
+ {
+ "geography_type": "taz",
+ "geography_id": "10",
+ "workers_without_free_parking_count": 1.0,
+ "workers_with_free_parking_count": 2.0,
+ },
+ {
+ "geography_type": "taz",
+ "geography_id": "20",
+ "workers_without_free_parking_count": 3.0,
+ "workers_with_free_parking_count": 0.0,
+ },
+ ]
+
+
+def test_free_parking_summary_accepts_estimation_alternative_codes() -> None:
+ result = free_parking(
+ _run(
+ per=pl.DataFrame(
+ {
+ "is_worker": [True, True, True],
+ "free_parking_at_work": [0, 1, 2],
+ "workplace_zone_id": [10, 10, 10],
+ "finalweight": [1.0, 2.0, 3.0],
+ }
+ )
+ ),
+ _config(),
+ )
+
+ assert result.schema == {
+ "geography_type": pl.Utf8,
+ "geography_id": pl.Utf8,
+ "workers_without_free_parking_count": pl.Float64,
+ "workers_with_free_parking_count": pl.Float64,
+ }
+ assert result.to_dicts() == [
+ {
+ "geography_type": "taz",
+ "geography_id": "10",
+ "workers_without_free_parking_count": 3.0,
+ "workers_with_free_parking_count": 3.0,
+ }
+ ]
diff --git a/tests/test_validation_derived.py b/tests/test_validation_derived.py
new file mode 100644
index 0000000..ace89f0
--- /dev/null
+++ b/tests/test_validation_derived.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+
+import polars as pl
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from processor.summarize.cache import load_summary_run_cache, write_summary_run_cache
+from processor.summarize.cache_types import create_summary_run
+from processor.summarize.validation_derived import (
+ COUNT_LOCATION_FIT_ID,
+ COUNT_LOCATION_SCATTER_ID,
+ apply_validation_derived_summaries,
+ build_count_location_fit_validation_summary,
+ build_count_location_scatter_validation_summary,
+)
+from processor.summarize.external import load_summary_table_map, merge_summary_table_map_run
+from runtime.config import Config
+
+
+def _write_config(tmp_path: Path) -> Config:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ 'name: "Derived Validation Test"',
+ "runs: []",
+ "root: summary_cache",
+ "summarize:",
+ " weighting_modes: [weighted, unweighted]",
+ "dashboard:",
+ ' title: "Derived Validation Test"',
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return Config.from_yaml(config_path)
+
+
+def _counts() -> pl.DataFrame:
+ return pl.DataFrame(
+ {
+ "id": [1, 2, 3],
+ "FACTYPE": [3, 3, 4],
+ "am_vol": [1.0, 2.0, 3.0],
+ "md_vol": [2.0, 3.0, 4.0],
+ "pm_vol": [3.0, 4.0, 5.0],
+ "day_vol": [10.0, 20.0, 30.0],
+ }
+ )
+
+
+def _volumes() -> pl.DataFrame:
+ return pl.DataFrame(
+ {
+ "id": [1, 2, 3],
+ "FACTYPE": [3, 3, 4],
+ "am_vol": [7.0, 9.0, 11.0],
+ "md_vol": [9.0, 11.0, 13.0],
+ "pm_vol": [11.0, 13.0, 15.0],
+ "day_vol": [25.0, 45.0, 65.0],
+ }
+ )
+
+
+def test_count_location_scatter_validation_summary_and_fit_summaries() -> None:
+ scatter = build_count_location_scatter_validation_summary(_counts(), _volumes())
+
+ assert scatter.height == 12
+ assert set(scatter["period"].to_list()) == {"AM", "MD", "PM", "Day"}
+ assert (
+ scatter.filter((pl.col("period") == "Day") & (pl.col("facility_type") == "3"))
+ .sort("id")
+ .to_dicts()
+ == [
+ {
+ "id": 1,
+ "facility_type": "3",
+ "period": "Day",
+ "observed_volume": 10.0,
+ "modeled_volume": 25.0,
+ },
+ {
+ "id": 2,
+ "facility_type": "3",
+ "period": "Day",
+ "observed_volume": 20.0,
+ "modeled_volume": 45.0,
+ },
+ ]
+ )
+
+ fit = build_count_location_fit_validation_summary(scatter)
+ all_day = fit.filter(
+ (pl.col("period") == "Day") & (pl.col("facility_type") == "All")
+ ).row(0, named=True)
+ facility_day = fit.filter(
+ (pl.col("period") == "Day") & (pl.col("facility_type") == "3")
+ ).row(0, named=True)
+
+ assert round(all_day["slope"], 6) == 2.0
+ assert round(all_day["intercept"], 6) == 5.0
+ assert round(all_day["r_squared"], 6) == 1.0
+ assert all_day["n_locations"] == 3
+ assert round(facility_day["slope"], 6) == 2.0
+ assert round(facility_day["intercept"], 6) == 5.0
+
+
+def test_count_location_fit_validation_summary_handles_zero_observed_variance() -> None:
+ scatter = pl.DataFrame(
+ {
+ "id": [1, 2],
+ "facility_type": ["3", "3"],
+ "period": ["Day", "Day"],
+ "observed_volume": [10.0, 10.0],
+ "modeled_volume": [20.0, 30.0],
+ }
+ )
+
+ fit = build_count_location_fit_validation_summary(scatter)
+
+ assert fit.filter(pl.col("facility_type") == "3")["slope"].to_list() == [None]
+ assert fit.filter(pl.col("facility_type") == "3")["r_squared"].to_list() == [None]
+
+
+def test_apply_validation_derived_summaries_replaces_stale_and_skips_missing() -> None:
+ stale = pl.DataFrame(
+ {
+ "id": [999],
+ "facility_type": ["All"],
+ "period": ["Day"],
+ "observed_volume": [999.0],
+ "modeled_volume": [999.0],
+ }
+ )
+ summary_run = create_summary_run(
+ label="Run",
+ run_key="run",
+ summaries_by_mode={
+ "weighted": {
+ "count_location_counts_validation_summary": _counts(),
+ "count_location_volumes_validation_summary": _volumes(),
+ COUNT_LOCATION_SCATTER_ID: stale,
+ },
+ "unweighted": {
+ "count_location_counts_validation_summary": _counts(),
+ COUNT_LOCATION_SCATTER_ID: stale,
+ },
+ },
+ )
+
+ result = apply_validation_derived_summaries([summary_run])[0]
+
+ weighted = result.summaries_by_mode["weighted"]
+ assert weighted[COUNT_LOCATION_SCATTER_ID]["id"].max() == 3
+ assert COUNT_LOCATION_FIT_ID in weighted
+ assert COUNT_LOCATION_SCATTER_ID not in result.summaries_by_mode["unweighted"]
+ assert COUNT_LOCATION_FIT_ID not in result.summaries_by_mode["unweighted"]
+
+
+def test_summary_table_map_run_builds_and_caches_count_location_validation_derived_tables(
+ tmp_path: Path,
+) -> None:
+ config = _write_config(tmp_path)
+ counts_path = tmp_path / "counts.csv"
+ volumes_path = tmp_path / "volumes.csv"
+ _counts().write_csv(counts_path)
+ _volumes().write_csv(volumes_path)
+
+ validation_run = load_summary_table_map(
+ summary_table_map={
+ "count_location_counts_validation_summary": str(counts_path),
+ "count_location_volumes_validation_summary": str(volumes_path),
+ },
+ label="Mapped",
+ run_key="mapped",
+ config=config,
+ )
+ merged = merge_summary_table_map_run([], validation_run)
+
+ assert COUNT_LOCATION_SCATTER_ID in merged[0].summaries_by_mode["weighted"]
+ assert COUNT_LOCATION_FIT_ID in merged[0].summaries_by_mode["weighted"]
+
+ cache_dir = write_summary_run_cache(merged[0], config)
+ loaded = load_summary_run_cache(
+ cache_dir,
+ config,
+ expected_modes=config.weighting_modes,
+ expected_summary_ids=[
+ "count_location_counts_validation_summary",
+ "count_location_volumes_validation_summary",
+ COUNT_LOCATION_SCATTER_ID,
+ COUNT_LOCATION_FIT_ID,
+ ],
+ expected_summary_config_digest=config.summary_config_digest,
+ expected_label="Mapped",
+ expected_run_key="mapped",
+ )
+
+ assert loaded.summaries_by_mode["weighted"][COUNT_LOCATION_SCATTER_ID].height == 12
+ assert (
+ loaded.summary_metadata_by_mode["weighted"][COUNT_LOCATION_FIT_ID]["state"]
+ == "available"
+ )
diff --git a/tests/test_validation_vmt_summary.py b/tests/test_validation_vmt_summary.py
new file mode 100644
index 0000000..9cdafb3
--- /dev/null
+++ b/tests/test_validation_vmt_summary.py
@@ -0,0 +1,401 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+
+import polars as pl
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from processor.models import RunData
+from processor.summarize.summaries import validation
+
+
+def _run_data(
+ *,
+ trips: pl.DataFrame,
+ hh: pl.DataFrame | None = None,
+ skimjoin_manifest: dict[str, object] | None = None,
+) -> RunData:
+ return RunData(
+ label="Base",
+ run_dir="",
+ skim_file=None,
+ hh=hh if hh is not None else pl.DataFrame(),
+ per=pl.DataFrame(),
+ tours=pl.DataFrame(),
+ trips=trips,
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ skimjoin_manifest=skimjoin_manifest or {},
+ )
+
+
+def _all_geographies_rows(df: pl.DataFrame) -> pl.DataFrame:
+ return df.filter(pl.col("geography_type") == "all_geographies").sort(
+ ["income_segment", "household_size", "mode", "time_period"]
+ )
+
+
+def test_auto_vmt_segment_summary_prefers_skim_auto_distance() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "household_id": [1, 1, 1],
+ "trip_mode": ["SOV", "PNR_TRANSIT", "WALK"],
+ "skim_auto_distance": [10.0, 5.0, None],
+ "od_dist": [99.0, 99.0, 99.0],
+ "trip_period": ["AM", "AM", "AM"],
+ "num_participants": [1, 1, 1],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ ),
+ hh=pl.DataFrame(
+ {
+ "household_id": [1],
+ "income_segment": [3],
+ "HHSIZE": [2],
+ "home_taz": [101],
+ "home_county": ["County"],
+ "home_mpo": ["Metro"],
+ "DISTRICT9": ["Legacy District"],
+ }
+ ),
+ )
+
+ result = validation.auto_vmt_by_home_geography_income_hhsize_time_period(rd, None)
+
+ all_geo = _all_geographies_rows(result)
+ assert all_geo.to_dicts() == [
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "3",
+ "household_size": "2",
+ "time_period": "AM",
+ "mode": "PNR_TRANSIT",
+ "auto_vmt": 5.0,
+ "trip_count": 1.0,
+ "distance_source": "skim_auto_distance",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "3",
+ "household_size": "2",
+ "time_period": "Daily",
+ "mode": "PNR_TRANSIT",
+ "auto_vmt": 5.0,
+ "trip_count": 1.0,
+ "distance_source": "skim_auto_distance",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "3",
+ "household_size": "2",
+ "time_period": "AM",
+ "mode": "SOV",
+ "auto_vmt": 10.0,
+ "trip_count": 1.0,
+ "distance_source": "skim_auto_distance",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "3",
+ "household_size": "2",
+ "time_period": "Daily",
+ "mode": "SOV",
+ "auto_vmt": 10.0,
+ "trip_count": 1.0,
+ "distance_source": "skim_auto_distance",
+ "time_period_source": "trip_period",
+ }
+ ]
+ assert (
+ all_geo.group_by("time_period")
+ .agg(
+ pl.col("auto_vmt").sum().alias("auto_vmt"),
+ pl.col("trip_count").sum().alias("trip_count"),
+ )
+ .sort("time_period")
+ .to_dicts()
+ == [
+ {"time_period": "AM", "auto_vmt": 15.0, "trip_count": 2.0},
+ {"time_period": "Daily", "auto_vmt": 15.0, "trip_count": 2.0},
+ ]
+ )
+ assert set(result["geography_type"].to_list()) == {
+ "all_geographies",
+ "home_county",
+ "home_mpo",
+ "home_taz",
+ }
+ assert "DISTRICT9" not in set(result["geography_type"].to_list())
+
+
+def test_auto_vmt_segment_summary_falls_back_to_od_dist_auto_modes() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "household_id": [1, 1, 1],
+ "trip_mode": ["SOV", "HOV2", "WALK"],
+ "od_dist": [10.0, 6.0, 100.0],
+ "trip_period": ["MD", "MD", "MD"],
+ "num_participants": [1, 2, 1],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ ),
+ hh=pl.DataFrame(
+ {"household_id": [1], "income_segment": [2], "HHSIZE": [3]}
+ ),
+ )
+
+ result = validation.auto_vmt_by_home_geography_income_hhsize_time_period(rd, None)
+
+ assert _all_geographies_rows(result).to_dicts() == [
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "2",
+ "household_size": "3",
+ "time_period": "Daily",
+ "mode": "HOV2",
+ "auto_vmt": 3.0,
+ "trip_count": 1.0,
+ "distance_source": "od_dist",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "2",
+ "household_size": "3",
+ "time_period": "MD",
+ "mode": "HOV2",
+ "auto_vmt": 3.0,
+ "trip_count": 1.0,
+ "distance_source": "od_dist",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "2",
+ "household_size": "3",
+ "time_period": "Daily",
+ "mode": "SOV",
+ "auto_vmt": 10.0,
+ "trip_count": 1.0,
+ "distance_source": "od_dist",
+ "time_period_source": "trip_period",
+ },
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "2",
+ "household_size": "3",
+ "time_period": "MD",
+ "mode": "SOV",
+ "auto_vmt": 10.0,
+ "trip_count": 1.0,
+ "distance_source": "od_dist",
+ "time_period_source": "trip_period",
+ }
+ ]
+
+
+def test_auto_vmt_segment_summary_includes_taxi_and_tnc_auto_modes() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "trip_mode": ["TAXI", "TNC_SINGLE", "WALK"],
+ "od_dist": [4.0, 8.0, 100.0],
+ "trip_period": ["MD", "MD", "MD"],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ ),
+ )
+
+ result = _all_geographies_rows(
+ validation.auto_vmt_by_home_geography_income_hhsize_time_period(rd, None)
+ )
+
+ assert result.select("mode", "time_period", "auto_vmt").to_dicts() == [
+ {"mode": "TAXI", "time_period": "Daily", "auto_vmt": 4.0},
+ {"mode": "TAXI", "time_period": "MD", "auto_vmt": 4.0},
+ {"mode": "TNC_SINGLE", "time_period": "Daily", "auto_vmt": 8.0},
+ {"mode": "TNC_SINGLE", "time_period": "MD", "auto_vmt": 8.0},
+ ]
+
+
+def test_auto_vmt_segment_summary_ignores_skimjoin_period_mapping() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "household_id": [1, 1, 1],
+ "trip_mode": ["SOV", "SOV", "SOV"],
+ "od_dist": [10.0, 20.0, 5.0],
+ "depart_hour": [1, 7, 20],
+ "num_participants": [1, 1, 1],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ ),
+ hh=pl.DataFrame({"household_id": [1], "income_segment": [1], "HHSIZE": [1]}),
+ skimjoin_manifest={
+ "skimjoin_resolved_network_los_file": "ignored_network_los.yaml",
+ },
+ )
+
+ result = _all_geographies_rows(
+ validation.auto_vmt_by_home_geography_income_hhsize_time_period(rd, None)
+ )
+
+ assert result.select("time_period", "auto_vmt", "time_period_source").to_dicts() == [
+ {"time_period": "Daily", "auto_vmt": 35.0, "time_period_source": "daily"},
+ ]
+
+
+def test_auto_vmt_segment_summary_uses_daily_and_dimension_fallbacks() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "trip_mode": ["SOV"],
+ "od_dist": [7.0],
+ "finalweight": [2.0],
+ }
+ ),
+ )
+
+ result = validation.auto_vmt_by_home_geography_income_hhsize_time_period(rd, None)
+
+ assert result.to_dicts() == [
+ {
+ "geography_type": "all_geographies",
+ "geography_id": "all_geographies",
+ "income_segment": "all_income_segments",
+ "household_size": "all_household_sizes",
+ "time_period": "Daily",
+ "mode": "SOV",
+ "auto_vmt": 14.0,
+ "trip_count": 2.0,
+ "distance_source": "od_dist",
+ "time_period_source": "daily",
+ }
+ ]
+
+
+def test_non_motorized_vmt_summary_prefers_skimjoin_distances() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "household_id": [1, 1, 1, 1],
+ "trip_mode": ["WALK", "BIKE", "EBIKE", "SOV"],
+ "skim_walk_distance": [1.0, None, None, None],
+ "skim_bike_distance": [None, 2.0, None, None],
+ "prepared_non_motorized_distance": [9.0, 9.0, 3.0, 99.0],
+ "trip_period": ["AM", "AM", "PM", "AM"],
+ "finalweight": [2.0, 3.0, 4.0, 1.0],
+ }
+ ),
+ hh=pl.DataFrame(
+ {
+ "household_id": [1],
+ "income_segment": [2],
+ "HHSIZE": [1],
+ }
+ ),
+ )
+
+ result = validation.non_motorized_vmt_by_home_geography_income_hhsize_time_period(
+ rd,
+ None,
+ )
+ all_geo = _all_geographies_rows(result)
+
+ assert all_geo.select(
+ "mode",
+ "time_period",
+ "non_motorized_vmt",
+ "trip_count",
+ "distance_source",
+ ).to_dicts() == [
+ {
+ "mode": "BIKE",
+ "time_period": "AM",
+ "non_motorized_vmt": 6.0,
+ "trip_count": 3.0,
+ "distance_source": "skim_bike_distance",
+ },
+ {
+ "mode": "BIKE",
+ "time_period": "Daily",
+ "non_motorized_vmt": 6.0,
+ "trip_count": 3.0,
+ "distance_source": "skim_bike_distance",
+ },
+ {
+ "mode": "EBIKE",
+ "time_period": "Daily",
+ "non_motorized_vmt": 12.0,
+ "trip_count": 4.0,
+ "distance_source": "prepared_non_motorized_distance",
+ },
+ {
+ "mode": "EBIKE",
+ "time_period": "PM",
+ "non_motorized_vmt": 12.0,
+ "trip_count": 4.0,
+ "distance_source": "prepared_non_motorized_distance",
+ },
+ {
+ "mode": "WALK",
+ "time_period": "AM",
+ "non_motorized_vmt": 2.0,
+ "trip_count": 2.0,
+ "distance_source": "skim_walk_distance",
+ },
+ {
+ "mode": "WALK",
+ "time_period": "Daily",
+ "non_motorized_vmt": 2.0,
+ "trip_count": 2.0,
+ "distance_source": "skim_walk_distance",
+ },
+ ]
+
+
+def test_non_motorized_vmt_summary_returns_empty_without_distance() -> None:
+ rd = _run_data(
+ trips=pl.DataFrame(
+ {
+ "trip_mode": ["WALK", "BIKE", "SOV"],
+ "trip_period": ["AM", "AM", "AM"],
+ "finalweight": [1.0, 1.0, 1.0],
+ }
+ )
+ )
+
+ result = validation.non_motorized_vmt_by_home_geography_income_hhsize_time_period(
+ rd,
+ None,
+ )
+
+ assert result.is_empty()
+ assert result.columns == [
+ "geography_type",
+ "geography_id",
+ "income_segment",
+ "household_size",
+ "time_period",
+ "mode",
+ "non_motorized_vmt",
+ "trip_count",
+ "distance_source",
+ "time_period_source",
+ ]
diff --git a/tests/test_weighting_registry.py b/tests/test_weighting_registry.py
new file mode 100644
index 0000000..04e09cc
--- /dev/null
+++ b/tests/test_weighting_registry.py
@@ -0,0 +1,488 @@
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import types
+
+import polars as pl
+import pytest
+
+from dashboard import DashboardState
+from dashboard.data_access import DashboardPreparedRunProvider
+from processor.models import RunData, map_run_data_tables
+from processor.summarize.builder import build_mode_summaries
+from processor.summarize.cache import (
+ load_summary_run_cache,
+ write_summary_run_cache,
+)
+from processor.summarize.cache_types import create_summary_run
+from processor.summarize.external import load_summary_table_map
+from runtime.config import Config
+from runtime.weighting import (
+ WeightingModeDefinition,
+ WeightingModeRegistry,
+ load_weighting_mode_extensions,
+)
+
+
+def _run() -> RunData:
+ return RunData(
+ label="Base",
+ run_dir="C:/runs/base",
+ skim_file=None,
+ hh=pl.DataFrame({"finalweight": [2.0]}),
+ per=pl.DataFrame({"finalweight": [3.0]}),
+ tours=pl.DataFrame({"finalweight": [4.0]}),
+ trips=pl.DataFrame({"finalweight": [5.0], "stops": [1]}),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ )
+
+
+def _column_weight_run() -> RunData:
+ return RunData(
+ label="Column weights",
+ run_dir="C:/runs/columns",
+ skim_file=None,
+ hh=pl.DataFrame(
+ {
+ "household_id": [1, 2],
+ "finalweight": [2.0, 2.0],
+ "calibrated_hh": [10.0, 20.0],
+ }
+ ),
+ per=pl.DataFrame(
+ {
+ "person_id": [11, 12, 21],
+ "household_id": [1, 1, 2],
+ "finalweight": [2.0, 2.0, 2.0],
+ "calibrated_person": [1.5, 2.5, 3.5],
+ }
+ ),
+ day=pl.DataFrame(
+ {
+ "person_id": [11, 21],
+ "finalweight": [2.0, 2.0],
+ }
+ ),
+ tours=pl.DataFrame(
+ {
+ "tour_id": [101, 102],
+ "person_id": [11, 21],
+ "finalweight": [2.0, 2.0],
+ }
+ ),
+ trips=pl.DataFrame(
+ {
+ "trip_id": [1001, 1002, 1003],
+ "tour_id": [101, 101, 102],
+ "person_id": [11, 11, 21],
+ "finalweight": [2.0, 2.0, 2.0],
+ "calibrated_trip": [4.0, 6.0, 8.0],
+ "stops": [0, 1, 0],
+ }
+ ),
+ vehicles=pl.DataFrame(
+ {
+ "vehicle_id": [1, 2],
+ "household_id": [1, 2],
+ "finalweight": [2.0, 2.0],
+ }
+ ),
+ trip_hypothetical_skims=pl.DataFrame(
+ {
+ "trip_id": [1001, 1003],
+ "finalweight": [2.0, 2.0],
+ }
+ ),
+ tour_hypothetical_skims=pl.DataFrame(
+ {
+ "tour_id": [101, 102],
+ "finalweight": [2.0, 2.0],
+ }
+ ),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ )
+
+
+def _column_mode_config(tmp_path: Path, *, trip_column: str = "calibrated_trip") -> Config:
+ config_path = tmp_path / f"column-mode-{trip_column}.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ "weighting:",
+ " modes:",
+ " calibrated:",
+ " label: Calibrated Weights",
+ " columns:",
+ " households: calibrated_hh",
+ " persons: calibrated_person",
+ f" trips: {trip_column}",
+ "summarize:",
+ " weighting_modes: [weighted, unweighted, calibrated]",
+ "dashboard:",
+ " export:",
+ " dashboard:",
+ " weighting: all",
+ "runs: []",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return Config.from_yaml(config_path)
+
+
+def _install_tripled_module(monkeypatch: pytest.MonkeyPatch) -> str:
+ module_name = "test_project_weighting_extension"
+ module = types.ModuleType(module_name)
+
+ def tripled(run: RunData, config: Config | None) -> RunData:
+ assert config is not None
+ factor = float(config.extension_settings["tripled"]["factor"])
+ return map_run_data_tables(
+ run,
+ lambda _table_name, frame: (
+ frame.with_columns(
+ (pl.col("finalweight") * factor).alias("finalweight")
+ )
+ if "finalweight" in frame.columns
+ else frame
+ ),
+ )
+
+ def register_weighting_modes(registry: WeightingModeRegistry) -> None:
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="tripled_test",
+ label="Tripled Test",
+ transform=tripled,
+ version="2026.1",
+ required_columns={
+ "hh": ("finalweight",),
+ "per": ("finalweight",),
+ "tours": ("finalweight",),
+ "trips": ("finalweight",),
+ },
+ external_summary_policy="reject",
+ )
+ )
+
+ module.register_weighting_modes = register_weighting_modes
+ monkeypatch.setitem(sys.modules, module_name, module)
+ return module_name
+
+
+def _plugin_config(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> Config:
+ module_name = _install_tripled_module(monkeypatch)
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ "extensions:",
+ " modules:",
+ f" - {module_name}",
+ " settings:",
+ " tripled:",
+ " factor: 3",
+ "summarize:",
+ " weighting_modes: [weighted, tripled_test]",
+ "dashboard:",
+ " export:",
+ " dashboard:",
+ " weighting: all",
+ "runs: []",
+ ]
+ ),
+ encoding="utf-8",
+ )
+ return Config.from_yaml(config_path)
+
+
+def test_registry_validates_ids_labels_and_mode_order() -> None:
+ registry = WeightingModeRegistry()
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="first",
+ label="First",
+ transform=lambda run, config: run,
+ version="1",
+ default_enabled=True,
+ )
+ )
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="second",
+ label="Second",
+ transform=lambda run, config: run,
+ version="2",
+ )
+ )
+
+ assert registry.normalize(None) == ["first"]
+ assert registry.normalize(["second", "first", "second"]) == [
+ "second",
+ "first",
+ ]
+ with pytest.raises(ValueError, match="Duplicate weighting mode id"):
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="first",
+ label="Another",
+ transform=lambda run, config: run,
+ version="1",
+ )
+ )
+ with pytest.raises(ValueError, match="already used"):
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="another",
+ label="SECOND",
+ transform=lambda run, config: run,
+ version="1",
+ )
+ )
+
+
+def test_entry_point_registration_uses_the_same_registry_contract(monkeypatch) -> None:
+ registry = WeightingModeRegistry()
+
+ class FakeEntryPoint:
+ name = "sample"
+ value = "sample.plugin:register"
+
+ @staticmethod
+ def load():
+ def register(target: WeightingModeRegistry) -> None:
+ target.register(
+ WeightingModeDefinition(
+ mode_id="sample",
+ label="Sample",
+ transform=lambda run, config: run,
+ version="1",
+ )
+ )
+
+ return register
+
+ class FakeEntryPoints(list):
+ def select(self, *, group: str):
+ assert group == "activitysim_visualizer.weighting_modes"
+ return self
+
+ monkeypatch.setattr(
+ "runtime.weighting.metadata.entry_points",
+ lambda: FakeEntryPoints([FakeEntryPoint()]),
+ )
+
+ load_weighting_mode_extensions((), registry=registry)
+
+ assert registry.get("sample").label == "Sample"
+
+
+def test_custom_mode_flows_through_config_summary_dashboard_and_export(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config = _plugin_config(tmp_path, monkeypatch)
+
+ assert config.weighting_modes == ["weighted", "tripled_test"]
+ assert config.weighting_mode_label("tripled_test") == "Tripled Test"
+ assert config.export_html.panel_weighting_values() == [
+ "Weighted",
+ "Tripled Test",
+ ]
+ assert config.summary_signature_payload()["weighting_modes"][1]["version"] == (
+ "2026.1"
+ )
+ assert config.summary_signature_payload()["extension_settings"] == {
+ "tripled": {"factor": 3}
+ }
+
+ summaries = build_mode_summaries(
+ _run(),
+ config,
+ summary_ids=["population_totals"],
+ )
+ assert summaries["weighted"]["population_totals"]["person_count"][0] == 3.0
+ assert summaries["tripled_test"]["population_totals"]["person_count"][0] == 9.0
+
+ cache_dir = write_summary_run_cache(
+ create_summary_run(
+ label="Base",
+ run_key="base",
+ summaries_by_mode=summaries,
+ ),
+ config,
+ output_root=tmp_path / "summary_cache",
+ )
+ loaded = load_summary_run_cache(
+ cache_dir,
+ config,
+ expected_modes=config.weighting_modes,
+ expected_summary_ids=["population_totals"],
+ expected_summary_config_digest=config.summary_config_digest,
+ )
+ assert list(loaded.summaries_by_mode) == ["weighted", "tripled_test"]
+
+ state = DashboardState(
+ weighting_modes=config.weighting_modes,
+ weighting_definitions=config.weighting_mode_definitions,
+ config=config,
+ prepared_run_provider=DashboardPreparedRunProvider.loaded([("Base", _run())]),
+ )
+ assert list(state.param.weight_mode.objects) == ["Weighted", "Tripled Test"]
+ state.weight_mode = "Tripled Test"
+ prepared = state.get_prepared_runs_if_loaded()
+ prepared_again = state.get_prepared_runs_if_loaded()
+ assert state.weighting_key() == "tripled_test"
+ assert prepared is not None
+ assert prepared_again is not None
+ assert prepared[0][1].trips["finalweight"][0] == 15.0
+ assert prepared_again[0][1] is prepared[0][1]
+
+
+def test_custom_mode_must_explicitly_accept_mode_independent_outside_summaries(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config = _plugin_config(tmp_path, monkeypatch)
+ summary_path = tmp_path / "population_totals.csv"
+ pl.DataFrame(
+ {
+ "person_count": [1.0],
+ "household_count": [1.0],
+ "tour_count": [1.0],
+ "trip_count": [1.0],
+ "stop_count": [0.0],
+ }
+ ).write_csv(summary_path)
+
+ with pytest.raises(ValueError, match="tripled_test"):
+ load_summary_table_map(
+ summary_table_map={"population_totals": str(summary_path)},
+ label="Base",
+ run_key="base",
+ config=config,
+ )
+
+
+def test_registered_mode_reports_missing_required_columns() -> None:
+ definition = WeightingModeDefinition(
+ mode_id="needs_calibration",
+ label="Needs Calibration",
+ transform=lambda run, config: run,
+ version="1",
+ required_columns={"hh": ("calibrated_weight",)},
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="requires columns on 'hh': calibrated_weight",
+ ):
+ definition.apply(_run(), None)
+
+
+def test_declarative_column_mode_propagates_to_dependent_tables_and_sidecars(
+ tmp_path: Path,
+) -> None:
+ config = _column_mode_config(tmp_path)
+
+ assert config.weighting_modes == ["weighted", "unweighted", "calibrated"]
+ assert config.weighting_mode_label("calibrated") == "Calibrated Weights"
+ assert config.export_html.panel_weighting_values() == [
+ "Weighted",
+ "Unweighted",
+ "Calibrated Weights",
+ ]
+
+ weighted = config.weighting_mode_definition("calibrated").apply(
+ _column_weight_run(),
+ config,
+ )
+
+ assert weighted.hh["finalweight"].to_list() == [10.0, 20.0]
+ assert weighted.per["finalweight"].to_list() == [1.5, 2.5, 3.5]
+ assert weighted.trips["finalweight"].to_list() == [4.0, 6.0, 8.0]
+ assert weighted.tours["finalweight"].to_list() == [5.0, 8.0]
+ assert weighted.day["finalweight"].to_list() == [1.5, 3.5]
+ assert weighted.vehicles["finalweight"].to_list() == [10.0, 20.0]
+ assert weighted.trip_hypothetical_skims["finalweight"].to_list() == [4.0, 8.0]
+ assert weighted.tour_hypothetical_skims["finalweight"].to_list() == [5.0, 8.0]
+ assert weighted.hh["calibrated_hh"].to_list() == [10.0, 20.0]
+
+ summaries = build_mode_summaries(
+ _column_weight_run(),
+ config,
+ summary_ids=["population_totals"],
+ )
+ assert summaries["weighted"]["population_totals"]["person_count"][0] == 6.0
+ assert summaries["calibrated"]["population_totals"]["person_count"][0] == 7.5
+
+ cache_dir = write_summary_run_cache(
+ create_summary_run(
+ label="Column weights",
+ run_key="column-weights",
+ summaries_by_mode=summaries,
+ ),
+ config,
+ output_root=tmp_path / "declarative_summary_cache",
+ )
+ loaded = load_summary_run_cache(
+ cache_dir,
+ config,
+ expected_modes=config.weighting_modes,
+ expected_summary_ids=["population_totals"],
+ expected_summary_config_digest=config.summary_config_digest,
+ )
+ assert list(loaded.summaries_by_mode) == [
+ "weighted",
+ "unweighted",
+ "calibrated",
+ ]
+
+
+def test_declarative_column_mode_validates_columns_and_enters_cache_identity(
+ tmp_path: Path,
+) -> None:
+ config = _column_mode_config(tmp_path)
+ definition = config.weighting_mode_definition("calibrated")
+
+ run = _column_weight_run()
+ run.trips = run.trips.drop("calibrated_trip")
+ with pytest.raises(
+ ValueError,
+ match="requires columns on 'trips': calibrated_trip",
+ ):
+ definition.apply(run, config)
+
+ changed = _column_mode_config(tmp_path, trip_column="recalibrated_trip")
+ assert (
+ config.summary_signature_payload()["weighting_modes"]
+ != changed.summary_signature_payload()["weighting_modes"]
+ )
+
+
+def test_declarative_column_mode_rejects_unknown_table_name(tmp_path: Path) -> None:
+ config_path = tmp_path / "invalid-column-mode.yaml"
+ config_path.write_text(
+ "\n".join(
+ [
+ "weighting:",
+ " modes:",
+ " calibrated:",
+ " columns:",
+ " tours: calibrated_tour",
+ "runs: []",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="Supported names: households, persons, trips"):
+ Config.from_yaml(config_path)
diff --git a/uv.lock b/uv.lock
index de5c1ee..10442dc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -17,10 +17,9 @@ resolution-markers = [
[[package]]
name = "activitysim-visualizer"
version = "0.1.0"
-source = { virtual = "." }
+source = { editable = "." }
dependencies = [
{ name = "h5py" },
- { name = "jupyter" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "openmatrix" },
@@ -32,14 +31,20 @@ dependencies = [
{ name = "pyarrow" },
{ name = "pydantic" },
{ name = "pyyaml" },
- { name = "shiny" },
- { name = "shinywidgets" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "ruff" },
+]
+notebooks = [
+ { name = "jupyter" },
]
[package.metadata]
requires-dist = [
{ name = "h5py" },
- { name = "jupyter" },
{ name = "numpy" },
{ name = "openmatrix" },
{ name = "pandas" },
@@ -49,10 +54,15 @@ requires-dist = [
{ name = "pyarrow" },
{ name = "pydantic" },
{ name = "pyyaml" },
- { name = "shiny" },
- { name = "shinywidgets" },
]
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest" },
+ { name = "ruff" },
+]
+notebooks = [{ name = "jupyter" }]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -76,20 +86,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
-[[package]]
-name = "anywidget"
-version = "0.9.21"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "ipywidgets" },
- { name = "psygnal" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" },
-]
-
[[package]]
name = "appnope"
version = "0.1.4"
@@ -160,18 +156,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
]
-[[package]]
-name = "asgiref"
-version = "3.11.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
-]
-
[[package]]
name = "asttokens"
version = "3.0.1"
@@ -491,18 +475,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
-[[package]]
-name = "click"
-version = "8.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
-]
-
[[package]]
name = "colorama"
version = "0.4.6"
@@ -838,19 +810,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" },
]
-[[package]]
-name = "htmltools"
-version = "0.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "packaging" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/cd/1d/c568d17e9fb5ad5aa0ca3531c58d36fe69deb8eee4e53ff6425c1f99f210/htmltools-0.6.0.tar.gz", hash = "sha256:e8a3fb023d748935035db7ff17f620612ffc814a6a80b6ae388f7b7ab182adf7", size = 97152, upload-time = "2024-10-29T20:21:43.378Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0a/ba/aa99706246f1938ca905eb6eeb7db832ac2e157aa4b805acb5cd4cd1791a/htmltools-0.6.0-py3-none-any.whl", hash = "sha256:072a274ff5e2851e0acce13fc5bb2bbdbbad8268dc8b123f881c05012ce7dce0", size = 84954, upload-time = "2024-10-29T20:21:42.067Z" },
-]
-
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -889,15 +848,12 @@ wheels = [
]
[[package]]
-name = "importlib-metadata"
-version = "8.7.1"
+name = "iniconfig"
+version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "zipp" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
@@ -1969,100 +1925,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e8/6a8227e97127a2a1db98f5a22ca565bfe942e89de0644837627920101213/OpenMatrix-0.3.5.0-py3-none-any.whl", hash = "sha256:eeeb1a628b5eeefb572361537df5ad6e4d12c3cda1233023c4c367815cab6b5f", size = 13003, upload-time = "2019-12-21T17:52:57.777Z" },
]
-[[package]]
-name = "opentelemetry-api"
-version = "1.40.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "importlib-metadata" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" },
-]
-
-[[package]]
-name = "orjson"
-version = "3.11.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" },
- { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" },
- { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" },
- { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" },
- { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" },
- { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" },
- { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" },
- { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" },
- { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" },
- { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" },
- { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" },
- { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" },
- { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" },
- { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" },
- { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" },
- { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" },
- { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" },
- { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" },
- { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" },
- { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" },
- { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" },
- { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" },
- { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" },
- { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" },
- { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" },
- { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" },
- { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" },
- { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" },
- { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" },
- { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" },
- { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" },
- { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" },
- { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" },
- { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" },
- { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" },
- { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" },
- { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" },
- { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" },
- { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" },
- { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" },
- { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" },
- { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" },
- { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" },
- { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" },
- { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" },
- { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" },
- { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" },
- { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" },
- { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" },
- { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" },
- { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" },
- { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" },
- { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" },
- { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" },
- { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" },
- { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" },
- { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" },
- { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" },
- { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
- { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
- { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
- { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
- { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
- { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
- { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
- { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
- { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
- { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
- { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
- { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
- { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
- { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
- { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
-]
-
[[package]]
name = "overrides"
version = "7.7.0"
@@ -2401,6 +2263,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "polars"
version = "1.38.1"
@@ -2478,40 +2349,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
-[[package]]
-name = "psygnal"
-version = "0.15.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/44/ab13cb6147d010258826a43e574ad94599af0de29df13795fff9efee656c/psygnal-0.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ee55e3997f796fd84d4fdbd829bb1b19d323e087c43d072744604a3016c8851", size = 587322, upload-time = "2026-01-04T16:38:04.827Z" },
- { url = "https://files.pythonhosted.org/packages/f2/a2/68c042a607ca613e9450dfee99cc5c2a4d10d95392fb1de2ba932dd0a605/psygnal-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:912bcf110bfe7b4aa121d24987b6a58afb967ff090a049dad136eaf3cbcc7bea", size = 576207, upload-time = "2026-01-04T16:38:06.183Z" },
- { url = "https://files.pythonhosted.org/packages/4b/86/123c7b169ad32994a0cd801cd1f11c1a2be84555807e9c8a8a4682c67a9f/psygnal-0.15.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e860c11fe075fd80c93a24081c577ef7ec5c9da41f0e75990aa4cccf3f79cf", size = 864261, upload-time = "2026-01-04T16:38:07.895Z" },
- { url = "https://files.pythonhosted.org/packages/20/f1/886cec7bec2f27fe453cfa32bfcaac08a83aab2a04895af68f93e1c493b8/psygnal-0.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8bebcf99699ef50b6ef572868a490f6d191dc4466e5bd9818ca27e17cd581", size = 872582, upload-time = "2026-01-04T16:38:09.745Z" },
- { url = "https://files.pythonhosted.org/packages/21/a3/da972a05568ee8a9dc6c6567bee2c0cc5af8c681baebcb9fdbbf3cceb4f7/psygnal-0.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:06e0a90490e1205620d97ac52fbbe3282a22b126a26d02b3e1196bb46de16c7a", size = 411043, upload-time = "2026-01-04T16:38:11.588Z" },
- { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" },
- { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" },
- { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" },
- { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" },
- { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" },
- { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" },
- { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" },
- { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" },
- { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" },
- { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" },
- { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" },
- { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" },
- { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" },
- { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" },
- { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" },
- { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" },
- { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" },
- { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" },
- { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" },
- { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" },
- { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" },
-]
-
[[package]]
name = "ptyprocess"
version = "0.7.0"
@@ -2745,6 +2582,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -2766,15 +2621,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" },
]
-[[package]]
-name = "python-multipart"
-version = "0.0.22"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
-]
-
[[package]]
name = "pytz"
version = "2026.1.post1"
@@ -2955,18 +2801,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" },
]
-[[package]]
-name = "questionary"
-version = "2.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "prompt-toolkit", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
-]
-
[[package]]
name = "referencing"
version = "0.37.0"
@@ -3151,6 +2985,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
]
+[[package]]
+name = "ruff"
+version = "0.15.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" },
+ { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" },
+ { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" },
+]
+
[[package]]
name = "send2trash"
version = "2.1.0"
@@ -3169,67 +3028,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
-[[package]]
-name = "shiny"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asgiref" },
- { name = "click", marker = "sys_platform != 'emscripten'" },
- { name = "htmltools" },
- { name = "linkify-it-py" },
- { name = "markdown-it-py" },
- { name = "mdit-py-plugins" },
- { name = "narwhals" },
- { name = "opentelemetry-api" },
- { name = "orjson" },
- { name = "packaging" },
- { name = "platformdirs" },
- { name = "prompt-toolkit", marker = "sys_platform != 'emscripten'" },
- { name = "python-multipart", marker = "sys_platform != 'emscripten'" },
- { name = "questionary", marker = "sys_platform != 'emscripten'" },
- { name = "setuptools", marker = "python_full_version >= '3.12'" },
- { name = "shinychat" },
- { name = "starlette" },
- { name = "typing-extensions" },
- { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
- { name = "watchfiles", marker = "sys_platform != 'emscripten'" },
- { name = "websockets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ee/f2/d75ad19f6fa0cb244ff04990c61aa28d88300c69d6220dec3ffb8f3e29eb/shiny-1.6.0.tar.gz", hash = "sha256:54bec20a9cfde0dd3a36a9873863be7dbb4729a33fe7abc9ad9488df6efd6f58", size = 5079489, upload-time = "2026-03-20T20:42:58.2Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/30/d442d84e873786cd31b4bdf69b5981119953ebcbdeaf1988c98bf5a9703b/shiny-1.6.0-py3-none-any.whl", hash = "sha256:8e8ae840f565ecf4fb856a843fdc2d73c39e8ffe78141ccc29d802ecfce3657b", size = 3908742, upload-time = "2026-03-20T20:42:56.535Z" },
-]
-
-[[package]]
-name = "shinychat"
-version = "0.2.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "htmltools" },
- { name = "shiny" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/47/34/4aae4d21ae2603b40a99ce12d34848f271c076cbb112aac528242093751d/shinychat-0.2.9.tar.gz", hash = "sha256:59d48522dc0d04d0a89bbf33d3e1a9b5b21378405563d0692f1abff151f2d1aa", size = 547957, upload-time = "2026-02-09T15:58:09.117Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8d/27/f1115e431e7156b268c5792b15a3abbe12ed5b45dd9f96f041da93ae9335/shinychat-0.2.9-py3-none-any.whl", hash = "sha256:20e2101b22dfa2eeb5318e95b622e62ddcf5f2dd8cebe870c36673dd8eb4d901", size = 561839, upload-time = "2026-02-09T15:58:07.279Z" },
-]
-
-[[package]]
-name = "shinywidgets"
-version = "0.7.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anywidget" },
- { name = "ipywidgets" },
- { name = "jupyter-core" },
- { name = "python-dateutil" },
- { name = "shiny" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e4/28/2d1832f7bf1a47845b740f8a5b46bb55fea534f73f70772511a9aa7cd809/shinywidgets-0.7.1.tar.gz", hash = "sha256:3e5ed56f6cdbb53db15a29c19635e0416bd37480f19924781137791bdbdff9b2", size = 1807406, upload-time = "2026-01-22T01:19:16.196Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/f6/504399a006d50a85606d63e9aed033aae1ec8007037b1bda1bbd115997d3/shinywidgets-0.7.1-py3-none-any.whl", hash = "sha256:343e583dfa001cd674111e2e8a35b33bd3d9186996514ab01cbb4343dad65f89", size = 1816339, upload-time = "2026-01-22T01:19:14.838Z" },
-]
-
[[package]]
name = "six"
version = "1.17.0"
@@ -3262,19 +3060,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
]
-[[package]]
-name = "starlette"
-version = "1.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
-]
-
[[package]]
name = "tables"
version = "3.10.1"
@@ -3526,123 +3311,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
-[[package]]
-name = "uvicorn"
-version = "0.42.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
- { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
-]
-
-[[package]]
-name = "watchfiles"
-version = "1.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" },
- { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" },
- { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" },
- { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" },
- { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" },
- { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" },
- { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" },
- { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" },
- { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" },
- { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" },
- { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" },
- { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
- { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
- { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
- { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
- { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
- { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
- { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
- { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
- { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
- { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
- { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
- { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
- { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
- { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
- { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
- { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
- { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
- { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
- { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
- { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
- { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
- { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
- { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
- { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
- { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
- { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
- { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
- { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
- { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
- { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
- { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
- { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
- { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
- { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
- { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
- { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
- { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
- { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
- { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
- { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
- { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
- { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
- { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
- { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
- { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
- { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
- { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
- { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
- { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
- { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
- { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
- { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
- { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
- { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
- { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
- { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
- { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
- { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
- { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
- { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
- { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
- { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
- { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
- { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
- { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
- { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
- { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
- { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
- { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
- { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" },
- { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" },
- { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" },
- { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" },
- { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
- { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
- { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
- { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
-]
-
[[package]]
name = "wcwidth"
version = "0.6.0"
@@ -3679,74 +3347,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
]
-[[package]]
-name = "websockets"
-version = "16.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
- { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
- { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
- { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
- { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
- { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
- { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
- { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
- { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
- { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
- { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
- { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
- { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
- { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
- { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
- { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
- { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
- { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
- { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
- { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
- { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
- { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
- { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
- { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
- { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
- { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
- { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
- { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
- { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
- { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
- { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
- { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
- { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
- { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
- { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
- { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
- { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
- { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
- { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
- { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
- { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
- { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
- { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
- { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
- { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
- { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
- { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
- { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
- { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
- { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
- { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
- { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
- { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
- { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
- { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
- { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
- { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
- { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
- { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
-]
-
[[package]]
name = "widgetsnbextension"
version = "4.0.15"
@@ -3764,12 +3364,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" },
]
-
-[[package]]
-name = "zipp"
-version = "3.23.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
-]
diff --git a/wiki/00-home.md b/wiki/00-home.md
new file mode 100644
index 0000000..ddf390c
--- /dev/null
+++ b/wiki/00-home.md
@@ -0,0 +1,105 @@
+# ActivitySim Visualizer Wiki
+
+This wiki is the main documentation home for the ActivitySim Visualizer. It is
+written for two audiences:
+
+- users who need to run the visualizer on ActivitySim outputs
+- developers who need to extend the processor, summaries, skimjoin, or dashboard
+
+The short mental model:
+
+```text
+ActivitySim outputs
+ -> Output Processor
+ -> prepared tables and summary caches
+ -> Output Visualizer
+ -> live dashboard or standalone HTML export
+```
+
+For the subsystem boundaries and complete repository map, see
+[01 - Architecture](01-architecture.md).
+
+## I Am Using The Visualizer
+
+You only need three short chapters for normal use:
+
+1. [Get a dashboard running](10-getting-started.md).
+2. [Choose raw, prepared, or summary inputs](11-configuring-your-data.md).
+3. [Configure a live, export, or processor workflow](12-running-workflows.md).
+
+Use [Troubleshooting](90-troubleshooting.md) when something is missing. The
+[Configuration Reference](13-configuration-reference.md) is there when you need
+an exact field or default; it is not required reading.
+
+## I Am Extending The Visualizer
+
+| If you want to... | Read |
+|---|---|
+| Find every main config field and option | [13 - Configuration Reference](13-configuration-reference.md) |
+| Understand the Output Processor | [20 - Output Processor](20-output-processor.md) |
+| Add a prepared column | [41 - Data Extension Cookbook](41-data-extension-cookbook.md#worked-example-add-a-column-to-an-existing-prepared-table) |
+| Add or debug skimjoin outputs | [22 - Skimjoin](22-skimjoin.md) |
+| Find every skimjoin config field and lookup option | [25 - Skimjoin Config Reference](25-skimjoin-config-reference.md) |
+| Add a summary function | [44 - Summary Function Cookbook](44-summary-function-cookbook.md) |
+| Find every registered summary table | [24 - Summary Catalog](24-summary-catalog.md) |
+| Understand the Output Visualizer | [30 - Output Visualizer](30-output-visualizer.md) |
+| Add a dashboard page or page group | [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md) |
+| Add a figure, table, selector, or widget | [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md) |
+| Add an outside summary, prepared column, or prepared table | [41 - Data Extension Cookbook](41-data-extension-cookbook.md) |
+| Add a config item, column alias, or label mapping | [42 - Config, Columns, and Labels](42-config-column-label-cookbook.md) |
+| Add a weighting mode or hosting adapter | [43 - Weighting and Hosting Extensions](43-weighting-hosting-extensions.md) |
+| Debug an empty page, bad cache, or export mismatch | [90 - Troubleshooting](90-troubleshooting.md) |
+
+## Chapters
+
+### User Guides
+
+- [01 - Architecture](01-architecture.md)
+- [10 - Getting Started](10-getting-started.md)
+- [11 - Configuring Your Data](11-configuring-your-data.md)
+- [12 - Running Workflows](12-running-workflows.md)
+- [13 - Configuration Reference](13-configuration-reference.md)
+
+### Output Processor
+
+- [20 - Output Processor](20-output-processor.md)
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [22 - Skimjoin](22-skimjoin.md)
+- [23 - Summary Functions](23-summary-functions.md)
+- [24 - Summary Catalog](24-summary-catalog.md)
+- [25 - Skimjoin Config Reference](25-skimjoin-config-reference.md)
+
+### Output Visualizer
+
+- [30 - Output Visualizer](30-output-visualizer.md)
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [32 - Figures and Widgets](32-figures-and-widgets.md)
+- [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md)
+- [34 - HTML Export](34-html-export.md)
+
+### Developer Reference
+
+- [40 - Developer Workflows](40-developer-workflows.md)
+- [41 - Data Extension Cookbook](41-data-extension-cookbook.md)
+- [42 - Config, Columns, and Labels](42-config-column-label-cookbook.md)
+- [43 - Weighting and Hosting Extensions](43-weighting-hosting-extensions.md)
+- [44 - Summary Function Cookbook](44-summary-function-cookbook.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
+- [46 - Testing](46-testing.md)
+- [90 - Troubleshooting](90-troubleshooting.md)
+- [99 - Glossary](99-glossary.md)
+
+## Generated Pages
+
+Some wiki sections are generated from code to keep reference material from
+drifting:
+
+- [24 - Summary Catalog](24-summary-catalog.md)
+- the generated page catalog in [31 - Dashboard Pages](31-dashboard-pages.md)
+
+Regenerate them after changing summary declarations/contracts, dashboard page
+definitions, or page data requirements:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+```
diff --git a/docs/architecture.md b/wiki/01-architecture.md
similarity index 50%
rename from docs/architecture.md
rename to wiki/01-architecture.md
index f767fa3..85e46b8 100644
--- a/docs/architecture.md
+++ b/wiki/01-architecture.md
@@ -1,4 +1,4 @@
-# Architecture Overview
+# 01 - Architecture
`activitysim_visualizer` has three main jobs:
@@ -10,9 +10,8 @@ The codebase is organized around those jobs rather than around one monolithic ap
The config surface is now intentionally split into top-level domains such as
`pipeline`, `dashboard`, `display`, `summarize`, `segment`, and `skimjoin`.
-`runtime.config.load_config()` still accepts legacy keys for compatibility, but
-normalizes them into one canonical `Config` contract before any workflow code
-sees them.
+`runtime.config.load_config_from_yaml()` validates that canonical schema before
+any workflow code sees it. Removed and unknown keys fail with a focused error.
## Main Subsystems
@@ -21,9 +20,10 @@ sees them.
| CLI and workflow orchestration | Parse step selections, choose cache-first vs rebuild flow, and hand off to prepare/summarize/dashboard workflows | `run.py`, `runtime/workflows/` |
| Shared runtime contracts | Normalize YAML config and expose shared cross-cutting contracts used by both processor and dashboard | public surface `runtime.config`, implementation in `runtime/config/` |
| Processor prepare step | Read raw ActivitySim outputs, materialize canonical prepared columns, and manage prepared-table cache I/O | `processor/models.py`, `processor/prepare/*` |
-| Summary generation and cache I/O | Register summary builders, compute weighted/unweighted tables, write/load cache manifests and CSVs | `processor/summarize/cache.py`, `processor/summarize/schema.py`, `processor/summarize/summaries/*.py` |
-| Dashboard registry and state | Discover pages, validate page contracts, hold live state and cached filtered views | `dashboard/page_registry.py`, `dashboard/page_definitions.py`, `dashboard/state.py`, `dashboard/page_base.py` |
-| Rendering | Build the live Panel app or serialize a client-side HTML document | `dashboard/app.py`, `dashboard/components.py`, `dashboard/export/` |
+| Summary generation | Declare builders and compute weighted/unweighted tables | `processor/summarize/contracts.py`, `processor/summarize/catalog.py`, `processor/summarize/summaries/*.py` |
+| Summary cache I/O | Inspect, write, and load cache manifests and CSVs | `processor/summarize/cache.py`, `processor/summarize/cache_storage.py` |
+| Dashboard page runtime | Discover pages, validate contracts, refresh declared features, and memoize section queries | `dashboard/page_registry.py`, `dashboard/page_definitions.py`, `dashboard/page_lifecycle.py`, `dashboard/page_declarations.py` |
+| Rendering | Build context-bound Plotly figures and the live or exported view | `dashboard/rendering/`, `dashboard/app.py`, `dashboard/export/` |
## End-to-End Flow
@@ -41,7 +41,7 @@ run.py
B. run_summary_workflow()
-> processor.summarize.cache.load_summary_run_cache()
-> run_prepare_workflow() on summary-cache miss
- -> processor.summarize.cache.build_mode_summaries()
+ -> processor.summarize.builder.build_mode_summaries_with_metadata()
-> processor.summarize.cache.write_summary_run_cache()
C. load_summary_runs_from_cache() for dashboard-only cache runs
-> processor.summarize.cache.load_summary_run_cache()
@@ -49,6 +49,11 @@ run.py
E. dashboard.export.html.build_export_html_document()
```
+`WorkflowPlan` is the single resolved execution plan passed into these
+operations. `run_prepare_workflow()` returns `PreparedRunsArtifact`, and
+`run_summary_workflow()` returns `SummaryRunsArtifact`. Cache policy stays in
+these runtime workflows; processor functions only transform tables.
+
## Core Runtime Contracts
### `Config`
@@ -68,7 +73,12 @@ Treat it as the contract for:
- which pages are enabled
- how export selector requests are configured
-If a new feature adds a config key or changes config behavior, the README and any relevant docs in this folder should be updated in the same change.
+`dashboard.host` is a reserved placeholder for a future hosting integration.
+The schema accepts `account`, `app_id`, `title`, and `verify`, but the current
+runtime deliberately does not store or act on them.
+
+If a new feature adds a config key or changes config behavior, update the README
+and the relevant wiki chapters in the same change.
`Config.pipeline` is the canonical home for workflow defaults. Today the
logical step names are:
@@ -87,35 +97,55 @@ workflow, and `segment` currently resolves inside the summarize workflow.
`processor.models.RunData` is the prepared-data contract consumed by summary builders and prepared-data dashboard pages. Summary code should rely on canonical prepared columns rather than guessing raw ActivitySim column names directly. `processor/prepare/` is the layer that materializes those canonical fields and owns prepared-table cache helpers.
-### `SummarySpec` and `SUMMARY_SPECS`
+### `@summary` and the summary catalog
-`processor.summarize.summary_specs.SUMMARY_SPECS` is the summary registry. It defines:
+Each persisted summary is declared beside its builder with `@summary(...)`. The
+declaration defines:
- the stable summary id used by dashboard pages
- the CSV filename stem used in cache directories
-- the builder function that produces the summary table
+- its ordered output schema and prepared-input prerequisites
+- whether it is built by default
-Adding a summary is not complete until it is registered there.
+`processor.summarize.catalog` imports the owning domain modules explicitly,
+collects those declarations deterministically, and rejects duplicate ids.
+Successful builder results are validated for exact columns, order, and dtypes.
+Unexpected builder exceptions follow `summarize.failure_policy`: `record` keeps
+typed failure metadata for an interactive dashboard, while `error` is the
+fail-fast setting for validation and batch workflows.
### `DashboardPageDefinition` and `DashboardPage`
-Dashboard pages are registered through module-level `PAGE = DashboardPageDefinition(...)` objects in `dashboard/pages/`. `PAGE` is now intentionally narrow: it holds identity, navigation grouping, ordering, and the page's summary/prepared-data contract through `required_summary_ids`, `prepared_data_mode`, and, when needed, `required_prepared_tables`.
+Dashboard pages are registered with `@dashboard_page(...)` on the page class in
+`dashboard/pages/`. The decorator holds identity, navigation grouping, ordering,
+and the summary/prepared-data contract through `required_summary_ids`,
+`optional_summary_ids`, `prepared_data_mode`, and `required_prepared_tables`.
-The public page authoring API lives on `dashboard.page_base.DashboardPage`.
+`dashboard.page_base` is the small public facade. Lifecycle, declarations,
+diagnostics, feature composition, data access, and grouped navigation live in
+separate implementation modules.
Page authors are expected to:
-- implement `build_page()` to create widgets and stable layout once
-- optionally implement `sync_controls()` to reconcile selector options and values
-- register page-local controls with `self.selector(...)`
-- register refreshable/exportable regions with `self.section(...)`
-- return section content from section render functions
+- implement `build_page()` to declare selectors, features, sections, and layout
+- give selectors an option provider and default policy when their domain is dynamic
+- compose unrelated user-visible blocks with `self.feature(...)`
+- memoize chart-ready transformations with `self.query(...)`
+- keep section render methods to lookup/query/render
+
+Large controllers may keep their registered page module as a compatibility
+facade and compose page-local implementation mixins from a private `_/`
+package. This convention, its constraints, and its distinction from
+`PageFeature` are documented in
+[Figures And Widgets](32-figures-and-widgets.md#sections-and-features).
The framework now owns:
- widget watchers
- section containers
- section-aware refresh
+- selector option refresh and stale-value repair
+- query identity derived from page/global/section/selector state
- stale tracking
- export selector metadata
- export region metadata
@@ -131,10 +161,16 @@ authoring model:
- `time_distance_helpers.py` centralizes repeated time-bin and distance-bin behavior
- `comparison_helpers.py` centralizes percent-error formatting and base-run comparisons
-The skim pages intentionally keep their own family-specific shared module at
-`dashboard/pages/skim_summaries/_shared.py`. That file is the reference pattern
-for shared logic that is reusable within one page family but not broad enough
-for `dashboard/helpers/`.
+For page-local table shaping, `dashboard.data_access.RunTables` applies one
+fluent query to every run while preserving run labels. Pages should prefer its
+`where`, `with_columns`, `group`, `select`, `sort`, `join`, `requiring`,
+`drop_empty`, and `map` operations over open-coded loops through
+run/dataframe pairs.
+
+The skim pages share their family-specific model/query service while exposing
+small summary and distribution features. This is the reference pattern for
+logic reusable within one page family but not broad enough for
+`dashboard/helpers/`.
## Repository Map
@@ -156,6 +192,7 @@ activitysim_visualizer/
| | | |-- __init__.py
| | | |-- canonicalize.py
| | | |-- columns.py
+| | | |-- domains.py
| | | |-- finalize.py
| | | |-- households_persons.py
| | | |-- pipeline.py
@@ -167,31 +204,51 @@ activitysim_visualizer/
| | |-- reader.py
| | `-- writer.py
| `-- summarize/
-| |-- __init__.py
+| |-- builder.py
| |-- cache.py
+| |-- cache_storage.py
+| |-- cache_types.py
+| |-- catalog.py
+| |-- contracts.py
+| |-- csv_export.py
| |-- schema.py
-| |-- summary_specs.py
-| |-- writer.py
| `-- summaries/
-| |-- daily_travel.py
+| |-- daily_travel_activity.py
+| |-- daily_travel_escort_counts.py
+| |-- daily_travel_escort_distributions.py
| |-- demographics.py
| |-- joint_travel.py
-| |-- legacy.py
-| |-- long_term.py
+| |-- long_term_person.py
+| |-- long_term_vehicle.py
+| |-- long_term_geography.py
+| |-- long_term_distance.py
| |-- tour.py
| |-- trip.py
| `-- validation.py
|-- dashboard/
| |-- app.py
-| |-- components.py
+| |-- rendering/
+| | |-- context.py
+| | |-- figures.py
+| | |-- plotter.py
+| | |-- layout.py
+| | `-- tables.py
| |-- export/
| | |-- html.py
| | |-- payload.py
+| | |-- page_serializer.py
+| | |-- selector_states.py
| | |-- serializer.py
+| | |-- traversal.py
| | |-- runtime_assets.py
| | |-- types.py
| | `-- assets/
| |-- page_base.py
+| |-- page_declarations.py
+| |-- page_diagnostics.py
+| |-- page_features.py
+| |-- page_lifecycle.py
+| |-- page_navigation.py
| |-- page_definitions.py
| |-- page_registry.py
| |-- state.py
@@ -201,7 +258,12 @@ activitysim_visualizer/
## What to Read First
-- Start with [summary-workflow.md](summary-workflow.md) if you need to understand cache generation, cache loading, and prepared-run usage.
-- Read [adding-summaries.md](adding-summaries.md) before changing anything under `processor/summarize/`.
-- Read [adding-dashboard-pages.md](adding-dashboard-pages.md) before adding a dashboard page or page-local export selector.
-- Read [export_html_schema.md](export_html_schema.md) and [export_html_contributor_guide.md](export_html_contributor_guide.md) before changing the offline export contract.
+- Start with [Running Workflows](12-running-workflows.md) to understand cache
+ generation, cache loading, and prepared-run usage.
+- Read the [Summary Function Cookbook](44-summary-function-cookbook.md) before
+ changing anything under `processor/summarize/`.
+- Read the [Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
+ before adding a page, selector, figure, or table.
+- Read [HTML Export](34-html-export.md) and the
+ [HTML Export Schema](36-html-export-schema.md) before changing the offline
+ export contract.
diff --git a/wiki/10-getting-started.md b/wiki/10-getting-started.md
new file mode 100644
index 0000000..dee4f37
--- /dev/null
+++ b/wiki/10-getting-started.md
@@ -0,0 +1,92 @@
+# 10 - Getting Started
+
+This is the shortest path from a clone to a local dashboard.
+
+## 1. Install
+
+From the repository root:
+
+```bash
+uv sync --locked
+```
+
+If Windows reports a hardlink problem:
+
+```bash
+uv sync --locked --link-mode=copy
+```
+
+## 2. Create A Small Config
+
+Create `local_config.yaml`. This file defines both the inputs and what the run
+should produce:
+
+```yaml
+root: artifacts
+
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: live
+ overwrite: false
+
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+
+zones:
+ use_maz: false
+ maz_col: zone_id
+ taz_col: TAZ
+
+dashboard:
+ title: Regional Model Comparison
+ export:
+ output_path: exports/dashboard.html
+```
+
+Change the two `dir` values to real ActivitySim output folders. The default
+input names are `final_households`, `final_persons`, `final_tours`,
+`final_trips`, `final_joint_tour_participants`, and `final_land_use`; each may be
+CSV or Parquet.
+
+If your files have different names, read
+[File Names](11-configuring-your-data.md#raw-activitysim-output).
+
+`root` is the visualizer's artifact location. Summary caches are written below
+it, and relative export paths resolve below it. Keep the export path configured
+even for a live workflow; switching from a live dashboard to an HTML file then
+requires changing only `pipeline.dashboard_mode` from `live` to `export`.
+
+## 3. Run The Config
+
+```bash
+uv run activitysim-viz --config local_config.yaml
+```
+
+The first run prepares data, builds summaries, and starts the dashboard at
+[http://localhost:5006](http://localhost:5006). Later runs reuse valid caches.
+
+Stop the server with `Ctrl+C`.
+
+Use this same command for live dashboards, HTML exports, and processor-only
+workflows. Change the `pipeline` and `dashboard` sections in the config instead
+of maintaining different launch commands.
+
+## If The First Run Fails
+
+Check these first:
+
+1. each `runs[*].dir` exists;
+2. the expected tables are present as `.csv` or `.parquet`;
+3. `zones.use_maz`, `maz_col`, and `taz_col` match the model; and
+4. the log names the missing file or column.
+
+Then use [Troubleshooting](90-troubleshooting.md).
+
+## Next
+
+- [Choose an input type](11-configuring-your-data.md)
+- [Configure live, export, and processor workflows](12-running-workflows.md)
+- [Use the dashboard](30-output-visualizer.md)
diff --git a/wiki/11-configuring-your-data.md b/wiki/11-configuring-your-data.md
new file mode 100644
index 0000000..8390ddb
--- /dev/null
+++ b/wiki/11-configuring-your-data.md
@@ -0,0 +1,177 @@
+# 11 - Configuring Your Data
+
+Most users only need to choose an input type and name their runs. Use one of the
+three patterns below.
+
+## Raw ActivitySim Output
+
+Use this when you have normal ActivitySim output folders:
+
+```yaml
+root: artifacts
+
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+```
+
+The label is what appears in the dashboard.
+
+### File Names
+
+The default files are:
+
+```yaml
+files:
+ households: final_households
+ persons: final_persons
+ tours: final_tours
+ trips: final_trips
+ joint_tour_participants: final_joint_tour_participants
+ land_use: final_land_use
+```
+
+A bare name accepts either `.parquet` or `.csv`. Override one unusual run with
+`file_map`:
+
+```yaml
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+ file_map:
+ trips: linked_trips
+ persons: final_people.csv
+```
+
+### Column Names
+
+If a model uses different column names, list the candidates in preferred order:
+
+```yaml
+columns:
+ household_id: [household_id, hh_id]
+ tour_purpose: [primary_purpose, tour_type, purpose]
+ trip_mode: mode
+```
+
+Prepare converts the selected source to the visualizer's canonical column. See
+chapter 13 for the [complete column list](13-configuration-reference.md#columns).
+
+## Already-Prepared Tables
+
+Use `prepared_table_map` for canonical tables that were prepared, skimjoined,
+or filtered elsewhere:
+
+```yaml
+runs:
+ - label: Filtered Run
+ prepared_table_map:
+ households: prepared/households.parquet
+ persons: prepared/persons.parquet
+ tours: prepared/tours.parquet
+ trips: prepared/trips.parquet
+ land_use: prepared/land_use.parquet
+```
+
+Paths must end in `.csv` or `.parquet` and are relative to the config file.
+These tables must already use the canonical prepared columns expected by
+summaries. Raw prepare and integrated skimjoin are skipped for this run.
+
+## Dashboard-Ready Summary Tables
+
+Use `summary_table_map` when another process has already produced registered
+summary tables:
+
+```yaml
+runs:
+ - label: External Validation
+ summary_table_map:
+ population_totals: summaries/population_totals.csv
+ traffic_count_comparisons: summaries/traffic_counts.parquet
+```
+
+Keys must appear in the [Summary Catalog](24-summary-catalog.md). Files must
+match the registered columns exactly. A run may contain only outside summaries,
+or they may override selected summaries generated from raw/prepared data.
+
+## Weights
+
+The normal modes are configured with:
+
+```yaml
+summarize:
+ weighting_modes: [weighted, unweighted]
+```
+
+If a run has explicit weight columns:
+
+```yaml
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ hh_weight_col: household_weight
+ person_weight_col: person_weight
+ trip_weight_col: trip_weight
+```
+
+Otherwise prepare uses a configured sample-rate column when available, then
+falls back to `1.0`.
+
+If the same output tables contain an additional set of weights, add a named
+column mode instead of duplicating the run or writing Python:
+
+```yaml
+weighting:
+ modes:
+ calibrated:
+ label: Calibrated
+ columns:
+ households: calibrated_hh_weight
+ persons: calibrated_person_weight
+ trips: calibrated_trip_weight
+
+summarize:
+ weighting_modes: [weighted, unweighted, calibrated]
+```
+
+The named sources are validated and propagated to tours, days, vehicles, and
+skimjoin sidecars as appropriate. See [43 - Weighting And Hosting Extensions](43-weighting-hosting-extensions.md#worked-example-add-a-weighting-mode) for the exact rules.
+
+## Zones
+
+TAZ-only model:
+
+```yaml
+zones:
+ use_maz: false
+ maz_col: zone_id
+ taz_col: TAZ
+```
+
+MAZ/TAZ model:
+
+```yaml
+zones:
+ use_maz: true
+ maz_col: [MAZ, zone_id]
+ taz_col: [TAZ, taz]
+```
+
+## Optional Features
+
+- For skim enrichment, read [Skimjoin](22-skimjoin.md).
+- For custom geography aggregation, read the
+ [`summarize.geography` reference](13-configuration-reference.md#summarize).
+- For segmentation, read the
+ [`segment` reference](13-configuration-reference.md#segment).
+- For every accepted key and default, use the
+ [Configuration Reference](13-configuration-reference.md).
+
+## Next
+
+- [Run workflows and manage caches](12-running-workflows.md)
+- [Troubleshoot missing data](90-troubleshooting.md)
diff --git a/wiki/12-running-workflows.md b/wiki/12-running-workflows.md
new file mode 100644
index 0000000..670a1da
--- /dev/null
+++ b/wiki/12-running-workflows.md
@@ -0,0 +1,192 @@
+# 12 - Running Workflows
+
+The normal user experience is config-driven. Keep one launch command:
+
+```bash
+uv run activitysim-viz --config local_config.yaml
+```
+
+The config decides which work runs, where artifacts are stored, and whether the
+result is a live dashboard or an HTML file. Command-line flags are intended for
+development and one-off diagnostics, not normal operation.
+
+## The Three Main Steps
+
+```text
+prepare -> summarize -> dashboard
+```
+
+- **Prepare** reads raw outputs and creates canonical prepared tables.
+- **Summarize** creates the smaller tables used by dashboard pages.
+- **Dashboard** serves the live application or writes standalone HTML.
+
+Skimjoin runs inside prepare when selected. Segmentation runs with summarize.
+
+These are requested workflow boundaries, not isolated commands. In particular,
+`summarize` must have prepared data: it reuses a valid prepared cache or builds
+prepared data from the configured raw/prepared inputs when the cache is missing
+or stale. Adding `prepare` explicitly runs and persists that boundary first;
+the summarize boundary then reuses the in-memory or cached result rather than
+preparing a second time.
+
+| Requested step | What it guarantees | Prerequisites resolved automatically |
+|---|---|---|
+| `prepare` | Prepared tables are loaded/built and cached. | Raw files or `prepared_table_map`. |
+| `summarize` | Default registered summaries are loaded/built and cached. | Prepared data is loaded/built as needed. |
+| `dashboard` | Existing summary caches are loaded and displayed/exported. | No summaries are built; required caches must exist or come from `summary_table_map`. |
+
+## Configure A Live Workflow
+
+```yaml
+root: artifacts
+
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: live
+ overwrite: false
+
+dashboard:
+ title: Regional Model Comparison
+ live:
+ pages:
+ - overview
+ - long_term_choices
+ - daily_travel
+ - tour_summaries
+ - trip_summaries
+```
+
+This builds missing or stale artifacts, reuses valid caches, and starts the
+dashboard. `dashboard.live.pages` controls which page groups are available.
+
+## Configure An HTML Export
+
+```yaml
+root: artifacts
+
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: export
+ overwrite: false
+
+dashboard:
+ export:
+ output_path: exports/dashboard.html
+```
+
+The configured output is `artifacts/exports/dashboard.html`: relative export
+paths resolve below `root`. Use an absolute path when the file must be written
+elsewhere. Page and selector choices are covered in
+[HTML Export](34-html-export.md).
+
+## Configure A Processor-Only Workflow
+
+Build prepared tables and summaries without opening or exporting a dashboard:
+
+```yaml
+pipeline:
+ steps: [prepare, summarize]
+ dashboard_mode: none
+ overwrite: false
+```
+
+Other focused workflows use the same fields:
+
+| Goal | `pipeline.steps` | `dashboard_mode` |
+|---|---|---|
+| Prepare tables only | `[prepare]` | `none` |
+| Build or reuse summaries, preparing on cache miss | `[summarize]` | `none` |
+| Open a live dashboard from existing caches | `[dashboard]` | `live` |
+| Export HTML from existing caches | `[dashboard]` | `export` |
+
+For loose dashboard-ready CSV or Parquet inputs, configure
+`runs[*].summary_table_map`; do not treat them as cache directories.
+
+## Pipeline Rules
+
+Available logical steps are `prepare`, `skimjoin`, `segment`, `summarize`, and
+`dashboard`. Dashboard must be last. `skimjoin` requires `prepare`; `segment`
+requires `summarize`.
+
+The default when `pipeline.steps` is omitted is `[summarize, dashboard]`.
+That default still prepares raw inputs when a valid prepared cache is not
+available. Include `prepare` explicitly when prepared-cache creation is itself
+an intended, visible stage or when `skimjoin` is enabled.
+
+Dashboard modes:
+
+- `live`: local Panel server;
+- `export`: standalone HTML;
+- `none`: no dashboard; and
+- `host`: reserved extension point that currently logs a warning and executes
+ the normal live server; it does not publish to a hosting provider.
+
+## Artifact And Cache Paths
+
+Prepared and summary caches live under the configured `root`. Each run has a
+manifest describing its inputs and config identity.
+
+Set `root` once for the workflow:
+
+```yaml
+root: D:\activitysim_visualizer\regional_comparison
+```
+
+For two runs labeled `Base` and `Build`, the normal layout is:
+
+```text
+regional_comparison/
+ base/
+ manifest.json
+ prepared_tables/
+ households.parquet
+ persons.parquet
+ tours.parquet
+ trips.parquet
+ ...
+ summary_tables/
+ weighted/
+ .csv
+ unweighted/
+ .csv
+ build/
+ manifest.json
+ prepared_tables/
+ summary_tables/
+```
+
+The run-key directory is a filesystem-safe lowercase slug of the run label.
+For example, `Build Scenario` becomes `build-scenario`. Colliding labels receive
+ordered suffixes such as `build-1` and `build-2`; avoid duplicate labels because
+reordering them changes which run receives each suffix.
+
+Relative paths in `dashboard.export.output_path` resolve below this directory.
+Input paths follow the path rules documented in
+[Configuration Reference](13-configuration-reference.md#reading-this-reference).
+
+Valid caches are reused automatically. To deliberately rebuild every cache
+used by the configured steps, temporarily set:
+
+```yaml
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: live
+ overwrite: true
+```
+
+Return `overwrite` to `false` after the forced rebuild. Presentation-only
+changes such as labels, colors, or enabled pages normally do not require cache
+rebuilding.
+
+## CLI Overrides
+
+CLI step, refresh, export-path, and port flags remain available for developers
+and troubleshooting. They override the configured workflow for that one
+invocation. Users should normally change the YAML and continue running the same
+command so the intended workflow remains reproducible.
+
+## Related Chapters
+
+- [Getting Started](10-getting-started.md)
+- [Configuring Your Data](11-configuring-your-data.md)
+- [Troubleshooting](90-troubleshooting.md)
diff --git a/wiki/13-configuration-reference.md b/wiki/13-configuration-reference.md
new file mode 100644
index 0000000..cb091a9
--- /dev/null
+++ b/wiki/13-configuration-reference.md
@@ -0,0 +1,719 @@
+# 13 - Configuration Reference
+
+This page is the field-by-field reference for the main ActivitySim Visualizer
+config file. For a shorter orientation, start with
+[11 - Configuring Your Data](11-configuring-your-data.md). The canonical
+example is [`config.yaml`](../config.yaml).
+
+This page documents the current canonical config layout. Unknown and removed
+keys fail validation and, where possible, name their canonical replacement.
+
+## Reading This Reference
+
+Path resolution depends on the field:
+
+| Field family | Relative to | Notes |
+|---|---|---|
+| `root` | main config directory | Becomes an absolute artifact/cache root during config loading. |
+| `runs[*].dir` | main config directory | Raw ActivitySim output directory. |
+| `files.*`, `runs[*].file_map.*` | the resolved run directory | File stems may omit `.parquet` or `.csv`; Parquet is tried before CSV. |
+| `fallback_files.*`, `prepared_table_map.*`, `summary_table_map.*` | main config directory | Values must include `.parquet` or `.csv`. |
+| main-config skim, lookup, and skimjoin override paths | main config directory | Includes `prepare.distance_skim.file`, segmentation CSVs, and `skimjoin.defaults.*`. |
+| paths inside the standalone skimjoin config | standalone skimjoin config directory | See chapter 25. |
+| `dashboard.export.output_path` | resolved `root` | Absolute output paths remain absolute. |
+
+Cache impact uses these labels:
+
+| Impact | Meaning |
+|---|---|
+| Prepare | Can change prepared tables or prepared cache identity. |
+| Summary | Can change summary cache outputs. |
+| Presentation | Can change dashboard labels, pages, colors, or export output. |
+| Runtime | Controls which workflow runs, but does not directly define data content. |
+
+## Common Recipes
+
+### Minimal Two-Run Comparison
+
+```yaml
+name: Regional Model Comparison
+root: artifacts
+
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: live
+
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+```
+
+### Prepared-Table Workflow
+
+Use `prepared_table_map` when a run should skip raw prepare and load canonical
+prepared tables directly.
+
+```yaml
+pipeline:
+ steps: [summarize, dashboard]
+
+runs:
+ - label: Filtered Run
+ prepared_table_map:
+ households: C:\prepared\households.parquet
+ persons: C:\prepared\persons.parquet
+ tours: C:\prepared\tours.parquet
+ trips: C:\prepared\trips.parquet
+ land_use: C:\prepared\land_use.parquet
+```
+
+### Export Workflow
+
+```yaml
+pipeline:
+ steps: [summarize, dashboard]
+ dashboard_mode: export
+
+dashboard:
+ export:
+ output_path: exports\dashboard.html
+ dashboard:
+ weighting: [weighted]
+ values: [percent, count]
+```
+
+### Global And Run-Level Skimjoin
+
+```yaml
+pipeline:
+ steps: [prepare, skimjoin, summarize, dashboard]
+
+skimjoin:
+ defaults:
+ config_path: configs\skimjoin_default.yaml
+ skim_files:
+ - C:\skims\*.omx
+ network_los_file: C:\skims\network_los.yaml
+
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+ skimjoin:
+ skim_files:
+ - C:\build_skims\*.omx
+```
+
+## Top-Level Fields
+
+| Field | Type | Default | Impact | Purpose |
+|---|---|---|---|---|
+| `name` | string | `""` | Presentation | Human-readable config name. |
+| `root` | path string | `artifacts/summary_cache` when omitted | Prepare, Summary, Presentation | Artifact root used by prepared caches, summary caches, and relative export paths. |
+| `log_level` | string | `INFO` | Runtime | Logging verbosity. Allowed: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
+| `pipeline` | mapping | `summarize`, `dashboard`, live dashboard | Runtime | Workflow step selection and dashboard mode. |
+| `runs` | list of mappings | `[]` | Prepare, Summary | Model runs to process and compare. |
+| `files` | mapping | ActivitySim `final_*` table stems | Prepare | Default raw input table stems. |
+| `fallback_files` | mapping | `{}` | Prepare | Shared explicit optional files used when run-local optional files are absent. |
+| `zones` | mapping | MAZ/TAZ defaults | Prepare, Summary | Zone-system behavior and land-use zone aliases. |
+| `columns` | mapping | Built-in column aliases | Prepare, Summary | Source column aliases for non-standard ActivitySim outputs. |
+| `prepare` | mapping | Built-in prepare defaults | Prepare | Prepared table format, validation, distance skims, VOT bins, and auto sufficiency. |
+| `skimjoin` | mapping | disabled unless `pipeline.steps` includes `skimjoin` | Prepare, Summary | Optional wiring to a separate standalone skimjoin config. |
+| `segment` | mapping | disabled | Summary, Presentation | Optional segmented summaries and dashboard segment controls. |
+| `weighting` | mapping | `{}` | Summary, Presentation | Declarative named weighting modes backed by prepared source columns. |
+| `summarize` | mapping | weighted and unweighted summaries | Summary | Summary weighting, purpose grouping, geography, and PNR mode behavior. |
+| `dashboard` | mapping | live dashboard defaults | Presentation | Dashboard title, page selection, MAZ geography toggle, and export settings. |
+| `display` | mapping | built-in labels and colors | Presentation | Dashboard labels, category order, and run colors. |
+| `extensions` | mapping | `{}` | Summary, Presentation | Advanced importable weighting calculation modules and their settings. Extension code is trusted. |
+| `modes` | mapping | `{}` | Presentation | Optional mode ordering used when `display.labels.mode` is absent. |
+
+## `weighting`
+
+`weighting.modes` defines named alternatives by pointing at columns already
+present in prepared household, person, or trip tables.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `modes` | mapping | `{}` | Summary, Presentation | Mode ID to a definition containing optional `label` and required non-empty `columns`. |
+| `modes..label` | string | title-cased mode ID | Presentation | Unique dashboard/export label. |
+| `modes..columns.households` | string | none | Summary | Household source column; propagates to dependent tables unless overridden. |
+| `modes..columns.persons` | string | none | Summary | Person source column; propagates to trips, tours, and days unless overridden. |
+| `modes..columns.trips` | string | none | Summary | Trip source column; tour weight becomes the mean selected trip weight by `tour_id`. |
+
+```yaml
+weighting:
+ modes:
+ calibrated:
+ label: Calibrated
+ columns:
+ households: calibrated_hh_weight
+ persons: calibrated_person_weight
+ trips: calibrated_trip_weight
+
+summarize:
+ weighting_modes: [weighted, unweighted, calibrated]
+```
+
+Each definition needs at least one supported source table. Named columns are
+validated against every prepared run. See the [weighting cookbook](43-weighting-hosting-extensions.md#worked-example-add-a-weighting-mode).
+
+## `extensions`
+
+This is the advanced path for calculations that cannot be represented by
+`weighting.modes` column selection.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `modules` | list of strings | `[]` | Summary | Importable modules that define `register_weighting_modes(registry)`. Installed weighting entry points are discovered separately. |
+| `settings` | mapping | `{}` | Summary | Arbitrary YAML settings available to transforms as `config.extension_settings`. Included in summary cache identity. |
+
+```yaml
+extensions:
+ modules: [my_project.weighting]
+ settings:
+ calibrated:
+ multiplier: 1.0
+```
+
+See [Advanced: Custom Weight Calculations](43-weighting-hosting-extensions.md#advanced-custom-weight-calculations).
+
+## `pipeline`
+
+`pipeline` selects workflow steps and output mode.
+
+| Field | Type | Default | Allowed values | Impact | Notes |
+|---|---|---|---|---|---|
+| `steps` | non-empty list of strings | `[summarize, dashboard]` | `prepare`, `skimjoin`, `segment`, `summarize`, `dashboard` | Runtime | Steps must be lowercase, unique, and valid. `skimjoin` requires `prepare`; `segment` requires `summarize`; `dashboard` must be last when present. |
+| `dashboard_mode` | string | `live` | `none`, `live`, `export`, `host` | Runtime, Presentation | Controls what the dashboard step does. `host` is reserved and currently warns, then falls back to the ordinary live server; it does not publish an application. |
+| `overwrite` | boolean | `false` | `true`, `false` | Runtime | Bypasses reusable prepared/summary caches for configured processor steps and writes rebuilt artifacts. Return it to `false` after a forced rebuild. |
+
+```yaml
+pipeline:
+ steps: [prepare, skimjoin, segment, summarize, dashboard]
+ dashboard_mode: export
+ overwrite: false
+```
+
+## `runs`
+
+Each run entry describes one scenario. `label` is strongly recommended because
+it becomes the display name and helps cache/debug output remain understandable.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `dir` | path string | none | Prepare | Raw ActivitySim output folder. Required unless the run is supplied by `prepared_table_map`, by `summary_table_map` alone, or both. |
+| `label` | string | folder name or `run` fallback | Summary, Presentation | Dashboard and cache-facing run name. Keep stable across reruns. |
+| `file_map` | mapping | inherits top-level `files` | Prepare | Per-run raw file stem overrides. Cannot be combined with `prepared_table_map`. |
+| `prepared_table_map` | mapping | none | Prepare, Summary | Explicit `.parquet` or `.csv` canonical prepared tables. Skips raw prepare for that run. |
+| `summary_table_map` | mapping | none | Summary, Presentation | Registered summary IDs mapped to dashboard-ready `.parquet` or `.csv` files. May be used alone or override generated summaries. |
+| `skimjoin` | mapping | inherits global `skimjoin` | Prepare | Per-run skimjoin `config_path`, `skim_files`, and `network_los_file` overrides. |
+| `hh_weight_col` | string | none | Prepare, Summary | Household source for the run's primary `weighted` mode. |
+| `person_weight_col` | string | none | Prepare, Summary | Person source for the run's primary `weighted` mode. |
+| `trip_weight_col` | string | none | Prepare, Summary | Trip source for the run's primary `weighted` mode. |
+
+Allowed `file_map` and `prepared_table_map` table ids are:
+
+`households`, `persons`, `day`, `tours`, `trips`, `vehicles`,
+`joint_tour_participants`, `land_use`.
+
+`prepared_table_map` paths must include `.parquet` or `.csv`. Relative paths are
+resolved relative to the config file.
+
+`summary_table_map` uses registered IDs from the summary catalog. Its paths must
+also end in `.parquet` or `.csv` and are resolved relative to the config file.
+
+### Run Labels And Run Keys
+
+`label` is the dashboard name. Its filesystem-safe lowercase slug is the run
+key used by cache directories, manifests, and settings such as
+`prepare.vot_bins.mappings`:
+
+| Label | Run key |
+|---|---|
+| `Base` | `base` |
+| `Build Scenario` | `build-scenario` |
+| `2026 / Toll Test` | `2026-toll-test` |
+
+If normalized labels collide, every colliding key receives an ordered numeric
+suffix (`build-1`, `build-2`). Keep labels unique and stable: changing their
+order can change those suffixes and therefore cache/mapping identity.
+
+```yaml
+runs:
+ - dir: C:\models\base\output
+ label: Base
+ - dir: C:\models\build\output
+ label: Build
+ file_map:
+ trips: final_trips_linked
+```
+
+## `files` And `fallback_files`
+
+`files` maps logical table ids to raw ActivitySim output file stems. If the value
+has no extension, the reader tries `.parquet` first, then `.csv`, inside each
+run directory.
+
+| Table id | Default stem |
+|---|---|
+| `households` | `final_households` |
+| `persons` | `final_persons` |
+| `day` | `final_day` |
+| `tours` | `final_tours` |
+| `trips` | `final_trips` |
+| `vehicles` | `final_vehicles` |
+| `joint_tour_participants` | `final_joint_tour_participants` |
+| `land_use` | `final_land_use` |
+
+`fallback_files` supports optional table ids only: `day`, `vehicles`,
+`joint_tour_participants`, and `land_use`. Values must be explicit `.parquet` or
+`.csv` paths.
+
+```yaml
+files:
+ trips: trips_with_links
+
+fallback_files:
+ land_use: C:\shared_inputs\land_use.parquet
+```
+
+Impact: Prepare.
+
+## `zones`
+
+`zones` controls MAZ/TAZ normalization.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `use_maz` | boolean | `true` | Prepare, Summary | Set `false` for TAZ-only models. |
+| `maz_col` | string or list of strings | `[MAZ, zone_id]` | Prepare | Ordered candidate columns for MAZ ids in land use. |
+| `taz_col` | string or list of strings | `[TAZ, taz]` | Prepare | Ordered candidate columns for TAZ ids in land use. |
+
+```yaml
+zones:
+ use_maz: false
+ maz_col: zone_id
+ taz_col: TAZ
+```
+
+## `columns`
+
+Most `columns` values may be a string or an ordered list of candidate source
+column names. The first available candidate is used. The few scalar fields
+listed first are read as single names.
+
+| Field | Default | Impact | Purpose |
+|---|---|---|---|
+| `ptype` | `ptype` | Prepare, Summary | Person type. |
+| `hhsize` | `hhsize` | Prepare, Summary | Household size. |
+| `auto_ownership` | `auto_ownership` | Prepare, Summary | Household vehicle ownership. |
+| `num_workers` | `num_workers` | Prepare, Summary | Household workers. |
+| `num_adults` | `num_adults` | Prepare, Summary | Household adults. |
+| `sample_rate` | none | Prepare, Summary | Optional sample-rate source for weights. |
+| `household_id` | `household_id` | Prepare | Household key. |
+| `person_id` | `person_id` | Prepare | Person key. |
+| `tour_id` | `tour_id` | Prepare | Tour key. |
+| `trip_id` | `trip_id` | Prepare | Trip key. |
+| `tour_purpose` | `tour_purpose`, `primary_purpose`, `tour_type`, `purpose` | Prepare, Summary | Tour purpose. |
+| `trip_purpose` | `trip_purpose`, `purpose` | Prepare, Summary | Trip purpose. |
+| `tour_mode` | `tour_mode` | Prepare, Summary | Tour mode. |
+| `trip_mode` | `trip_mode` | Prepare, Summary | Trip mode. |
+| `tour_category` | `tour_category` | Prepare, Summary | Mandatory, non-mandatory, at-work, or other tour category. |
+| `tour_start` | `start`, `start_hour` | Prepare, Summary | Tour start period/hour. |
+| `tour_end` | `end`, `end_hour` | Prepare, Summary | Tour end period/hour. |
+| `tour_duration` | `duration`, `tourdur` | Prepare, Summary | Tour duration. |
+| `trip_depart` | `depart`, `depart_hour` | Prepare, Summary | Trip departure period/hour. |
+| `total_employment` | `EMP_TOTAL`, `EMP_Total`, `EMPLOY_TOT`, `TOTEMP`, `total_employment`, `employment` | Prepare, Summary | Land-use employment. |
+| `income_segment` | `income_segment`, `income_broad`, `income` | Prepare, Summary | Household income segment. |
+| `home_zone_id` | `home_zone_id` | Prepare | Home zone. |
+| `workplace_zone_id` | `workplace_zone_id` | Prepare | Workplace zone. |
+| `school_zone_id` | `school_zone_id` | Prepare | School zone. |
+| `has_license` | `has_license` | Prepare | License flag. |
+| `mandatory_tour_frequency` | `mandatory_tour_frequency` | Prepare, Summary | Mandatory tour frequency. |
+| `is_student` | `is_student`, `student` | Prepare, Summary | Student flag. |
+| `is_university` | `is_university`, `major_uni` | Prepare, Summary | University student flag. |
+| `school_segment` | `school_segment` | Prepare, Summary | School segment. |
+| `schg` | `SCHG` | Prepare, Summary | School grade/category. |
+| `pstudent` | `pstudent` | Prepare, Summary | ActivitySim student category. |
+| `tour_origin` | `origin` | Prepare | Tour origin. |
+| `tour_destination` | `destination` | Prepare | Tour destination. |
+| `trip_origin` | `origin` | Prepare | Trip origin. |
+| `trip_destination` | `destination` | Prepare | Trip destination. |
+| `stop_frequency` | `stop_frequency` | Prepare, Summary | Tour stop frequency. |
+| `trip_outbound` | `outbound` | Prepare, Summary | Trip outbound/inbound flag. |
+| `trip_num` | `trip_num` | Prepare | Trip sequence number. |
+| `pnr_zone_id` | `pnr_zone_id` | Prepare, Skimjoin | Park-and-ride zone id. |
+| `pnr_lot_capacity` | `PNR_SPACES` | Prepare, Summary | Park-and-ride lot capacity. |
+| `is_worker` | `is_worker` | Prepare, Summary | Worker flag. |
+| `adult` | `adult`, `is_adult` | Prepare, Summary | Adult flag. |
+| `day_id` | `day_id` | Prepare, Summary | Day table id. |
+| `day_weight` | `day_weight` | Prepare, Summary | Day-level weight. |
+| `vehicle_id` | `vehicle_id` | Prepare, Summary | Vehicle id. |
+| `vehicle_num` | `vehicle_num` | Prepare, Summary | Vehicle number. |
+| `vehicle_type` | `vehicle_type` | Prepare, Summary | Vehicle type. |
+| `school_esc_outbound` | `school_esc_outbound` | Prepare, Summary | School escort outbound indicator. |
+| `school_esc_inbound` | `school_esc_inbound` | Prepare, Summary | School escort inbound indicator. |
+| `num_escortees` | `num_escortees`, `num_escorted` | Prepare, Summary | Number of escortees. |
+| `out_escorted_tour_ids` | `out_escorted_tour_ids` | Prepare, Summary | Outbound escorted tour ids. |
+| `inb_escorted_tour_ids` | `inb_escorted_tour_ids` | Prepare, Summary | Inbound escorted tour ids. |
+| `out_escorting_type` | `out_escorting_type` | Prepare, Summary | Outbound escorting type. |
+| `inb_escorting_type` | `inb_escorting_type` | Prepare, Summary | Inbound escorting type. |
+| `out_chauffeur_tour_id` | `out_chauffeur_tour_id` | Prepare, Summary | Outbound chauffeur tour id. |
+| `inb_chauffeur_tour_id` | `inb_chauffeur_tour_id` | Prepare, Summary | Inbound chauffeur tour id. |
+
+```yaml
+columns:
+ household_id: [household_id, hh_id]
+ tour_purpose: [primary_purpose, purpose]
+ trip_mode: mode
+```
+
+## `prepare`
+
+`prepare` controls canonical prepared table output and enrichment.
+
+| Field | Type | Default | Allowed values | Impact | Notes |
+|---|---|---|---|---|---|
+| `output.file_format` | string | `parquet` | `parquet`, `csv` | Prepare | File format for prepared cache tables. |
+| `validation.relationship_checks` | string or `false` | `warn` | `off`, `warn`, `error`, or `false` for `off` | Prepare | Controls relationship-check failures during prepare. |
+| `distance_skim.file` | path string | none | OMX path | Prepare, Summary | Optional distance skim used by prepare. |
+| `distance_skim.matrix` | string | `SOV_DIST__MD` | matrix name | Prepare, Summary | Matrix read from `distance_skim.file`. |
+| `auto_sufficiency_basis` | string | `licensed_drivers` | `licensed_drivers`, `workers`, `adults` | Prepare, Summary | Basis for household auto-sufficiency derivation. |
+| `student_types` | list of mappings | `[]` | student-type definitions | Prepare, Summary | School/university enrollment definitions used by prepared fields and shadow-pricing summaries. |
+| `time_periods` | mapping | built-in periods | period definitions or ActivitySim config source | Prepare, Summary | Canonical time-period labels used by prepared tours and trips. |
+| `non_motorized_distance_skim` | mapping | disabled | configured lookup | Prepare, Summary | Optional non-motorized distance enrichment. |
+| `vot_bins.source_column` | string | `income_segment` | any source column | Prepare, Skimjoin | Source value used to derive VOT bins. |
+| `vot_bins.output_column` | string | `vot_bin` | any output column | Prepare, Skimjoin | Prepared column written for skimjoin dimensions. |
+| `vot_bins.fallback_value` | scalar string | none | any value | Prepare, Skimjoin | Value used when no run-specific mapping applies. |
+| `vot_bins.mappings` | mapping | `{}` | run key to value mapping | Prepare, Skimjoin | Enables VOT bin derivation. Run keys are normalized from run labels. |
+
+```yaml
+prepare:
+ output:
+ file_format: parquet
+ validation:
+ relationship_checks: warn
+ distance_skim:
+ file: C:\skims\auto.omx
+ matrix: SOV_DIST__MD
+ auto_sufficiency_basis: licensed_drivers
+ vot_bins:
+ source_column: income_segment
+ output_column: vot_bin
+ fallback_value: M
+ mappings:
+ base:
+ 1: L
+ 2: M
+ 3: H
+```
+
+## `skimjoin`
+
+The main config `skimjoin` section wires the visualizer runtime to a separate
+skimjoin config file. See
+[25 - Skimjoin Config Reference](25-skimjoin-config-reference.md) for the
+lookup-rule schema.
+
+```text
+main visualizer config
+ pipeline.steps: enables the integrated skimjoin stage
+ skimjoin.defaults: selects the standalone config and optional path overrides
+ -> standalone skimjoin config
+ project/activitysim/defaults/modes: defines the actual lookup rules
+```
+
+Merely providing `skimjoin.defaults.config_path` does not run skimjoin;
+`pipeline.steps` must also contain both `prepare` and `skimjoin`.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `defaults.config_path` | path string | none | Prepare, Summary | Shared skimjoin config path. |
+| `defaults.skim_files` | list of path strings | from skimjoin config | Prepare, Summary | Overrides `project.skim_files` in the skimjoin config for integrated runtime. |
+| `defaults.network_los_file` | path string | from skimjoin config | Prepare, Summary | Overrides `project.network_los_file`. |
+| `failure_policy` | string | `record` | Runtime, Prepare | `record` keeps a failed enrichment as diagnostics; `error` stops the run. |
+| `create_hypothetical_skim_tables` | boolean | `false` | Prepare | Enables configured hypothetical skim tables. |
+
+Run-level `runs[*].skimjoin` supports `config_path`, `skim_files`, and
+`network_los_file`. Enable skimjoin by including it in `pipeline.steps`;
+top-level `skimjoin.enabled` and `skimjoin.config_path` are removed keys.
+
+Integrated skim files must resolve to `.omx`, `.csv`, `.h5`, or `.hdf5`.
+
+## `segment`
+
+`segment` config is canonical in user YAML. Internally it is normalized to the
+segmentation runtime settings.
+
+| Field | Type | Default | Allowed values | Impact | Notes |
+|---|---|---|---|---|---|
+| `dashboard.segmentation_type` | string | first configured definition | configured definition name | Presentation | Selected segment type shown in dashboard/export. |
+| `dashboard.visibility` | string | `full_and_segments` | `full_only`, `segments_only`, `full_and_segments` | Presentation | Whether the dashboard shows full-run outputs, segmented outputs, or both. |
+| `definitions` | mapping | required when segment step is enabled | path-safe lowercase names | Summary | Segment definitions. |
+| `definitions.*.include_full` | boolean | `true` | `true`, `false` | Summary | Also build full-run summaries. |
+| `definitions.*.persist_segmented_prepared_tables` | boolean | `false` | `true`, `false` | Prepare, Summary | Persist segment-specific prepared tables. |
+| `definitions.*.allow_overlapping` | boolean | `false` | `true`, `false` | Summary | Allows one source value to appear in multiple segments. |
+| `definitions.*.on_empty_segment` | string | `warn` | `error`, `warn`, `skip` | Summary | Behavior when a segment has no rows. |
+| `definitions.*.source` | mapping | required | `prepared_column` or `csv_lookup` | Summary | Source of segment values. |
+| `definitions.*.segments` | list | required | list of segment mappings | Summary, Presentation | Segment ids, labels, and matched values. |
+
+Prepared-column source:
+
+```yaml
+segment:
+ dashboard:
+ segmentation_type: person_sex
+ visibility: segments_only
+ definitions:
+ person_sex:
+ source:
+ type: prepared_column
+ source_table: per
+ column: sex
+ segments:
+ - id: female
+ label: Female
+ values: [2]
+ - id: male
+ label: Male
+ values: [1]
+```
+
+`source_table` may be `hh`, `per`, `tours`, `trips`, or `land_use`.
+
+CSV lookup source:
+
+```yaml
+segment:
+ definitions:
+ district:
+ source:
+ type: csv_lookup
+ file: lookups\household_district.csv
+ join:
+ source_table: hh
+ source_key_column: household_id
+ csv_key_column: household_id
+ segment_value_column: district
+ segments:
+ - id: north
+ label: North
+ values: [north]
+```
+
+## `summarize`
+
+| Field | Type | Default | Allowed values | Impact | Notes |
+|---|---|---|---|---|---|
+| `weighting_modes` | list of strings | `[weighted, unweighted]` | built-in, declarative, or registered custom mode IDs | Summary, Presentation | Summary variants to build in the listed order. Empty lists use definitions with `default_enabled=True`. |
+| `failure_policy` | string | `record` | `record`, `error` | Summary | Record failed summaries as diagnostics or stop on the first builder exception. |
+| `category_normalization` | mapping | `{}` plus escort defaults | category definitions | Summary | Canonical summary-value normalization and regrouping; affects cache identity. |
+| `pnr_tour_modes` | list of strings | `[PNR_TRANSIT]` | any mode names | Summary | Modes treated as park-and-ride tours. Must resolve to at least one mode. |
+| `group_joint_tour_purposes` | boolean | `true` | `true`, `false` | Summary | Group joint tour purposes in summaries. |
+| `group_atwork_tour_purposes` | boolean | `true` | `true`, `false` | Summary | Group at-work tour purposes in summaries. |
+| `group_school_tour_purposes` | boolean | `true` | `true`, `false` | Summary | Group school tour purposes in summaries. |
+| `geography.enabled` | boolean | `false` | `true`, `false` | Summary, Presentation | Enables custom geography mapping and aggregations. |
+| `geography.landuse_col` | string | none | land-use column | Summary | Existing land-use geography column used for geography summaries. |
+| `geography.mapping` | mapping | none | raw value to label | Summary, Presentation | Label mapping for geography values. |
+| `geography.aggregations` | mapping | none | aggregation definitions | Summary, Presentation | Additional zone-to-geography lookup definitions. |
+
+Each `geography.aggregations.*` entry requires:
+
+| Field | Type | Notes |
+|---|---|---|
+| `source_zone_system` | string | `maz` or `taz`. |
+| `mapping` | mapping | Inline label to zone id or list of zone ids. Mutually exclusive with `file`. |
+| `file` | path string | CSV lookup file. Mutually exclusive with `mapping`. |
+| `zone_id_col` | string | Required with `file`. |
+| `geography_col` | string | Required with `file`. |
+
+```yaml
+summarize:
+ weighting_modes: [weighted, unweighted]
+ pnr_tour_modes: [PNR_TRANSIT]
+ geography:
+ enabled: true
+ aggregations:
+ district:
+ source_zone_system: maz
+ file: C:\lookups\maz_district.csv
+ zone_id_col: MAZ
+ geography_col: DISTRICT
+```
+
+## `dashboard`
+
+| Field | Type | Default | Allowed values | Impact | Notes |
+|---|---|---|---|---|---|
+| `title` | string | `ActivitySim Visualizer` | any string | Presentation | Dashboard title. |
+| `enable_maz_geographies` | boolean | `false` | `true`, `false` | Presentation | Enables MAZ geography options in dashboard pages that support them. |
+| `live.pages` | list | all/default page registry behavior | page or group ids | Presentation | Live dashboard page selection. |
+| `export.output_path` | path string | none | HTML path | Presentation | Relative paths resolve under `root`. |
+| `export.dashboard.weighting` | string or list | `default` | `default`, `all`, or configured weighting modes | Presentation | Exported weighting states. |
+| `export.dashboard.values` | string or list | `default` | `default`, `all`, `percent`, `count` | Presentation | Exported value-display states. |
+| `export.dashboard.segmentation_type` | string | selected segment type | configured segment definition | Presentation | Exported segmentation type. |
+| `export.dashboard.segmentation_visibility` | string | segment dashboard visibility | `full_only`, `segments_only`, `full_and_segments` | Presentation | Exported segment visibility. |
+| `export.pages` | mapping | `{}` | page/group override mapping | Presentation | Export selector and part overrides. |
+| `export.exclude_pages` | list of strings | `[]` | page ids | Presentation | Pages excluded from export. |
+| `export.exclude_groups` | list of strings | `[]` | group ids | Presentation | Groups excluded from export. |
+
+`live.pages` entries may be strings or group mappings:
+
+```yaml
+dashboard:
+ live:
+ pages:
+ - overview
+ - long_term_choices
+ - trip_summaries:
+ - trip_mode
+ - trip_stop_distance
+```
+
+Export page overrides are keyed by page id or by nested group/page id. Selector
+keys depend on the page. Selector values may be `default`, `all`, a single
+string, or a list of strings. `parts.*.enabled` can hide named export parts.
+
+Export inherits the page set resolved by `dashboard.live.pages`.
+`dashboard.export.pages` is an override mapping, not an allow-list: mentioning
+one page does not remove the others. A page override with `enabled: false`, or
+`exclude_pages` / `exclude_groups`, can narrow the inherited set. Export cannot
+add a page that live configuration did not select. Find valid IDs in:
+
+- page and group IDs: the generated catalog in chapter 31;
+- selector IDs: `self.select(...)` and `self.selector(...)` calls on the page;
+- part IDs: `self.section(...)` calls, including feature-prefixed IDs such as
+ `comparison.body`; and
+- current runtime expectations: `tests/test_page_registry_contract.py` and
+ export payload tests.
+
+```yaml
+dashboard:
+ export:
+ output_path: exports\dashboard.html
+ pages:
+ long_term_choices:
+ shadow_pricing:
+ geography_level: [all]
+ student_type: all
+ parts:
+ workplace_table:
+ enabled: false
+```
+
+## `display`
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `labels` | mapping | built-in defaults plus data values | Presentation | Category display labels and order. |
+| `run_colors` | list of strings | default color cycle | Presentation | Dashboard run colors; reused cyclically. |
+| `missing_data_display` | string | `card` | Presentation | `card` shows missing-data diagnostics; `blank` omits them. |
+| `bar_hover_mode` | string | `closest` | Presentation | `closest` or `all` hover behavior for bar charts. |
+| `density_hover_mode` | string | `closest` | Presentation | `closest` or `all` hover behavior for density plots. |
+
+Each label category supports:
+
+| Field | Type | Default | Notes |
+|---|---|---|---|
+| `mapping` | mapping | `{}` | Raw value to display label. Keys are compared as strings. |
+| `order` | string | `data` | `data`, `ascending`, or `descending` for unmapped extra values. |
+
+```yaml
+display:
+ labels:
+ mode:
+ mapping:
+ SOV: Drive Alone
+ WALK_TRANSIT: Walk-Transit
+ order: data
+ run_colors:
+ - "#298c8c"
+ - "#a00000"
+```
+
+## Advanced Category Config
+
+`summarize.category_normalization` uses the same category shape as
+`display.labels`, but changes normalized values written into summary outputs.
+Use it for summary-affecting normalization or grouping, not cosmetic relabeling.
+
+```yaml
+summarize:
+ category_normalization:
+ geography:
+ mapping:
+ 1: County 1
+ 2: County 2
+```
+
+Impact: Summary.
+
+## `prepare.student_types`
+
+`prepare.student_types` customizes school-related prepared fields and summaries.
+
+| Field | Type | Default | Impact | Notes |
+|---|---|---|---|---|
+| `label` | string | required | Prepare, Summary | Display label for the student type. |
+| `land_use_columns` | string or list | `[]` | Prepare, Summary | Land-use columns used for this student type. |
+| `person` | mapping | optional | Prepare, Summary | Person-side selector. See the exact default and multi-entry rules below. |
+| `person.is_university` | boolean | none | Prepare, Summary | Match university flag. |
+| `person.school_segment` | scalar or list | none | Prepare, Summary | Match school segment values. |
+| `person.SCHG` | scalar or list | none | Prepare, Summary | Match `SCHG` values. |
+| `person.pstudent` | scalar or list | none | Prepare, Summary | Match `pstudent` values. |
+
+```yaml
+prepare:
+ student_types:
+ - label: K-12
+ land_use_columns: [K12_ENROLL]
+ person:
+ school_segment: [K12]
+ - label: University
+ land_use_columns: [UNIV_ENROLL]
+ person:
+ is_university: true
+```
+
+Matching rules are deterministic:
+
+1. When `prepare.student_types` is empty, prepare infers `School` from available
+ `ENROLLGRADEKto8`/`ENROLLGRADE9to12` columns and `University` from
+ `COLLEGEENROLL`.
+2. When a configured entry omits `person`, labels or land-use column names
+ containing `univ` or `college` match `is_university`; other entries match
+ `is_student` and exclude university students.
+3. With more than two configured entries, every non-university-defaulting entry
+ must provide `person`; otherwise config validation fails.
+4. A `person` mapping combines all supplied conditions with AND. Scalar and
+ list values are both accepted for `school_segment`, `SCHG`, and `pstudent`.
+5. If multiple entries match one person, the first configured entry wins.
+
+For example, three school levels must select their person rows explicitly:
+
+```yaml
+prepare:
+ student_types:
+ - label: Elementary
+ land_use_columns: [ELEM_ENROLL]
+ person:
+ SCHG: [1, 2]
+ - label: High School
+ land_use_columns: [HIGH_ENROLL]
+ person:
+ SCHG: [3]
+ - label: University
+ land_use_columns: [COLLEGEENROLL]
+ person:
+ is_university: true
+```
+
+## Related Chapters
+
+- [11 - Configuring Your Data](11-configuring-your-data.md)
+- [12 - Running Workflows](12-running-workflows.md)
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [25 - Skimjoin Config Reference](25-skimjoin-config-reference.md)
diff --git a/wiki/20-output-processor.md b/wiki/20-output-processor.md
new file mode 100644
index 0000000..da5be12
--- /dev/null
+++ b/wiki/20-output-processor.md
@@ -0,0 +1,131 @@
+# 20 - Output Processor
+
+The Output Processor turns ActivitySim model outputs into stable data products
+for the dashboard.
+
+```text
+raw ActivitySim tables or prepared table inputs
+ -> prepare
+ -> optional skimjoin
+ -> optional segmentation
+ -> summarize
+ -> prepared caches and summary caches
+```
+
+The main code lives under [`processor/`](../processor), with workflow
+orchestration under [`runtime/workflows`](../runtime/workflows).
+
+## Responsibilities
+
+The processor is responsible for:
+
+- reading raw `.csv` or `.parquet` ActivitySim outputs
+- normalizing identifiers and column names
+- deriving canonical fields used by summary builders
+- applying weights
+- adding geography and zone fields
+- optionally joining skim values to trips and tours
+- optionally slicing outputs into configured segments
+- writing prepared and summary caches
+- recording manifests and diagnostics so stale outputs can be detected
+
+The dashboard should not re-read raw ActivitySim files. It should consume
+summary caches and, only for pages that explicitly ask for them, prepared tables.
+
+## Runtime Data Contract
+
+The key runtime object is `RunData` in
+[`processor/models.py`](../processor/models.py). It holds one prepared run:
+
+| Attribute | Meaning |
+|---|---|
+| `hh` | Prepared households. |
+| `per` | Prepared persons. |
+| `day` | Optional day table. |
+| `tours` | Prepared tours. |
+| `trips` | Prepared trips. |
+| `vehicles` | Optional vehicles. |
+| `joint_participants` | Joint tour participants. |
+| `land_use` | Prepared land-use/geography table. |
+| `skim_matrix` | Optional distance skim support. |
+| `skimjoin_artifacts` | Optional skimjoin manifest and QA reports. |
+
+Summary builders and prepared-data dashboard pages should depend on this
+prepared contract rather than raw model-specific table layouts.
+
+## Processor Subsystems
+
+| Subsystem | Chapter | What to use it for |
+|---|---|---|
+| Prepare | [21 - Prepared Tables](21-prepared-tables.md) | Normalize raw outputs and add derived fields. |
+| Skimjoin | [22 - Skimjoin](22-skimjoin.md) | Add skim-derived trip and tour columns. |
+| Summaries | [23 - Summary Functions](23-summary-functions.md) | Build dashboard-ready tables. |
+| Summary catalog | [24 - Summary Catalog](24-summary-catalog.md) | Inspect registered summary outputs. |
+
+The former static prepared-cache schema document recorded one
+`estimation-output` dataset, including its row counts and model-specific
+columns. It was not a portable runtime contract and became stale as inputs
+changed. Use [Prepared Table Names and Fields](21-prepared-tables.md) for the
+stable contract and inspect the manifest and table schema of the actual cache
+when exact model-specific columns are needed.
+
+## Where Processor Output Goes
+
+Prepared caches are reusable canonical data. Summary caches are smaller,
+dashboard-ready CSVs. The summary cache is the normal dashboard input.
+
+The processor also carries diagnostic state. A table or summary can be:
+
+- available and populated
+- available but empty
+- unavailable because an optional input is missing
+- failed, with a recorded diagnostic
+
+This is intentional. The dashboard can show partial results instead of failing
+the entire workflow when one optional table or summary is unavailable.
+
+“Empty” and “unavailable” are different contracts. Empty means the input and
+calculation were valid but produced zero rows. Unavailable means a prerequisite
+table/column was absent or a declared operation could not run. Failed means an
+exception was recorded under the configured failure policy. Preserve the
+availability metadata when copying `RunData`; checking only
+`DataFrame.is_empty()` loses that distinction.
+
+### Example: Follow One Metric
+
+For a chart of trips by mode, the processor path is:
+
+```text
+final_trips.csv
+ -> prepare canonicalizes trip_mode and finalweight
+ -> RunData.trips
+ -> trips_by_mode summary groups and weights rows
+ -> registered summary cache
+ -> page reads the table through self.data.summary(...)
+```
+
+Each boundary has one owner. Prepare resolves source filenames and aliases;
+the summary defines the aggregate; the cache validates the persisted contract;
+the page handles presentation. This separation is why a page should not open a
+raw file or reproduce a weighted aggregation. Chapter 44 works through this
+example in code.
+
+## Extension Checklist
+
+When adding new processor-visible behavior:
+
+1. Decide whether the new data belongs in prepared tables, skimjoin outputs, or
+ a summary table.
+2. Add or update the smallest processor subsystem that owns that behavior.
+3. Preserve stable output schemas and use typed empty fallbacks where possible.
+4. Update dashboard page requirements if a page depends on the new output.
+5. Add focused tests for the new behavior.
+6. Regenerate wiki catalogs if summary declarations or page definitions changed.
+
+## Related Chapters
+
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [22 - Skimjoin](22-skimjoin.md)
+- [23 - Summary Functions](23-summary-functions.md)
+- [44 - Summary Function Cookbook](44-summary-function-cookbook.md)
+- [40 - Developer Workflows](40-developer-workflows.md)
diff --git a/wiki/21-prepared-tables.md b/wiki/21-prepared-tables.md
new file mode 100644
index 0000000..e8e70ba
--- /dev/null
+++ b/wiki/21-prepared-tables.md
@@ -0,0 +1,165 @@
+# 21 - Prepared Tables
+
+Prepared tables are the processor's canonical form of ActivitySim output. They
+hide raw file naming differences and expose stable fields for summaries and
+dashboard pages.
+
+## Prepare Data Flow
+
+```text
+reader
+ -> identifier and column canonicalization
+ -> escort and weight normalization
+ -> zone context
+ -> household/person enrichment
+ -> student enrollment
+ -> day and vehicle preparation
+ -> tour enrichment
+ -> trip enrichment
+ -> non-motorized distance and time-period enrichment
+ -> VOT bins
+ -> final casts
+ -> prepared cache
+```
+
+The public orchestration lives in
+[`processor/prepare/enrichment/pipeline.py`](../processor/prepare/enrichment/pipeline.py),
+with domain boundaries in `processor/prepare/enrichment/domains.py`.
+
+## Important Modules
+
+| Module | Role |
+|---|---|
+| `processor/prepare/reader.py` | Loads raw files and prepared-table inputs. |
+| `processor/prepare/enrichment/pipeline.py` | Public prepare entry point. |
+| `processor/prepare/enrichment/domains.py` | Source, people/place, mobility, and final-output orchestration. |
+| `processor/prepare/enrichment/canonicalize.py` | Identifier and core column normalization. |
+| `processor/prepare/enrichment/weights.py` | `finalweight` behavior. |
+| `processor/prepare/enrichment/zones.py` | MAZ/TAZ and geography fields. |
+| `processor/prepare/enrichment/tours.py` | Tour-level prepared fields. |
+| `processor/prepare/enrichment/trips.py` | Trip-level prepared fields. |
+| `processor/prepare/enrichment/non_motorized_distance.py` | Optional walk/bike distance enrichment. |
+| `processor/prepare/enrichment/time_periods.py` | Canonical trip and tour period fields. |
+| `processor/prepare/enrichment/finalize.py` | Final table casting. |
+| `processor/prepare/cache.py` | Prepared cache IO and manifests. |
+
+## Prepared Table Names
+
+Runtime table names are defined in `processor.models.PreparedTableName`:
+
+| Config/file table ID | `RunData`/summary-contract name | Meaning |
+|---|---|---|
+| `households` | `hh` | Households. |
+| `persons` | `per` | Persons. |
+| `day` | `day` | Day table when available. |
+| `tours` | `tours` | Tours. |
+| `trips` | `trips` | Trips. |
+| `vehicles` | `vehicles` | Vehicles when available. |
+| `joint_tour_participants` | `joint_participants` | Joint tour participants. |
+| `land_use` | `land_use` | Land use and geography lookup data. |
+| no file-map ID | `skim` | Optional `skim_matrix` support exposed as a special prepared requirement. |
+
+Use config/file IDs in `files`, `file_map`, and `prepared_table_map`. Use the
+runtime names in `RunData` access and `@summary(required_columns=...)`; for
+example, `run.per` and `required_columns={"per": ("person_type",)}`.
+
+## Common Prepared Fields
+
+The exact schema can differ by model and optional inputs, but summaries commonly
+rely on:
+
+- canonical IDs: `household_id`, `person_id`, `tour_id`, `trip_id`
+- purpose and mode fields: `tour_purpose`, `trip_purpose`, `tour_mode`, `trip_mode`
+- time fields: `start_hour`, `end_hour`, `depart_hour`
+- stop fields: `num_ob_stops`, `num_ib_stops`, `num_tot_stops`, `stops`
+- distance/geography fields: `SKIMDIST`, `OTAZ`, `DTAZ`, `HGEO`, `WGEO`
+- household/person aliases: `HHVEH`, `HHSIZE`, `AUTOSUFF`, `NUMBER_HH`
+- aggregation weight: `finalweight`
+
+Use the prepared field when it exists rather than probing raw names in a summary
+or page.
+
+This list is orientation, not a guarantee that every table has every field.
+For a specific summary, the generated catalog in chapter 24 is the authoritative
+list of required prepared columns. At runtime, `@summary` prerequisites and
+prepared-table availability metadata determine whether a calculation can run.
+
+## Inspecting An Exact Prepared Schema
+
+There is intentionally no repository-wide dump of every column from one sample
+prepared cache. Raw model extensions and optional inputs make such a snapshot
+model-specific and quickly stale.
+
+For the cache you are actually using:
+
+1. Read the run's `manifest.json` to find the prepared-table files and recorded
+ availability state.
+2. Inspect the Parquet or CSV schema for the relevant table.
+3. Use `processor.models.RunData` names at runtime and the file/config names in
+ [Prepared Table Names](#prepared-table-names).
+4. Use the generated [Summary Catalog](24-summary-catalog.md) to find the exact
+ prepared columns required by each registered summary.
+
+Stable additions belong in the owning prepare enrichment module and should be
+covered by a prepare test. A row count or a column found only in one regional
+model output is evidence about that dataset, not part of the visualizer's
+portable contract.
+
+## Adding A Prepared Column
+
+For an end-to-end worked example, see
+[Add A Column To An Existing Prepared Table](41-data-extension-cookbook.md#worked-example-add-a-column-to-an-existing-prepared-table).
+
+Use this path when many summaries/pages need the same derived field or when the
+field is part of canonical model-output normalization.
+
+Checklist:
+
+1. Choose the owning enrichment module.
+2. Add the Polars expression or transformation in the appropriate stage.
+3. Keep missing source columns graceful when the input is optional.
+4. Add final type/cast behavior if the field must be stable.
+5. Add or update tests that prepare a minimal run and assert the new column.
+6. If a summary depends on the column, add it to that summary's contract
+ `required_columns`.
+7. If a page reads it directly, add the table to `required_prepared_tables`.
+
+Example pattern:
+
+```python
+if "source_column" in state.trips.columns:
+ state.trips = state.trips.with_columns(
+ pl.col("source_column").cast(pl.Float64).alias("new_prepared_column")
+ )
+```
+
+Do not add page-only formatting columns to prepared tables. Prefer page helpers
+or summary output columns for presentation concerns.
+
+## Using Prepared Tables As Inputs
+
+`prepared_table_map` lets a config bypass raw prepare for a run:
+
+```yaml
+runs:
+ - label: Custom Prepared
+ prepared_table_map:
+ households: C:\prepared\households.parquet
+ persons: C:\prepared\persons.parquet
+ tours: C:\prepared\tours.parquet
+ trips: C:\prepared\trips.parquet
+ land_use: C:\prepared\land_use.parquet
+```
+
+This path assumes the supplied tables already match the prepared contract.
+
+Adding a new prepared table type is a larger change covering config, `RunData`,
+reader, availability, cache IO, pruning, and possibly segmentation. Follow the
+[complete worked example](41-data-extension-cookbook.md#worked-example-add-a-prepared-table).
+
+## Related Chapters
+
+- [11 - Configuring Your Data](11-configuring-your-data.md#already-prepared-tables)
+- [23 - Summary Functions](23-summary-functions.md)
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [01 - Architecture](01-architecture.md)
diff --git a/wiki/22-skimjoin.md b/wiki/22-skimjoin.md
new file mode 100644
index 0000000..dda6607
--- /dev/null
+++ b/wiki/22-skimjoin.md
@@ -0,0 +1,118 @@
+# 22 - Skimjoin
+
+Skimjoin enriches prepared trips and tours with skim-derived columns. It runs as
+an optional late-prepare step after raw outputs have been normalized.
+
+Use skimjoin when summaries or dashboard pages need values from OMX skims or
+sidecar lookup files, such as time, cost, distance, walk access, or composed
+tour-level attributes.
+
+For full field-by-field skimjoin config options, lookup-rule grammar, defaults,
+and examples, see
+[25 - Skimjoin Config Reference](25-skimjoin-config-reference.md).
+
+Two YAML files participate in integrated use:
+
+- the **main visualizer config** enables the stage with `pipeline.steps` and
+ points at files through `skimjoin.defaults` or per-run overrides; and
+- the **standalone skimjoin config** defines `project`, `activitysim`,
+ dimensions, mode/component lookup rules, fallbacks, and tour aggregation.
+
+Paths in the first file resolve from the main config; paths owned by the second
+resolve from the standalone skimjoin config. Supplying a config path alone does
+not enable the stage—`pipeline.steps` must contain `prepare` and `skimjoin`.
+
+## Runtime Placement
+
+```text
+prepare raw outputs
+ -> canonical prepared trips and tours
+ -> apply skimjoin rules
+ -> enriched trips and tours
+ -> prepared cache
+ -> summaries
+```
+
+The runtime adapter is
+[`processor/skimjoin/pipeline.py`](../processor/skimjoin/pipeline.py).
+
+## Config Anatomy
+
+A skimjoin config describes:
+
+| Section | Purpose |
+|---|---|
+| `project` | Paths such as skim files and `network_los.yaml`. |
+| `activitysim` | Source columns in prepared trips/tours. |
+| `defaults` | Default origin, destination, output prefix, and missing-data policies. |
+| `zone_mapping` | Optional zone lookup behavior. |
+| `dimensions` | Time period or other dimensions used to resolve matrix names. |
+| `modes` | Mode-specific lookup rules. |
+| `tour_aggregation` | How trip skim values roll up to tours. |
+
+Per-run overrides in the main visualizer config can change selected skim files,
+`network_los_file`, or the whole skimjoin config path.
+
+## Adding A Skim Output
+
+Start with the [Basic OD Lookup](25-skimjoin-config-reference.md#basic-od-lookup)
+for a complete mode rule, then add dimensions, fallbacks, or tour aggregation
+only when the new output requires them.
+
+Checklist:
+
+1. Confirm the prepared trips/tours contain the source columns needed for lookup.
+2. Add or update a lookup rule in the skimjoin config.
+3. Choose the output name and keep the `skim_` prefix convention unless there is
+ a strong reason not to.
+4. Set missing matrix and missing OD policies deliberately.
+5. Add fallback lookup rules only when a real fallback is meaningful.
+6. If tours need the value, configure tour aggregation or directional outputs.
+7. Add/update a summary in `processor/summarize/summaries/skimjoin.py` if the
+ dashboard needs aggregate reporting.
+8. Regenerate wiki catalogs if summary declarations or dashboard requirements
+ changed.
+
+Set `skimjoin.create_hypothetical_skim_tables: true` (globally or in a run
+override) when the configured lookups should also produce hypothetical skim
+sidecar tables. This is opt-in because it adds output work and artifacts.
+
+## Debugging Skimjoin
+
+Start with the skimjoin artifacts on the prepared run:
+
+- `skim_lookup_summary`
+- `missing_lookup_report`
+- `fallback_lookup_report`
+- `skipped_rule_report`
+- `tour_aggregation_summary`
+- `failure_report`
+
+Common causes:
+
+| Symptom | Check |
+|---|---|
+| No skim columns appear | `pipeline.steps`, resolved config path, run overrides, and skim file glob resolution. |
+| Rule skipped | Source mode, `when` clause, ignored modes, and required dimensions. |
+| Missing matrix | Matrix naming pattern, dimensions, network LOS periods, and OMX contents. |
+| Missing OD values | Origin/destination columns, zone mapping, sentinel values, and missing OD policy. |
+| Tours missing values | Tour aggregation config and outbound/inbound source columns. |
+
+## Where To Change Code
+
+| Task | Start here |
+|---|---|
+| Config shape or validation | `processor/skimjoin/config/schema.py` |
+| Config normalization | `processor/skimjoin/config/normalize.py` |
+| Skim store behavior | `processor/skimjoin/skimstore/` |
+| Trip annotation | `processor/skimjoin/annotate/trips.py` |
+| Tour annotation | `processor/skimjoin/annotate/tours.py` |
+| Runtime reports | `processor/skimjoin/runtime_reports.py` |
+| Skim summary tables | `processor/summarize/summaries/skimjoin.py` |
+
+## Related Chapters
+
+- [13 - Configuration Reference](13-configuration-reference.md#skimjoin)
+- [25 - Skimjoin Config Reference](25-skimjoin-config-reference.md)
+- [23 - Summary Functions](23-summary-functions.md)
+- [90 - Troubleshooting](90-troubleshooting.md)
diff --git a/wiki/23-summary-functions.md b/wiki/23-summary-functions.md
new file mode 100644
index 0000000..eb68960
--- /dev/null
+++ b/wiki/23-summary-functions.md
@@ -0,0 +1,153 @@
+# 23 - Summary Functions
+
+Summary functions turn prepared `RunData` into dashboard-ready Polars
+`DataFrame`s. A summary's identity, prerequisites, output schema, cache name,
+and builder are declared together.
+
+## Mental Model
+
+```text
+RunData + Config
+ -> @summary declaration and builder
+ -> validated Polars DataFrame
+ -> weighted/unweighted summary cache
+ -> dashboard page
+```
+
+Builders live under [`processor/summarize/summaries`](../processor/summarize/summaries).
+`processor.summarize.catalog` explicitly imports those owning modules and
+discovers their declarations. There is no separate summary-spec registry to
+edit.
+
+## Summary Declaration
+
+Use `@summary(...)` from `processor.summarize`. The declaration provides:
+
+- the stable summary ID and optional cache filename
+- an ordered Polars output schema
+- required prepared tables and columns
+- a typed empty result
+- strict result validation
+- whether the summary is built by default
+
+```python
+import polars as pl
+
+from processor.models import RunData
+from processor.summarize import summary
+from runtime.config import Config
+
+
+@summary(
+ id="trip_distance_by_mode",
+ schema={
+ "trip_mode": pl.Utf8,
+ "trip_count": pl.Float64,
+ "average_distance": pl.Float64,
+ },
+ required_columns={
+ "trips": ("trip_mode", "od_dist", "finalweight"),
+ },
+)
+def trip_distance_by_mode(run: RunData, config: Config) -> pl.DataFrame:
+ return (
+ run.trips.group_by("trip_mode")
+ .agg(
+ trip_count=pl.col("finalweight").sum(),
+ average_distance=(
+ (pl.col("od_dist") * pl.col("finalweight")).sum()
+ / pl.col("finalweight").sum()
+ ),
+ )
+ .with_columns(
+ pl.col("trip_mode").cast(pl.Utf8),
+ pl.col("trip_count").cast(pl.Float64),
+ pl.col("average_distance").cast(pl.Float64),
+ )
+ .select("trip_mode", "trip_count", "average_distance")
+ )
+```
+
+Successful builders must return exactly the declared columns, in the declared
+order and with the declared dtypes. Missing declared inputs are handled before
+the builder runs and produce its typed empty result.
+
+Use `required_tables` only when the presence of an entire table or `skim` is
+enough to express the prerequisite. Use `required_columns` for ordinary table
+dependencies; it also implies that the named runtime table must exist. Table
+names here are `RunData` names (`hh`, `per`, `tours`, `trips`,
+`joint_participants`, `land_use`), not config IDs such as `households` or
+`persons`.
+
+## Weighting
+
+Builders aggregate `finalweight`; they do not branch on weighting mode. The
+summary workflow supplies the appropriate prepared data for weighted and
+unweighted builds.
+
+## Adding A Summary Function
+
+For a complete calculation, contract test, catalog, and page-wiring example,
+follow the [Summary Function Cookbook](44-summary-function-cookbook.md).
+
+1. Put the builder in the domain module that owns the calculation.
+2. Decorate it with `@summary(...)` and declare identity, ordered schema, and
+ mechanical prerequisites.
+3. Read prepared `RunData` tables, not raw files.
+4. Aggregate `finalweight` and return one long-form `pl.DataFrame`.
+5. Cast and select explicitly at the end of the builder.
+6. Use `builder.empty()` only for domain-specific empty conditions that the
+ declared prerequisites cannot express.
+7. Add focused calculation and contract tests.
+8. Add the summary ID to a page's required or optional summaries when needed.
+9. Run `uv run python scripts/generate_wiki_catalogs.py`.
+
+The catalog import rejects duplicate IDs. Ordinary summarize workflows build
+every declaration with `build_by_default=True`; enabled page requirements do
+not narrow or expand that build set. `build_by_default=False` registers a
+contract without adding it to ordinary generated builds. In the current public
+workflow this is the external-table pattern: provide the table through
+`summary_table_map`. Merely listing a non-default ID in a page declaration does
+not cause its builder to run.
+
+## Summary CSV Boundary
+
+Summary caches are the dashboard input and their registered tables are already
+stored as CSV files under each run and weighting mode. Normal summarize runs
+write missing or stale cache tables unless `--skip-summary-cache-write` is used.
+
+For a developer diagnostic, this command bypasses reusable summary caches,
+rebuilds the configured summaries, and forces the cache CSVs/manifests to be
+written:
+
+```bash
+uv run activitysim-viz --config local_config.yaml --summarize --write-csvs
+```
+
+It does not create a second export format or a separate calibration directory.
+`processor.summarize.csv_export.write_summary_csvs()` is the shared low-level
+writer used by cache storage. Dashboard pages load registered summaries through
+`self.data`; they do not open those CSVs directly.
+
+To register a new dashboard-ready table produced outside the visualizer, use
+the [outside summary table recipe](41-data-extension-cookbook.md#worked-example-add-an-outside-summary-table).
+
+## Segmentation
+
+Segmentation runs inside the summarize workflow and builds the same declarations
+for configured slices of the prepared data. Segment sources may be a prepared
+column or a CSV lookup. Dashboard visibility is controlled by
+`segment.dashboard`.
+
+## Summary Catalog
+
+The generated [24 - Summary Catalog](24-summary-catalog.md) lists every current
+declaration, output filename, builder, schema, and prerequisite. Regenerate it
+after summary declarations change.
+
+## Related Chapters
+
+- [20 - Output Processor](20-output-processor.md)
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [44 - Summary Function Cookbook](44-summary-function-cookbook.md)
diff --git a/wiki/24-summary-catalog.md b/wiki/24-summary-catalog.md
new file mode 100644
index 0000000..a64deb1
--- /dev/null
+++ b/wiki/24-summary-catalog.md
@@ -0,0 +1,119 @@
+# 24 - Summary Catalog
+
+This page is generated from the `@summary(...)` declarations collected by
+`processor.summarize.catalog`.
+
+Regenerate it with:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+```
+
+
+_Generated from `processor.summarize.catalog.SUMMARY_DEFINITIONS`._
+
+Total registered summaries: **100**
+
+| Summary ID | Filename | Builder | Output schema | Required inputs |
+|---|---|---|---|---|
+| `adult_escort_event_stop_distribution` | `adult_escort_event_stop_distribution.csv` | `processor.summarize.summaries.daily_travel_escort_distributions.adult_escort_event_stop_distribution` | `segment: String`
`stop_count: Int32`
`tour_count: Float64` | tours: `tour_id`, `school_esc_outbound`, `school_esc_inbound`
trips: `tour_id`, `escort_event_role`, `escort_stops_before_event`, `escort_stops_after_event`, `finalweight` |
+| `adult_escort_trip_stop_frequency` | `adult_escort_trip_stop_frequency.csv` | `processor.summarize.summaries.daily_travel_escort_distributions.adult_escort_trip_stop_frequency` | `tour_purpose: String`
`outbound_stop_count: Int32`
`inbound_stop_count: Int32`
`total_stop_count: Int32`
`tour_count: Float64` | tours: `tour_purpose`, `school_esc_outbound`, `school_esc_inbound`, `num_ob_stops`, `num_ib_stops`, `num_tot_stops`, `finalweight` |
+| `adult_escorted_tour_distance_distribution_by_direction` | `adult_escorted_tour_distance_distribution_by_direction.csv` | `processor.summarize.summaries.daily_travel_escort_distributions.adult_escorted_tour_distance_distribution_by_direction` | `distance_bin: String`
`direction: String`
`tour_count: Float64` | tours: `SKIMDIST`, `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `adult_escorted_tour_purposes_by_direction` | `adult_escorted_tour_purposes_by_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.adult_escorted_tour_purposes_by_direction` | `tour_purpose: String`
`direction: String`
`tour_count: Float64` | tours: `tour_purpose`, `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `adult_escorted_tours_by_person_type_and_direction` | `adult_escorted_tours_by_person_type_and_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.adult_escorted_tours_by_person_type_and_direction` | `person_type: String`
`direction: String`
`tour_count: Float64` | tours: `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `adult_escorted_trip_distance_distribution_by_direction` | `adult_escorted_trip_distance_distribution_by_direction.csv` | `processor.summarize.summaries.daily_travel_escort_distributions.adult_escorted_trip_distance_distribution_by_direction` | `distance_bin: String`
`direction: String`
`trip_count: Float64` | tours: `tour_id`, `school_esc_outbound`, `school_esc_inbound`
trips: `tour_id`, `od_dist`, `finalweight` |
+| `allocated_vehicle_age_by_occupancy` | `allocated_vehicle_age_by_occupancy.csv` | `processor.summarize.summaries.tour_vehicles.allocated_vehicle_age` | `age: String`
`occupancy: String`
`vehicle_count: Float64` | tours: `vehicle_occup_1`, `vehicle_occup_2`, `vehicle_occup_3.5`, `finalweight` |
+| `allocated_vehicle_body_type_by_occupancy` | `allocated_vehicle_body_type_by_occupancy.csv` | `processor.summarize.summaries.tour_vehicles.allocated_vehicle_body` | `body_type: String`
`occupancy: String`
`vehicle_count: Float64` | tours: `vehicle_occup_1`, `vehicle_occup_2`, `vehicle_occup_3.5`, `finalweight` |
+| `allocated_vehicle_fuel_type_by_occupancy` | `allocated_vehicle_fuel_type_by_occupancy.csv` | `processor.summarize.summaries.tour_vehicles.allocated_vehicle_fuel` | `fuel_type: String`
`occupancy: String`
`vehicle_count: Float64` | tours: `vehicle_occup_1`, `vehicle_occup_2`, `vehicle_occup_3.5`, `finalweight` |
+| `atwork_subtour_frequency_distribution` | `atwork_subtour_frequency_distribution.csv` | `processor.summarize.summaries.tour_profiles.at_work_sub_tour_freq` | `atwork_subtour_frequency_category: String`
`atwork_subtour_count: Float64` | tours: `tour_purpose`, `tour_category`, `atwork_subtour_frequency`, `finalweight` |
+| `auto_ownership_distribution` | `auto_ownership_distribution.csv` | `processor.summarize.summaries.long_term_vehicle.auto_ownership` | `household_size: String`
`household_vehicle_count: Int64`
`household_count: Float64` | hh: `HHSIZE`, `HHVEH`, `finalweight` |
+| `auto_vmt_by_home_geography_income_hhsize_time_period` | `auto_vmt_by_home_geography_income_hhsize_time_period.csv` | `processor.summarize.summaries.validation.auto_vmt_by_home_geography_income_hhsize_time_period` | `geography_type: String`
`geography_id: String`
`income_segment: String`
`household_size: String`
`time_period: String`
`mode: String`
`auto_vmt: Float64`
`trip_count: Float64`
`distance_source: String`
`time_period_source: String` | trips: `finalweight` |
+| `auto_vmt_totals` | `auto_vmt_totals.csv` | `processor.summarize.summaries.validation.auto_vmt_totals` | `auto_vmt: Float64` | trips: `trip_mode`, `od_dist`, `finalweight` |
+| `auto_vmt_validation_summary` | `auto_vmt_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.auto_vmt_validation_summary` | `TOD: String`
`SOV: Float64`
`HOV2: Float64`
`HOV3: Float64`
`Truck: Float64`
`Total: Float64` | - |
+| `autonomous_vehicle_ownership_totals` | `autonomous_vehicle_ownership_totals.csv` | `processor.summarize.summaries.long_term_vehicle.av_ownership` | `household_with_autonomous_vehicle_count: Float64` | hh: `av_ownership`, `finalweight` |
+| `average_mandatory_tour_distance_by_purpose_and_geography` | `average_mandatory_tour_distance_by_purpose_and_geography.csv` | `processor.summarize.summaries.tour_geography.avg_mand_tour_distance` | `mandatory_tour_purpose: String`
`geography_type: String`
`geography_id: String`
`average_tour_distance: Float64`
`person_count: Float64` | per: `finalweight` |
+| `average_nonmandatory_tour_distance_by_purpose_and_geography` | `average_nonmandatory_tour_distance_by_purpose_and_geography.csv` | `processor.summarize.summaries.tour_geography.avg_non_mand_tour_distance` | `nonmandatory_tour_purpose: String`
`geography_type: String`
`geography_id: String`
`average_tour_distance: Float64`
`tour_count: Float64` | per: `person_id`, `home_zone_id`
tours: `person_id`, `tour_category`, `tour_purpose`, `SKIMDIST`, `finalweight` |
+| `bicycle_comfort_level_distribution` | `bicycle_comfort_level_distribution.csv` | `processor.summarize.summaries.long_term_person.bicycle_comfort_level` | `person_type: String`
`bicycle_comfort_level: String`
`person_type_label: String`
`person_count: Float64` | per: `person_type`, `bike_comfort`, `finalweight` |
+| `bicycle_vmt_by_facility_type` | `bicycle_vmt_by_facility_type.csv` | `processor.summarize.summaries.validation.bicycle_vmt_by_facility` | `facility_type: String`
`bicycle_vmt: Float64` | - |
+| `commercial_vehicle_validation_summary` | `commercial_vehicle_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.commercial_vehicle_validation_summary` | `tod: String`
`car: Float64`
`mu: Float64`
`su: Float64`
`Total: Float64` | - |
+| `commercial_vehicle_vmt_validation_summary` | `commercial_vehicle_vmt_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.commercial_vehicle_vmt_validation_summary` | `tod: String`
`car: Float64`
`mu: Float64`
`su: Float64`
`Total: Float64` | - |
+| `commercial_vmt_totals` | `commercial_vmt_totals.csv` | `processor.summarize.summaries.validation.commercial_vehicle_vmt` | `commercial_vehicle_type: String`
`external_vmt: Float64`
`internal_vmt: Float64` | - |
+| `commuting_flows` | `commuting_flows.csv` | `processor.summarize.summaries.long_term_geography.commuting_flows` | `origin_geography_type: String`
`origin_geography_id: String`
`destination_geography_type: String`
`destination_geography_id: String`
`commuter_count: Float64` | per: `home_zone_id`, `workplace_zone_id`, `is_worker`, `finalweight` |
+| `count_location_counts_validation_summary` | `count_location_counts_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.count_location_counts_validation_summary` | `id: Int64`
`FACTYPE: Int64`
`am_vol: Float64`
`md_vol: Float64`
`pm_vol: Float64`
`day_vol: Float64` | - |
+| `count_location_fit_validation_summary` | `count_location_fit_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.count_location_fit_validation_summary` | `facility_type: String`
`period: String`
`slope: Float64`
`intercept: Float64`
`r_squared: Float64`
`n_locations: Int64`
`observed_min: Float64`
`observed_max: Float64`
`equation_label: String`
`r_squared_label: String` | - |
+| `count_location_scatter_validation_summary` | `count_location_scatter_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.count_location_scatter_validation_summary` | `id: Int64`
`facility_type: String`
`period: String`
`observed_volume: Float64`
`modeled_volume: Float64` | - |
+| `count_location_volumes_validation_summary` | `count_location_volumes_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.count_location_volumes_validation_summary` | `id: Int64`
`FACTYPE: Int64`
`am_vol: Float64`
`md_vol: Float64`
`pm_vol: Float64`
`day_vol: Float64` | - |
+| `county_flows_joja_validation_summary` | `county_flows_joja_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.county_flows_joja_validation_summary` | `: String`
`Benton: Float64`
`Linn: Float64`
`Marion: Float64`
`Total: Float64` | - |
+| `county_flows_validation_summary` | `county_flows_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.county_flows_validation_summary` | `: String`
`Albany: Float64`
`Corvallis: Float64`
`Lebanon: Float64`
`Philomath: Float64`
`Total: Float64` | - |
+| `daily_activity_pattern_by_person_type` | `daily_activity_pattern_by_person_type.csv` | `processor.summarize.summaries.daily_travel_activity.dap_summary` | `person_type: String`
`daily_activity_pattern: String`
`person_count: Float64` | per: `person_type`, `cdap_activity`, `finalweight` |
+| `escorted_tour_totals` | `escorted_tour_totals.csv` | `processor.summarize.summaries.daily_travel_escort_counts.total_escorted_tours` | `tour_count: Float64` | tours: `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `external_nonmandatory_tour_locations` | `external_nonmandatory_tour_locations.csv` | `processor.summarize.summaries.tour_geography.ext_non_mand_tour_loc` | `geography_type: String`
`geography_id: String`
`external_nonmandatory_tour_count: Float64` | tours: `tour_category`, `is_external_tour`, `destination`, `finalweight` |
+| `external_trip_validation_summary` | `external_trip_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.external_trip_validation_summary` | `tod: String`
`hbcoll: Float64`
`hbo: Float64`
`hbr: Float64`
`hbs: Float64`
`hbsch: Float64`
`hbw: Float64`
`nhbnw: Float64`
`nhbw: Float64`
`truck: Float64`
`Total: Float64` | - |
+| `external_vmt_validation_summary` | `external_vmt_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.external_vmt_validation_summary` | `tod: String`
`hbcoll: Float64`
`hbo: Float64`
`hbr: Float64`
`hbs: Float64`
`hbsch: Float64`
`hbw: Float64`
`nhbnw: Float64`
`nhbw: Float64`
`truck: Float64`
`Total: Float64` | - |
+| `external_worker_workplace_locations` | `external_worker_workplace_locations.csv` | `processor.summarize.summaries.long_term_geography.external_workplace_loc` | `geography_type: String`
`geography_id: String`
`external_worker_count: Float64`
`all_worker_count: Float64` | per: `is_worker`, `is_external_worker`, `external_workplace_zone_id`, `finalweight` |
+| `free_parking_eligibility_by_workplace_geography` | `free_parking_eligibility_by_workplace_geography.csv` | `processor.summarize.summaries.long_term_geography.free_parking` | `geography_type: String`
`geography_id: String`
`workers_without_free_parking_count: Float64`
`workers_with_free_parking_count: Float64` | per: `is_worker`, `free_parking_at_work`, `workplace_zone_id`, `finalweight` |
+| `household_jtp_by_household_size_and_jtf` | `household_jtp_by_household_size_and_jtf.csv` | `processor.summarize.summaries.joint_travel.jtf_by_hhsize` | `jtf: String`
`household_size: String`
`household_percent: Float64` | hh: `household_id`, `HHSIZE`, `finalweight`
tours: `tour_category`, `household_id` |
+| `household_size_distribution` | `household_size_distribution.csv` | `processor.summarize.summaries.demographics.hh_size` | `household_size: Int64`
`household_count: Float64` | hh: `HHSIZE`, `finalweight` |
+| `households_with_school_escorting_by_student_count_and_direction` | `households_with_school_escorting_by_student_count_and_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.households_with_school_escorting_by_student_count_and_direction` | `student_count: Int64`
`direction: String`
`household_count: Float64` | hh: `household_id`, `finalweight`
per: `household_id`, `person_type`
tours: `tour_purpose`, `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `internal_external_nonmandatory_tour_frequency_by_home_geography` | `internal_external_nonmandatory_tour_frequency_by_home_geography.csv` | `processor.summarize.summaries.tour_geography.int_vs_ext_non_mand_tour_freq` | `geography_type: String`
`geography_id: String`
`internal_nonmandatory_tour_count: Float64`
`external_nonmandatory_tour_count: Float64` | per: `person_id`, `home_zone_id`
tours: `person_id`, `tour_category`, `is_external_tour`, `finalweight` |
+| `internal_external_worker_by_geography` | `internal_external_worker_by_geography.csv` | `processor.summarize.summaries.long_term_geography.internal_vs_external` | `geography_type: String`
`geography_id: String`
`internal_worker_count: Float64`
`external_worker_count: Float64` | per: `is_worker`, `is_external_worker`, `home_zone_id`, `finalweight` |
+| `joint_tour_composition_by_party_size` | `joint_tour_composition_by_party_size.csv` | `processor.summarize.summaries.joint_travel.joint_composition_by_party_size` | `tour_composition: String`
`party_size: Int64`
`joint_tour_count: Float64` | tours: `tour_category`, `composition`, `number_of_participants`, `finalweight` |
+| `joint_tour_composition_distribution` | `joint_tour_composition_distribution.csv` | `processor.summarize.summaries.joint_travel.joint_composition` | `tour_composition: String`
`joint_tour_count: Float64` | tours: `tour_category`, `finalweight` |
+| `joint_tour_party_size_distribution` | `joint_tour_party_size_distribution.csv` | `processor.summarize.summaries.joint_travel.joint_party_size` | `party_size: Int32`
`joint_tour_count: Float64` | tours: `tour_category`, `NUMBER_HH`, `finalweight` |
+| `joint_tours_by_household_size` | `joint_tours_by_household_size.csv` | `processor.summarize.summaries.joint_travel.joint_tours_hhsize` | `household_size: Int32`
`household_count: Float64`
`joint_tour_hh_count: Float64` | hh: `household_id`, `HHSIZE`, `finalweight`
tours: `tour_category`, `household_id` |
+| `jtf_distribution` | `jtf_distribution.csv` | `processor.summarize.summaries.joint_travel.joint_tour_freq` | `jtf_code: Int32`
`jtf_label: String`
`household_count: Float64` | hh: `household_id`, `finalweight` |
+| `license_holding_status_distribution` | `license_holding_status_distribution.csv` | `processor.summarize.summaries.long_term_person.license_holding_status` | `person_type: String`
`license_holding_status: String`
`person_type_label: String`
`person_count: Float64` | per: `person_type`, `has_license`, `finalweight`, `age` |
+| `link_validation_summary` | `link_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.link_validation_summary` | `id: Int64`
`From_Node: Int64`
`To_Node: Int64`
`FACTYPE: Int64`
`am_vol: Float64`
`md_vol: Float64`
`pm_vol: Float64`
`day_vol: Float64` | - |
+| `mandatory_tour_frequency_by_person_type` | `mandatory_tour_frequency_by_person_type.csv` | `processor.summarize.summaries.daily_travel_activity.mandatory_tour_freq` | `person_type: String`
`mandatory_tour_frequency: Int32`
`person_count: Float64` | per: `person_type`, `imf_choice`, `finalweight` |
+| `non_motorized_vmt_by_home_geography_income_hhsize_time_period` | `non_motorized_vmt_by_home_geography_income_hhsize_time_period.csv` | `processor.summarize.summaries.validation.non_motorized_vmt_by_home_geography_income_hhsize_time_period` | `geography_type: String`
`geography_id: String`
`income_segment: String`
`household_size: String`
`time_period: String`
`mode: String`
`non_motorized_vmt: Float64`
`trip_count: Float64`
`distance_source: String`
`time_period_source: String` | trips: `finalweight`, `trip_mode` |
+| `nonmandatory_tour_frequency_by_person_type` | `nonmandatory_tour_frequency_by_person_type.csv` | `processor.summarize.summaries.daily_travel_activity.indiv_nm_summary` | `person_type: String`
`nonmandatory_tour_frequency: String`
`person_count: Float64` | joint_participants: `person_id`
per: `person_id`, `person_type`, `finalweight`
tours: `person_id`, `tour_category` |
+| `park_and_ride_location_residual_histogram` | `park_and_ride_location_residual_histogram.csv` | `processor.summarize.summaries.long_term_geography.park_and_ride_location_residual_histogram` | `geography_type: String`
`bin_start: Float64`
`bin_end: Float64`
`geography_count: Float64` | land_use: -
tours: `tour_mode`, `finalweight` |
+| `park_and_ride_location_residuals` | `park_and_ride_location_residuals.csv` | `processor.summarize.summaries.long_term_geography.park_and_ride_location_residuals` | `geography_type: String`
`geography_id: String`
`pnr_tour_count: Float64`
`pnr_lot_capacity: Float64`
`residual_count: Float64`
`absolute_residual_count: Float64`
`percent_error: Float64` | land_use: -
tours: `tour_mode`, `finalweight` |
+| `parking_locations` | `parking_locations.csv` | `processor.summarize.summaries.trip.parking_locations` | `geography_type: String`
`geography_id: String`
`trip_count: Float64` | trips: `parking_zone`, `finalweight` |
+| `person_jtp_by_household_size` | `person_jtp_by_household_size.csv` | `processor.summarize.summaries.joint_travel.joint_participation_person_by_hhsize` | `household_size: Int64`
`joint_tour_person_count: Float64`
`total_person_count: Float64` | hh: `household_id`, `hhsize`
per: `household_id`, `num_joint_tours`, `finalweight` |
+| `person_type_distribution` | `person_type_distribution.csv` | `processor.summarize.summaries.demographics.person_type` | `person_type: String`
`person_type_label: String`
`person_count: Float64` | per: `person_type`, `finalweight` |
+| `population_totals` | `population_totals.csv` | `processor.summarize.summaries.demographics.population_totals` | `person_count: Float64`
`household_count: Float64`
`tour_count: Float64`
`trip_count: Float64`
`stop_count: Float64` | hh: `finalweight`
per: `finalweight`
tours: `finalweight`
trips: `finalweight`, `stops` |
+| `school_escorted_tours_by_escort_type_and_direction` | `school_escorted_tours_by_escort_type_and_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.escorted_tours_to_from_school` | `escort_type: String`
`direction: String`
`tour_count: Float64` | tours: `tour_purpose`, `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `school_location_distance_distribution_by_geography` | `school_location_distance_distribution_by_geography.csv` | `processor.summarize.summaries.long_term_distance.schl_tlfd` | `distance_bin: Int32`
`geography_type: String`
`geography_id: String`
`person_count: Float64` | per: `distance_to_school`, `finalweight` |
+| `school_location_enrollment_comparison` | `school_location_enrollment_comparison.csv` | `processor.summarize.summaries.long_term_geography.school_loc_vs_land_use_enrollment` | `geography_type: String`
`geography_id: String`
`student_type: String`
`enrollment_count: Float64`
`student_count: Float64` | land_use: `MAZ`, `enrollment_count`, `student_type`
per: `school_zone_id`, `is_student`, `finalweight` |
+| `school_shadow_pricing_residual_histogram` | `school_shadow_pricing_residual_histogram.csv` | `processor.summarize.summaries.long_term_geography.school_shadow_pricing_residual_histogram` | `geography_type: String`
`student_type: String`
`bin_start: Float64`
`bin_end: Float64`
`geography_count: Float64` | land_use: `MAZ`, `enrollment_count`, `student_type`
per: `school_zone_id`, `is_student`, `finalweight`, `student_type` |
+| `school_shadow_pricing_residuals` | `school_shadow_pricing_residuals.csv` | `processor.summarize.summaries.long_term_geography.school_shadow_pricing_residuals` | `geography_type: String`
`geography_id: String`
`student_type: String`
`target_count: Float64`
`modeled_count: Float64`
`residual_count: Float64`
`absolute_residual_count: Float64`
`percent_error: Float64` | land_use: `MAZ`, `enrollment_count`, `student_type`
per: `school_zone_id`, `is_student`, `finalweight`, `student_type` |
+| `schoolkids_per_escorted_tour_by_student_count_and_direction` | `schoolkids_per_escorted_tour_by_student_count_and_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.schoolkids_per_escorted_tour_by_student_count_and_direction` | `student_count: Int64`
`direction: String`
`avg_schoolkids_per_tour: Float64`
`tour_count: Float64` | hh: `household_id`, `finalweight`
per: `household_id`, `person_type`
tours: `school_esc_outbound`, `school_esc_inbound`, `num_escortees`, `finalweight` |
+| `screenline_flow_comparisons` | `screenline_flow_comparisons.csv` | `processor.summarize.summaries.validation.screenline_flow_comparisons` | `screenline_id: String`
`direction: String`
`count_period: String`
`observed_volume: Float64`
`modeled_volume: Float64` | - |
+| `skimjoin_tour_component_ecdf` | `skimjoin_tour_component_ecdf.csv` | `processor.summarize.summaries.skimjoin.tour_skim_component_ecdf` | `skim_scenario: String`
`tour_mode: String`
`component: String`
`percentile: Float64`
`value: Float64`
`n_valid: Float64` | tours: `tour_mode`, `finalweight` |
+| `skimjoin_tour_component_stats` | `skimjoin_tour_component_stats.csv` | `processor.summarize.summaries.skimjoin.tour_skim_component_stats` | `skim_scenario: String`
`tour_mode: String`
`component: String`
`n_total: Float64`
`n_valid: Float64`
`mean: Float64`
`std: Float64`
`min: Float64`
`max: Float64`
`median: Float64`
`mode: Float64`
`zero_share: Float64`
`missing_share: Float64` | tours: `tour_mode`, `finalweight` |
+| `skimjoin_trip_component_ecdf` | `skimjoin_trip_component_ecdf.csv` | `processor.summarize.summaries.skimjoin.trip_skim_component_ecdf` | `skim_scenario: String`
`trip_mode: String`
`component: String`
`percentile: Float64`
`value: Float64`
`n_valid: Float64` | trips: `trip_mode`, `finalweight` |
+| `skimjoin_trip_component_stats` | `skimjoin_trip_component_stats.csv` | `processor.summarize.summaries.skimjoin.trip_skim_component_stats` | `skim_scenario: String`
`trip_mode: String`
`component: String`
`n_total: Float64`
`n_valid: Float64`
`mean: Float64`
`std: Float64`
`min: Float64`
`max: Float64`
`median: Float64`
`mode: Float64`
`zero_share: Float64`
`missing_share: Float64` | trips: `trip_mode`, `finalweight` |
+| `stop_destination_purpose_by_tour_purpose` | `stop_destination_purpose_by_tour_purpose.csv` | `processor.summarize.summaries.trip.stop_purpose_by_tour_purpose` | `stop_destination_purpose: String`
`tour_purpose: String`
`stop_count: Float64` | trips: `stops`, `tour_purpose`, `trip_purpose`, `finalweight` |
+| `stop_out_of_direction_distance_by_tour_purpose` | `stop_out_of_direction_distance_by_tour_purpose.csv` | `processor.summarize.summaries.trip_distributions.stop_ood_distance` | `distance_bin: Int32`
`tour_purpose: String`
`stop_count: Float64` | trips: `stops`, `out_dir_dist`, `tour_purpose`, `finalweight` |
+| `student_households_by_student_count` | `student_households_by_student_count.csv` | `processor.summarize.summaries.daily_travel_escort_counts.student_households_by_student_count` | `student_count: Int64`
`household_count: Float64` | hh: `household_id`, `finalweight`
per: `household_id`, `person_type` |
+| `student_school_escort_status_by_direction` | `student_school_escort_status_by_direction.csv` | `processor.summarize.summaries.daily_travel_escort_counts.student_school_escort_status_by_direction` | `direction: String`
`escort_type: String`
`tour_count: Float64` | tours: `tour_purpose`, `school_esc_outbound`, `school_esc_inbound`, `finalweight` |
+| `telecommute_frequency_distribution` | `telecommute_frequency_distribution.csv` | `processor.summarize.summaries.long_term_person.telecommute` | `geography_type: String`
`geography_id: String`
`telecommute_frequency: String`
`person_count: Float64` | per: `telecommute_frequency`, `finalweight`, `is_worker`, `work_from_home`, `home_zone_id` |
+| `tour_category_distribution` | `tour_category_distribution.csv` | `processor.summarize.summaries.tour.tour_category` | `tour_category: String`
`tour_count: Float64` | tours: `tour_category`, `finalweight` |
+| `tour_distance_by_tour_purpose` | `tour_distance_by_tour_purpose.csv` | `processor.summarize.summaries.tour_profiles.tour_distance` | `distance_bin: String`
`tour_purpose: String`
`tour_count: Float64` | tours: `tour_purpose`, `tour_category`, `number_of_participants`, `SKIMDIST`, `finalweight` |
+| `tour_mode_by_tour_purpose_and_auto_sufficiency` | `tour_mode_by_tour_purpose_and_auto_sufficiency.csv` | `processor.summarize.summaries.tour_profiles.tour_mode` | `tour_mode: String`
`tour_purpose: String`
`tour_count_zero_auto: Float64`
`tour_count_auto_deficient: Float64`
`tour_count_auto_sufficient: Float64`
`tour_count_all_households: Float64` | tours: `tour_mode`, `tour_purpose`, `finalweight`, `AUTOSUFF` |
+| `tour_purpose_distribution` | `tour_purpose_distribution.csv` | `processor.summarize.summaries.tour.tour_purpose` | `tour_purpose: String`
`tour_count: Float64` | tours: `tour_purpose`, `finalweight` |
+| `tour_rates_by_person_type_and_tour_purpose` | `tour_rates_by_person_type_and_tour_purpose.csv` | `processor.summarize.summaries.daily_travel_activity.tour_rate_per_person` | `person_type: String`
`tour_purpose: String`
`tour_rate: Float64` | per: `person_id`, `person_type`, `finalweight`
tours: `person_id`, `tour_purpose` |
+| `tour_stop_frequency_by_tour_purpose` | `tour_stop_frequency_by_tour_purpose.csv` | `processor.summarize.summaries.tour_profiles.stop_freq` | `tour_purpose: String`
`outbound_stop_count: Int32`
`inbound_stop_count: Int32`
`total_stop_count: Int32`
`tour_count: Float64` | tours: `tour_purpose`, `tour_category`, `num_ob_stops`, `num_ib_stops`, `num_tot_stops`, `finalweight` |
+| `tour_time_of_day_by_tour_purpose` | `tour_time_of_day_by_tour_purpose.csv` | `processor.summarize.summaries.tour_profiles.tour_tod` | `time_bin: Int32`
`tour_purpose: String`
`departure_tour_count: Float64`
`arrival_tour_count: Float64`
`duration_tour_count: Float64` | tours: `tour_category`, `tour_purpose`, `finalweight` |
+| `traffic_count_comparisons` | `traffic_count_comparisons.csv` | `processor.summarize.summaries.validation.traffic_count_comparisons` | `count_location_id: String`
`direction: String`
`count_period: String`
`observed_volume: Float64`
`modeled_volume: Float64` | - |
+| `transit_boardings_by_operator_and_technology` | `transit_boardings_by_operator_and_technology.csv` | `processor.summarize.summaries.validation.total_transit_boardings` | `operator: String`
`technology: String`
`boardings: Float64` | - |
+| `transit_pass_ownership_by_person_type` | `transit_pass_ownership_by_person_type.csv` | `processor.summarize.summaries.long_term_person.transit_pass` | `person_type: String`
`transit_pass_ownership_status: String`
`person_type_label: String`
`person_count: Float64` | per: `person_type`, `transit_pass_ownership`, `finalweight` |
+| `transit_subsidy_by_person_type` | `transit_subsidy_by_person_type.csv` | `processor.summarize.summaries.long_term_person.transit_subsidy` | `person_type: String`
`transit_subsidy_status: String`
`transit_subsidy_label: String`
`person_type_label: String`
`person_count: Float64` | per: `person_type`, `transit_pass_subsidy`, `is_worker`, `is_student`, `finalweight` |
+| `transit_transfer_rate` | `transit_transfer_rate.csv` | `processor.summarize.summaries.validation.transit_transfer_rate` | `operator: String`
`technology: String`
`access_mode: String`
`transfer_rate: Float64` | - |
+| `trip_departure_time_by_purpose` | `trip_departure_time_by_purpose.csv` | `processor.summarize.summaries.trip_distributions.trip_stop_tod` | `tour_purpose: String`
`time_bin: Int32`
`departure_trip_count: Float64`
`departure_stop_count: Float64` | trips: `tour_purpose`, `stops`, `finalweight` |
+| `trip_distance_by_purpose` | `trip_distance_by_purpose.csv` | `processor.summarize.summaries.trip_distributions.trip_distance` | `distance_bin: String`
`tour_purpose: String`
`trip_count: Float64` | trips: `tour_purpose`, `od_dist`, `num_participants`, `finalweight` |
+| `trip_mode_by_tour_purpose_and_tour_mode` | `trip_mode_by_tour_purpose_and_tour_mode.csv` | `processor.summarize.summaries.trip.trip_mode` | `tour_purpose: String`
`tour_mode: String`
`trip_mode: String`
`trip_count: Float64` | trips: `tour_purpose`, `tour_mode`, `trip_mode`, `finalweight` |
+| `trip_purpose_distribution` | `trip_purpose_distribution.csv` | `processor.summarize.summaries.trip.trip_purpose` | `tour_purpose: String`
`trip_purpose: String`
`trip_count: Float64` | trips: `tour_purpose`, `trip_purpose`, `finalweight` |
+| `trip_rates_by_person_type_and_trip_purpose` | `trip_rates_by_person_type_and_trip_purpose.csv` | `processor.summarize.summaries.daily_travel_activity.trip_rate_per_person` | `person_type: String`
`trip_purpose: String`
`trip_rate: Float64` | per: `person_id`, `person_type`, `finalweight`
trips: `person_id`, `trip_purpose`, `finalweight` |
+| `university_location_distance_distribution_by_geography` | `university_location_distance_distribution_by_geography.csv` | `processor.summarize.summaries.long_term_distance.univ_tlfd` | `distance_bin: Int32`
`geography_type: String`
`geography_id: String`
`person_count: Float64` | per: `distance_to_school`, `finalweight` |
+| `vehicle_age_distribution` | `vehicle_age_distribution.csv` | `processor.summarize.summaries.long_term_vehicle.vehicle_char_age` | `age: String`
`vehicle_count: Float64` | vehicles: `vehicle_age`, `finalweight` |
+| `vehicle_body_type_distribution` | `vehicle_body_type_distribution.csv` | `processor.summarize.summaries.long_term_vehicle.vehicle_char_body` | `body_type: String`
`vehicle_count: Float64` | vehicles: `body_type`, `finalweight` |
+| `vehicle_fuel_type_distribution` | `vehicle_fuel_type_distribution.csv` | `processor.summarize.summaries.long_term_vehicle.vehicle_char_fuel` | `fuel_type: String`
`vehicle_count: Float64` | vehicles: `fuel_type`, `finalweight` |
+| `work_from_home_rate_by_geography` | `work_from_home_rate_by_geography.csv` | `processor.summarize.summaries.long_term_geography.wfh` | `geography_type: String`
`geography_id: String`
`worker_count: Float64`
`work_from_home_worker_count: Float64` | per: `is_worker`, `home_zone_id`, `finalweight` |
+| `work_from_home_validation_summary` | `work_from_home_validation_summary.csv` | `processor.summarize.summaries.validation_scaffolds.work_from_home_validation_summary` | `District: String`
`Workers: Float64`
`WFH: Float64` | - |
+| `work_location_distance_distribution_by_geography` | `work_location_distance_distribution_by_geography.csv` | `processor.summarize.summaries.long_term_distance.work_tlfd` | `distance_bin: Int32`
`geography_type: String`
`geography_id: String`
`person_count: Float64` | per: `distance_to_work`, `finalweight` |
+| `workplace_location_employment_comparison` | `workplace_location_employment_comparison.csv` | `processor.summarize.summaries.long_term_geography.workplace_vs_land_use_employment` | `geography_type: String`
`geography_id: String`
`employment_count: Float64`
`worker_count: Float64` | land_use: `MAZ`, `employment_count`
per: `workplace_zone_id`, `is_worker`, `finalweight` |
+| `workplace_shadow_pricing_residual_histogram` | `workplace_shadow_pricing_residual_histogram.csv` | `processor.summarize.summaries.long_term_geography.workplace_shadow_pricing_residual_histogram` | `geography_type: String`
`bin_start: Float64`
`bin_end: Float64`
`geography_count: Float64` | land_use: `MAZ`, `employment_count`
per: `workplace_zone_id`, `is_worker`, `finalweight` |
+| `workplace_shadow_pricing_residuals` | `workplace_shadow_pricing_residuals.csv` | `processor.summarize.summaries.long_term_geography.workplace_shadow_pricing_residuals` | `geography_type: String`
`geography_id: String`
`target_count: Float64`
`modeled_count: Float64`
`residual_count: Float64`
`absolute_residual_count: Float64`
`percent_error: Float64` | land_use: `MAZ`, `employment_count`
per: `workplace_zone_id`, `is_worker`, `finalweight` |
+
diff --git a/wiki/25-skimjoin-config-reference.md b/wiki/25-skimjoin-config-reference.md
new file mode 100644
index 0000000..7543637
--- /dev/null
+++ b/wiki/25-skimjoin-config-reference.md
@@ -0,0 +1,543 @@
+# 25 - Skimjoin Config Reference
+
+This page is the field-by-field reference for the standalone skimjoin config
+file used by the main visualizer `skimjoin` step. For the workflow overview,
+start with [22 - Skimjoin](22-skimjoin.md). The canonical example is
+[`example_skimjoin_config.yaml`](../example_skimjoin_config.yaml).
+
+Skimjoin config answers four questions:
+
+1. Which skim files and optional `network_los.yaml` should be used?
+2. Which prepared trip and tour columns provide modes, ids, dimensions, and OD
+ lookup columns?
+3. Which matrix or sidecar table should be read for each mode/component?
+4. What should happen when matrices, OD pairs, or dimension values are missing?
+
+## Common Recipes
+
+### Basic OD Lookup
+
+```yaml
+project:
+ skim_files:
+ - C:\skims\auto.omx
+
+activitysim:
+ trip_mode_column: trip_mode
+ tour_mode_column: tour_mode
+ trip_id_column: trip_id
+ tour_id_column: tour_id
+ outbound_column: outbound
+
+defaults:
+ origin: OTAZ
+ destination: DTAZ
+ output_prefix: skim_
+
+zone_mapping:
+ lookup_name: taz
+
+modes:
+ SOV:
+ time: SOV_TIME
+ distance: SOV_DIST
+```
+
+`time: SOV_TIME` is shorthand for:
+
+```yaml
+time:
+ matrix: SOV_TIME
+```
+
+### Period Dimension Lookup
+
+```yaml
+project:
+ skim_files:
+ - C:\skims\auto.omx
+ network_los_file: C:\skims\network_los.yaml
+
+activitysim:
+ trip_mode_column: trip_mode
+ tour_mode_column: tour_mode
+ trip_id_column: trip_id
+ tour_id_column: tour_id
+ outbound_column: outbound
+
+defaults:
+ origin: OTAZ
+ destination: DTAZ
+
+dimensions:
+ PERIOD:
+ source_columns:
+ trip_source_column: depart
+ outbound_tour_source_column: start
+ inbound_tour_source_column: first_inbound_trip_depart
+ values_from_network_los: true
+
+modes:
+ SOV:
+ time: SOV_TIME__{PERIOD}
+```
+
+### Segmented PNR Lookup
+
+```yaml
+modes:
+ PNR_TRANSIT:
+ output_prefix: skim_
+ segment_on: outbound
+ segments:
+ true:
+ auto_time:
+ matrix: SOV_TIME__{PERIOD}
+ origin: OTAZ
+ destination: pnr_taz
+ transit_time:
+ matrix: WTW_TIV__{PERIOD}
+ origin: pnr_taz
+ destination: DTAZ
+ false:
+ auto_time:
+ matrix: SOV_TIME__{PERIOD}
+ origin: pnr_taz
+ destination: DTAZ
+ transit_time:
+ matrix: WTW_TIV__{PERIOD}
+ origin: OTAZ
+ destination: pnr_taz
+```
+
+### Fallback Lookup
+
+```yaml
+modes:
+ SOV:
+ time:
+ output: skim_auto_time
+ matrix: SOV_TIME__{PERIOD}
+ fallbacks:
+ - matrix: SOV_TIME__MD
+```
+
+Fallbacks run after the primary lookup for rows where the earlier step did not
+produce a valid value. Fallback steps share the same final output column.
+
+### Tour Aggregation
+
+```yaml
+tour_aggregation:
+ method: aggregate_trips
+ aggregations:
+ skim_auto_time: sum
+ skim_auto_distance: sum
+ skim_transit_fare: sum
+ directional_outputs:
+ skim_auto_time: true
+```
+
+Tour lookups are also generated directly from mode rules. For tour lookup rules,
+outputs receive `_outbound` and `_inbound` suffixes.
+
+## Top-Level Sections
+
+| Section | Type | Default | Purpose |
+|---|---|---|---|
+| `project` | mapping | optional | Skim paths and standalone CLI paths. |
+| `skim_files` | list | promoted from `project.skim_files` | Direct skim file list. Usually set under `project`. |
+| `activitysim` | mapping | required | Prepared trip/tour source column names. |
+| `defaults` | mapping | built-in lookup defaults | Origin, destination, output prefix, missing-data policy, and sentinels. |
+| `zone_mapping` | mapping | no mapping name | OMX zone lookup name behavior. |
+| `dimensions` | mapping | `{}` | Placeholder definitions for matrix names. |
+| `ignore_modes` | list | `[]` | Trip modes allowed to have no lookup rules. |
+| `modes` | mapping | required | Mode-specific lookup rules. |
+| `tour_aggregation` | mapping | `aggregate_trips` with no configured aggregations | Trip-to-tour aggregation settings. |
+
+Unknown keys are rejected by the Pydantic schema for typed sections.
+
+## `project`
+
+| Field | Type | Default | Notes |
+|---|---|---|---|
+| `skim_files` | list of path strings | `[]` | OMX, CSV, HDF5, or H5 skim inputs. In integrated visualizer use, main config overrides may replace this list. |
+| `network_los_file` | path string | none | ActivitySim `network_los.yaml`, used when `dimensions.PERIOD.values_from_network_los` is true. |
+| `trips_table` | path string | none | Standalone skimjoin CLI input. Not required for integrated visualizer use. |
+| `tours_table` | path string | none | Standalone skimjoin CLI input. Optional. |
+| `output_dir` | path string | none | Standalone skimjoin CLI output directory. |
+
+```yaml
+project:
+ skim_files:
+ - C:\skims\*.omx
+ - C:\skims\maz_stop_walk.csv
+ network_los_file: C:\skims\network_los.yaml
+```
+
+## `activitysim`
+
+`activitysim` names columns in prepared trip and tour tables.
+
+| Field | Type | Default | Notes |
+|---|---|---|---|
+| `trips_table` | path string | none | Standalone CLI trips table. May also be promoted from `project.trips_table`. |
+| `tours_table` | path string | none | Standalone CLI tours table. May also be promoted from `project.tours_table`. |
+| `trip_mode_column` | string | `trip_mode` | Mode column in prepared trips. |
+| `tour_mode_column` | string | `tour_mode` | Mode column in prepared tours. |
+| `trip_id_column` | string | `trip_id` | Trip id column. |
+| `tour_id_column` | string | `tour_id` | Tour id column. |
+| `outbound_column` | string | `outbound` | Trip outbound/inbound flag. |
+
+Column names cannot be blank.
+
+## `defaults`
+
+Defaults are inherited by every mode, segment, and component unless overridden
+closer to the rule.
+
+| Field | Type | Default | Allowed values | Notes |
+|---|---|---|---|---|
+| `origin` | string | `origin` | source column | Origin column for OD lookups. |
+| `destination` | string | `destination` | source column | Destination column for OD lookups. |
+| `output_prefix` | string | `skim_` | any string | Prefix used when a component does not set `output`. |
+| `missing_matrix_policy` | string | `error` | `error`, `warn`, `set_null` | Policy for absent matrices or matrix names that cannot be resolved. |
+| `missing_od_policy` | string | `error` | `error`, `warn`, `set_null` | Policy for missing/out-of-bounds OD values. |
+| `sentinel_values` | list of numbers | `[]` | numeric list | Lookup results equal to these values are treated as missing. |
+
+```yaml
+defaults:
+ origin: OTAZ
+ destination: DTAZ
+ output_prefix: skim_auto_
+ missing_matrix_policy: warn
+ missing_od_policy: set_null
+ sentinel_values: [9999, 999999]
+```
+
+## Context Inheritance
+
+These keys may be set at the top `defaults` level, on a mode, inside a mode
+`defaults` block, inside a segment, or inside a component:
+
+`origin`, `destination`, `output_prefix`, `missing_matrix_policy`,
+`missing_od_policy`, `sentinel_values`, `when`, and `dimensions`.
+
+Closer settings override or merge with parent settings:
+
+| Key | Merge behavior |
+|---|---|
+| `origin`, `destination`, `output_prefix`, policies | Override parent value. |
+| `sentinel_values` | Override parent list. |
+| `when` | Merge by source column; child values replace same-column parent values. |
+| `dimensions` | Merge by dimension name; child dimension replaces same-name parent dimension. |
+
+```yaml
+modes:
+ SOV:
+ output_prefix: skim_auto_
+ defaults:
+ missing_od_policy: warn
+ time:
+ matrix: SOV_TIME
+ missing_od_policy: set_null
+```
+
+## `zone_mapping`
+
+`zone_mapping` controls OMX lookup-name selection.
+
+| Field | Type | Default | Allowed values | Notes |
+|---|---|---|---|---|
+| `lookup_name` | string or null | `null` | OMX mapping name | Default mapping name used for OMX OD matrices. |
+| `file_lookup_names` | mapping | `{}` | file pattern to lookup name | Overrides `lookup_name` for matching file paths or file names. Patterns use shell-style matching. |
+| `missing_zone_policy` | string | `error` | `error`, `warn`, `set_null` | Policy for missing zone mappings. |
+
+```yaml
+zone_mapping:
+ lookup_name: taz
+ file_lookup_names:
+ fares.omx: zone_number
+ "*maz*.omx": maz
+ missing_zone_policy: error
+```
+
+## `dimensions`
+
+Dimensions provide placeholder values for matrix names such as
+`SOV_TIME__{PERIOD}`.
+
+Each dimension entry has this shape:
+
+| Field | Type | Default | Notes |
+|---|---|---|---|
+| `source_columns.trip_source_column` | string | required | Source column used for trip lookup rules. |
+| `source_columns.outbound_tour_source_column` | string | required | Source column used for outbound tour lookup rules. |
+| `source_columns.inbound_tour_source_column` | string | required | Source column used for inbound tour lookup rules. |
+| `values_from_network_los` | boolean | `false` | Only supported for `PERIOD`. Requires `project.network_los_file`. |
+| `values` | mapping | `{}` | Raw source value to matrix-name token. Keys and values are normalized to strings. |
+
+If `values` is empty, the raw source value is converted to a string and inserted
+into the matrix name. If `values` is present, observed values must have a
+mapping.
+
+```yaml
+dimensions:
+ PERIOD:
+ source_columns:
+ trip_source_column: depart
+ outbound_tour_source_column: start
+ inbound_tour_source_column: first_inbound_trip_depart
+ values_from_network_los: true
+
+ VOT:
+ source_columns:
+ trip_source_column: vot_bin
+ outbound_tour_source_column: vot_bin
+ inbound_tour_source_column: vot_bin
+ values:
+ L: L
+ M: M
+ H: H
+```
+
+## `ignore_modes`
+
+`ignore_modes` lists trip modes that are allowed to appear in prepared trips
+without a matching `modes` rule. This is useful for modes where skim enrichment
+is intentionally skipped.
+
+```yaml
+ignore_modes:
+ - BIKE
+ - WALK
+ - OTHER
+```
+
+## `modes`
+
+`modes` is the heart of skimjoin. Each key is a prepared trip or tour mode. Each
+mode block may contain context keys plus component lookup rules.
+
+Reserved keys inside a mode block:
+
+| Key | Purpose |
+|---|---|
+| `output_prefix` | Overrides inherited output prefix. |
+| `origin` | Overrides inherited origin column. |
+| `destination` | Overrides inherited destination column. |
+| `dimensions` | Overrides or adds dimension definitions for this mode. |
+| `when` | Adds row filters for this mode. |
+| `segment_on` | Splits the mode into segment-specific rule blocks. |
+| `segments` | Segment value to component block mapping. |
+| `defaults` | Nested context defaults for this mode. |
+| `missing_matrix_policy` | Mode-level missing matrix policy. |
+| `missing_od_policy` | Mode-level missing OD policy. |
+| `sentinel_values` | Mode-level sentinel list. |
+| `skip` | If `true`, skip the mode block. |
+| `apply_to` | Reserved for component rules. |
+| `combine` | Reserved for component rules. |
+| `fallbacks` | Reserved for component rules. |
+| `tour_origin` | Reserved for future/compatibility context. |
+| `tour_destination` | Reserved for future/compatibility context. |
+
+Every non-reserved key in a mode or segment block is treated as a component
+name.
+
+```yaml
+modes:
+ HOV2:
+ output_prefix: skim_auto_
+ time: SR2_TIME
+ distance: SR2_DIST
+```
+
+The output names above are `skim_auto_time` and `skim_auto_distance`.
+
+## Component Rules
+
+A component rule may be a string matrix name or a mapping.
+
+| Field | Type | Default | Allowed values | Notes |
+|---|---|---|---|---|
+| `matrix` | string | required | matrix/table value name | Matrix name or matrix-name template using `{DIMENSION}` placeholders. |
+| `output` | string | `output_prefix` + component name | output column | Final output column. Tour lookup outputs also receive `_outbound` or `_inbound`. |
+| `lookup` | string | `od` | `od`, `key` | Lookup type. |
+| `key_column` | string | none | source column | Required when `lookup: key`. |
+| `origin` | string | inherited | source column | Origin column for OD lookup, or key value when `lookup: key` and `key_column` is absent. |
+| `destination` | string | inherited | source column | Destination column for OD lookup. Ignored for `lookup: key`. |
+| `when` | mapping | inherited/merged | equality or `in` filters | Additional row filters. |
+| `dimensions` | mapping | inherited/merged | dimension definitions | Component-specific placeholder definitions. |
+| `missing_matrix_policy` | string | inherited | `error`, `warn`, `set_null` | Component missing-matrix policy. |
+| `missing_od_policy` | string | inherited | `error`, `warn`, `set_null` | Component missing-OD policy. |
+| `sentinel_values` | list of numbers | inherited | numeric list | Component sentinel values. |
+| `combine` | string | `replace` | `replace`, `sum` | How to combine overlapping outputs for a row. |
+| `apply_to` | string | `both` | `trips`, `tours`, `both` | Whether the component creates trip rules, tour rules, or both. |
+| `fallbacks` | list | `[]` | list of component mappings or strings | Lookup chain attempted after the primary step. |
+
+```yaml
+modes:
+ WALK_TRANSIT:
+ output_prefix: skim_
+ access_walk_time:
+ output: skim_walk_time
+ combine: sum
+ matrix: WTW_ACC__{PERIOD}
+ egress_walk_time:
+ output: skim_walk_time
+ combine: sum
+ matrix: WTW_EGR__{PERIOD}
+```
+
+When multiple rules write the same output on overlapping rows, use
+`combine: sum` on all overlapping rules. Otherwise validation treats the overlap
+as an output collision.
+
+## `when` Filters
+
+`when` narrows a rule to rows that match source column conditions. Conditions
+may be scalar equality or an `in` list.
+
+```yaml
+modes:
+ DRIVE:
+ time:
+ matrix: SOV_TIME
+ when:
+ income_segment:
+ in: [1, 2]
+ outbound: true
+```
+
+`when` filters merge through context inheritance. A mode-level filter applies to
+all of its components unless a child filter replaces the same column key.
+
+## `segment_on` And `segments`
+
+Use `segment_on` when one mode needs different lookup rules for different
+source values. Each key under `segments` is a value from the `segment_on`
+column. Skimjoin automatically adds a matching `when` filter for each segment.
+
+```yaml
+modes:
+ PNR_TRANSIT:
+ segment_on: outbound
+ segments:
+ true:
+ auto_time:
+ matrix: SOV_TIME
+ origin: OTAZ
+ destination: pnr_taz
+ false:
+ auto_time:
+ matrix: SOV_TIME
+ origin: pnr_taz
+ destination: DTAZ
+```
+
+Validation checks that observed segment values for a covered mode have
+configured segment blocks.
+
+## `fallbacks`
+
+Fallback entries use the same string or mapping shape as primary component
+rules. They are attempted in list order after failed prior steps. A fallback
+inherits the parent component output unless it explicitly sets an output, and
+validation requires all steps in a fallback chain to share the same final
+output.
+
+```yaml
+modes:
+ SOV:
+ time:
+ matrix: SOV_TIME__{PERIOD}
+ fallbacks:
+ - matrix: SOV_TIME__MD
+ - matrix: SOV_TIME
+ missing_matrix_policy: set_null
+```
+
+Fallback reports are written to `fallback_lookup_report`.
+
+## Lookup Types
+
+| `lookup` | Required fields | Behavior |
+|---|---|---|
+| `od` | `matrix`, `origin`, `destination` | Reads an OMX OD matrix or CSV OD table by origin and destination. |
+| `key` | `matrix`, `key_column` | Reads a keyed sidecar table by one source column. |
+
+For CSV skim files, inventory code identifies key/value or origin/destination
+columns from the file structure. For OMX, OD lookups use the configured
+`zone_mapping` lookup name.
+
+```yaml
+modes:
+ WALK:
+ terminal_walk:
+ lookup: key
+ key_column: MAZ
+ matrix: walk_dist_local_bus
+ output: skim_walk_dist
+```
+
+## Trip And Tour Rules
+
+Every component creates trip and tour lookup rules by default:
+
+| Target | Source mode column | Dimension source | Output name |
+|---|---|---|---|
+| Trips | `activitysim.trip_mode_column` | `trip_source_column` | `output` |
+| Outbound tours | `activitysim.tour_mode_column` | `outbound_tour_source_column` | `output_outbound` |
+| Inbound tours | `activitysim.tour_mode_column` | `inbound_tour_source_column` | `output_inbound` |
+
+Set `apply_to: trips` or `apply_to: tours` when a component should only run on
+one target table.
+
+## `tour_aggregation`
+
+`tour_aggregation` controls trip-to-tour rollups for skim columns.
+
+| Field | Type | Default | Allowed values | Notes |
+|---|---|---|---|---|
+| `method` | string | `aggregate_trips` | `aggregate_trips` | Only supported aggregation method. |
+| `aggregations` | mapping | `{}` | `sum`, `mean`, `min`, `max`, `first`, `last` | Output column to aggregation method. |
+| `directional_outputs` | mapping | `{}` | output column to boolean | Requests directional outbound/inbound tour outputs for selected components. |
+
+```yaml
+tour_aggregation:
+ method: aggregate_trips
+ aggregations:
+ skim_auto_time: sum
+ skim_auto_distance: sum
+ skim_transit_fare: sum
+ directional_outputs:
+ skim_auto_time: true
+```
+
+## Missing Data And Reports
+
+Skimjoin writes report artifacts during integrated prepare:
+
+| Report | Purpose |
+|---|---|
+| `skim_lookup_summary` | Successful lookup counts and output summaries. |
+| `missing_lookup_report` | Missing matrix, missing OD, missing dimension, and skipped lookup details. |
+| `fallback_lookup_report` | Fallback attempts and outcomes. |
+| `skipped_rule_report` | Rules skipped by missing source columns or other selection conditions. |
+| `tour_aggregation_summary` | Tour lookup and aggregation details. |
+| `failure_report` | Runtime failure detail when skimjoin cannot complete. |
+
+Policies:
+
+| Policy | Behavior |
+|---|---|
+| `error` | Treat the missing condition as a validation/runtime failure where enforced. |
+| `warn` | Record warning/missing report rows and continue. |
+| `set_null` | Write null for the missing value and continue. |
+
+## Related Chapters
+
+- [13 - Configuration Reference](13-configuration-reference.md#skimjoin)
+- [13 - Configuration Reference](13-configuration-reference.md#skimjoin)
+- [22 - Skimjoin](22-skimjoin.md)
+- [90 - Troubleshooting](90-troubleshooting.md)
diff --git a/wiki/30-output-visualizer.md b/wiki/30-output-visualizer.md
new file mode 100644
index 0000000..964f751
--- /dev/null
+++ b/wiki/30-output-visualizer.md
@@ -0,0 +1,110 @@
+# 30 - Output Visualizer
+
+The Output Visualizer reads processor outputs and presents them as either a
+live Panel dashboard or a standalone HTML export.
+
+```text
+summary caches + optional prepared tables
+ -> dashboard state
+ -> registered pages
+ -> live Panel app or serialized HTML export
+```
+
+The main code lives under [`dashboard/`](../dashboard).
+
+## Visualizer Responsibilities
+
+The visualizer is responsible for:
+
+- loading summary runs
+- loading prepared tables only for pages that request them
+- applying global dashboard state such as weighting mode and value mode
+- resolving enabled page groups
+- rendering figures, tables, cards, and widgets
+- exporting supported page states to standalone HTML
+
+It should not rebuild summaries. If a summary is missing, run the processor
+workflow first.
+
+## Live Dashboard
+
+The live dashboard is assembled in
+[`dashboard/app.py`](../dashboard/app.py). It creates:
+
+- run colors and run legend
+- `DashboardState`
+- global weighting and value controls
+- registered page instances
+- grouped navigation tabs
+
+Configure live mode:
+
+```yaml
+pipeline:
+ steps: [summarize, dashboard]
+ dashboard_mode: live
+```
+
+Then run the normal config command:
+
+```bash
+uv run activitysim-viz --config local_config.yaml
+```
+
+## HTML Export
+
+HTML export uses the same page registry, but serializes supported page content
+into one self-contained HTML document. Export only includes states and selector
+variants generated at export time.
+
+Configure `pipeline.dashboard_mode: export` and an output path:
+
+```yaml
+pipeline:
+ steps: [summarize, dashboard]
+ dashboard_mode: export
+
+dashboard:
+ export:
+ output_path: exports/dashboard.html
+```
+
+The same normal config command then writes the export. For details, read
+[34 - HTML Export](34-html-export.md).
+
+## Dashboard State
+
+`DashboardState` centralizes the global state pages react to:
+
+- loaded run labels
+- selected weighting mode
+- selected value mode, usually percent or count
+- optional segmentation type and visibility
+- prepared-data provider state
+
+Pages should read state through the `DashboardPage` helpers instead of
+duplicating cache or run-selection logic.
+
+## Extension Path
+
+The [Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md) shows
+complete page, page-group, selector, widget, table, and figure examples.
+
+When adding visual output:
+
+1. Confirm the summary table or prepared table exists.
+2. Decide whether the output belongs on an existing page or a new page.
+3. Use shared helper modules before adding page-local utilities.
+4. Register selectors and sections through the page API when the output is
+ interactive.
+5. Declare summary and prepared-table requirements in the page definition.
+6. Add export support only through registered selectors and sections.
+7. Regenerate wiki catalogs if page definitions changed.
+
+## Related Chapters
+
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [32 - Figures and Widgets](32-figures-and-widgets.md)
+- [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md)
+- [34 - HTML Export](34-html-export.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
diff --git a/wiki/31-dashboard-pages.md b/wiki/31-dashboard-pages.md
new file mode 100644
index 0000000..8796af5
--- /dev/null
+++ b/wiki/31-dashboard-pages.md
@@ -0,0 +1,161 @@
+# 31 - Dashboard Pages
+
+Dashboard pages are discovered from modules under
+[`dashboard/pages`](../dashboard/pages). Each leaf module contains one
+`DashboardPage` subclass decorated with `@dashboard_page(...)`; page packages
+export a `DashboardGroupDefinition` as `GROUP`.
+
+## Page Definition Contract
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `page_id` | Stable config-facing page ID. |
+| `title` | Display title. |
+| `group_id` | Optional group such as `tour_summaries`. |
+| `default_enabled` | Whether the page appears by default. |
+| `prepared_data_mode` | `none`, `optional`, or `required`. |
+| `required_summary_ids` | Summary tables required by the page. |
+| `optional_summary_ids` | Independent add-on summaries that may be absent. |
+| `required_prepared_tables` | Prepared tables required by the page. |
+
+These declarations control dashboard cache loading, pruning, availability
+diagnostics, and prepared-table loading. They do **not** select which generated
+summaries the summarize workflow builds; ordinary summarize runs build every
+`build_by_default=True` declaration.
+
+`required_summary_ids` marks the page's primary data. If no run has a usable
+required table, `self.data.summary(...)` records a required-data warning and the
+page should render a standard unavailable card. `optional_summary_ids` declares
+an independent add-on: its absence should hide or replace only that feature.
+Neither declaration crashes the whole dashboard, and both can be partially
+available when some runs are usable and others are excluded.
+
+## Enabling Pages
+
+Live pages are selected in config:
+
+```yaml
+dashboard:
+ live:
+ pages:
+ - overview
+ - long_term_choices
+ - daily_travel
+ - tour_summaries
+```
+
+Group selection modes are:
+
+| Config entry | Selected children |
+|---|---|
+| `trip_summaries` | Default-enabled children, or the group's fallback child when none are default-enabled. |
+| `trip_summaries: default` | Same default-child behavior, stated explicitly. |
+| `trip_summaries: all` | Every registered child, including children with `default_enabled=False`. |
+| `trip_summaries: [trip_mode, trip_stop_distance]` | Exactly the listed children in that order. |
+
+When `dashboard.live.pages` is omitted, standalone pages and groups must be
+default-enabled, and grouped children must also be default-enabled. A group's
+`default_page_id` selects the initially visible tab/fallback; it does not by
+itself enable every child.
+
+`dashboard.export.pages` modifies matching pages in the resolved live page set;
+it is not an allow-list. Unmentioned live pages keep their default export
+behavior. Use `enabled: false`, `exclude_pages`, or `exclude_groups` to narrow
+the export. Export cannot add a page omitted from `dashboard.live.pages`.
+
+For example, enable only two trip-summary children:
+
+```yaml
+dashboard:
+ live:
+ pages:
+ - overview
+ - trip_summaries:
+ - trip_mode
+ - trip_stop_distance
+```
+
+## Prepared-Data Pages
+
+Most pages are summary-backed. A prepared-data page declares
+`prepared_data_mode` and `required_prepared_tables`. Use prepared data only when
+the page truly needs disaggregate records.
+
+Current runtime behavior is:
+
+| Mode | Live dashboard behavior |
+|---|---|
+| `none` | Prepared caches are not requested for the page. `required_prepared_tables` must be empty. |
+| `optional` | Prepared caches are requested, but the page's primary summary-backed workflow should remain useful when they are unavailable. |
+| `required` | Prepared caches are requested and the page should present an unavailable state when they cannot be loaded. |
+
+Both `optional` and `required` trigger loading; the distinction communicates
+feature criticality and contributes to the strongest requirement across enabled
+pages. Page render code remains responsible for the fallback. Standalone HTML
+export does not load prepared tables; see chapter 34 for section-level export
+rules.
+
+## Generated Page Catalog
+
+The catalog below is generated from the dashboard page registry. Regenerate it
+with:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+```
+
+
+_Generated from the dashboard page registry._
+
+Total registered pages: **27**
+
+| Page ID | Title | Group | Default | Prepared data | Required summaries | Optional summaries | Required prepared tables |
+|---|---|---|---|---|---|---|---|
+| `overview` | Overview | - | yes | `none` | `population_totals`, `person_type_distribution`, `household_size_distribution`, `auto_vmt_totals` | - | - |
+| `daily_activity_pattern` | Daily Activity Pattern | Daily Travel | yes | `none` | `daily_activity_pattern_by_person_type`, `mandatory_tour_frequency_by_person_type`, `nonmandatory_tour_frequency_by_person_type`, `tour_rates_by_person_type_and_tour_purpose`, `trip_rates_by_person_type_and_trip_purpose` | - | - |
+| `escorted_tours` | Escorted Tours | Daily Travel | yes | `none` | `escorted_tour_totals`, `school_escorted_tours_by_escort_type_and_direction`, `adult_escort_event_stop_distribution`, `adult_escorted_tours_by_person_type_and_direction`, `adult_escorted_tour_distance_distribution_by_direction`, `adult_escorted_trip_distance_distribution_by_direction`, `student_school_escort_status_by_direction`, `student_households_by_student_count`, `households_with_school_escorting_by_student_count_and_direction`, `schoolkids_per_escorted_tour_by_student_count_and_direction` | - | - |
+| `joint_travel` | Joint Travel | - | yes | `none` | `jtf_distribution`, `joint_tours_by_household_size`, `joint_tour_party_size_distribution`, `joint_tour_composition_by_party_size`, `person_jtp_by_household_size`, `household_jtp_by_household_size_and_jtf` | - | - |
+| `individual_choices` | Individual Choices | Long-Term Choices | yes | `none` | `license_holding_status_distribution`, `bicycle_comfort_level_distribution`, `transit_pass_ownership_by_person_type`, `transit_subsidy_by_person_type` | - | - |
+| `vehicle_ownership_type` | Vehicle Ownership and Type | Long-Term Choices | yes | `none` | `auto_ownership_distribution`, `autonomous_vehicle_ownership_totals`, `vehicle_age_distribution`, `vehicle_fuel_type_distribution`, `vehicle_body_type_distribution` | - | - |
+| `mandatory_location_choice` | Mandatory Location Choice | Long-Term Choices | yes | `none` | `internal_external_worker_by_geography`, `external_worker_workplace_locations`, `work_location_distance_distribution_by_geography`, `school_location_distance_distribution_by_geography`, `university_location_distance_distribution_by_geography`, `work_from_home_rate_by_geography`, `telecommute_frequency_distribution`, `average_mandatory_tour_distance_by_purpose_and_geography` | - | - |
+| `shadow_pricing` | Employment\Enrollment Match By Geography | Long-Term Choices | yes | `none` | `workplace_shadow_pricing_residuals`, `workplace_shadow_pricing_residual_histogram`, `school_shadow_pricing_residuals`, `school_shadow_pricing_residual_histogram` | - | - |
+| `tour_skims` | Tour Skims | Skim Summaries | yes | `optional` | `skimjoin_tour_component_stats` | - | `tours` |
+| `trip_skims` | Trip Skims | Skim Summaries | yes | `optional` | `skimjoin_trip_component_stats` | - | `trips` |
+| `tour_purpose` | Tour Purpose | Tour Summaries | yes | `none` | `tour_category_distribution`, `tour_purpose_distribution` | - | - |
+| `tour_mode` | Tour Mode | Tour Summaries | yes | `none` | `tour_mode_by_tour_purpose_and_auto_sufficiency`, `allocated_vehicle_age_by_occupancy`, `allocated_vehicle_fuel_type_by_occupancy`, `allocated_vehicle_body_type_by_occupancy` | - | - |
+| `tour_time` | Tour Time | Tour Summaries | yes | `none` | `tour_time_of_day_by_tour_purpose` | - | - |
+| `tour_distance` | Tour Distance | Tour Summaries | yes | `none` | `tour_distance_by_tour_purpose`, `average_mandatory_tour_distance_by_purpose_and_geography`, `average_nonmandatory_tour_distance_by_purpose_and_geography` | - | - |
+| `tour_stop_frequency` | Tour Stop Frequency | Tour Summaries | yes | `none` | `tour_stop_frequency_by_tour_purpose`, `atwork_subtour_frequency_distribution` | - | - |
+| `internal_external_tours` | Internal vs. External Tours | Tour Summaries | yes | `none` | `internal_external_nonmandatory_tour_frequency_by_home_geography`, `external_nonmandatory_tour_locations` | - | - |
+| `park_and_ride_location` | Park-and-Ride Location | Tour Summaries | yes | `none` | `park_and_ride_location_residuals`, `park_and_ride_location_residual_histogram` | - | - |
+| `trip_stop_purpose` | Trip and Stop Purpose | Trip Summaries | yes | `none` | `trip_purpose_distribution`, `stop_destination_purpose_by_tour_purpose` | - | - |
+| `trip_mode` | Trip Mode | Trip Summaries | yes | `none` | `trip_mode_by_tour_purpose_and_tour_mode` | - | - |
+| `trip_stop_time` | Trip and Stop Time | Trip Summaries | yes | `none` | `trip_departure_time_by_purpose` | - | - |
+| `trip_stop_distance` | Trip and Stop Distance | Trip Summaries | yes | `none` | `trip_distance_by_purpose`, `stop_out_of_direction_distance_by_tour_purpose` | - | - |
+| `parking_location` | Parking Location | Trip Summaries | no | `required` | `parking_locations` | - | `land_use` |
+| `traffic` | Traffic Validation | Validation Summaries | yes | `none` | `screenline_flow_comparisons` | `link_validation_summary`, `count_location_counts_validation_summary`, `count_location_volumes_validation_summary`, `count_location_scatter_validation_summary`, `count_location_fit_validation_summary` | - |
+| `transit` | Transit Validation | Validation Summaries | yes | `none` | `transit_boardings_by_operator_and_technology`, `transit_transfer_rate` | - | - |
+| `vmt` | VMT Validation | Validation Summaries | yes | `none` | `auto_vmt_by_home_geography_income_hhsize_time_period`, `non_motorized_vmt_by_home_geography_income_hhsize_time_period`, `bicycle_vmt_by_facility_type` | `commercial_vehicle_validation_summary`, `commercial_vehicle_vmt_validation_summary`, `external_trip_validation_summary`, `external_vmt_validation_summary` | - |
+| `regional_validation` | Regional Validation | Validation Summaries | no | `none` | - | `county_flows_validation_summary`, `county_flows_joja_validation_summary`, `commuting_flows` | - |
+| `raw_trip_demo` | Prepared Trip Demo | - | no | `required` | - | - | `trips` |
+
+## Registered Page Groups
+
+| Group ID | Title | Default page | Default enabled |
+|---|---|---|---|
+| `daily_travel` | Daily Travel | `daily_activity_pattern` | yes |
+| `long_term_choices` | Long-Term Choices | `individual_choices` | yes |
+| `skim_summaries` | Skim Summaries | `tour_skims` | yes |
+| `tour_summaries` | Tour Summaries | `tour_purpose` | yes |
+| `trip_summaries` | Trip Summaries | `trip_stop_purpose` | yes |
+| `validation` | Validation Summaries | `traffic` | yes |
+
+
+## Related Chapters
+
+- [30 - Output Visualizer](30-output-visualizer.md)
+- [32 - Figures and Widgets](32-figures-and-widgets.md)
+- [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
diff --git a/wiki/32-figures-and-widgets.md b/wiki/32-figures-and-widgets.md
new file mode 100644
index 0000000..06458f5
--- /dev/null
+++ b/wiki/32-figures-and-widgets.md
@@ -0,0 +1,204 @@
+# 32 - Figures And Widgets
+
+Current pages declare data access, selectors, independently refreshable
+sections, and figures through one shared authoring model. Framework code owns
+widget synchronization, query identity, missing-data diagnostics, and export
+metadata.
+
+## Page Lifecycle
+
+Every page subclasses `DashboardPage` and implements `build_page()`. That method
+declares selectors and sections once and returns a stable Panel layout.
+
+`DashboardPage.__init__()` calls `build_page()` after it creates `self.data`,
+page state, and the component registries. Ordinary pages should therefore not
+define their own `__init__`. If specialized initialization is unavoidable, it
+must call `super().__init__(state, config)`, and attributes used by
+`build_page()` must exist before that call. In practice, put declarations in
+`build_page()` and keep implementation mixins free of `__init__` methods.
+
+The main author-facing objects are:
+
+- `self.data` for summary and prepared `RunTables`
+- `self.select(...)` for ordinary dropdowns, including dynamic options
+- `self.selector(...)` only for custom widgets
+- `self.section(...)` for refreshable visible regions
+- `self.feature(...)` for a namespaced group of selectors and sections
+- `self.query(...)` for repeated or expensive transformations
+- `self.plot` for figures and tables
+
+Do not add routine `sync_controls()` or page-authored cache keys. Option
+providers and section dependencies give the framework enough information to do
+that work.
+
+## Data And Figures
+
+For end-to-end examples of an ordinary chart, a Plotly customization, a new
+shared figure type, a custom widget, and a table, use the
+[Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md).
+For the complete chart-method, count/share, table, and figure-testing API, see
+the [Plotting Reference](35-plotting-reference.md).
+
+Load the narrowest useful data selection through `self.data.summary(...)` or
+`self.data.summaries(...)`. `RunTables` applies the same Polars operation across
+runs while retaining labels and availability issues; it supports operations
+such as `where`, `with_columns`, `group`, `select`, `sort`, `join`, `map`,
+`requiring`, and `drop_empty`.
+
+A `RunTables` value is truthy when at least one run has a non-empty compatible
+table. Runs with a missing table, schema mismatch, failure, or empty input are
+excluded from iteration and described in `data.issues`; consequently,
+`data.partial` means there are both usable and excluded runs. Fluent operations
+preserve those issues. Filtering can make a frame empty without removing it, so
+call `.drop_empty()` when downstream code should ignore those runs.
+
+The `columns=` argument to `summary()` and `prepared()` is a compatibility
+check: a run missing any named column is excluded with a schema diagnostic. It
+does **not** project the returned frames. Use `.select(...)` when a transform
+needs a narrower schema.
+
+Pass `RunTables` to `self.plot` methods where possible. Shared rendering lives
+under `dashboard/rendering/`, including figures, tables, layout, and plotter
+logic. Cross-page domain helpers live under `dashboard/helpers/`.
+
+```python
+def render_mode_chart(self):
+ data = self.data.summary(
+ "trip_mode_by_tour_purpose_and_tour_mode",
+ columns=("tour_purpose", "trip_mode", "trip_count"),
+ )
+ if not data:
+ return self.summary_only_unavailable_card()
+ chart_data = self.query(
+ lambda: data.where(tour_purpose=self.purpose.value)
+ .group("trip_mode", pl.col("trip_count").sum())
+ .drop_empty()
+ )
+ return self.plot.bar(chart_data, x="trip_mode", y="trip_count")
+```
+
+## Selectors
+
+Declare a normal dropdown with its option domain in one place:
+
+```python
+self.purpose = self.select(
+ "purpose",
+ "Purpose",
+ options=self.purpose_options,
+ default="first",
+)
+```
+
+An option provider is called before dependent sections render. The framework
+repairs stale values. `default` may be `"first"`, `"last"`, or a callable.
+Use `self.selector(...)` only when wrapping a custom checkbox, numeric input, or
+another widget that `select(...)` cannot express.
+
+## Sections And Features
+
+Sections declare exactly which selectors affect them:
+
+```python
+chart = self.section(
+ "purpose_chart",
+ selectors=("purpose",),
+ render=self.render_mode_chart,
+)
+```
+
+A section renderer may return one Panel `Viewable`, or a list/tuple of
+`Viewable` objects. It should not mutate the stable section container itself;
+the lifecycle replaces that container's contents after each render.
+
+For a large page, use `self.feature("comparison")` to namespace a coherent
+workflow. Feature component IDs become `comparison.metric`, `comparison.body`,
+and so on. Features participate in the same lifecycle and export behavior as
+the parent page.
+
+Large controllers may also use private implementation mixins under a
+`_/` package. Mixins organize source responsibilities; `PageFeature`
+organizes live components. A refactored page commonly uses both. Keep mixins
+focused, do not give them `__init__` methods, keep pure transforms as functions,
+and preserve page/component IDs during source-only refactors.
+
+### Large-Page Implementation Mixins
+
+Keep the registered page module as the public facade and add only the private
+modules that correspond to real responsibilities:
+
+```text
+pages/example.py
+pages/_example/
+ __init__.py
+ contracts.py
+ transforms.py
+ composition.py
+ selector_domains.py
+ features.py
+```
+
+- `contracts.py` owns stable summary, category, option, and ordering IDs.
+- `transforms.py` owns pure dataframe-to-dataframe calculations.
+- `composition.py` owns selector, feature, section, and layout declaration.
+- `selector_domains.py` owns dynamic options and display-to-raw mappings.
+- `features.py` owns lookup/query/render methods grouped by visible workflow.
+
+The public class may assemble those responsibilities with multiple inheritance:
+
+```python
+@dashboard_page(page_id="example", title="Example", group_id="group")
+class ExamplePage(
+ ExampleCompositionMixin,
+ ExampleSelectorDomainsMixin,
+ ExampleFeatureMixin,
+ DashboardPage,
+):
+ pass
+```
+
+Every mixin method receives the final `ExamplePage` instance. Python resolves
+methods left to right through the declared bases and then `DashboardPage`.
+Mixins are not standalone pages and must not be instantiated.
+
+Keep this pattern narrow:
+
+- do not define `__init__` in an implementation mixin
+- give each mixin one coherent responsibility
+- do not define the same method in multiple mixins
+- make cross-mixin calls clear from names and module boundaries
+- keep stateless pure functions outside mixins
+- preserve page, selector, section, and export IDs during source-only refactors
+
+Mixins organize Python source; `PageFeature` organizes registered live
+components. One does not replace the other. Prefer one page class until stable
+composition, domain, transformation, and rendering boundaries make the split
+easier to understand.
+
+## Shared Helpers
+
+Check these before adding page-local utilities:
+
+| Module | Use |
+|---|---|
+| `dashboard/helpers/category_helpers.py` | Category ordering, labels, and completion. |
+| `dashboard/helpers/comparison_helpers.py` | Base-run comparisons and percent differences. |
+| `dashboard/helpers/distance_range.py` | Shared distance-range behavior. |
+| `dashboard/helpers/geography_helpers.py` | Geography levels and filters. |
+| `dashboard/helpers/person_type_helpers.py` | Person-type selectors and filters. |
+| `dashboard/helpers/time_distance_helpers.py` | Time and distance bins. |
+
+## Export Considerations
+
+Export behavior derives from the same selectors and sections used live. Keep
+render methods deterministic for each selector state and avoid unregistered
+live-only callbacks. Export can only include selector values generated at
+export time.
+
+## Related Chapters
+
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md)
+- [34 - HTML Export](34-html-export.md)
+- [35 - Plotting Reference](35-plotting-reference.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
diff --git a/wiki/33-dashboard-page-recipes.md b/wiki/33-dashboard-page-recipes.md
new file mode 100644
index 0000000..ed8af47
--- /dev/null
+++ b/wiki/33-dashboard-page-recipes.md
@@ -0,0 +1,190 @@
+# 33 - Dashboard Page Recipes
+
+Use the smallest page shape that fits the behavior. Each discoverable page
+module contains one class decorated with `@dashboard_page(...)`.
+
+## Recipe 1: Simple Summary Page
+
+```python
+import panel as pn
+
+from dashboard import DashboardPage, dashboard_page
+from dashboard.rendering import data_table
+
+
+@dashboard_page(
+ page_id="my_summary",
+ title="My Summary",
+ order=120,
+ required_summary_ids=("my_summary_table",),
+)
+class MySummaryPage(DashboardPage):
+ def build_page(self):
+ body = self.section("body", render=self.render_body)
+ return pn.Column(pn.pane.Markdown("## My Summary"), body)
+
+ def render_body(self):
+ data = self.data.summary("my_summary_table")
+ if not data:
+ return self.summary_only_unavailable_card()
+ return data_table(data, title="My Summary")
+```
+
+Use `required_summary_ids` for the page's primary workflow and
+`optional_summary_ids` for independent add-on features.
+
+## Recipe 2: Dynamic Selector
+
+Declare the option provider, selector, dependency, and render method:
+
+```python
+from dashboard.helpers.category_helpers import column_options
+from dashboard.rendering import selector_row
+
+
+def build_page(self):
+ self.purpose = self.select(
+ "purpose", "Purpose", options=self.purpose_options
+ )
+ chart = self.section(
+ "chart", selectors=("purpose",), render=self.render_chart
+ )
+ return pn.Column(selector_row(self.purpose), chart)
+
+
+def purpose_options(self):
+ data = self.data.summary("my_summary_table")
+ options, self._purpose_by_label = column_options(
+ data.to_list(),
+ "purpose",
+ category_id="tour_purpose",
+ config=self.config,
+ )
+ return options
+
+
+def render_chart(self):
+ data = self.data.summary(
+ "my_summary_table",
+ columns=("purpose", "category", "count"),
+ )
+ if not data:
+ return self.summary_only_unavailable_card()
+ raw_purpose = self._purpose_by_label[self.purpose.value]
+ return self.plot.bar(
+ data.where(purpose=raw_purpose),
+ x="category",
+ y="count",
+ title=f"Results For {self.purpose.value}",
+ )
+```
+
+The framework refreshes options and dependent sections. Use
+`self.selector(...)` only for a genuinely custom widget. Keep the label-to-raw
+mapping so display labels do not leak into data filters.
+
+## Recipe 3: Multi-Workflow Page
+
+Create one `PageFeature` per coherent user workflow:
+
+```python
+comparison = self.feature("comparison")
+self.metric = comparison.select("metric", "Metric", options=["Count", "Share"])
+comparison_body = comparison.section(
+ "body", selectors=("metric",), render=self.render_comparison
+)
+```
+
+When the Python controller itself becomes difficult to navigate, keep the
+registered page as a small facade and split implementation mixins into a
+private `_/` package. Current examples include tour mode, mandatory
+location choice, escorted tours, VMT, and traffic validation.
+
+## Recipe 4: Prepared-Data Page
+
+Declare prepared-data requirements in the decorator:
+
+```python
+@dashboard_page(
+ page_id="raw_trip_demo",
+ title="Raw Trip Demo",
+ prepared_data_mode="required",
+ required_prepared_tables=("trips",),
+ default_enabled=False,
+)
+class RawTripDemoPage(DashboardPage):
+ def build_page(self):
+ body = self.section(
+ "trip_table",
+ export_data_mode="required",
+ render=self.render_trip_table,
+ )
+ return pn.Column(pn.pane.Markdown("## Raw Trips"), body)
+
+ def render_trip_table(self):
+ data = self.data.prepared(
+ "trips",
+ columns=("trip_id", "trip_mode"),
+ )
+ if not data:
+ return self.data_not_available_card(
+ detail="This page requires disaggregate prepared trip records.",
+ missing_items=["trips"],
+ )
+ return data_table(
+ data.select("trip_id", "trip_mode"),
+ title="Raw Trips",
+ )
+```
+
+Load prepared data through `self.data`, handle an unavailable selection with a
+standard card, and keep disaggregate use limited. Prefer summaries for repeated
+aggregate views. `raw_trip_demo.py`, the skim pages, and parking location show
+the current required/optional patterns.
+
+Mark every section that reads prepared data with
+`export_data_mode="optional"` or `"required"`. Standalone export does not load
+prepared tables and skips those sections. If the page also has a summary-backed
+view that should export, place it in a separate section whose
+`export_data_mode` remains `"none"`.
+
+## Adding A New Page Group
+
+For a complete file layout, config example, discovery explanation, and tests,
+see [Add A New Page Group](45-dashboard-extension-cookbook.md#add-a-new-page-group).
+
+Create a package under `dashboard/pages/` and define `GROUP` in `__init__.py`:
+
+```python
+from dashboard.page_definitions import DashboardGroupDefinition
+
+GROUP = DashboardGroupDefinition(
+ group_id="my_group",
+ title="My Group",
+ order=90,
+ default_page_id="my_first_page",
+)
+```
+
+Every child decorator sets `group_id="my_group"`. Discovery rejects duplicate
+IDs, missing definitions, unknown groups, and invalid summary or prepared-table
+requirements.
+
+## Page Review Checklist
+
+- Decorator IDs are stable and config-friendly.
+- Required and optional data declarations match rendered workflows.
+- Selectors declare option providers and sections declare dependencies.
+- Repeated transformations use `self.query(...)` without authored cache keys.
+- Large pages use features and focused mixins only where they improve ownership.
+- Missing data produces a standard, useful diagnostic card.
+- Live and export behavior derive from the same component declarations.
+- Focused page-authoring and figure tests pass.
+- `uv run python scripts/generate_wiki_catalogs.py` leaves catalogs current.
+
+## Related Chapters
+
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [32 - Figures and Widgets](32-figures-and-widgets.md)
+- [34 - HTML Export](34-html-export.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
diff --git a/wiki/34-html-export.md b/wiki/34-html-export.md
new file mode 100644
index 0000000..1adae1f
--- /dev/null
+++ b/wiki/34-html-export.md
@@ -0,0 +1,201 @@
+# 34 - HTML Export
+
+HTML export writes a standalone dashboard file that can be opened without a
+Python server.
+
+```text
+registered dashboard pages
+ -> export payload
+ -> serialized Panel nodes
+ -> embedded CSS, Plotly, and runtime JS
+ -> one HTML file
+```
+
+## When To Use Export
+
+Use export when you need:
+
+- an offline deliverable
+- a dashboard that can be emailed or archived
+- a frozen set of run comparisons
+- no Python server dependency for viewers
+
+Use live mode when you need:
+
+- full Python-backed interactivity
+- exploratory pages that are not export-ready
+- development/debugging feedback
+
+## Export Configuration
+
+Keep the workflow and output path in the main config:
+
+```yaml
+root: artifacts
+
+pipeline:
+ steps: [summarize, dashboard]
+ dashboard_mode: export
+
+dashboard:
+ export:
+ output_path: exports/dashboard.html
+```
+
+Run the same command used for every configured workflow:
+
+```bash
+uv run activitysim-viz --config local_config.yaml
+```
+
+This writes `artifacts/exports/dashboard.html`. Relative export paths resolve
+below `root`; an absolute path writes elsewhere. Change
+`pipeline.dashboard_mode` back to `live` when the same config should serve the
+dashboard instead.
+
+Export begins with the pages resolved by `dashboard.live.pages`. The
+`dashboard.export.pages` mapping modifies matching page selectors and parts; it
+does not select the included page set. Use a page override with `enabled: false`,
+`exclude_pages`, or `exclude_groups` to narrow the live set. Export cannot add a
+page that live configuration omitted.
+
+## Supported Runtime Behavior
+
+The export runtime supports a deliberately small set of rendered objects:
+
+- containers
+- cards
+- tabs
+- Plotly panes
+- tables
+- Markdown/HTML panes
+- registered regions
+- registered selector widgets
+
+The Python-to-JavaScript contract lives in `dashboard/export/types.py`, and the
+browser runtime lives under `dashboard/export/js_runtime/`.
+
+## Selector Variants
+
+Page-local export interactivity is pre-rendered. During export, the runtime
+walks configured selector values, renders page regions, serializes them, and
+stores them as variants.
+
+That means:
+
+- exported selectors can only switch among values generated at export time
+- large selector domains can make export files large
+- pages must register selectors and sections through the page API
+- live-only callbacks do not automatically work in export
+
+Selector and part names are author-defined IDs, not widget labels or section
+titles. Find selector IDs in a page's `self.select(...)` and
+`self.selector(...)` calls, and part IDs in `self.section(...)` calls. Feature
+IDs prefix their components (for example, `comparison.metric` and
+`comparison.body`). The page/group IDs are listed in the generated catalog in
+chapter 31, and chapter 13 contains a complete override example. Invalid page,
+selector, part, or selector-value entries fail or produce a targeted warning
+rather than being silently guessed.
+
+For a concrete selector/section declaration that works in both modes, see the
+[Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md#add-a-dynamic-selector).
+
+## Page Authoring Contract
+
+Export metadata comes from the same page registration graph used by the live
+dashboard:
+
+- `@dashboard_page(...)` owns page identity, grouping, order, and data
+ requirements.
+- `build_page()` creates stable widgets, sections, features, and layout.
+- `self.select(...)` registers ordinary dropdowns and their option/default
+ policy.
+- `self.selector(...)` registers custom widgets.
+- `self.section(...)` defines refresh and export-region boundaries.
+
+Keep section renderers deterministic for a given selector state. Set
+`export=False` on a section that should remain static in the exported shell,
+and `exportable=False` on a selector that should remain live-only. Do not add a
+second export-only registry or duplicate selector metadata on the page
+definition.
+
+Grouped export configuration addresses children by their leaf `page_id`:
+
+```yaml
+dashboard:
+ export:
+ pages:
+ trip_summaries:
+ children:
+ trip_mode:
+ tour_purpose: all
+```
+
+Validation rejects unknown page, group, selector, and part IDs against this
+shared runtime graph.
+
+## Prepared Data Is A Live-Only Boundary
+
+The export workflow loads summary caches but does not load prepared runs. A
+section that reads prepared data must declare that boundary:
+
+```python
+trip_table = self.section(
+ "trip_table",
+ export_data_mode="required",
+ render=self.render_trip_table,
+)
+```
+
+During HTML export, any section whose `export_data_mode` is `optional` or
+`required` is skipped. The distinction still documents whether the feature is
+optional or essential in live mode. Summary-only sections use the default
+`export_data_mode="none"` and remain eligible for export. Split mixed pages
+into separate prepared-backed and summary-backed sections so the latter can be
+exported safely.
+
+## Important Files
+
+| File | Role |
+|---|---|
+| `dashboard/export/html.py` | Builds and writes the final HTML document. |
+| `dashboard/export/payload.py` | Builds export payloads and selector variants. |
+| `dashboard/export/page_serializer.py` | Walks one registered page and serializes its selector states and sections. |
+| `dashboard/export/selector_states.py` | Resolves selector domains, configured values, and scoped widget state. |
+| `dashboard/export/traversal.py` | Projects registered page components onto the export traversal contract. |
+| `dashboard/export/serializer.py` | Converts Panel objects to export nodes. |
+| `dashboard/export/types.py` | Defines payload and node dataclasses. |
+| `dashboard/export/runtime_assets.py` | Loads CSS and JavaScript runtime assets. |
+| `dashboard/export/js_runtime/` | Readable browser runtime source. |
+| `dashboard/export/assets/export_runtime.js` | Built browser runtime embedded in exports. |
+
+## Changing Export Runtime Behavior
+
+Checklist:
+
+1. Update Python payload or node types.
+2. Update serializer or payload builder.
+3. Update JavaScript runtime source.
+4. Rebuild `assets/export_runtime.js` with
+ `uv run python dashboard/export/build_export_runtime.py`.
+5. Add/update fixture, contract, and smoke tests.
+6. Bump `EXPORT_SCHEMA_VERSION` if older payloads are no longer safe.
+7. Update this wiki chapter if user-visible behavior changed.
+8. Update the [HTML Export Schema](36-html-export-schema.md) when the payload
+ contract changed.
+
+## Debugging Exports
+
+1. Open the exported HTML in a browser.
+2. Open developer tools and check the console.
+3. Look for `ExportRuntimeError` messages.
+4. Try `?debug_export=1` in the URL.
+5. Compare live mode to export mode with the same config and summary caches.
+
+## Related Chapters
+
+- [30 - Output Visualizer](30-output-visualizer.md)
+- [32 - Figures and Widgets](32-figures-and-widgets.md)
+- [90 - Troubleshooting](90-troubleshooting.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
+- [36 - HTML Export Schema](36-html-export-schema.md)
diff --git a/wiki/35-plotting-reference.md b/wiki/35-plotting-reference.md
new file mode 100644
index 0000000..6cc4874
--- /dev/null
+++ b/wiki/35-plotting-reference.md
@@ -0,0 +1,163 @@
+# 35 - Plotting Reference
+
+Dashboard pages use one plotting surface: `self.plot`. It accepts the same
+`RunTables` object returned by `self.data`, applies the session's run colors and
+count/share mode, and returns a Panel view ready for a section.
+
+## The normal path
+
+Fetch, query, and plot without converting the data to tuple lists:
+
+```python
+data = (
+ self.data.summary(
+ "trip_mode_by_purpose",
+ columns=("purpose", "mode", "trip_count"),
+ )
+ .where(purpose=self.purpose_sel.value)
+ .group("mode", pl.col("trip_count").sum())
+ .sort("mode")
+)
+
+return self.plot.bar(
+ data,
+ x="mode",
+ y="trip_count",
+ title="Trip Mode",
+ x_title="Mode",
+ y_title="Trips",
+)
+```
+
+Every chart argument after `data` is keyword-only. The short names (`x`, `y`,
+`x_title`, and `y_title`) are the complete public vocabulary; the former
+`x_col`, `y_col`, and `xaxis_title` aliases are not supported.
+
+## Chart types
+
+Use:
+
+- `self.plot.bar(...)` for discrete categories;
+- `self.plot.density(...)` for ordered distributions such as time or distance;
+- `self.plot.line(...)` for an unfilled profile; and
+- `self.plot.scatter(...)` for observed-versus-modeled comparisons.
+
+All four validate their required columns before calling Plotly. An error names
+the chart type, run, and missing columns.
+
+```python
+return self.plot.density(
+ data,
+ x="distance_bin",
+ y="tour_count",
+ title="Tour Distance",
+ x_title="Distance (miles)",
+ y_title="Tours",
+ x_range=(0, 40),
+)
+```
+
+Sort ordered data in the query. For categorical bars, pass
+`category_order=[...]` when the configured display order matters or missing
+categories must keep a stable axis position.
+
+## Count and share behavior
+
+`value_mode` has three values:
+
+- `"dashboard"` (the default) follows the Count/Share dashboard toggle;
+- `"count"` always plots the supplied values; and
+- `"share"` always normalizes each run to 100 percent.
+
+```python
+return self.plot.bar(
+ data,
+ x="mode",
+ y="trip_count",
+ value_mode="share",
+)
+```
+
+If a summary already contains a specifically defined share, provide that
+column with `share_y`. The renderer selects it only in share mode:
+
+```python
+return self.plot.bar(
+ data,
+ x="mode",
+ y="trip_count",
+ share_y="trip_count_percent",
+)
+```
+
+Use `share_y` when the denominator has domain meaning that cannot be recovered
+by summing `y`. Do not select between count and percent columns in the page just
+to follow the global toggle. There are no `as_percent`, `normalize`,
+`percent_y_col`, or `pct_col` plotting arguments.
+
+## Figure-first escape hatch
+
+The core builders return `plotly.graph_objects.Figure`, which is useful for
+testing or for adding a genuinely page-specific annotation:
+
+```python
+figure = self.plot.figure.scatter(
+ data,
+ x="observed_volume",
+ y="modeled_volume",
+ one_to_one=True,
+)
+figure.add_vline(x=1000, line_dash="dot")
+return self.plot.panel(figure)
+```
+
+Prefer the normal `self.plot.*` methods when no figure customization is needed.
+They use the same immutable `RenderContext` as export, so live and exported
+charts receive identical colors, labels, hover policy, and value mode without
+module-global setup.
+
+## Tables and layout
+
+Display helpers are grouped by responsibility under `dashboard.rendering`:
+
+```python
+from dashboard.rendering import data_table, selector_row
+```
+
+`data_table(data, title)` accepts `RunTables` directly. Page KPI values should
+use `self.plot.kpi(...)`, which shares the same run context as charts. Selector
+rows, missing-data cards, legends, and other layout helpers live in
+`dashboard.rendering.layout`; numeric and column formatting lives in
+`dashboard.rendering.tables`.
+
+## Testing charts
+
+Test the figure instead of constructing a full Panel layout:
+
+```python
+context = RenderContext(
+ run_colors=("#3366cc",),
+ run_labels=("Base",),
+)
+figure = Plotter(context).figure.bar(data, x="mode", y="trip_count")
+
+assert figure.data[0].name == "Base"
+assert list(figure.data[0].x) == ["Walk", "Bike"]
+```
+
+This keeps plot tests fast and isolates data/query behavior from Panel.
+
+Use the focused plotting target during development:
+
+```bash
+pytest tests/test_figure_builders.py
+```
+
+Page query behavior belongs in `tests/test_page_authoring.py`; the complete
+HTML export suite is a separate release-boundary check.
+
+## Related Chapters
+
+- [32 - Figures And Widgets](32-figures-and-widgets.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
+- [46 - Testing](46-testing.md)
diff --git a/docs/export_html_schema.md b/wiki/36-html-export-schema.md
similarity index 84%
rename from docs/export_html_schema.md
rename to wiki/36-html-export-schema.md
index f67bb2e..acb1f9d 100644
--- a/docs/export_html_schema.md
+++ b/wiki/36-html-export-schema.md
@@ -1,11 +1,14 @@
-# Export HTML Schema
+# 36 - HTML Export Schema
This document defines the Python-to-JavaScript contract used by the standalone offline dashboard export.
The implementation lives under `dashboard/export/`:
- `dashboard/export/html.py`: entry points that build and write the final self-contained HTML document
-- `dashboard/export/payload.py`: payload assembly, page-shell construction, and region-local selector expansion
+- `dashboard/export/payload.py`: dashboard-state and top-level payload composition
+- `dashboard/export/traversal.py`: page-tree and export-region resolution
+- `dashboard/export/selector_states.py`: selector request and canonical-state enumeration
+- `dashboard/export/page_serializer.py`: page-shell and region-variant serialization
- `dashboard/export/serializer.py`: Panel-to-payload node serialization
- `dashboard/export/runtime_assets.py`: asset loading and HTML shell assembly
- `dashboard/export/types.py`: typed payload and node definitions
@@ -76,9 +79,12 @@ Each `SelectorMetadataPayload` contains:
| `request_mode` | `str` | Config request mode such as `default`, `all`, or explicit values |
| `requested_values` | `list[str]` | Raw values requested by config before resolution against widget options |
| `resolved_values` | `list[str]` | Final export values after validation against widget options |
-| `default_value` | `str | None` | Selector value restored after variant generation and used for initial page state |
+| `default_value` | JSON-compatible value | Selector value restored after variant generation and used for initial page state |
| `options` | `list[str]` | Full live widget options observed during serialization |
| `export_enabled` | `bool` | Whether the selector is interactive in export or rendered as a disabled/static control |
+| `parent_selector_id` | `str` (optional) | Parent selector for a dependent option domain |
+| `options_by_parent_value` | `dict[str, list[str]]` (optional) | Child options keyed by parent value |
+| `disabled_parent_values` | `list[str]` (optional) | Parent values for which the dependent selector is disabled |
Selector config is driven from:
@@ -134,6 +140,7 @@ Fields:
| `default_key` | `str` | JSON-encoded selector combination restored on load/fallback |
| `default_content` | `ExportNode` | Serialized subtree for the default selector combination |
| `variants` | `dict[str, ExportNode]` | Mapping from selector-combination key to serialized subtree |
+| `variant_aliases` | `dict[str, str]` | Alternate selector keys mapped to a canonical rendered variant |
Variant keys are JSON strings generated by `dashboard.export.serializer.variant_key()`.
@@ -153,18 +160,22 @@ The browser runtime only supports the node kinds declared in `dashboard/export/t
| Kind | Produced from | Important fields |
|---|---|---|
-| `container` | `pn.Column`, `pn.Row` | `layout`, `children` |
+| `container` | `pn.Column`, `pn.Row` | `layout`, `children`, `child_count`, `styles`, `css_classes` |
| `card` | `pn.Card` | `title`, `children` |
| `tabs` | `pn.Tabs` | `tabs` |
| `region` | exportable `DashboardPage.section(...)` registrations | `region_id`, `selector_ids`, `default_content`, `variants` |
| `plotly` | `pn.pane.Plotly` | `figure` |
| `table` | `pn.widgets.Tabulator` | `columns`, `rows` |
-| `widget` | `pn.widgets.Select`, `pn.widgets.RadioButtonGroup` | `widget_type`, `value`, `options`, `selector_id`, `export_enabled` |
+| `widget` | registered Panel widgets | `widget_type`, `name`, `value`, `options`, `step`, `disabled`, `selector_id`, `export_enabled`, optional dependent-selector fields |
| `html` | `pn.pane.Markdown`, `pn.pane.HTML`, plain strings, unsupported fallback markup | `html` |
| `spacer` | `pn.Spacer` | no extra fields |
Unsupported objects currently serialize to an `html` node containing a visible fallback panel. The runtime itself treats unknown node kinds as an error and shows an error panel.
+Supported widget types are `select`, `radio_button_group`, `float_input`,
+`checkbox`, and `button`. `SelectorMetadataPayload.default_value` and widget
+values are JSON-compatible values and are not limited to strings.
+
## Runtime Validation Rules
The embedded runtime validates:
@@ -207,3 +218,9 @@ When adding a new serialized node kind:
4. Add serializer coverage in `tests/test_export_serializer.py`.
5. Add or update payload/smoke assertions if the new node can appear in representative exports.
6. Update this document.
+
+## Related Chapters
+
+- [34 - HTML Export](34-html-export.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
+- [46 - Testing](46-testing.md)
diff --git a/wiki/40-developer-workflows.md b/wiki/40-developer-workflows.md
new file mode 100644
index 0000000..a6438a5
--- /dev/null
+++ b/wiki/40-developer-workflows.md
@@ -0,0 +1,118 @@
+# 40 - Developer Workflows
+
+This chapter is for contributors changing code or documentation.
+
+## Codebase Map
+
+```text
+activitysim_visualizer/
+|-- run.py
+|-- runtime/
+| |-- config/
+| `-- workflows/
+|-- processor/
+| |-- prepare/
+| |-- skimjoin/
+| |-- summarize/
+| `-- models.py
+|-- dashboard/
+| |-- app.py
+| |-- export/
+| |-- helpers/
+| |-- rendering/
+| |-- pages/
+| |-- page_base.py
+| |-- page_declarations.py
+| |-- page_definitions.py
+| |-- page_features.py
+| |-- page_lifecycle.py
+| |-- page_registry.py
+| `-- state.py
+|-- scripts/
+|-- tests/
+`-- wiki/
+```
+
+## Common Change Paths
+
+| Change | Start with |
+|---|---|
+| New raw-output normalization | [21 - Prepared Tables](21-prepared-tables.md) |
+| New prepared column | [21 - Prepared Tables](21-prepared-tables.md#adding-a-prepared-column) |
+| New skim-derived output | [22 - Skimjoin](22-skimjoin.md#adding-a-skim-output) |
+| New generated summary function/table | [44 - Summary Function Cookbook](44-summary-function-cookbook.md) |
+| New figure or table on existing page | [32 - Figures and Widgets](32-figures-and-widgets.md) |
+| New dashboard page | [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md) |
+| New export node/runtime behavior | [34 - HTML Export](34-html-export.md#changing-export-runtime-behavior) |
+| Plotting API or count/share behavior | [35 - Plotting Reference](35-plotting-reference.md) |
+| New externally produced summary table/file | [41 - Data Extension Cookbook](41-data-extension-cookbook.md#worked-example-add-an-outside-summary-table) |
+| New prepared column or table | [41 - Data Extension Cookbook](41-data-extension-cookbook.md) |
+| New config key or source-column alias | [42 - Config, Columns, and Labels](42-config-column-label-cookbook.md) |
+| New dashboard label mapping | [42 - Config, Columns, and Labels](42-config-column-label-cookbook.md#worked-example-add-a-label-mapping-and-use-it-on-a-page) |
+| New weighting mode or host | [43 - Weighting and Hosting Extensions](43-weighting-hosting-extensions.md) |
+| New page, page group, widget, table, or figure | [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md) |
+
+## Testing Guidance
+
+Use focused tests for the subsystem you changed:
+
+- prepare changes: minimal raw/prepared input tests and cache identity tests
+- skimjoin changes: config normalization, lookup behavior, reports
+- summary changes: builder output schema, weighted behavior, missing inputs
+- page changes: page registry requirements and page render smoke tests
+- export changes: serializer, payload contract, and export HTML smoke tests
+
+Common command:
+
+```bash
+uv run --with pytest pytest --basetemp .pytest_tmp
+```
+
+Run narrower tests while iterating when possible.
+The [Testing](46-testing.md) chapter documents the fast/full marker split and
+the required release-boundary commands.
+
+## Generated Wiki Catalogs
+
+Regenerate catalogs after changing:
+
+- `@summary(...)` declarations and contracts
+- `processor/summarize/catalog.py`
+- dashboard page definitions
+- page data requirements
+
+Command:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+```
+
+Generated sections are marked with comments. Do not edit inside generated
+markers by hand.
+
+## Documentation Maintenance
+
+When behavior changes, update docs in the same change:
+
+| Change | Wiki updates |
+|---|---|
+| Config behavior | `11-configuring-your-data.md` and `13-configuration-reference.md` |
+| Prepare behavior | `21-prepared-tables.md` |
+| Skimjoin behavior | `22-skimjoin.md` |
+| Summary contract or registration | `23-summary-functions.md`, then regenerate catalogs |
+| Dashboard page API | `31-dashboard-pages.md`, `32-figures-and-widgets.md`, `33-dashboard-page-recipes.md` |
+| Export payload/runtime | `34-html-export.md` |
+| Export payload schema | `36-html-export-schema.md` |
+| User-visible failure mode | `90-troubleshooting.md` |
+| Cross-cutting extension recipe | `41-data-extension-cookbook.md` through `45-dashboard-extension-cookbook.md` |
+
+## Review Checklist
+
+- The change follows the owning subsystem's existing patterns.
+- Config and cache behavior are explicit.
+- Missing optional inputs fail gracefully.
+- Summary/page requirements are declared where the runtime can see them.
+- Tests cover the behavior rather than only the implementation detail.
+- Generated wiki catalogs are current.
+- The fast suite passes, and the `full_export` boundary passes when the change
+ affects pages, plotting, summaries, or export.
diff --git a/wiki/41-data-extension-cookbook.md b/wiki/41-data-extension-cookbook.md
new file mode 100644
index 0000000..295247e
--- /dev/null
+++ b/wiki/41-data-extension-cookbook.md
@@ -0,0 +1,330 @@
+# 41 - Data Extension Cookbook
+
+This chapter contains end-to-end examples for extending the data that reaches
+the dashboard. Each recipe starts at the narrowest supported boundary.
+
+## Choose The Smallest Extension
+
+| Need | Extension |
+|---|---|
+| Load a dashboard-ready file produced elsewhere | Register an external summary schema and use `summary_table_map`. |
+| Reuse one derived value in several summaries | Add a column to an existing prepared table. |
+| Carry a genuinely new row grain through the whole application | Add a prepared table. |
+
+Adding a prepared table is much more invasive than adding a column. Prefer a
+column unless the new data has its own stable row grain and lifecycle.
+
+## Worked Example: Add An Outside Summary Table
+
+Suppose another process writes `regional_emissions.csv`:
+
+```csv
+pollutant,tons
+CO2,1250.5
+NOX,18.2
+```
+
+The visualizer only accepts registered summary IDs with exact schemas. Register
+the outside table with a no-op builder in an owning summary module. For a group
+of project-supplied tables, a module such as
+`processor/summarize/summaries/external_project.py` is appropriate:
+
+```python
+import polars as pl
+
+from processor.models import RunData
+from processor.summarize import summary
+from runtime.config import Config
+
+
+@summary(
+ id="regional_emissions",
+ build_by_default=False,
+ schema={
+ "pollutant": pl.Utf8,
+ "tons": pl.Float64,
+ },
+)
+def regional_emissions(run: RunData, config: Config) -> pl.DataFrame:
+ return regional_emissions.empty()
+```
+
+`build_by_default=False` is important: raw ActivitySim runs cannot build this
+table, but the ID and contract must exist so an outside file can be validated.
+
+If this is a new module, import it and add it to `SUMMARY_MODULES` in
+`processor/summarize/catalog.py`:
+
+```python
+from processor.summarize.summaries import external_project
+
+SUMMARY_MODULES = (
+ # existing modules...
+ external_project,
+)
+```
+
+Point a run at the file:
+
+```yaml
+runs:
+ - label: Regional Inventory
+ summary_table_map:
+ regional_emissions: inputs/regional_emissions.csv
+```
+
+Relative paths resolve from the main config file. CSV and Parquet are
+supported. The loader:
+
+1. rejects unknown summary IDs;
+2. rejects missing or unexpected columns;
+3. casts to the declared dtypes and declared column order; and
+4. exposes the same outside table under every configured weighting mode.
+
+The fourth behavior matters: an outside table is assumed to be already
+aggregated. Selecting Weighted or Unweighted does not recalculate it.
+
+Wire the table to a page as optional data:
+
+```python
+@dashboard_page(
+ page_id="regional_validation",
+ title="Regional Validation",
+ optional_summary_ids=("regional_emissions",),
+)
+class RegionalValidationPage(DashboardPage):
+ def render_emissions(self):
+ data = self.data.summary("regional_emissions")
+ if not data:
+ return self.data_not_available_card(
+ detail="Provide regional_emissions with summary_table_map.",
+ missing_items=["regional_emissions"],
+ )
+ return self.plot.bar(data, x="pollutant", y="tons")
+```
+
+Use `required_summary_ids` only if the page has no meaningful primary view
+without the table.
+
+Tests should prove registration, strict schema validation, loading, and page
+requirements:
+
+```python
+def test_regional_emissions_is_external_only():
+ definition = SUMMARY_BY_ID["regional_emissions"]
+ assert definition.build_by_default is False
+ assert list(definition.contract.schema) == ["pollutant", "tons"]
+
+
+def test_external_emissions_loads(tmp_path, config):
+ path = tmp_path / "regional_emissions.csv"
+ pl.DataFrame(
+ {"pollutant": ["CO2"], "tons": [1250.5]}
+ ).write_csv(path)
+ run = load_summary_table_map(
+ summary_table_map={"regional_emissions": str(path)},
+ label="Inventory",
+ run_key="inventory",
+ config=config,
+ )
+ assert run.summaries_by_mode["weighted"]["regional_emissions"].height == 1
+```
+
+Run:
+
+```bash
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_summary_declarations.py tests/test_runtime_workflows.py
+uv run python scripts/generate_wiki_catalogs.py
+```
+
+## Worked Example: Add A Column To An Existing Prepared Table
+
+Suppose several summaries need a canonical household field named
+`area_type`. The raw table already contains enough information to derive it.
+
+Put the transformation in the enrichment module that owns the domain. For a
+household field, that is normally
+`processor/prepare/enrichment/households_persons.py`:
+
+```python
+def _add_household_area_type(state: _PrepareState) -> _PrepareState:
+ if "density" not in state.hh.columns:
+ return state
+ state.hh = state.hh.with_columns(
+ pl.when(pl.col("density") >= 10_000)
+ .then(pl.lit("urban"))
+ .otherwise(pl.lit("non_urban"))
+ .alias("area_type")
+ )
+ return state
+```
+
+Call it from the owning domain in
+`processor/prepare/enrichment/domains.py`:
+
+```python
+def enrich_people_and_places_domain(state, config):
+ # existing enrichment...
+ state = _add_household_area_type(state)
+ return state
+```
+
+Then declare the prepared dependency where it is consumed:
+
+```python
+@summary(
+ id="households_by_area_type",
+ schema={
+ "area_type": pl.Utf8,
+ "household_count": pl.Float64,
+ },
+ required_columns={"hh": ("area_type", "finalweight")},
+)
+def households_by_area_type(run, config):
+ return (
+ run.hh.group_by("area_type")
+ .agg(pl.col("finalweight").sum().alias("household_count"))
+ .with_columns(
+ pl.col("area_type").cast(pl.Utf8),
+ pl.col("household_count").cast(pl.Float64),
+ )
+ .select("area_type", "household_count")
+ )
+```
+
+Add a prepare test with the source column present and another with it absent.
+Optional source data should leave the table usable; the summary contract will
+record the new summary as unavailable when `area_type` is absent.
+
+If config affects the derived value, also add that config value to
+`prepare_signature_payload()` in `runtime/config/signatures.py`. Otherwise a
+prepared cache built with old config could be reused incorrectly.
+
+## Worked Example: Add A Prepared Table
+
+Assume ActivitySim now emits one row per zone in `final_accessibility.csv`, and
+the table cannot sensibly be represented as columns on `land_use`.
+
+### 1. Define Names And Runtime Storage
+
+Add the raw/config ID in `runtime/config/constants.py`:
+
+```python
+FILE_MAPPING_DEFAULTS = {
+ # existing tables...
+ "accessibility": "final_accessibility",
+}
+OPTIONAL_PREPARED_TABLE_IDS = {
+ # existing optional tables...
+ "accessibility",
+}
+```
+
+Add the runtime attribute in `processor/models.py`:
+
+```python
+PreparedTableName = Literal[
+ # existing names...
+ "accessibility",
+]
+
+@dataclass
+class RunData:
+ # existing fields...
+ accessibility: pl.DataFrame = field(default_factory=pl.DataFrame)
+```
+
+Also update `PREPARED_TABLE_NAMES`, `prune_prepared_run()`, and every explicit
+`RunData(...)` copy constructor. Copy constructors are intentionally explicit;
+missing one is a common source of a table disappearing between workflows.
+
+### 2. Read It And Track Availability
+
+In `processor/prepare/reader.py`:
+
+```python
+accessibility = _read("accessibility")
+
+return attach_table_availability(
+ RunData(
+ # existing arguments...
+ accessibility=accessibility,
+ ),
+ table_states=table_states,
+ table_reasons=table_reasons,
+)
+```
+
+Add `("accessibility", "accessibility")` to `RUN_TABLE_ATTRS` and the ID to
+`PREPARED_TABLE_IDS` in `processor/prepare/availability.py`.
+
+### 3. Carry It Through Prepare State And Cache IO
+
+Add the field to `_PrepareState.from_run()` and `_PrepareState.to_run()` in
+`processor/prepare/enrichment/types.py`. Then add this cache mapping in
+`processor/prepare/cache.py`:
+
+```python
+PREPARED_TABLE_ATTRS = (
+ # attribute, config/table ID, file stem
+ # existing entries...
+ ("accessibility", "accessibility", "accessibility"),
+)
+```
+
+That one tuple drives prepared filenames, manifest entries, writes, and most
+loads. Because it changes the prepared cache contract, increment
+`SCHEMA_VERSION` and decide whether old schema versions remain readable.
+
+### 4. Decide Segmentation And Dashboard Behavior
+
+If segmentation must filter or anchor on the new table, add explicit rules in
+`processor/segmentation.py` and aliases in
+`runtime/config/normalize_segmentation.py`. Do not silently copy the full table
+into every segment unless that is correct for its row grain.
+
+Pages can now declare:
+
+```python
+@dashboard_page(
+ page_id="accessibility",
+ title="Accessibility",
+ prepared_data_mode="required",
+ required_prepared_tables=("accessibility",),
+)
+```
+
+### 5. Test Every Boundary
+
+At minimum, cover:
+
+- config filename and `prepared_table_map` acceptance;
+- raw reader success and optional-file absence;
+- prepare-state round trip;
+- prepared cache write/read and manifest schema version;
+- pruning for pages that request or do not request the table;
+- segmentation behavior, if supported; and
+- one page-registry requirement test.
+
+Useful suites:
+
+```bash
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_processor_prepare.py tests/test_prepare_cache.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_runtime_workflows.py tests/test_page_registry_contract.py
+```
+
+## Completion Checklist
+
+- The extension uses the narrowest suitable boundary.
+- IDs are stable across config, runtime, cache, and dashboard declarations.
+- Cache identity changes whenever config changes data content.
+- Missing optional input produces typed empty/unavailable state, not a crash.
+- External schemas reject extra as well as missing columns.
+- Generated catalogs have been refreshed.
+
+## Related Chapters
+
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [23 - Summary Functions](23-summary-functions.md)
+- [24 - Summary Catalog](24-summary-catalog.md)
+- [42 - Config, Columns, and Labels](42-config-column-label-cookbook.md)
diff --git a/wiki/42-config-column-label-cookbook.md b/wiki/42-config-column-label-cookbook.md
new file mode 100644
index 0000000..02a658a
--- /dev/null
+++ b/wiki/42-config-column-label-cookbook.md
@@ -0,0 +1,319 @@
+# 42 - Config, Columns, And Labels
+
+This chapter shows how one YAML value travels through validation, typed config,
+cache identity, prepared data, and dashboard presentation.
+
+## First Decide Which Boundary Owns The Setting
+
+| Setting changes... | Put it under... | Signature impact |
+|---|---|---|
+| prepared rows or columns | `prepare` or `columns` | Prepare, and usually summary downstream |
+| summary values or grouping | `summarize` | Summary |
+| labels, ordering, colors, or page appearance | `display` or `dashboard` | Presentation |
+| which workflow executes | `pipeline` | Runtime plan; include data effects in the owning signature too |
+
+Do not add a setting only to `Config`. A complete setting has validation,
+normalization, a typed field, cache/signature ownership, a consumer, an example,
+and tests.
+
+## Worked Example: Add A New Config Item
+
+Suppose the dashboard needs a presentation-only switch:
+
+```yaml
+display:
+ show_zero_categories: true
+```
+
+### 1. Validate The Key
+
+Add it to the `display` allow-list in `runtime/config/schema.py`:
+
+```python
+_reject_unknown_keys(
+ display,
+ field_name="display",
+ allowed={
+ # existing keys...
+ "show_zero_categories",
+ },
+)
+```
+
+### 2. Normalize Once In The Loader
+
+In `runtime/config/loader.py`, reject YAML values that are not booleans:
+
+```python
+show_zero_categories = display_cfg.get("show_zero_categories", False)
+if not isinstance(show_zero_categories, bool):
+ raise ValueError(
+ "display.show_zero_categories must be true or false when provided."
+ )
+```
+
+Pass it into `Config(...)` and add the typed field in
+`runtime/config/models.py`:
+
+```python
+@dataclass
+class Config:
+ # existing fields...
+ show_zero_categories: bool
+```
+
+Downstream code should read `config.show_zero_categories`, never the raw YAML
+mapping.
+
+### 3. Put It In The Correct Signature
+
+Because this switch changes only rendering, add it to
+`presentation_signature_payload()` in `runtime/config/signatures.py`:
+
+```python
+return {
+ # existing presentation values...
+ "show_zero_categories": config.show_zero_categories,
+}
+```
+
+Do not add it to the prepare or summary signatures. That would cause expensive
+cache rebuilds for a display-only change.
+
+### 4. Consume It At The Presentation Boundary
+
+For example, a shared category helper can choose whether to complete absent
+categories:
+
+```python
+if config.show_zero_categories:
+ chart_data = complete_category_rows(chart_data, expected_categories)
+```
+
+Prefer a shared helper if several pages need the setting. Keep one-off behavior
+on the owning page.
+
+### 5. Document And Test It
+
+Update `config.yaml` and chapter 13. Add tests for the default, explicit value,
+wrong type, signature ownership, and visible consumer behavior:
+
+The snippets below use illustrative module-local helpers named
+`_write_config()` and `_raw_run()`. They are not repository-wide pytest
+fixtures: define the minimal helper in the owning test module, or adapt that
+module's existing config/run factory. Likewise, `extra_lines` and
+`column_lines` are example helper arguments rather than public config APIs.
+
+```python
+def test_show_zero_categories_is_presentation_only(tmp_path):
+ config = _write_config(
+ tmp_path,
+ extra_lines=["display:", " show_zero_categories: true"],
+ )
+ assert config.show_zero_categories is True
+ assert "show_zero_categories" in config.presentation_signature_payload()
+ assert "show_zero_categories" not in config.prepare_signature_payload()
+ assert "show_zero_categories" not in config.summary_signature_payload()
+```
+
+## Worked Example: Wire A Configured Column Name Into Prepare
+
+Suppose different models call household area type `area_type`, `ATYPE`, or
+`area_class`. The prepared contract should expose one stable name:
+`area_type`.
+
+### 1. Add The Alias Setting
+
+Add one entry to `_ALIAS_COLUMN_DEFAULTS` in `runtime/config/sections.py`:
+
+```python
+_ALIAS_COLUMN_DEFAULTS = {
+ # existing aliases...
+ "col_area_type": (
+ "area_type",
+ ["area_type", "ATYPE", "area_class"],
+ ),
+}
+```
+
+`CANONICAL_COLUMN_KEYS` is derived from this mapping, so
+`columns.area_type` becomes valid automatically. Add the typed field to
+`Config`:
+
+```python
+col_area_type: list[str]
+```
+
+The user can now override precedence:
+
+```yaml
+columns:
+ area_type: [area_class, ATYPE]
+```
+
+The first available candidate wins.
+
+### 2. Materialize The Canonical Column
+
+In `processor/prepare/enrichment/canonicalize.py`:
+
+```python
+def _canonicalize_households(hh: pl.DataFrame, config: Config) -> pl.DataFrame:
+ # existing canonical columns...
+ return _materialize_column(
+ hh,
+ "area_type",
+ _resolve_source_column(hh, config.col_area_type),
+ )
+```
+
+Keep the configured source candidates in config and the stable output name in
+prepare. Summary builders should require `hh.area_type`; they should never
+probe `ATYPE` or `area_class`.
+
+Use `_materialize_preferred_column(...)` only when candidate selection needs
+extra rules, such as rejecting numeric purpose codes. Use `overwrite=True` only
+when prepare intentionally replaces an existing canonical column.
+
+### 3. Add Cache Identity
+
+Add the candidate list to the `columns` mapping returned by
+`prepare_signature_payload()`:
+
+```python
+"area_type": list(config.col_area_type),
+```
+
+The summary signature currently incorporates the prepared column payload, so
+this also invalidates affected summary caches.
+
+### 4. Test Precedence And Materialization
+
+```python
+def test_area_type_alias_materializes_canonical_column(tmp_path):
+ config = _write_config(
+ tmp_path,
+ column_lines=["area_type: [area_class, ATYPE]"],
+ )
+ raw = _raw_run()
+ raw.hh = raw.hh.with_columns(
+ pl.Series("area_class", ["urban"]),
+ pl.Series("ATYPE", [99]),
+ )
+
+ prepared = prepare_data(raw, config)
+
+ assert prepared.hh["area_type"].to_list() == ["urban"]
+ assert config.prepare_signature_payload()["columns"]["area_type"] == [
+ "area_class",
+ "ATYPE",
+ ]
+```
+
+Also test the default candidate list and missing-source behavior.
+
+## Worked Example: Add A Label Mapping And Use It On A Page
+
+Label mappings are presentation data. They do not change raw values used for
+filtering or summary grouping.
+
+Suppose a summary contains `employment_status` values `0`, `1`, and `2`:
+
+```yaml
+display:
+ labels:
+ employment_status:
+ mapping:
+ "0": Not employed
+ "1": Part time
+ "2": Full time
+ order: data
+```
+
+New category IDs do not require a schema change. `normalize_categories()` loads
+arbitrary category IDs into `config.dashboard_labels`.
+
+### Selector With Display-To-Raw Mapping
+
+Use `column_options()` from `dashboard.helpers.category_helpers`:
+
+```python
+def employment_status_options(self):
+ data = self.data.summary("workers_by_employment_status")
+ if not data:
+ return ["All"]
+ options, self._employment_status_by_label = column_options(
+ data.to_list(),
+ "employment_status",
+ category_id="employment_status",
+ config=self.config,
+ total_raw=None,
+ total_label="All",
+ )
+ return options
+
+
+def selected_employment_status_raw(self):
+ return self._employment_status_by_label.get(self.employment_status.value)
+```
+
+The widget shows `Full time`; the data filter still uses raw value `2`. This
+avoids corrupting joins, selector state, or summary contracts with display
+text.
+
+### Add A Label Column For A Figure
+
+Use `label_category_data()` when a plot needs a labeled column:
+
+```python
+labeled = label_category_data(
+ data.to_list(),
+ source_col="employment_status",
+ category_id="employment_status",
+ config=self.config,
+ target_col="employment_status_label",
+)
+return self.plot.bar(
+ labeled,
+ x="employment_status_label",
+ y="person_count",
+ category_order=self.config.ordered_labels(
+ "employment_status", ["0", "1", "2"]
+ ),
+)
+```
+
+If many pages use the category, keep mapping mechanics in
+`dashboard/helpers/category_helpers.py`. If the mapping changes canonical
+summary values rather than appearance, it belongs under
+`summarize.category_normalization` and must be applied by the owning summary
+logic.
+
+### Test Raw And Display Behavior Separately
+
+```python
+assert config.label_value("employment_status", "2") == "Full time"
+assert config.ordered_values(
+ "employment_status", ["2", "0", "1"]
+) == ["0", "1", "2"]
+```
+
+Add a page/helper test proving that selection of `Full time` filters raw `2`.
+This catches the most common label-wiring regression.
+
+## Completion Checklist
+
+- Unknown keys and wrong types fail near the config boundary.
+- Raw YAML is normalized once and represented by a typed `Config` field.
+- The setting belongs to exactly the cache signatures it can affect.
+- Prepared code emits canonical names; summaries do not probe source aliases.
+- Dashboard filtering retains raw values and labels only at presentation time.
+- `config.yaml`, chapter 13, and focused tests are updated together.
+
+## Related Chapters
+
+- [13 - Configuration Reference](13-configuration-reference.md)
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [32 - Figures And Widgets](32-figures-and-widgets.md)
+- [41 - Data Extension Cookbook](41-data-extension-cookbook.md)
+- [43 - Weighting And Hosting Extensions](43-weighting-hosting-extensions.md)
diff --git a/wiki/43-weighting-hosting-extensions.md b/wiki/43-weighting-hosting-extensions.md
new file mode 100644
index 0000000..41538d6
--- /dev/null
+++ b/wiki/43-weighting-hosting-extensions.md
@@ -0,0 +1,364 @@
+# 43 - Weighting And Hosting Extensions
+
+Weighting and hosting both cross major runtime boundaries. Ordinary alternative
+weights are configuration-driven. A Python registry remains available for
+calculations that cannot be expressed as column selection, while hosting remains
+a deliberately limited extension point.
+
+## Worked Example: Add A Weighting Mode
+
+The built-in modes are `weighted` and `unweighted`. A named column mode adds
+another complete set of summary tables, cache entries, dashboard selector state,
+and export states without requiring Python code.
+
+Suppose ActivitySim writes `calibrated_hh_weight`, `calibrated_person_weight`,
+and `calibrated_trip_weight` alongside its ordinary weights.
+
+### 1. Define The Named Column Mode
+
+Add the mode under `weighting.modes`, then select its ID under
+`summarize.weighting_modes`:
+
+```yaml
+weighting:
+ modes:
+ calibrated:
+ label: Calibrated
+ columns:
+ households: calibrated_hh_weight
+ persons: calibrated_person_weight
+ trips: calibrated_trip_weight
+
+summarize:
+ weighting_modes: [weighted, unweighted, calibrated]
+```
+
+`label` is optional; an omitted label is generated from the mode ID. At least one
+column must be configured. Supported source tables are `households`, `persons`,
+and `trips`.
+
+This differs from the three weight fields on a run. `hh_weight_col`,
+`person_weight_col`, and `trip_weight_col` choose the one primary `weighted`
+definition during prepare. `weighting.modes` preserves that primary definition
+and adds named alternatives that can be compared in one dashboard.
+
+### 2. Understand Propagation
+
+The configured source columns replace `finalweight` on their respective
+prepared tables. Related tables then receive consistent weights:
+
+- a household source propagates to persons, trips, tours, days, and vehicles
+ unless a more specific source is configured;
+- a person source propagates to trips, tours, and days;
+- a trip source propagates to tours as the mean selected trip weight for each
+ `tour_id`; and
+- trip and tour hypothetical-skim sidecars inherit the selected trip and tour
+ weights.
+
+You can configure only the levels that differ. For example, a mode containing
+only `trips` changes trips and tours while leaving household, person, day, and
+vehicle weights at their primary prepared values.
+
+Source columns are validated on every prepared run before summaries begin. A
+misspelling therefore produces an error naming the missing table and column
+instead of silently reverting to another weight. Raw ActivitySim columns are
+normally retained by prepare. When using `prepared_table_map`, include the named
+source columns in those prepared files.
+
+### 3. Cache, Dashboard, And Outside-Summary Behavior
+
+The mode ID, selected source columns, and column-mode implementation version are
+part of summary cache identity. Changing a source column invalidates incompatible
+summary caches. The configured label is used by live and exported dashboard
+selectors.
+
+Declarative column modes reject mode-independent `summary_table_map` inputs.
+An already aggregated outside table does not contain enough information to
+recalculate another weighting mode. Use generated summaries for these modes or
+provide the outside data through a custom workflow that makes its weighting
+semantics explicit.
+
+## Advanced: Custom Weight Calculations
+
+Use a Python weighting module only when selecting columns is insufficient; for
+example, when weights must be capped, scaled, joined from a control table, or
+calculated from several prepared columns.
+
+### 1. Create An Importable Extension Module
+
+This example adds a capped form of the primary prepared weights. Create
+`my_project/weighting.py` in an installed package or another location on
+`PYTHONPATH`:
+
+```python
+import polars as pl
+
+from processor.models import map_run_data_tables
+from runtime.weighting import WeightingModeDefinition, WeightingModeRegistry
+
+
+def cap_weights(run, config):
+ maximum = float(
+ config.extension_settings.get("capped", {}).get("maximum", 10.0)
+ )
+
+ def cap(_table_name, table: pl.DataFrame) -> pl.DataFrame:
+ if "finalweight" not in table.columns:
+ return table
+ return table.with_columns(
+ pl.col("finalweight").cast(pl.Float64).clip(upper_bound=maximum)
+ )
+
+ return map_run_data_tables(run, cap)
+
+
+def register_weighting_modes(registry: WeightingModeRegistry) -> None:
+ registry.register(
+ WeightingModeDefinition(
+ mode_id="capped",
+ label="Capped",
+ transform=cap_weights,
+ version="1",
+ required_columns={
+ "hh": ("finalweight",),
+ "per": ("finalweight",),
+ "tours": ("finalweight",),
+ "trips": ("finalweight",),
+ },
+ external_summary_policy="reject",
+ )
+ )
+```
+
+`map_run_data_tables()` copies the complete `RunData`, transforms each DataFrame
+table, and preserves availability metadata, diagnostics, skims, and skimjoin
+artifacts. A transform must return a new `RunData` and must not mutate its input.
+
+The registration fields are:
+
+| Field | Meaning |
+|---|---|
+| `mode_id` | Stable lowercase config/cache ID. |
+| `label` | Unique dashboard/export label. |
+| `transform` | Callable receiving `(RunData, Config)` and returning `RunData`. |
+| `version` | Cache-facing implementation version. Change it when results can change. |
+| `required_columns` | Prepared columns validated before the transform runs. |
+| `external_summary_policy` | `copy` permits mode-independent `summary_table_map` data; `reject` fails instead of silently mislabeling it. |
+| `default_enabled` | Whether omission/empty `summarize.weighting_modes` includes the mode. Custom modes should normally leave this `false`. |
+
+### 2. Load And Configure The Extension
+
+Use `extensions.modules` for a project-local/importable module and keep plugin
+settings under `extensions.settings`:
+
+```yaml
+extensions:
+ modules:
+ - my_project.weighting
+ settings:
+ capped:
+ maximum: 10.0
+
+summarize:
+ weighting_modes: [weighted, unweighted, capped]
+```
+
+Module imports are executable code, so configuration containing extensions is
+trusted configuration. Extension settings and each selected definition's
+version, requirements, and outside-summary policy enter summary cache identity.
+
+An installed package can advertise the same registration function with a
+Python entry point instead:
+
+```toml
+[project.entry-points."activitysim_visualizer.weighting_modes"]
+capped = "my_project.weighting:register_weighting_modes"
+```
+
+Use either the installed entry point or `extensions.modules`, not both for the
+same definition. Duplicate IDs and labels fail during config loading.
+
+### 3. Runtime Behavior
+
+The weighting definition contract is the single source for config validation,
+summary transforms, prepared-data transforms, display labels, and cache
+compatibility:
+
+- config preserves the requested mode order and rejects unknown IDs;
+- the summary workflow applies each registered transform before running builders;
+- cache directories and manifests use `mode_id`, while plugin `version` enters
+ the summary config digest;
+- dashboard/export selectors use `label` without deriving text from the ID;
+- `PageData.prepared()` and `prepared_runs()` apply the selected mode lazily and
+ cache the result for the dashboard session; and
+- required source columns fail before a transform can silently fall back.
+
+Ordinary pages do not branch on particular modes:
+
+```python
+prepared = self.data.prepared("trips")
+
+# Only specialized code that deliberately requests another mode supplies it:
+weighted = self.data.prepared("trips", weighting_mode="weighted")
+```
+
+### 4. Outside Summary Tables
+
+Built-in `weighted` and `unweighted` definitions explicitly use
+`external_summary_policy="copy"`, preserving current behavior. A custom mode
+defaults to `reject`: a run using `summary_table_map` then fails clearly because
+the runtime cannot prove that an already-aggregated file represents that mode.
+
+Set the custom definition to `copy` only when the outside table is genuinely
+mode-independent. Per-mode outside file maps are not currently supported.
+
+### 5. Test The Whole Mode
+
+At minimum, prove:
+
+- config accepts, orders, deduplicates, and rejects mode names correctly;
+- both module and installed-entry-point discovery use the registration contract;
+- the transform replaces weights on every relevant prepared table;
+- a known summary produces expected custom-weight values;
+- cache write/load retains all modes;
+- dashboard state selects the correct summary and prepared runs;
+- export enumerates configured custom states; and
+- outside summary behavior is explicit.
+
+Useful suites:
+
+```bash
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_weighting_registry.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_config_refactor_phase1.py tests/test_summary_cache.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_dashboard_live.py tests/test_export_payload.py
+```
+
+## Worked Example: Connect A Hosting Script
+
+The safest first hosting extension is a thin deployment entrypoint that uses
+the existing config, cache loader, page requirements, and `build_dashboard()`.
+It should not duplicate prepare or summarize logic.
+
+The current `pipeline.dashboard_mode: host` is only a placeholder: `run.py`
+logs a warning and falls back to live `pn.serve`. The `dashboard.host` keys are
+validated but are not yet normalized into `Config` or consumed.
+
+## Option A: Provider Script Without Core Runtime Changes
+
+Create a deployment-specific script such as `scripts/host_dashboard.py`:
+
+```python
+from __future__ import annotations
+
+import os
+
+from dashboard.app import build_dashboard
+from dashboard.page_registry import live_data_requirements
+from runtime.workflows import (
+ load_prepared_runs_for_dashboard,
+ load_runtime_config,
+ load_summary_runs_from_cache,
+ summary_cache_root,
+)
+
+
+config = load_runtime_config(
+ os.environ.get("ACTIVITYSIM_VIZ_CONFIG", "config.yaml")
+)
+requirements = live_data_requirements(config)
+summary_runs = load_summary_runs_from_cache(
+ config=config,
+ cache_root=summary_cache_root(config, create=False),
+ explicit_cache_dirs=None,
+ run_entries=config.runs,
+ required_summary_ids=requirements.required_summary_ids,
+)
+prepared_runs = []
+if requirements.prepared_data_mode != "none":
+ prepared_runs = load_prepared_runs_for_dashboard(
+ config=config,
+ run_entries=config.runs,
+ required_run_keys=[run.run_key for run in summary_runs],
+ required_prepared_tables=requirements.required_prepared_tables,
+ )
+
+dashboard = build_dashboard(
+ prepared_runs,
+ config,
+ summary_runs=summary_runs,
+)
+dashboard.servable()
+```
+
+Panel-compatible hosts can launch this module with their normal command. A
+provider SDK can instead receive `dashboard` from the same script. Keep secrets
+and deployment IDs in environment variables or provider configuration, not the
+main visualizer YAML.
+
+This approach has useful properties:
+
+- hosting imports a ready-to-serve object instead of calling blocking
+ `pn.serve()`;
+- summary and prepared cache contracts remain identical to local mode;
+- enabled pages determine the data loaded; and
+- provider dependencies can live in an optional dependency group.
+
+For a hosted service, caches must already exist or be available on persistent
+storage. If startup should build them, call the public prepare/summarize
+workflows before `build_dashboard()` and make the cost and write permissions
+explicit.
+
+## Option B: Make `dashboard_mode: host` A Core Adapter
+
+Use this only when the same hosting provider should be a supported runtime
+mode.
+
+1. Add a typed `HostSettings` model in `runtime/config/models.py`.
+2. Normalize `dashboard.host` in a focused parser and pass it into `Config`.
+3. Add hosting settings to the presentation signature, excluding secrets.
+4. Create a narrow adapter such as `runtime/hosting.py`:
+
+ ```python
+ def publish_dashboard(*, dashboard, settings: HostSettings) -> None:
+ """Hand a built Panel object to the configured hosting provider."""
+ ```
+
+5. Let `resolve_dashboard_execution_mode("host")` remain `host` instead of
+ converting it to `live`.
+6. Reuse the normal workflow loading and `build_dashboard()` path, then call
+ the adapter instead of `pn.serve()`.
+7. Put provider SDKs in a `hosting` optional dependency group in
+ `pyproject.toml`.
+8. Test dispatch with a fake adapter; do not contact the provider in unit tests.
+
+The boundary should look like:
+
+```text
+config + validated caches
+ -> normal dashboard data requirements
+ -> build_dashboard(...)
+ -> provider adapter
+ -> hosted application
+```
+
+Avoid putting provider logic in pages, `dashboard/app.py`, or summary
+workflows. Those layers should remain usable locally, in export, and with any
+future host.
+
+## Hosting Test Matrix
+
+- missing config/cache produces an actionable startup error;
+- summary-only hosting does not load prepared tables;
+- prepared-data pages load only declared tables;
+- provider adapter receives the built dashboard and typed settings;
+- secrets do not enter logs, signatures, manifests, or exports;
+- live and export modes remain unchanged; and
+- `host` no longer emits the fallback warning once implemented.
+
+## Related Chapters
+
+- [12 - Running Workflows](12-running-workflows.md)
+- [34 - HTML Export](34-html-export.md)
+- [40 - Developer Workflows](40-developer-workflows.md)
+- [41 - Data Extension Cookbook](41-data-extension-cookbook.md)
+- [42 - Config, Columns, And Labels](42-config-column-label-cookbook.md)
diff --git a/wiki/44-summary-function-cookbook.md b/wiki/44-summary-function-cookbook.md
new file mode 100644
index 0000000..8026eef
--- /dev/null
+++ b/wiki/44-summary-function-cookbook.md
@@ -0,0 +1,254 @@
+# 44 - Summary Function Cookbook
+
+This chapter follows one new summary from a question to a tested dashboard
+dependency. Use it with the shorter contract reference in chapter 23.
+
+## Worked Example: Trips By Mode
+
+Suppose a page needs total trips by canonical `trip_mode`. The output grain is
+one row per mode, per run, per weighting mode:
+
+| trip_mode | trip_count |
+|---|---:|
+| DRIVEALONE | 14230.0 |
+| WALK | 3180.0 |
+
+Write the grain down first. It determines the grouping keys, schema, tests, and
+figure axes.
+
+## 1. Put Pure Calculation Before Registration
+
+Add the calculation to the domain owner, such as
+`processor/summarize/summaries/trip.py`:
+
+```python
+import polars as pl
+
+
+def trips_by_mode_frame(trips: pl.DataFrame) -> pl.DataFrame:
+ return (
+ trips.drop_nulls("trip_mode")
+ .group_by("trip_mode")
+ .agg(pl.col("finalweight").sum().alias("trip_count"))
+ .with_columns(
+ pl.col("trip_mode").cast(pl.Utf8),
+ pl.col("trip_count").cast(pl.Float64),
+ )
+ .sort("trip_mode")
+ .select("trip_mode", "trip_count")
+ )
+```
+
+Keeping the transform pure makes the calculation easy to test without cache or
+dashboard setup. Use canonical prepared columns; do not probe raw aliases here.
+
+## 2. Declare The Runtime Contract
+
+Wrap the transform with `@summary` in the same module:
+
+```python
+from processor.models import RunData
+from processor.summarize import summary
+from runtime.config import Config
+
+
+@summary(
+ id="trips_by_mode",
+ schema={
+ "trip_mode": pl.Utf8,
+ "trip_count": pl.Float64,
+ },
+ required_columns={
+ "trips": ("trip_mode", "finalweight"),
+ },
+)
+def trips_by_mode(run: RunData, config: Config) -> pl.DataFrame:
+ return trips_by_mode_frame(run.trips)
+```
+
+The declaration does four jobs:
+
+1. gives the table a stable config/cache ID;
+2. prevents the builder from running when inputs are unavailable;
+3. supplies a correctly typed empty result; and
+4. rejects successful results with wrong columns, order, or dtypes.
+
+The unused `config` argument is still part of the uniform builder interface. If
+config changes the calculation, use it here and ensure the setting belongs to
+the summary signature.
+
+## 3. Let The Workflow Handle Weighting
+
+Always aggregate `finalweight`. The workflow supplies ordinary weights for the
+weighted build and replaces them for the unweighted build. Do not add a
+`weighted` branch to the builder.
+
+For an average, use a weighted numerator and denominator:
+
+```python
+.agg(
+ average_distance=(
+ (pl.col("od_dist") * pl.col("finalweight")).sum()
+ / pl.col("finalweight").sum()
+ )
+)
+```
+
+Decide how zero total weight should behave and test it explicitly.
+
+## 4. Register A New Owning Module Only Once
+
+Adding a function to an existing module in `SUMMARY_MODULES` needs no catalog
+edit. If you create `processor/summarize/summaries/emissions.py`, import that
+module and add it to `SUMMARY_MODULES` in `processor/summarize/catalog.py`.
+
+Do not maintain a second list of individual functions. Catalog discovery reads
+decorated functions from the explicitly imported owning modules and rejects
+duplicate IDs.
+
+## 5. Test Calculation And Contract Separately
+
+Test the numbers with a tiny frame:
+
+```python
+def test_trips_by_mode_frame_uses_finalweight():
+ trips = pl.DataFrame(
+ {
+ "trip_mode": ["WALK", "WALK", "DRIVEALONE"],
+ "finalweight": [1.0, 2.5, 4.0],
+ }
+ )
+
+ result = trips_by_mode_frame(trips)
+
+ assert result.to_dicts() == [
+ {"trip_mode": "DRIVEALONE", "trip_count": 4.0},
+ {"trip_mode": "WALK", "trip_count": 3.5},
+ ]
+ assert result.schema == {
+ "trip_mode": pl.Utf8,
+ "trip_count": pl.Float64,
+ }
+```
+
+Then test the declaration boundary with a minimal `RunData`:
+
+```python
+def test_trips_by_mode_preflights_missing_columns():
+ empty_run = RunData(
+ label="Test",
+ run_dir="C:/runs/test",
+ skim_file=None,
+ hh=pl.DataFrame(),
+ per=pl.DataFrame(),
+ tours=pl.DataFrame(),
+ trips=pl.DataFrame(),
+ joint_participants=pl.DataFrame(),
+ land_use=pl.DataFrame(),
+ skim_matrix=None,
+ )
+
+ # Config is not read because prerequisite checking returns first.
+ result = trips_by_mode(empty_run, None)
+
+ assert result.is_empty()
+ assert result.schema == {
+ "trip_mode": pl.Utf8,
+ "trip_count": pl.Float64,
+ }
+```
+
+Also add a catalog assertion when a new module is introduced. The shared
+declaration tests already cover generic wrong-schema behavior; domain tests
+should focus on your calculation and prerequisites.
+
+## 6. Wire It To A Page
+
+Declare the dependency on the page:
+
+```python
+@dashboard_page(
+ page_id="trip_mode_totals",
+ title="Trip Mode Totals",
+ group_id="trip_summaries",
+ required_summary_ids=("trips_by_mode",),
+)
+class TripModeTotalsPage(DashboardPage):
+ ...
+```
+
+Read the table through page data access and state the columns the view uses:
+
+```python
+data = self.data.summary(
+ "trips_by_mode",
+ columns=("trip_mode", "trip_count"),
+)
+if not data:
+ return self.summary_only_unavailable_card()
+return self.plot.bar(
+ data,
+ x="trip_mode",
+ y="trip_count",
+ title="Trips By Mode",
+ x_title="Trip Mode",
+ y_title="Trips",
+)
+```
+
+The page declaration controls cache pruning and startup requirements. The
+`columns=` check provides a useful page-level diagnostic if an old or external
+cache does not satisfy the view.
+
+## 7. Regenerate And Verify
+
+Run:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_summary_declarations.py tests/test_page_registry_contract.py
+```
+
+Confirm the new ID appears in chapter 24 and, once wired to a page, in the page
+catalog in chapter 31.
+
+## Variations
+
+### Optional Summary
+
+Use `optional_summary_ids` when the page retains a meaningful primary view
+without the new table. Render an unavailable card only for the optional
+section.
+
+### External-Only Summary
+
+Use `build_by_default=False` and a typed no-op builder for a registered table
+that must come from `summary_table_map`. Follow the outside-table recipe in
+chapter 41.
+
+### Segmented Summary
+
+Usually no builder change is needed. Segmentation slices prepared `RunData`
+before invoking the same declaration. A summary that depends on a table or
+column removed by segmentation should become unavailable through its declared
+prerequisites, not fail inside the builder.
+
+## Review Checklist
+
+- The row grain and value meaning are written down.
+- Grouping uses canonical prepared fields.
+- Counts, totals, and averages apply `finalweight` deliberately.
+- The schema is ordered and explicitly cast.
+- Mechanical prerequisites are in the decorator.
+- Domain-specific empty conditions return `builder.empty()`.
+- Pure calculation and declaration behavior have focused tests.
+- The consuming page declares the ID.
+- Generated catalogs are current.
+
+## Related Chapters
+
+- [21 - Prepared Tables](21-prepared-tables.md)
+- [23 - Summary Functions](23-summary-functions.md)
+- [24 - Summary Catalog](24-summary-catalog.md)
+- [41 - Data Extension Cookbook](41-data-extension-cookbook.md)
+- [45 - Dashboard Extension Cookbook](45-dashboard-extension-cookbook.md)
diff --git a/wiki/45-dashboard-extension-cookbook.md b/wiki/45-dashboard-extension-cookbook.md
new file mode 100644
index 0000000..f0f4452
--- /dev/null
+++ b/wiki/45-dashboard-extension-cookbook.md
@@ -0,0 +1,423 @@
+# 45 - Dashboard Extension Cookbook
+
+This chapter gives worked examples for adding a page, page group, selector,
+custom widget, table, and reusable figure behavior. The examples use the
+current declarative page lifecycle: selectors own option domains, sections own
+refresh dependencies, and pages read data through `self.data`.
+
+## Worked Example: Add A Page To An Existing Group
+
+Assume the registered summary `trips_by_mode` has columns `trip_mode` and
+`trip_count`. Create one discoverable leaf module:
+
+```text
+dashboard/pages/trip_summaries/trip_mode_totals.py
+```
+
+The complete first version can stay small:
+
+```python
+from __future__ import annotations
+
+import panel as pn
+
+from dashboard import DashboardPage, dashboard_page
+
+
+@dashboard_page(
+ page_id="trip_mode_totals",
+ title="Trip Mode Totals",
+ group_id="trip_summaries",
+ order=90,
+ required_summary_ids=("trips_by_mode",),
+)
+class TripModeTotalsPage(DashboardPage):
+ def build_page(self) -> pn.viewable.Viewable:
+ body = self.section("body", render=self.render_body)
+ return self.new_section(
+ pn.pane.Markdown("## Trip Mode Totals"),
+ body,
+ )
+
+ def render_body(self):
+ data = self.data.summary(
+ "trips_by_mode",
+ columns=("trip_mode", "trip_count"),
+ )
+ if not data:
+ return self.summary_only_unavailable_card()
+ return self.plot.bar(
+ data,
+ x="trip_mode",
+ y="trip_count",
+ title="Trips By Mode",
+ x_title="Trip Mode",
+ y_title="Trips",
+ )
+```
+
+Discovery imports public child modules automatically. Do not edit a central
+page list. The decorator is the single source for identity and data needs.
+
+Enable the page explicitly while developing:
+
+```yaml
+dashboard:
+ live:
+ pages:
+ - trip_summaries:
+ - trip_mode_totals
+```
+
+## Add A Dynamic Selector
+
+Suppose the summary instead contains `tour_purpose`, `trip_mode`, and
+`trip_count`. Add a purpose dropdown whose options come from the loaded data:
+
+```python
+from dashboard.helpers.category_helpers import column_options
+from dashboard.rendering import selector_row
+
+
+def build_page(self):
+ self.purpose = self.select(
+ "purpose",
+ "Tour Purpose",
+ options=self.purpose_options,
+ )
+ body = self.section(
+ "body",
+ selectors=("purpose",),
+ render=self.render_body,
+ )
+ return self.new_section(
+ pn.pane.Markdown("## Trip Mode Totals"),
+ selector_row(self.purpose),
+ body,
+ )
+
+
+def purpose_options(self):
+ data = self.data.summary("trips_by_mode_and_purpose")
+ options, self._purpose_by_label = column_options(
+ data.to_list(),
+ "tour_purpose",
+ category_id="tour_purpose",
+ config=self.config,
+ )
+ return options
+```
+
+The option provider runs before a dependent section renders. If available
+options change, the framework repairs a stale selection using the selector's
+`default` policy.
+
+Filter with the raw value, not its display label:
+
+```python
+raw_purpose = self._purpose_by_label[self.purpose.value]
+data = self.data.summary("trips_by_mode_and_purpose")
+chart_data = self.query(
+ lambda: data.where(tour_purpose=raw_purpose).select(
+ "trip_mode", "trip_count"
+ )
+)
+```
+
+`self.query()` derives its cache identity from global state, active section,
+declared selectors, callable location, and captured values. Do not invent a
+page-local cache key.
+
+## Add A Custom Widget
+
+Use `self.select()` for ordinary dropdowns. For a checkbox, slider, or other
+Panel widget, register it with `self.selector()`:
+
+```python
+self.hide_auto = self.selector(
+ "hide_auto",
+ widget=pn.widgets.Checkbox(value=False),
+ label="Hide Auto Modes",
+)
+
+body = self.section(
+ "body",
+ selectors=("purpose", "hide_auto"),
+ render=self.render_body,
+)
+```
+
+Then apply its value inside the section query:
+
+```python
+if self.hide_auto.value:
+ chart_data = chart_data.map(
+ lambda frame: frame.filter(
+ ~pl.col("trip_mode").is_in(["DRIVEALONE", "SHARED2", "SHARED3"])
+ )
+ )
+```
+
+Registration is what connects the widget to refresh and HTML export. A widget
+created directly in the layout without `self.select()` or `self.selector()` is
+not part of that lifecycle.
+
+## Add A Figure With The Existing Plotter
+
+Pages should normally use `self.plot`:
+
+```python
+chart = self.plot.bar(
+ chart_data,
+ x="trip_mode",
+ y="trip_count",
+ title="Trips By Mode",
+ x_title="Trip Mode",
+ y_title="Trips",
+ category_order=mode_labels,
+)
+```
+
+This applies run colors, count/share state, layout conventions, and hover
+behavior. Available shared types are `bar`, `line`, `density`, and `scatter`.
+
+If one page needs a Plotly customization, build the figure through the escape
+hatch, mutate it, and wrap it:
+
+```python
+figure = self.plot.figure.bar(
+ chart_data,
+ x="trip_mode",
+ y="trip_count",
+ title="Trips By Mode",
+)
+figure.update_layout(legend_title_text="Model Run")
+return self.plot.panel(figure)
+```
+
+Keep ordinary titles, axes, modes, category order, and sizing in the shared
+arguments rather than post-processing every page.
+
+## Add A Reusable Figure Type
+
+When several pages need a genuinely new chart contract, add it to the shared
+renderer instead of copying Plotly construction.
+
+For an area chart:
+
+1. add `area_figure(context, data, *, x, y, ...)` to
+ `dashboard/rendering/figures.py`;
+2. add `FigureBuilder.area()` and `Plotter.area()` in
+ `dashboard/rendering/plotter.py`;
+3. use `RenderContext` for colors and value mode;
+4. validate required columns with the same clear errors as other builders; and
+5. test the Plotly figure before testing Panel wrapping.
+
+Here is a complete minimal builder for `dashboard/rendering/figures.py`. It
+uses the existing internal helpers because it lives beside the other builders:
+
+```python
+def area_figure(
+ context: RenderContext,
+ data: ChartTables,
+ *,
+ x: str,
+ y: str,
+ title: str = "",
+ x_title: str = "",
+ y_title: str = "Count",
+ value_mode: ChartValueMode = "dashboard",
+ height: int = 350,
+) -> go.Figure:
+ _require_columns(data, "area", x, y)
+ share = _share_mode(context, value_mode)
+ figure = go.Figure()
+
+ for index, (label, frame) in enumerate(data):
+ values = np.asarray(frame[y].to_list(), dtype=float)
+ if share and values.sum() > 0:
+ values = values / values.sum() * 100.0
+ figure.add_trace(
+ go.Scatter(
+ name=str(label),
+ x=frame[x].to_list(),
+ y=values.tolist(),
+ mode="lines",
+ fill="tozeroy",
+ line=dict(
+ color=context.color(str(label), index),
+ width=2,
+ ),
+ )
+ )
+
+ _layout(
+ figure,
+ title=title,
+ x_title=x_title,
+ y_title=_y_title(y_title, share),
+ height=height,
+ )
+ return figure
+```
+
+`ChartTables`, `ChartValueMode`, `go`, and `np` are already used by that
+module. The explicit `value_mode` keeps `"dashboard"`, forced count, and forced
+share behavior consistent with the existing figure types. `_require_columns`
+provides a run-specific error, while `RenderContext.color()` preserves the
+configured run-color mapping.
+
+The adapter shape is:
+
+```python
+class FigureBuilder:
+ def area(self, data, **kwargs):
+ return figures.area_figure(self.context, data, **kwargs)
+
+
+class Plotter:
+ def area(self, data, **kwargs) -> pn.pane.Plotly:
+ return self.panel(self.figure.area(data, **kwargs))
+```
+
+A focused test should inspect traces and layout:
+
+```python
+def test_area_figure_uses_run_labels_and_colors():
+ data = [("Base", pl.DataFrame({"period": [1, 2], "trips": [3.0, 5.0]}))]
+
+ figure = Plotter(RenderContext()).figure.area(
+ data, x="period", y="trips"
+ )
+
+ assert figure.data[0].name == "Base"
+ assert list(figure.data[0].x) == [1, 2]
+ assert figure.data[0].fill == "tozeroy"
+ assert figure.data[0].line.color == RenderContext().color("Base", 0)
+
+
+def test_area_figure_honors_dashboard_share_mode():
+ data = [("Base", pl.DataFrame({"period": [1, 2], "trips": [1.0, 3.0]}))]
+
+ figure = Plotter(RenderContext(value_mode="share")).figure.area(
+ data,
+ x="period",
+ y="trips",
+ y_title="Trips",
+ )
+
+ assert list(figure.data[0].y) == [25.0, 75.0]
+ assert figure.layout.yaxis.title.text == "Percent of Trips (%)"
+```
+
+## Add A Table
+
+Tables use `data_table()` rather than the figure plotter:
+
+```python
+from dashboard.rendering import data_table
+
+
+return data_table(
+ chart_data,
+ title="Trip Mode Totals",
+ height=280,
+ numeric_precision_by_column={"trip_count": 0},
+)
+```
+
+It produces one run tab per frame and applies shared column titles and numeric
+formatting. Use a page-local `Tabulator` only when the shared table contract
+cannot express the required interaction.
+
+## Add A New Page Group
+
+Create a public package and one public module per child page:
+
+```text
+dashboard/pages/emissions/
+ __init__.py
+ regional_emissions.py
+ household_emissions.py
+```
+
+In `__init__.py`:
+
+```python
+from dashboard.page_definitions import DashboardGroupDefinition
+
+
+GROUP = DashboardGroupDefinition(
+ group_id="emissions",
+ title="Emissions",
+ order=85,
+ default_page_id="regional_emissions",
+ default_enabled=False,
+)
+```
+
+Every child page declares `group_id="emissions"`. `default_page_id` must name
+one of those children. Private helper packages and modules begin with `_` so
+discovery ignores them.
+
+Users can enable the group's default pages or choose children:
+
+```yaml
+dashboard:
+ live:
+ pages:
+ - emissions
+ # Or:
+ # - emissions:
+ # - regional_emissions
+```
+
+## Test The Extension
+
+Test pure transforms separately from lifecycle wiring. Then add focused checks
+for declarations:
+
+```python
+def test_trip_mode_page_declares_its_runtime_contract():
+ definition = TripModeTotalsPage.definition
+
+ assert definition.page_id == "trip_mode_totals"
+ assert definition.group_id == "trip_summaries"
+ assert definition.required_summary_ids == ("trips_by_mode",)
+```
+
+For selector behavior, instantiate a small test page with `DashboardState`,
+change the option provider's domain, refresh, and assert that stale values are
+repaired. For figures, test `Plotter(RenderContext()).figure` so failures are
+independent of Panel. The full registry suites then prove discovery, unique
+IDs, requirements, and export protocol support.
+
+Run at least:
+
+```bash
+uv run python scripts/generate_wiki_catalogs.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_page_authoring.py tests/test_page_registry_contract.py
+uv run --with pytest pytest --basetemp .pytest_tmp tests/test_figure_builders.py tests/test_export_payload.py
+```
+
+## Review Checklist
+
+- One public leaf module contains one decorated page class.
+- Page and group IDs are stable, unique, and config-friendly.
+- Required and optional data match the visible workflows.
+- Summary reads declare the columns they consume.
+- Selectors own options; sections list every selector dependency.
+- Custom widgets are registered rather than inserted raw.
+- Pure transforms do not depend on Panel state.
+- Existing shared figures and tables are used before adding new renderers.
+- Missing data produces a standard diagnostic card.
+- Live and export behavior use the same declarations.
+- Catalogs and focused tests are current.
+
+## Related Chapters
+
+- [31 - Dashboard Pages](31-dashboard-pages.md)
+- [32 - Figures And Widgets](32-figures-and-widgets.md)
+- [33 - Dashboard Page Recipes](33-dashboard-page-recipes.md)
+- [34 - HTML Export](34-html-export.md)
+- [44 - Summary Function Cookbook](44-summary-function-cookbook.md)
diff --git a/wiki/46-testing.md b/wiki/46-testing.md
new file mode 100644
index 0000000..2a89ba7
--- /dev/null
+++ b/wiki/46-testing.md
@@ -0,0 +1,67 @@
+# 46 - Testing
+
+The default command runs every test, including the exhaustive offline HTML
+export checks:
+
+```powershell
+uv run pytest --basetemp .pytest_tmp
+```
+
+For a faster development loop, skip tests marked `full_export`:
+
+```powershell
+uv run pytest --basetemp .pytest_tmp -m "not full_export"
+```
+
+Run the exhaustive export boundary on its own before merging export, page,
+plotting, or summary changes:
+
+```powershell
+uv run pytest --basetemp .pytest_tmp -m full_export
+```
+
+The repository uses pytest's built-in `tmp_path` fixture with the workspace-local
+`--basetemp` above. Tests must not create persistent UUID-named directories at
+the repository root.
+
+Run the configured correctness lint before pushing:
+
+```powershell
+uv run ruff check .
+```
+
+`full_export` is reserved for behavior that requires every default dashboard
+page and all dashboard states. Tests of writing, validation, individual pages,
+selectors, and diagnostics should configure the smallest page and state set
+that exercises their contract. This keeps those tests focused without reducing
+the end-to-end coverage provided by the full-export tests.
+
+## Which Suite To Run
+
+| Change | During development | Before merge |
+|---|---|---|
+| Config, prepare, skimjoin, or isolated summary logic | Focused tests, then `-m "not full_export"` | Full default command |
+| Page query or figure behavior | Focused page/figure tests, then fast suite | Fast suite plus `-m full_export` |
+| Export serializer, payload, runtime, or state behavior | Focused export tests | Fast suite plus `-m full_export` |
+| Documentation only | Link/catalog checks and focused documentation tests | Fast suite if CI does not provide a docs-only path |
+
+The full-export tests are slow because they render every default page and
+dashboard state into a representative standalone HTML document. The shared
+fixture builds that document once per test session, so running the marked group
+together avoids repeating the expensive render.
+
+## Focused Commands
+
+```powershell
+uv run pytest --basetemp .pytest_tmp tests/test_page_authoring.py
+uv run pytest --basetemp .pytest_tmp tests/test_figure_builders.py
+uv run pytest --basetemp .pytest_tmp tests/test_export_serializer.py tests/test_export_payload.py
+```
+
+Use [Developer Workflows](40-developer-workflows.md) to choose tests by
+subsystem.
+
+## Related Chapters
+
+- [40 - Developer Workflows](40-developer-workflows.md)
+- [34 - HTML Export](34-html-export.md)
diff --git a/wiki/90-troubleshooting.md b/wiki/90-troubleshooting.md
new file mode 100644
index 0000000..6fdab0a
--- /dev/null
+++ b/wiki/90-troubleshooting.md
@@ -0,0 +1,127 @@
+# 90 - Troubleshooting
+
+Use this chapter when a run, cache, page, or export is not behaving as expected.
+
+## Fast Triage
+
+1. Confirm the config path you ran.
+2. Check the selected pipeline steps and dashboard mode in logs.
+3. Check whether the issue appears in prepare, summarize, dashboard, or export.
+4. Inspect `//manifest.json` for the affected run (see the
+ [cache layout](12-running-workflows.md#artifact-and-cache-paths)).
+5. If cache reuse is suspect, temporarily set `pipeline.overwrite: true` for
+ the affected configured steps.
+
+## Symptoms
+
+| Symptom | Likely causes | First checks |
+|---|---|---|
+| Run missing from dashboard | Missing summary cache, label mismatch, config run omitted | `runs`, cache directories, log run keys |
+| Summary cache rebuilds unexpectedly | Input fingerprint changed, config digest changed, summary contract changed | the run manifest's summary-cache entries |
+| Page says data unavailable | Required summary missing, optional raw input absent, prepared column missing | page catalog and summary catalog |
+| Counts look wrong | Weighting mode, sample rate, explicit weight columns | `summarize.weighting_modes`, prepared `finalweight` |
+| Geography options missing | Geography disabled, land-use columns missing, aggregation config wrong | `zones`, `summarize.geography` |
+| Skim pages empty | Skimjoin disabled, no skim outputs, missing lookup rules | skimjoin manifest and reports |
+| Export differs from live | Widget/section not registered, selector values omitted, unsupported node | page selector/section registrations |
+| Dashboard-only run fails | Summary cache missing or prepared-data page needs prepared cache | `pipeline.steps`, page prepared-data mode |
+
+## Cache Problems
+
+For a reproducible full rebuild, configure the steps and overwrite policy:
+
+```yaml
+pipeline:
+ steps: [prepare, summarize, dashboard]
+ dashboard_mode: live
+ overwrite: true
+```
+
+Return `overwrite` to `false` after the rebuild. Developers can use targeted
+one-off refresh flags while diagnosing a specific cache layer:
+
+```bash
+uv run activitysim-viz --config local_config.yaml --refresh-prepared-cache
+uv run activitysim-viz --config local_config.yaml --refresh-summary-cache
+uv run activitysim-viz --config local_config.yaml --refresh-caches
+```
+
+If only dashboard presentation changed, a refresh usually should not be needed.
+If raw inputs or prepare config changed, refresh both caches.
+
+## Missing Page Data
+
+Find the page in [31 - Dashboard Pages](31-dashboard-pages.md) and check:
+
+- required summary IDs
+- required prepared tables
+- prepared-data mode
+- whether the page is enabled in live/export config
+
+Then find each summary in [24 - Summary Catalog](24-summary-catalog.md) and
+check the required input tables/columns.
+
+### Worked Triage: A Page Says Data Is Unavailable
+
+Suppose Trip Mode opens but shows the standard unavailable card:
+
+1. Find `trip_mode` in chapter 31. It requires
+ `trip_mode_by_tour_purpose_and_tour_mode`.
+2. Find that ID in chapter 24. Note its required prepared table and columns.
+3. Open `//manifest.json` and inspect the summary entry. If the
+ summary is `unavailable`, read its recorded reason before rebuilding
+ anything.
+4. If a required prepared column is missing, inspect the same manifest's
+ prepared-cache entry and the canonical column settings in `columns`.
+5. If the contract recently changed, rebuild the configured summarize step
+ with `pipeline.overwrite: true`.
+6. If the summary is present and valid, confirm the page's `columns=` request
+ matches the cached schema and that the selected weighting mode exists.
+
+This sequence moves backward through the declared contracts. It avoids trying
+random cache refreshes when the real issue is an input or schema mismatch.
+
+## Skimjoin Problems
+
+Check the skimjoin reports:
+
+- `skim_lookup_summary`
+- `missing_lookup_report`
+- `fallback_lookup_report`
+- `skipped_rule_report`
+- `tour_aggregation_summary`
+- `failure_report`
+
+Common fixes:
+
+- correct skim file globs
+- correct `network_los_file`
+- align `activitysim` source columns with prepared tables
+- add missing dimension values
+- change missing matrix/OD policy only after confirming the missing data is
+ expected
+
+## Export Problems
+
+If live mode works but export does not:
+
+1. Confirm the page is included in export page selection.
+2. Confirm ordinary dropdowns use `self.select(...)` and custom widgets use
+ `self.selector(...)`.
+3. Confirm affected content is registered with `self.section(...)`.
+4. Check browser console errors.
+5. Try `?debug_export=1`.
+
+Export cannot reproduce arbitrary Python callbacks. It can only switch among
+serialized states and registered selector variants.
+
+## Still Stuck
+
+Create the smallest reproduction:
+
+1. one run
+2. one page or one summary
+3. one weighting mode
+4. fresh cache root
+5. copied log excerpt and manifest diagnostics
+
+That usually makes the owning subsystem obvious.
diff --git a/wiki/99-glossary.md b/wiki/99-glossary.md
new file mode 100644
index 0000000..aa2b97e
--- /dev/null
+++ b/wiki/99-glossary.md
@@ -0,0 +1,38 @@
+# 99 - Glossary
+
+| Term | Meaning |
+|---|---|
+| ActivitySim output | Raw model output tables such as households, persons, tours, trips, and land use. |
+| Dashboard page | One registered visualizer page with a stable `page_id`. |
+| Dashboard state | Shared visualizer state such as weighting mode, value mode, segmentation, and loaded runs. |
+| Export | Standalone HTML dashboard output that does not require a Python server. |
+| `file_map` | Per-run override for raw ActivitySim output filenames. |
+| `finalweight` | Canonical prepared weight column aggregated by summary builders. |
+| Live mode | Python-backed Panel dashboard served locally. |
+| MAZ | Micro analysis zone. |
+| OMX | Open Matrix file format commonly used for skims. |
+| Output Processor | Prepare, skimjoin, segmentation, and summarize workflows. |
+| Output Visualizer | Live dashboard and HTML export workflows. |
+| Prepared cache | Per-run cache of canonical prepared tables. |
+| Prepared table | Normalized table used by summaries and prepared-data pages. |
+| `prepared_table_map` | Config mapping that supplies canonical prepared tables directly and skips raw prepare. |
+| Run | One ActivitySim scenario/output set shown in the dashboard. |
+| Run key | Cache-directory identifier made by slugifying a run label, such as `Build 2035` to `build-2035`; duplicate normalized labels receive order-dependent `-1`, `-2`, and later suffixes. |
+| Segment | Configured slice of prepared data summarized separately. |
+| Selector | Registered page-local widget that can refresh sections and participate in export. |
+| Skim | Matrix or lookup data used to attach level-of-service values to trips/tours. |
+| Skimjoin | Optional processor step that joins skim-derived values to prepared trips and tours. |
+| Summary builder | Function that converts `RunData` and `Config` into one summary `DataFrame`. |
+| Summary cache | Per-run, per-weighting-mode CSV summary tables consumed by dashboard pages. |
+| Summary contract | Builder metadata defining output schema and required inputs. |
+| TAZ | Traffic analysis zone. |
+| Weighting mode | Versioned registered transform that presents prepared `finalweight` values to summary builders and prepared-data pages under one cache/dashboard mode ID. |
+
+## How The Terms Connect
+
+For a run labeled `Build`, raw `final_trips.csv` is normalized into the
+prepared `trips` table. A summary builder aggregates its canonical
+`finalweight` column and writes a registered summary under the run key's
+weighted and unweighted cache directories. A dashboard page declares that
+summary ID, reads it through `self.data`, and lets registered selectors refresh
+its sections. Export serializes those same declared page states into HTML.