Skip to content

export: write a step's per-tile output as a COG or a zarr - #20

Draft
danohu wants to merge 9 commits into
cornerstone-data:mainfrom
AdAstraEco:export-tile-upstream
Draft

danohu wants to merge 9 commits into
cornerstone-data:mainfrom
AdAstraEco:export-tile-upstream

Conversation

@danohu

@danohu danohu commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Stacked on #19 (and so #16). Review only the last 3 commits; I'll rebase onto main once #19 merges.

Adds export.write_tile, which writes a pipeline step's per-tile raster output under EXPORT_ROOT, as a COG or a zarr. It is a Python API only: nothing calls it automatically, and a workflow opts in.

export.write_tile(dset=emit.workflow(tile_id=tile_id), format=export.Format.COG, name="emit", tile_id=tile_id)
export.write_tile(dset=statistical.get_downscaled_luc_emissions(tile_id=tile_id), format=export.Format.ZARR, name="statistical-emissions", tile_id=tile_id)

Builds on #19 (jdluc/export.py, EXPORT_ROOT) and #16 (the .complete marker on zarrs), so it merges after both.

What it writes

  • Path: {output_root or EXPORT_ROOT}[/{version}]/{name}/{tile_id}.{tif|zarr}.
    • version is optional, for example a run id or a release label. Blank, the default, writes directly under the export root, so a rerun writes over the last one.
    • A set version puts the whole export under {root}/{version}/ and is stamped as cornerstone-export-version. It must be one path segment.
    • write_table takes the same version=, so the tiles and tables of one export sit together. trace.export_workflow doesn't pass one yet.
    • Provenance is stamped by get_metadata either way.
  • Variables: variables defaults to every data variable, in the dataset's order. A variable that isn't in dset, or a bare str in place of a list, raises before anything is written. dset is lazy, and only the selected variables are computed, as they are written.
  • COG: every variable becomes float32 2-D bands with NaN as no-data.
    • A variable with dimensions beyond (y, x) is flattened to one band per combination of coordinate values, named variable:value[:value…]. This is the : convention emit already uses for its band names.
    • Bands are chunked with geo.get_chunk_size.
    • The file is staged locally as a GeoTIFF, then band names are set, it is validated, converted to a COG and uploaded whole. write_dask_dataset_to_cog and stack_bands are added to storage, next to the zarr writer, and the COG compute runs under COMPUTE_LOCK, as the zarr write does.
  • Zarr: the variables are written as they are, keeping their dtypes and extra dimensions. String coordinates become object, because zarr v3 has no fixed-length strings. String data variables are left as they are, because converting a lazy one would load it whole. Metadata goes in the dataset attributes.
    • Exports are meant to be re-runnable, so an export writes over an existing zarr. This differs from the cache, which refuses to overwrite.
    • Write order: remove {uri}.complete, write with mode="w", then touch {uri}.complete.
    • write_dask_dataset_to_zarr gains mode=. It defaults to "w-", so the cache is unchanged.
    • The marker URI is now storage.get_zarr_complete_uri, shared with ZarrCacher.

utils.get_git_version now returns the short commit SHA on a detached HEAD, rather than raising. This is how CI checks out a pull request, and every export stamps the version. A checkout with no .git still fails, as before.

docs/data.md now points to write_tile as the way to write the raster outputs under EXPORT_ROOT.

Not in scope

Default deliverables or band selections, changes to emit or statistical, a CLI, orchestration, and mosaics.

Testing

  • jdluc/__tests__/export_test.py runs on synthetic data and needs no .env. It covers:
    • default and subset variables, a missing variable, and a bare str;
    • COG band names and order, flattening of a 3-D variable, float32 with NaN no-data, and metadata tags;
    • zarr dtypes and extra dimensions, the .complete marker, rewriting an export, and removal of a stale marker when a write fails;
    • the output URI with and without output_root and version, and a version that isn't one path segment;
    • the cornerstone-export-version tag, present only when a version is set.
  • pytest jdluc orchestration and pre-commit run --all-files pass.
  • Checked on a real emit tile: a 4096×4096 window with all 20 variables, written as a COG and as a zarr. Band names, dtypes, tags and .complete were as expected, including after a rewrite.

For zarr files, set up a .complete marker file to check when data is
fully written.

This replaces relying on zarr.json -- that doesn't work because
zarr.json is the first to be written. This might happen from a failed or
aborted calculation, or because multiple processes are trying to write
the same file (e.g. running 2 countries which share a tile)

A partially-written zarr might be not cause any warning signs later,
since it might be techncially valid in spite of missing data.

Now an empty .complete marker is written beside the zarr after its last chunk, and the
entry counts as cached only once the marker exists. A zarr found without its marker is a
write that was killed part-way or is still running: serialize raises FileExistsError and
leaves it for someone to resolve by hand, usually by deleting the zarr, rather than
writing over it and risking a race with a live writer.

Entries written before this change have no marker, so each raises once until deleted.
An autouse fixture in jdluc/__tests__/conftest.py stubs Config.from_dot_env
for every test not marked integration. Each field gets its type's empty value,
with two dask workers, so a unit test that reaches for a storage root fails
instead of touching a bucket. Integration tests still read the real .env.

This replaces the fixture storage_test carried for itself, so the next unit
test that writes a zarr needs no copy of it, and a new Config field needs no
edit to the tests.
An optional `orchestration` package with one subcommand per workflow task
(plan, ingest-tile, compute, trace), and an example Argo WorkflowTemplate
that runs each tile's ingest -> compute as a DAG of its own, so a failed
tile stops only itself. jdluc runs unmodified in the pods; every
cache-key argument passes straight through, and an import-linter contract
keeps jdluc from importing orchestration.

trace writes the emissions-factor table to a new EXPORT_ROOT, through a
small `export.write_table` utility.

The package is built to be wrapped rather than edited: `build_parser`
and `run` let a wrapper add subcommands, `plan.get_task_to_items` returns
the lists for it to add to before `write_task_to_items`, and the
template's tasks can be called by templateRef. See docs/orchestration.md.

Also adds the Dockerfile the workflow's image is built from.
get_name no longer mentions mosaics, get_task_to_items describes the flat lists
without a flat workflow to point at, and trace's --help is a whole sentence.
A Python API only: nothing calls it, a workflow opts in. Writes the chosen
variables of a lazy dataset to {EXPORT_ROOT}/{name}/{tile_id}.{tif|zarr},
provenance from get_metadata.

- COG: every variable as float32 2-D bands, NaN no-data; extra dims are
  flattened to one band per combination of values, named
  variable:value[:value...]. Staged locally and uploaded whole, through
  storage.write_dask_dataset_to_cog (moved into storage with stack_bands).
- zarr: variables as they are, dtypes and extra dims kept. An export is
  re-runnable, so it writes over an existing zarr (write_dask_dataset_to_zarr
  gains mode=, default "w-" so the cache is unchanged), dropping the
  .complete marker first and touching it once the write finishes.
…on}/

write_tile and write_table take version=, such as a run id or a release
label. Blank, the default, writes directly under EXPORT_ROOT as before; set,
the whole export goes under {EXPORT_ROOT}/{version}/ and the version is
stamped as cornerstone-export-version. A version must be one path segment.

Also from review:
- write_tile rejects a bare str as variables, rather than reading its letters
- the COG compute runs under COMPUTE_LOCK, as the zarr write does
- iter_bands notes how datetime and float coords name their bands
- to_zarr_dataset says why only coords become variable-length strings
A detached HEAD, as CI checks out a pull request, has no active branch, so
get_git_version raised and with it every export that stamps provenance. It
now returns the short commit SHA. No .git still fails, as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant