Skip to content

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.

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 conditions array is heterogeneous — a single rule can mix kinds. Across the two resources there are four distinct condition shapes:

KindGridsInventoriesWhat it tests
AttributeA single field compared against a value (band/attribute op value)
ExpressionA boolean expression over multiple tree attributes
Spatial — geometryThe item’s location against an inline GeoJSON geometry
Spatial — featureThe item’s location against a persisted Feature resource

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 value is only meaningful with eq or ne (it asks “is the field one of these?”). Combining a list with gt/lt/etc. is rejected at validation time.
  • The categorical inventory attribute fia_species_code only accepts eq and ne — 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 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.

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.”

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.

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 survivors

RemoveAction 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.

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 use attribute (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 target field (centroid or cell) 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 unit field 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.

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 properties block.

  • 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.

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 within test with no buffer catches very few trees. buffer_m is 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 by intersects. With target: "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” is target: "cell" with no buffer; the cleanest “mask every cell within a right-of-way” is buffer_m: <ROW width>.

  • Inline linestrings. A bare LineString supplied via source: "geometry" has zero area, so on its own it matches almost nothing — every tree point, and (under target: "cell" + within) every grid cell, fails the test. A non-zero buffer_m turns 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 intersects against 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.

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).

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.

For step-by-step recipes against real endpoints, see the feature how-to guides:

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.