Skip to content

Refreshing the dashboard for a new NRT monthπŸ”—

When a new month of near-real-time data lands, four separate artifacts have to be refreshed and the dashboard config has to be re-pointed at two of them. Doing only some of them fails quietly rather than loudly: the month simply does not appear in the sidebar, or it appears with the wrong colors, or its lakes render but do not respond to clicks.

Artifact What it drives Refreshed by
Dynamic World cube (.zarr) The per-lake timeseries panel Download from GCS, update dw_dataset_file
NRT run tiles (.pmtiles) The base lake polygons on the map Download from GCS, update pmtiles_file
NRT run geometries (.parquet) Resolving a map click to a lake Download, repartition-parquet, update vector_file
Breaks table (nrt_monthly_drain_breaks.parquet) Which lakes drained, and their confidence merge-nrt-confidence
Month tileset (nrt_<month>_drainage.pmtiles) The Drainage Status overlay build-nrt-pmtiles

The historical mode is untouched by an NRT month β€” it reads its own precomputed_historical directory and its own tileset.

0. See what upstream actually hasπŸ”—

Everything below is gated on what the upstream pipeline has published, so start here. Both listings need gcloud auth login first (interactive, in your own browser).

# The imagery-derived cube -- the ceiling on which months can have any NRT data
gcloud storage ls gs://pdg-storage-default/workflows_optimization/lake_change_detection/

# The monthly NRT pipeline runs
gcloud storage ls gs://pdg-storage-default/workflows_optimization/dashboard_nrt/

The cube lags real time by roughly a month, and it is the hard ceiling: if the newest cube stops at 2026-07, there is no 2026-08 data to show no matter what else exists. A month with a cube but no DW_NRT_<month>_run<date>_allGeoms_v*.parquet has no drainage confidence to extract, and the only route to one is the ~30 h/month breakpoint-analysis-nrt batch (see No full run for the month).

Note the exact run<date> suffix β€” it is stamped upstream and is not derivable from the month. 2026-06's run is run2025-06-25; 2026-07's is run2026-07-31.

1. Dynamic World cubeπŸ”—

gcloud storage cp -r \
    gs://pdg-storage-default/workflows_optimization/lake_change_detection/lakes_dw_V2d_2016-<month>_gapfilled_chunked.zarr \
    data/

Then point dw_dataset_file at it in configs/dashboard_panarctic.yaml (top level β€” both modes share the cube).

Prefer the _gapfilled_chunked variant. A cube published without that suffix has not been gap-filled or re-chunked, so the timeseries panel will be slower and may show holes; it works, but it is not what the config is tuned for.

Leave dw_start_month / dw_end_month alone. Those are the seasonal window (June–September), not a data cutoff β€” the dashboard reads them as range(dw_start_month, dw_end_month + 1). Only dw_end_year moves, and only when the year rolls over.

2. NRT run: base tiles and lake geometriesπŸ”—

Two files from the same run, both multi-GB (2026-07: 2.7 GB parquet, 4.0 GB tiles):

mkdir -p data/DW_NRT/DW_NRT_<month>_run<date>
gcloud storage cp \
    "gs://pdg-storage-default/workflows_optimization/dashboard_nrt/DW_NRT_<month>_run<date>_allGeoms_v*.parquet" \
    "gs://pdg-storage-default/workflows_optimization/dashboard_nrt/DW_NRT_<month>_run<date>_allGeoms_v*.pmtiles" \
    data/DW_NRT/DW_NRT_<month>_run<date>/

The parquet needs repartitioning before the dashboard can read single lakes out of it efficiently β€” sorted by id_geohash with small row groups, so a per-lake read touches one row group instead of scanning ~1M rows:

uv run water-timeseries repartition-parquet \
    data/DW_NRT/DW_NRT_<month>_run<date>/DW_NRT_<month>_run<date>_allGeoms_v3.parquet \
    data/DW_NRT/DW_NRT_<month>_run<date>/DW_NRT_<month>_run<date>_allGeoms_v3_repartitioned.parquet

Then update the nrt_drainage mode in configs/dashboard_panarctic.yaml:

modes:
  nrt_drainage:
    vector_file: data/DW_NRT/DW_NRT_<month>_run<date>/DW_NRT_<month>_run<date>_allGeoms_v3_repartitioned.parquet
    pmtiles_file: data/DW_NRT/DW_NRT_<month>_run<date>/DW_NRT_<month>_run<date>_allGeoms_v3.pmtiles

vector_file is what turns a map click into a lake id. If it still points at an older run, lakes that only exist in the new run will paint but not respond to clicks β€” the map reads as dead rather than as misconfigured.

This step is also the one you can legitimately skip for a while: the Drainage Status overlay has its own per-month tileset (step 4) and does not depend on the base run matching the month. Steps 3 and 4 alone will light up a new month on an older base.

3. Merge the month's drainage confidenceπŸ”—

uv run water-timeseries merge-nrt-confidence <month> \
    "DW_NRT_<month>_run<date>_allGeoms_v*.parquet" \
    --breaks-file data/precomputed_nrt/nrt_monthly_drain_breaks.parquet

Quote the glob so the shell leaves it alone. --breaks-file is required here: unlike build-nrt-pmtiles, this command reads top-level config keys only, so --config-file configs/dashboard_panarctic.yaml would not find precomputed_nrt_dir under modes.nrt_drainage.

Takes ~15 s β€” a column-pruned, filtered read over gcsfs, not a download of the multi-GB run. The previous table is backed up to .bak, and the month's existing rows are dropped before the merge, so re-running is safe.

This is what makes the month appear in the sidebar at all: the month list is built from analysis_month in this table, not from the tilesets on disk.

4. Build the month's Drainage Status tilesetπŸ”—

uv run water-timeseries build-nrt-pmtiles \
    --breaks-file data/precomputed_nrt/nrt_monthly_drain_breaks.parquet \
    --geometry-file data/DW_historicalbp_simple_merged_breaks_with_allgeoms_v4.parquet \
    --output-dir data/nrt_tiles \
    --months <month> --poly-max-zoom 12

Always pass --months. Without it every month is rebuilt: ~7 minutes instead of ~40 seconds. Needs tippecanoe (brew install tippecanoe).

Order matters: build this after step 3. A tileset baked from a month with no confidence bakes that absence into its tile properties, and the overlay falls back to a flat water-loss gradient. The only fix is rebuilding it.

The zoom where the map switches from centroids to polygons is POINT_POLY_SWITCH_ZOOM in utils/pmtiles_build.py (currently 8), and the style layers derive their minzoom/maxzoom from it. Both layers are baked POINT_POLY_OVERLAP_ZOOMS levels either side of it, so nudging the switch within that band is a one-line style change; moving it further needs every archive rebuilt (~7 minutes for all months) or the map draws nothing at the uncovered zooms.

Rebuilding a base archiveπŸ”—

Both base archives were rebuilt on 2026-08-27 and now bake real centroids below the switch, with slim centroid properties and a 2 MB per-tile cap (see below). Before that they did not, and the map went blank when zoomed out: they were built before _write_features stamped per-feature zoom ranges, so every centroid was baked at every zoom with the tileset's maxzoom of 14, and tippecanoe's default drop rate of 2.5 per level thinned them by ~2.5^14 β€” 4,026,306 centroids in tilestats against a dropped_by_rate of 4,026,295 at z0, one or two dots per tile. The previous archives are kept alongside as *.pre-centroids.pmtiles.

Nothing needs configuring either way: archive_bakes_low_zoom_centroids reads the answer out of each archive's own tippecanoe metadata, and map_viewer / pmtiles_viewer gate the polygons only when it is yes. An archive that says no gets its polygons drawn at every zoom, as it did before the handoff existed, so an old archive still renders.

To rebuild one, run the builder that matches its tile properties β€” mixing them up silently drops the columns the viz styles on:

# Historical (drainage_year): date_break, date_break_year, water_change_*, *_median
uv run python -c "
from water_timeseries.utils.pmtiles_build import build_pmtiles_drainage_year
build_pmtiles_drainage_year(
    'data/DW_historicalbp_simple_merged_breaks_with_allgeoms_v4.parquet',
    'data/rebuild.pmtiles')"

# NRT (nrt_drainage): date, drainage_confidence, water_*_absolute
uv run python -c "
from water_timeseries.utils.pmtiles_build import build_pmtiles_nrt_drainage
build_pmtiles_nrt_drainage(
    'data/DW_NRT/.../DW_NRT_2026-06_run2025-06-25_allGeoms_v3_repartitioned.parquet',
    'data/rebuild.pmtiles')"

Each takes ~30 minutes on a laptop for 4M lakes (~6 min of that exporting GeoJSONL, the rest tippecanoe) and needs ~15 GB of scratch space on top of the ~3 GB result.

Two settings decide how much of the map survives being zoomed out, because --drop-densest-as-needed discards lakes until each tile fits the byte cap: --maximum-tile-bytes in DEFAULT_TIPPECANOE_ARGS (2 MB, four times the tippecanoe default) and the per-builder *_POINT_PROPERTIES, which keep the centroids down to the id plus the column the mode colours by. Measured in one z7 region holding 3,052 lakes:

zoom 7 props, 500 KB slim, 500 KB slim, 2 MB (shipped)
6 32% 60% 100%
5 9% 26% 42%
4 6% 11% 19%

Going past 2 MB is not worth it β€” at 8 MB the tiles stop growing (~2 MB) because a different limit binds, and coverage barely moves. Below z6 the map is still a sample: 4M centroids cannot fit in one z4 tile at any cap. The NRT monthly overlay escapes this entirely by being small enough (tens of thousands of features) to build with --no-tile-size-limit --no-feature-limit, which is why its drained lakes are all present at every zoom. Build to a new path, check it, then swap it in β€” the dashboard keeps serving the old one meanwhile:

uv run python -c "
from water_timeseries.utils.pmtiles_reader import read_pmtiles_metadata
from water_timeseries.utils.pmtiles_build import archive_bakes_low_zoom_centroids
print(archive_bakes_low_zoom_centroids(read_pmtiles_metadata('<archive>.pmtiles')))"

# and that each zoom draws lakes exactly one way (z0-6 points, z7-9 both, z10+ polys)
tippecanoe-decode <archive>.pmtiles <z> <x> <y> | grep -o '"layer": "[a-z_]*"' | sort | uniq -c

5. VerifyπŸ”—

uv run python -c "
import pandas as pd
print(pd.read_parquet('data/precomputed_nrt/nrt_monthly_drain_breaks.parquet',
      columns=['analysis_month','drainage_confidence']).groupby('analysis_month').count())"

ls -la data/nrt_tiles/nrt_<month>_drainage.pmtiles

Then launch and switch to Near Real-Time:

uv run water-timeseries dashboard --config-file configs/dashboard_panarctic.yaml

The month should appear in the sidebar's "Drainage Status" list with its drained-lake count, and the log should carry NRT monthly tiles for <month>: .... That log line is the one to check β€” without it the overlay silently fell back to the slower runtime feature-state path, which means step 4's archive was not found.

6. Deploying the refreshπŸ”—

In production the config is mounted at /data/dashboard-config.yaml and the tiles are served by the serve-tiles sidecar, with PMTILES_BASE_URL pointing the browser at it. Two things follow:

  • serve-tiles reads only each mode's pmtiles_file out of the config β€” it does not pick up nrt_pmtiles_dir. It serves the parent directory of the archive it was given, so per-month archives are reachable by bare filename only if they sit in that same directory.
  • With PMTILES_BASE_URL set, the dashboard asks for <base_url>/nrt_<month>_drainage.pmtiles β€” a bare filename, no directory component.

So either copy the month archives next to the base .pmtiles on the PVC, or set nrt_pmtiles_dir to an http(s):// or gs:// prefix and let the browser fetch them straight from there.

No full run for the monthπŸ”—

If GCS has a cube but no DW_NRT_<month>_run<date> files, the confidence has to be computed locally β€” roughly 30 hours per month of ARIMA fitting:

uv run water-timeseries breakpoint-analysis-nrt <args>
uv run water-timeseries aggregate-nrt <nrt-dir>

aggregate-nrt consolidates the per-month nrt_*_drain_breaks.parquet files into nrt_monthly_drain_breaks.parquet plus nrt_monthly_drain_counts.parquet, which is the same table step 3 would have written. Steps 4 onward are unchanged.

Always check GCS for a newer full run before committing to this β€” the run may simply not have been published yet.