About modifications
You are viewing in-progress documentation for v2 (Beta). Switch to the stable version for the current production release.
A modification is a stored rule that edits a grid or a tree inventory. Each rule pairs conditions (predicates that select which cells or trees to act on) with actions (what to do to those cells or trees). The rule travels with the resource — it lives in the grid or inventory document in Firestore and is replayed by the processing service whenever the resource is built, so the same input domain plus the same modifications always produces the same output. The API is the place where modifications are defined; griddle and standgen are the places where they are executed.
This page is about the shape of those rules — what a condition is, what an action is, why grids and inventories use slightly different vocabularies for each, and when to reach for an inline geometry versus a persisted Feature reference. It does not show how to write a particular request. For that, see the feature how-to guides: Mask a fuel grid with features and Remove trees with features.
The anatomy of a rule
Section titled “The anatomy of a rule”A modification has exactly two parts:
{ "conditions": [ /* one or more — ANDed together */ ], "actions": [ /* one or more — all applied to matching items */ ]}Conditions are joined with logical AND: every condition must be satisfied
for an item to match. There is no OR operator in the schema. To express
disjunction, write multiple rules — each rule is independent and the
resource carries a list of them. A grid create call accepts a
modifications array; the inventory /modifications endpoint accepts a
modifications array; both are evaluated in order and idempotent under
replay.
Actions run on items the conditions select. For a grid, the items are
cells; for an inventory, the items are individual trees. A single rule can
carry several actions, and they are applied together. The only exception is
the inventory RemoveAction, which is destructive and must therefore be the
sole action in its rule.
The four condition kinds
Section titled “The four condition kinds”The conditions array is heterogeneous — a single rule can mix kinds. Across the two resources there are four distinct condition shapes:
| Kind | Grids | Inventories | What it tests |
|---|---|---|---|
| Attribute | ✅ | ✅ | A single field compared against a value (band/attribute op value) |
| Expression | ✅ | A boolean expression over multiple tree attributes | |
| Spatial — geometry | ✅ | ✅ | The item’s location against an inline GeoJSON geometry |
| Spatial — feature | ✅ | ✅ | The item’s location against a persisted Feature resource |
Attribute conditions
Section titled “Attribute conditions”The simplest kind: pick a field, pick an operator, pick a value.
// Grid — flag cells where the 1-hour fuel load is above 1.5 kg/m**3{ "band": "fuel_load.1hr", "operator": "gt", "value": 1.5 }
// Inventory — pick out trees taller than 30 m{ "attribute": "height", "operator": "gt", "value": 30 }Operators are eq, ne, gt, lt, ge, le. Two important constraints
catch most mistakes:
- A list-valued
valueis only meaningful witheqorne(it asks “is the field one of these?”). Combining a list withgt/lt/etc. is rejected at validation time. - The categorical inventory attribute
fia_species_codeonly acceptseqandne— ordering species codes numerically is not a meaningful test.
Inventory attribute conditions also carry an optional unit field. The
attribute’s native unit is fixed (dbh is cm, height is m), but a request
can declare "unit": "in" and the server will pint-convert the value into
the native unit before comparing. This makes it ergonomic to mix in
imperial inputs without translating yourself; the conversion is rejected if
the supplied unit is dimensionally incompatible.
Expression conditions (inventory only)
Section titled “Expression conditions (inventory only)”Expression conditions exist because attribute conditions can only test one field at a time, and many useful tree-level predicates touch several fields at once.
{ "expression": "dbh < 5 and height < 2" }The expression is parsed with a restricted Python AST: only the names
dbh, height, and crown_ratio are allowed, no function calls, no
attribute access, no imports. fia_species_code is deliberately excluded
because it is categorical — combining it with arithmetic would be
ill-defined. Expressions also have no unit knob; values are always in
native units (cm, m, 0-1 fraction). If you want imperial inputs, convert in
the expression itself.
Grids do not have an expression kind. A grid cell’s bands are independent — there is no equivalent of “this tree’s height and its dbh” because each band is a separate raster. Compounding across bands is handled by writing two attribute conditions in the same rule.
Spatial conditions — inline geometry
Section titled “Spatial conditions — inline geometry”A spatial condition asks: where is this item, relative to a shape? The inline variant carries the shape directly in the request body as a GeoJSON geometry.
{ "source": "geometry", "operator": "within", "geometry": { "type": "Polygon", "coordinates": [ /* ... */ ] }, "buffer_m": 5}Inline geometry is the right tool for one-off shapes: a polygon drawn by hand to mask a single small zone, or a shape derived programmatically that will not be reused. The geometry travels in the request body and is stored verbatim on the resource document, so the rule is fully self-describing — nothing external needs to exist for the modification to replay.
CRS is declared either through GeoJSON’s crs object or implicitly via the
domain. When the request omits crs, the server interprets the coordinates
in the domain’s CRS. This matches the convention used by the rest of the
v2 API: anything spatial defaults to “in the domain’s frame.”
Spatial conditions — feature reference
Section titled “Spatial conditions — feature reference”The feature variant points at a persisted Feature resource by id instead of carrying the shape inline.
{ "source": "feature", "operator": "intersects", "feature_id": "feat_road_abc", "buffer_m": 4}The processing service resolves the id, loads the Feature’s geometry, reprojects it into the domain CRS, optionally buffers it, and then evaluates the spatial operator. This is the right tool whenever the shape already exists as a Feature — typically a road network or a water body extracted from OSM, or a layerset polygon that was uploaded for some other purpose.
The choice between the two variants is not just stylistic — the trade-offs are discussed below in Feature references versus inline geometry.
The action vocabulary
Section titled “The action vocabulary”Once a rule has selected its items, it applies one or more actions.
For grids, every action transforms a band’s value at the matching cells:
{ "band": "fuel_load.1hr", "modifier": "replace", "value": 0 }{ "band": "fuel_load.1hr", "modifier": "multiply", "value": 0.1 }The five modifiers — multiply, divide, add, subtract, replace —
cover the common transforms: zeroing a band, scaling it down, shifting it
by a constant, or clobbering it outright. Division by zero is rejected at
validation time. There is intentionally no “remove” for grids: a grid cell
always exists at its location in the lattice, and a modification can only
change its value, never delete the cell itself.
Inventories carry the same five modifiers plus a sixth, destructive option:
{ "modifier": "remove" } // delete matching trees{ "attribute": "height", "modifier": "multiply", "value": 0.9 } // shrink survivorsRemoveAction deletes matching trees from the inventory rather than
editing their attributes. Because deletion and attribute editing don’t
compose meaningfully — “remove this tree, and also set its height” is
incoherent — the schema enforces that a rule containing a remove action
contains only that action.
Like attribute conditions, inventory actions optionally carry a unit for
unit conversion. Grid actions do not, because grid bands have a single
canonical unit declared on the band itself.
Grid versus inventory: why they differ
Section titled “Grid versus inventory: why they differ”The two resources accept structurally similar rule documents, but a few asymmetries are worth understanding because they trip people up:
-
Field name. Grids use
band(a dot-notation key like"fuel_load.1hr"or"fbfm"); inventories useattribute(a closed enum:dbh,height,crown_ratio,fia_species_code). A grid’s bands are open-ended — the set depends on what data the grid carries — so a string field is the only option. Tree attributes are fixed by the rasterizer’s contract. -
Spatial target. Grid spatial conditions carry a
targetfield (centroidorcell) controlling which part of each cell is tested against the geometry. Inventory spatial conditions do not, because a tree is a point — there is nothing to choose. This is the cleanest illustration of the rule of thumb: grids are cells with footprints; inventories are points. -
Removal. Inventories have a destructive
RemoveAction; grids do not. See above. -
Units on values. Inventory conditions and actions accept a
unitfield for pint-based conversion. Grid conditions and actions do not. -
Expression conditions. Inventory only. Grid bands are independent; cross-band conjunction is expressed by stacking attribute conditions in the same rule.
Feature references versus inline geometry
Section titled “Feature references versus inline geometry”When a shape is already persisted as a Feature, both source: "feature"
and source: "geometry" would technically work — you could re-export the
Feature’s geometry into the request body. The schema deliberately lets you
do either. In practice, prefer source: "feature" whenever the shape is or
could be reused, and reserve source: "geometry" for genuinely one-off
shapes. The reasons:
-
Single source of truth. A road network exists in exactly one place (the road Feature). Editing the road updates every rule that references it. With inline geometry, the same shape can drift across requests and the resource document carries a frozen copy that no one knows to update.
-
Payload size. Roads and water bodies pulled from OSM are routinely tens of megabytes of GeoJSON. Inlining them inflates every grid or inventory document and slows replay. A feature id is a string.
-
Auditability. A persisted Feature carries provenance — the OSM tags it was filtered from, the buffer it was extracted with, who uploaded it. An inline geometry has no provenance beyond what the requester chose to put in its
propertiesblock. -
Reprojection happens once, server-side. Both variants are reprojected into the domain CRS at evaluation time, but the feature variant uses the Feature’s stored, canonicalized CRS, so the request body doesn’t have to carry CRS metadata.
Inline geometry remains the right call when the shape is genuinely ephemeral — a quick experiment, a hand-drawn polygon over a single AOI, or a derived shape (e.g., the output of a per-request buffer-and-merge pipeline) that is not worth persisting as a Feature.
A constraint that applies to both: the Feature must live in the same domain as the grid or inventory being modified. Cross-domain references are rejected at submit time. This is a deliberate scoping rule — a domain is the unit of geographic scope in v2, and modifications that quietly reached across domains would make resource lineage much harder to reason about.
buffer_m semantics
Section titled “buffer_m semantics”Both spatial variants carry an optional buffer_m. The number is in
meters in the domain’s projected CRS — not degrees, not the geometry’s
native CRS. This is one of the few places in the v2 API where the user
supplies a distance, and it deserves a moment of care.
A buffer of 0 is the same as no buffer: the operator is evaluated
against the literal geometry. A buffer of 5 widens the geometry by 5 m on
every side before the test, so cells or trees within 5 m of the original
shape now match within/intersects, and a cell 4 m outside a road
becomes “inside the buffered road.” The buffer is applied uniformly — a
linestring becomes a strip, a point becomes a disk, a polygon becomes a
fatter polygon.
The buffer matters most when the shape is narrow relative to the things being tested. The interesting regimes:
-
Narrow features against tree inventories. Road and water Features are stored as polygons — the OSM extractor widens road centerlines into footprint polygons by road class, and water bodies are already areal — but a road footprint is still only a few meters wide. Trees are points, and a point rarely lands inside such a thin polygon, so a
withintest with no buffer catches very few trees.buffer_mis how you give the feature enough width to match: its value expresses the real corridor — a 4 m single-lane road, an 8 m paved road, a 20 m+ highway with shoulders, or a shoreline tolerance around water. -
Narrow features against grids. Grids have two modes. With
target: "cell", the entire cell footprint is tested, so a buffer is not required — every cell the road polygon touches is captured byintersects. Withtarget: "centroid"(the default), the cell is reduced to its center point and the narrow-feature problem reappears: a road that clips the corner of a cell may miss its centroid. The cleanest “mask every cell the road touches” istarget: "cell"with no buffer; the cleanest “mask every cell within a right-of-way” isbuffer_m: <ROW width>. -
Inline linestrings. A bare
LineStringsupplied viasource: "geometry"has zero area, so on its own it matches almost nothing — every tree point, and (undertarget: "cell" + within) every grid cell, fails the test. A non-zerobuffer_mturns the line into a strip and is effectively required. (Persisted road Features avoid this because they’re already widened to polygons.) -
Large polygons. A lake, a managed plot, a fuel layerset polygon — these have ample area, and
intersectsagainst a cell or a tree behaves naturally without a buffer. A buffer here expresses tolerance — shoreline slack, polygon-edge margin — not a way to escape a thin-geometry miss.
Compound conditions
Section titled “Compound conditions”Because the conditions array is ANDed, the four kinds compose freely inside a single rule. A few sketches of what that buys you:
// Remove only large trees that fall inside a road buffer{ "conditions": [ { "attribute": "dbh", "operator": "gt", "value": 30 }, { "source": "feature", "operator": "intersects", "feature_id": "feat_road_abc", "buffer_m": 4 } ], "actions": [{ "modifier": "remove" }]}
// Halve the 1-hour fuel load inside a polygon AND only where the// existing load is above 1.0 kg/m**3 (don't amplify near-zero noise){ "conditions": [ { "band": "fuel_load.1hr", "operator": "gt", "value": 1.0 }, { "source": "geometry", "operator": "within", "geometry": { /* ... */ } } ], "actions": [ { "band": "fuel_load.1hr", "modifier": "multiply", "value": 0.5 } ]}There is no OR; multiple rules express disjunction. Two rules that
match the same item run in sequence — second rule sees the output of the
first, which means rule order matters for non-commutative actions
(multiply after replace is not the same as replace after
multiply).
Reproducibility and persistence
Section titled “Reproducibility and persistence”Modifications are part of the resource’s lineage. A grid’s create payload
carries the modifications list, and that list is stored on the grid
document; the same is true for the new inventory derived through the
POST /inventories/{id}/modifications endpoint. The processing service
replays the stored list when it builds the resource, so:
- The output is a deterministic function of
(source data, modifications). Re-running with the same payload reproduces the same grid or inventory. - Removing or re-ordering rules requires creating a new resource. There is no in-place edit of the modifications list on a built resource — the resource is the artifact of a particular rule sequence.
- A persisted Feature referenced by a rule is loaded lazily at processing time. If you intend modifications to be exactly reproducible months later, treat the referenced Features as immutable, since changing them changes every downstream resource that references them on the next rebuild.
This is the design intent behind making modifications part of the resource contract rather than a separate “edit API.” A grid carries every decision needed to produce its array of bytes from its source.
Where to go next
Section titled “Where to go next”For step-by-step recipes against real endpoints, see the feature how-to guides:
- Mask a fuel grid with features — zero or thin surface fuel where roads and water cross a grid.
- Remove trees with features — drop trees near a road or water feature, optionally filtered by DBH.
The schema itself is documented in the OpenAPI surface — Swagger UI shows
the exact field types, validators, and json_schema_extra examples for
every variant covered above.