Skip to content

derive: a crop-class emissions layer over the CIE bands - #22

Draft
danohu wants to merge 11 commits into
cornerstone-data:mainfrom
AdAstraEco:derive-crop-class-upstream
Draft

danohu wants to merge 11 commits into
cornerstone-data:mainfrom
AdAstraEco:derive-crop-class-upstream

Conversation

@danohu

@danohu danohu commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Stacked on #21 (the CIE bands), and so #20, #19 and #16. Review only the last commit; I'll rebase onto main once those merge.

Summary

jdluc/derive.py adds a crop-class layer. For every pixel and each crop class (annual, perennial, paddy rice, pasture), it computes the emissions if that class occupied the pixel. It reads only emit's cie- bands, and adds the three things CIE leaves out:

  • Mineral soil. cie-mineral-soil-carbon-at-risk × (1 − F_LU), with F_LU by crop class and climate zone, capped at 1.
  • Peat occupation. An annual emission factor by crop class and peat climate regime.
  • Discounting. A weight by conversion year over the 20-year window. Three schemes: linear, equal, and five-year-spans (the weights emit uses).

Fields carry a crop_class dim where they depend on a factor, and a discounting dim where they are discounted. Nothing is stored. A Product names the fields, crop classes and discountings to write, and export.write_tile writes them per tile from emit's cached output, computing only what the product names. There are three products: crop-class-emissions (the default), perennial-debug (zarr, every field) and perennial-discounting-comparison.

CLI: python -m jdluc.derive HND [--product=…] [--format=cog|zarr] [--version=…] [--modulus/--residues]. The flags follow emit's.

Notes

  • Export goes through export: write a step's per-tile output as a COG or a zarr #20's write_tile. A product's selection applies to the whole dataset, and COG bands are named <variable>:<crop class>:<discounting>.
  • Annual F_LU is emit.CLIMATE_ZONE_TO_SOC_RETENTION_FRACTION itself. So derive and emit agree on every climate zone, and the regression test against derive_from_cie covers them all.
  • Factor tables cite IPCC 2019 Refinement Vol 4 Tables 5.5 and 6.2, and the IPCC 2013 Wetlands Supplement Ch 2, with no other source.
  • Import-linter: jdluc.derive sits between JD/statistical and emit, and is forbidden to the utilities.
  • docs/crop_independent_emissions.md gains a section on the layer.

Open questions

  • Peat occupation EFs (e.g. annual/tropical 56.92, perennial/tropical 60.76, pasture/tropical 42.32 tCO2e/ha/yr). These are totals across CO2, DOC, CH4 and N2O. Their derivation from the Wetlands Supplement tables (which rows, which GWPs) is not reproduced in code or comments, so it should be confirmed.
  • Perennial F_LU (temperate 0.72, tropical 1.01 → 1.0), checked against Table 5.5.
  • Paddy rice takes the cropland peat EF and an F_LU of 1.0 (1.35 capped).

Testing

danohu added 11 commits October 15, 2026 19:39
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.
… derive

emit writes nine `cie-` bands beside its output: the conversion source and year, and each
carbon pool the conversion puts at risk, before any destination gate, destination-specific
soil factor or discount. `derive_from_cie` rebuilds every existing band from them alone, and a
test checks it does on a grid covering each event, destination and soil case.

Additive: the existing bands, and every consumer of them, are unchanged. `CIE_BANDS` names the
bands for `export.write_tile`. docs/crop_independent_emissions.md sets the two side by side;
tools/compare-emit-layers.py checks the identities on a real tile.
For every pixel and each crop class (annual, perennial, paddy rice, pasture), the emissions if
that class occupied it: F_LU by crop class and climate zone on mineral soil, a peat occupation
factor by crop class and peat climate regime, and a choice of discounting over the 20-year
window. Nothing is stored; a product names the fields, crop classes and discountings to write,
and `export.write_tile` writes them per tile from `emit`'s cached output.

Annual crops take `emit`'s cropland retention fractions, so with five-year-span discounting the
layer reproduces `emit`'s cropland and pasture bands wherever the destination matches, which the
tests check.
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