This chapter follows one new summary from a question to a tested dashboard dependency. Use it with the shorter contract reference in chapter 23.
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.
Add the calculation to the domain owner, such as
processor/summarize/summaries/trip.py:
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.
Wrap the transform with @summary in the same module:
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:
- gives the table a stable config/cache ID;
- prevents the builder from running when inputs are unavailable;
- supplies a correctly typed empty result; and
- 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.
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:
.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.
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.
Test the numbers with a tiny frame:
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:
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.
Declare the dependency on the page:
@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:
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.
Run:
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.pyConfirm the new ID appears in chapter 24 and, once wired to a page, in the page catalog in chapter 31.
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.
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.
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.
- The row grain and value meaning are written down.
- Grouping uses canonical prepared fields.
- Counts, totals, and averages apply
finalweightdeliberately. - 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.