Skip to content

Add compose grid endpoint for band math and grid composition#371

Merged
amarcozzi merged 7 commits into
mainfrom
369-add-compose-grid-endpoint
Jun 17, 2026
Merged

Add compose grid endpoint for band math and grid composition#371
amarcozzi merged 7 commits into
mainfrom
369-add-compose-grid-endpoint

Conversation

@amarcozzi

Copy link
Copy Markdown
Contributor

Summary

Adds POST /v2/domains/{domain_id}/grids/compose to create a new Grid by selecting, computing, and conditionally composing bands from one or more completed source grids. This is a single grid-composition primitive that supersedes the older blend (#40) and derive (#41) proposals; it creates a normal async Grid resource rather than a new resource type.

What's included

API service (api.resources.grids.compose)

  • Schema, OpenAPI examples, router, and validation.
  • Validates input existence / ownership / domain / completion, alias & band references, output-key uniqueness, georeference alignment, operator arity, and unit compatibility.
  • multiply/divide derive output units; additive ops (add/subtract/average/min/max) require operands compatible with the output unit and reject mixing unitless and unitful operands; % is preserved.
  • Stays GDAL-free at runtime.

Griddle service (handlers/compose.py + dispatch case)

  • Re-validates source-grid status / domain / owner and the provenance checksum before loading.
  • Enforces 2D-only alignment, converts operand units to the output unit, propagates nodata, and fails on non-finite results (e.g. divide-by-zero) instead of writing inf.

FBFM40 fuel-model labels (lib.fuel_models)

  • Categorical fbfm conditions and fallbacks accept the human-readable Scott-Burgan labels (GR1, NB1, ...) interchangeably with the numeric codes. Labels resolve to codes at the API write boundary, so the processing services stay integer-only (no schema or worker changes).
  • The shared resolver is also wired into the existing in-place grid modifications endpoint, so both endpoints behave consistently. Unknown labels return 422.

Testing

  • lib resolver: 8 passed
  • compose schema (18) + router integration (15)
  • modifications unit + resolver (100) + in-place integration (17)
  • griddle compose handler + dispatch (72)
  • ruff clean across all touched files

Closes #369
Supersedes #40, #41

Add POST /v2/domains/{domain_id}/grids/compose to create a new Grid by
selecting, computing, and conditionally composing bands from one or more
completed source grids. Supersedes the older blend/derive proposals
(#40, #41) with a single composition primitive.

API service:
- New api.resources.grids.compose module (schema, examples, router,
  validation). Validates input existence/ownership/domain/completion,
  alias/band references, output-key uniqueness, alignment, operator
  arity, and unit compatibility. Multiply/divide derive output units;
  additive ops require operands compatible with the output unit and
  reject mixing unitless and unitful operands. Percent is preserved as
  a unit. String-literal fallbacks and string categorical condition
  values are rejected (categorical bands are integer-coded).
- Stays GDAL-free at runtime.

Griddle service:
- New handlers/compose.py plus the compose dispatch case. Re-validates
  source-grid status/domain/owner and provenance checksum before
  loading, enforces 2D-only alignment, converts operand units to the
  output unit, propagates nodata, and fails on non-finite results
  (e.g. divide-by-zero) instead of writing inf.

Tests: schema, router, and handler/dispatch coverage including unit
conversion, categorical-code validation, divide-by-zero, stringified
spatial-condition coordinates, checksum/status guards, and 3D rejection.
Categorical fbfm grid bands store integer Scott-Burgan codes, but users
prefer the human-readable labels (GR1, NB1, ...). Resolve labels to codes
at the API write boundary so stored rules are always integer codes and
the processing services stay integer-only — no schema or worker changes.

- lib.fuel_models: Scott-Burgan 40 label<->code table (45 codes, grounded
  in the griddle SB40 lookup) plus resolve_fuel_model_value(), which maps
  labels to codes, passes numbers through, handles lists, is case- and
  whitespace-insensitive, and raises on unknown labels.
- Compose: categorical condition values and categorical `else` fallbacks
  accept labels; band references (alias.band) are left untouched. Unknown
  labels return 422.
- Modifications: a shared resolver normalizes condition and action values;
  the compose endpoint reuses it for its post-compose modifications, so
  both endpoints behave consistently. Integer codes still work everywhere.
- Restore the readable labels in the compose OpenAPI examples and note
  label support in both endpoints' docs.
…l resolution

Address four review findings on the compose grid endpoint:

- Conditional divide-by-zero: the non-finite check ran over the full
  computed array before the conditional fallback was applied, so a
  divide whose denominator was zero in cells the conditions exclude
  still failed the job. Compute the condition mask first and restrict
  the non-finite check to cells that keep the computed branch (and an
  inline-compute fallback to cells where it is actually used).

- Cross-grid coordinate drift: alignment is validated only to a 1e-9
  transform tolerance, but xarray arithmetic and where() align by exact
  coordinate label. Sub-nanometer drift between inputs built by
  different code paths could silently inner-join to NaN or an empty
  result. Normalize every input onto the first input's y/x coordinates
  after alignment validation.

- Nodata + unit scaling: read the nodata sentinel from the raw band,
  before astype and the unit-conversion factor, so a sentinel like
  -9999 can't be scaled past == nodata recognition.

- Band-aware label resolution: resolve_modification_fuel_model_labels
  now takes the grid's band types and resolves FBFM labels only on
  categorical bands, rejecting strings on continuous bands with a clear
  message instead of mis-resolving them. The modifications router runs
  it inside the transaction against the authoritative band metadata;
  compose passes its output band types.

Adds regression tests for each fix.
The compose router hand-rolled request-only validation that Pydantic can
express declaratively. Relocate the checks that depend solely on the
request body into schema validators, leaving the router with only the
validation that needs the loaded source-grid metadata.

Schema (compose/schema.py):
- InlineCompute gains an after-validator for operand arity (variadic vs
  binary), "at least one band operand", and "no string-literal operands";
  ComposeCompute now inherits InlineCompute so the operator/operands
  fields and that validator exist once.
- ComposeAttributeCondition gains an after-validator for value shape
  (`in` requires a list; ordering operators reject lists).
- Scattered string-literal operator sets become named enum-based
  frozensets: VARIADIC_OPERATORS, CATEGORICAL_CONDITION_OPERATORS,
  _ORDERING_OPERATORS.

Router (compose/router.py):
- Drop _validate_compute_arity and _literal_is_numeric (now schema).
- _validate_compute_units and _validate_conditions keep only the
  grid-metadata-dependent checks (band types, unit derivation,
  categorical operator restriction + label resolution); reuse the shared
  operator-set constants.

These structural failures now surface as standard FastAPI 422 validation
errors at parse time. Adds schema unit tests for the relocated checks and
updates the in-condition router test for the list-shaped error body.
The compose request forced the caller to declare every output band's key,
type, and unit, then the router just validated that those matched what it
already derives from the operations and source grids. The key/type/unit
were redundant (and getting the multiply/divide unit exactly right was
pure busywork), so drop the `bands` field entirely.

Output bands are now derived from the operations, in order (selects then
computes):
- key:  the operation's `output`
- type: the source band's type for `select`; always continuous for `compute`
- unit: the source unit for `select`; the operand-derived unit for `compute`

Optional `name`/`description` move onto `select`/`compute`. `compute` also
gains an optional `unit` override (any dimensionally compatible unit; the
worker converts to it) — `select` has none because it copies verbatim.

Schema:
- Remove ComposeOutputBand, build_compose_bands, and the `bands` field /
  its validators from CreateComposeRequest.
- Add name/description to ComposeSelect; name/description/unit to
  ComposeCompute (unit canonical-validated).

Router:
- _validate_compute_units -> _derive_compute_unit: returns the output unit
  (derived, or a compatible override) rather than checking a supplied one.
- _validate_compose_operations -> _build_output_bands: derives the ordered
  Band list while validating conditions and `else` fallbacks.

Griddle is unchanged: it reads the resolved bands off the stored grid doc
(which the API still computes) and ignores the new operation fields.

Examples drop `bands`, gain a unit-override example, and the OpenAPI
descriptions are rewritten. Tests updated for the derived shape.
@amarcozzi amarcozzi merged commit f8f6db7 into main Jun 17, 2026
1 check passed
@amarcozzi amarcozzi deleted the 369-add-compose-grid-endpoint branch June 17, 2026 17:52
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.

Add compose grid endpoint for band math and grid composition

1 participant