Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions services/api/api/resources/grids/voxelize/inventory/tree/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
TreeInventoryVoxelizationSource,
build_tree_bands,
)
from api.resources.inventories.utils import (
inventory_column_keys,
require_inventory_columns,
)
from api.schema import JobStatus
from api.tasks import create_http_task_async
from lib.config import (
Expand All @@ -32,6 +36,7 @@
TREEVOX_QUEUE,
TREEVOX_SERVICE,
)
from lib.inventory import VOXELIZE_REQUIRED_COLUMNS

router = APIRouter()

Expand Down Expand Up @@ -122,6 +127,20 @@ async def create_tree_inventory_grid(
),
)

# Voxelization reads every per-tree measurement (diameter, species, crown
# ratio drive the crown-profile and biomass models; status keeps live trees).
# A position-and-height-only inventory (e.g. CHM/ITD extraction) can't be
# voxelized until those exist. Biomass / max-crown-radius inventory-column
# references are validated by treevox at read time.
require_inventory_columns(
inventory_column_keys(inventory_data),
VOXELIZE_REQUIRED_COLUMNS,
detail=(
"This inventory lacks the per-tree measurements voxelization needs "
"(a position-and-height-only CHM/ITD inventory must be enriched first)."
),
)

grid_id = uuid.uuid4().hex
request_time = datetime.now()

Expand Down
27 changes: 27 additions & 0 deletions services/api/api/resources/inventories/modification_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,30 @@ def validate_remove_is_sole_action(self):
if has_remove and len(self.actions) > 1:
raise ValueError("RemoveAction must be the sole action if present")
return self


def modification_referenced_columns(
modifications: list[InventoryModification],
) -> set[str]:
"""Return the inventory column names a list of modifications references.

Collects from both conditions and actions: attribute conditions/actions
contribute their ``attribute``; expression conditions contribute every name
used in the expression. Spatial conditions and ``RemoveAction`` reference no
measurement column (they test a tree's position or remove rows). Used to
reject a modification that references a column the target inventory lacks.
"""
columns: set[str] = set()
for mod in modifications:
for condition in mod.conditions:
if isinstance(condition, InventoryModificationCondition):
columns.add(condition.attribute.value)
elif isinstance(condition, InventoryExpressionCondition):
tree = ast.parse(condition.expression, mode="eval")
columns.update(
node.id for node in ast.walk(tree) if isinstance(node, ast.Name)
)
for action in mod.actions:
if isinstance(action, InventoryModificationAction):
columns.add(action.attribute.value)
return columns
18 changes: 17 additions & 1 deletion services/api/api/resources/inventories/modifications/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@

from api.db.documents import get_document_async, set_document_async
from api.dependencies import VerifiedDomain
from api.resources.inventories.modification_models import (
modification_referenced_columns,
)
from api.resources.inventories.modifications.examples import (
APPLY_MODIFICATIONS_OPENAPI_EXAMPLES,
)
from api.resources.inventories.modifications.schema import ApplyModificationsRequest
from api.resources.inventories.schema import Inventory
from api.resources.inventories.utils import validate_feature_conditions
from api.resources.inventories.utils import (
inventory_column_keys,
require_inventory_columns,
validate_feature_conditions,
)
from api.resources.modifications import stringify_modification_coordinates
from api.schema import JobStatus
from api.tasks import create_http_task_async
Expand Down Expand Up @@ -127,6 +134,15 @@ async def apply_modifications(
)
inventory_data = snapshot.to_dict()

# Reject rules that reference a column this inventory doesn't have (e.g.
# `dbh > 30` on an upload or CHM inventory with no dbh). Absence is loud now
# that the uploader no longer pads missing columns with nulls.
require_inventory_columns(
inventory_column_keys(inventory_data),
modification_referenced_columns(body.modifications),
detail="A modification references column(s) this inventory doesn't have.",
)

new_modifications = stringify_modification_coordinates(
[m.model_dump() for m in body.modifications]
)
Expand Down
21 changes: 11 additions & 10 deletions services/api/api/resources/inventories/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from api.resources.inventories.treatment_models import InventoryTreatment
from api.resources.modifications import parse_modification_coordinates
from api.schema import JobError, JobProgress, JobStatus, PaginatedResponse
from lib.inventory import CROWN_RATIO, DIAMETER, HEIGHT, SPECIES, STATUS, X, Y


class InventoryType(StrEnum):
Expand Down Expand Up @@ -99,22 +100,22 @@ class Column(BaseModel):
# subset: CHM produces CHM_INVENTORY_COLUMNS; uploads carry whichever optional
# columns the file contains (the uploader records the actual set on completion).
BASE_INVENTORY_COLUMNS = [
Column(key="x", type=ColumnType.continuous, unit="m"),
Column(key="y", type=ColumnType.continuous, unit="m"),
Column(key="fia_species_code", type=ColumnType.categorical),
Column(key="fia_status_code", type=ColumnType.categorical),
Column(key="dbh", type=ColumnType.continuous, unit="cm"),
Column(key="height", type=ColumnType.continuous, unit="m"),
Column(key="crown_ratio", type=ColumnType.continuous),
Column(key=X, type=ColumnType.continuous, unit="m"),
Column(key=Y, type=ColumnType.continuous, unit="m"),
Column(key=SPECIES, type=ColumnType.categorical),
Column(key=STATUS, type=ColumnType.categorical),
Column(key=DIAMETER, type=ColumnType.continuous, unit="cm"),
Column(key=HEIGHT, type=ColumnType.continuous, unit="m"),
Column(key=CROWN_RATIO, type=ColumnType.continuous),
]

# Columns produced by CHM stem isolation: height and position only — no dbh,
# species, or crown ratio. Treatments thin against dbh, so they cannot be
# applied to a CHM-derived inventory.
CHM_INVENTORY_COLUMNS = [
Column(key="x", type=ColumnType.continuous, unit="m"),
Column(key="y", type=ColumnType.continuous, unit="m"),
Column(key="height", type=ColumnType.continuous, unit="m"),
Column(key=X, type=ColumnType.continuous, unit="m"),
Column(key=Y, type=ColumnType.continuous, unit="m"),
Column(key=HEIGHT, type=ColumnType.continuous, unit="m"),
]


Expand Down
17 changes: 16 additions & 1 deletion services/api/api/resources/inventories/tree/chm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@

from api.db.documents import get_document_async, set_document_async
from api.dependencies import VerifiedDomain
from api.resources.inventories.modification_models import (
modification_referenced_columns,
)
from api.resources.inventories.schema import CHM_INVENTORY_COLUMNS, Inventory
from api.resources.inventories.tree.chm.examples import CREATE_CHM_OPENAPI_EXAMPLES
from api.resources.inventories.tree.chm.schema import (
ChmInventorySource,
CreateChmInventoryRequest,
)
from api.resources.inventories.utils import validate_feature_conditions
from api.resources.inventories.utils import (
require_inventory_columns,
validate_feature_conditions,
)
from api.resources.modifications import stringify_modification_coordinates
from api.schema import JobStatus
from api.tasks import create_http_task_async
Expand Down Expand Up @@ -80,6 +86,15 @@ async def create_chm_inventory(
[*body.modifications, *body.treatments], owner_id, domain_id
)

# A CHM inventory only ever carries position and height. Reject create-time
# modifications that reference columns it won't have (e.g. `dbh > 30`) at the
# boundary, mirroring the in-place modifications guard.
require_inventory_columns(
{column.key for column in CHM_INVENTORY_COLUMNS},
modification_referenced_columns(body.modifications),
detail="A modification references column(s) a CHM inventory doesn't have.",
)

# Validate source CHM grid exists, is owned, in this domain, and completed
_, source_snapshot = await get_document_async(
GRIDS_COLLECTION,
Expand Down
46 changes: 46 additions & 0 deletions services/api/api/resources/inventories/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,52 @@ def validate_inventory_wide_treatment_area(domain: dict, treatments: list) -> No
)


def inventory_column_keys(inventory_data: dict) -> set[str]:
"""Return the column keys an inventory provides, from its ``columns`` metadata.

Skips any malformed/legacy entry missing a ``key`` so a downstream guard
degrades to a clean 422 (the column is treated as absent) rather than a 500
(``KeyError`` on a bad entry).
"""
return {
key
for column in inventory_data.get("columns", [])
if (key := column.get("key")) is not None
}


def require_inventory_columns(
available_keys: set[str],
required: set[str],
*,
detail: str,
) -> None:
"""Reject an operation whose required columns aren't all present in the
inventory.

``available_keys`` is the set of column keys the inventory provides (from its
``columns`` metadata — the source of truth recorded by the uploader and
source services). ``required`` is the set of columns an operation needs or
that a modification rule references. ``detail`` is the lead-in message; the
required (asked-for) and available columns are appended so the caller sees
exactly what was requested versus what the inventory provides.

Raises:
HTTPException(422): If any required column is absent. The columns the
client effectively asked for aren't in this inventory, so this is a
validation error on the request, not a path-level 404.
"""
if required <= available_keys:
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"{detail} Required column(s): {sorted(required)}. "
f"Available column(s): {sorted(available_keys)}."
),
)


async def validate_feature_conditions(
items: list,
owner_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from api.resources.grids.voxelize.inventory.tree.examples import (
ALL_TREE_INVENTORY_EXAMPLE_VALUES,
)
from api.resources.inventories.schema import CHM_INVENTORY_COLUMNS

from lib.config import DOMAINS_COLLECTION, INVENTORIES_COLLECTION
from tests.fixtures import make_domain_data, make_inventory_data
Expand Down Expand Up @@ -46,6 +47,25 @@ def second_domain_for_tree_voxelization(firestore_client):
doc_ref.delete()


@pytest.fixture(scope="session")
def height_only_inventory(firestore_client, domain_for_testing):
"""A completed tree inventory carrying only position and height (e.g. CHM/ITD
extraction) — it lacks the per-tree measurements voxelization needs."""
inventory_data = make_inventory_data(
domain_id=domain_for_testing["id"],
name="Height-only inventory for voxelization guard",
status="completed",
inventory_type="tree",
)
inventory_data["columns"] = [c.model_dump() for c in CHM_INVENTORY_COLUMNS]
doc_ref = firestore_client.collection(INVENTORIES_COLLECTION).document(
inventory_data["id"]
)
doc_ref.set(inventory_data)
yield inventory_data
doc_ref.delete()


@pytest.fixture(scope="session")
def tree_inventory_in_different_domain(
firestore_client, second_domain_for_tree_voxelization
Expand Down Expand Up @@ -282,6 +302,25 @@ def test_source_inventory_not_completed_returns_422(
finally:
doc_ref.delete()

def test_height_only_inventory_returns_422(
self, client, domain_for_testing, height_only_inventory
):
"""A position-and-height-only inventory can't be voxelized — it's missing
diameter, species, crown ratio, and status. The error names what's
required versus what the inventory provides."""
body = {"source_inventory_id": height_only_inventory["id"]}
response = client.post(self.route(domain_for_testing["id"]), json=body)
assert response.status_code == 422
detail = response.json()["detail"]
assert "Required column(s)" in detail
assert "Available column(s)" in detail
# dbh is named as required but is not among the available columns —
# proving the guard reported the actually-missing column, not just that
# the word "dbh" appears somewhere in the (always-listed) required set.
required_part, available_part = detail.split("Available column(s)")
assert "dbh" in required_part
assert "dbh" not in available_part

# --- Request body validation ---

def test_missing_source_inventory_id_returns_422(self, client, domain_for_testing):
Expand Down
Loading
Loading