Skip to content
Merged
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
23 changes: 7 additions & 16 deletions services/api/api/resources/grids/compose/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Router for grid compose endpoints."""

import math
import uuid
from datetime import datetime
from typing import Annotated, Any
Expand Down Expand Up @@ -44,6 +43,7 @@
validate_feature_modifications,
validate_grid_has_band,
validate_grid_has_georeference,
validate_grids_share_horizontal_lattice,
)
from api.resources.modifications import stringify_modification_coordinates
from api.schema import JobStatus
Expand All @@ -54,7 +54,6 @@
GRIDDLE_SERVICE,
GRIDS_COLLECTION,
)
from lib.crs import crs_equal
from lib.fuel_models import UnknownFuelModelError, resolve_fuel_model_value
from lib.units import canonicalize_unit

Expand Down Expand Up @@ -121,24 +120,16 @@ def _validate_alignment(
) -> None:
first_alias = next(iter(source_grids))
first_grid = source_grids[first_alias]
_shape_rank(first_grid, input_by_alias[first_alias].grid_id)
first_georef = first_grid["georeference"]
first_transform = tuple(first_georef["transform"])
first_grid_id = input_by_alias[first_alias].grid_id
_shape_rank(first_grid, first_grid_id)

for alias, grid_data in source_grids.items():
grid_id = input_by_alias[alias].grid_id
_shape_rank(grid_data, grid_id)
georef = grid_data["georeference"]
if not crs_equal(georef.get("crs"), first_georef.get("crs")):
raise _http_422("All compose input grids must have the same CRS.")
if tuple(georef.get("shape", ())) != tuple(first_georef.get("shape", ())):
raise _http_422("All compose input grids must have the same shape.")
transform = tuple(georef.get("transform", ()))
if len(transform) != len(first_transform) or any(
not math.isclose(a, b, rel_tol=0.0, abs_tol=1e-9)
for a, b in zip(transform, first_transform, strict=True)
):
raise _http_422("All compose input grids must have the same transform.")
validate_grids_share_horizontal_lattice(
reference_grid=first_grid,
candidate_grid=grid_data,
)


async def _load_source_grids(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from api.resources.grids.utils import (
validate_grid_dimensionality,
validate_grid_has_band,
validate_grids_share_horizontal_lattice,
)
from api.schema import JobStatus
from api.tasks import create_http_task_async
Expand Down Expand Up @@ -121,6 +122,14 @@ async def create_fosberg_fuel_moisture_grid(
required=SURFACE_IRRADIANCE_KEY,
)

# The moisture surface is derived cell-for-cell from both grids and inherits
# the topography grid's georeference, so the irradiance grid must sit on the
# same horizontal lattice (the irradiance grid may be 3D; only y/x matter).
validate_grids_share_horizontal_lattice(
reference_grid=topo_grid,
candidate_grid=irr_grid,
)

source = FosbergFuelMoistureSource(
source_topography_grid_id=body.source_topography_grid_id,
source_topography_grid_checksum=topo_grid.get("checksum"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ class CreateFosbergFuelMoistureRequest(BaseModel):
source_irradiance_grid_id: str = Field(
description=(
"ID of a completed leaflux irradiance grid with an "
"`irradiance.surface.relative` band. Per-cell shading is derived "
"as 1 - irradiance.surface.relative."
"`irradiance.surface.relative` band, on the topography grid's exact "
"horizontal lattice (equivalent CRS, y/x shape, and affine "
"transform). Per-cell shading is derived as "
"1 - irradiance.surface.relative. Resample one grid onto the other "
"when their lattices differ."
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from api.resources.grids.utils import (
validate_grid_dimensionality,
validate_grid_has_band,
validate_grids_share_horizontal_lattice,
)
from api.schema import JobStatus
from api.tasks import create_http_task_async
Expand Down Expand Up @@ -76,6 +77,7 @@ async def create_leaflux_irradiance_grid(
collection=COLLECTION,
document_id=body.source_lad_grid_id,
owner_id=owner_id,
domain_id=domain_id,
document_status="completed",
)

Expand All @@ -98,6 +100,7 @@ async def create_leaflux_irradiance_grid(
collection=COLLECTION,
document_id=body.source_terrain_grid_id,
owner_id=owner_id,
domain_id=domain_id,
document_status="completed",
)
terrain_grid_data = terrain_source_snapshot.to_dict()
Expand All @@ -116,6 +119,11 @@ async def create_leaflux_irradiance_grid(
expected=2,
)

validate_grids_share_horizontal_lattice(
reference_grid=grid_data,
candidate_grid=terrain_grid_data,
)

source = IrradianceLeafluxSource(
source_lad_grid_id=body.source_lad_grid_id,
source_lad_grid_checksum=grid_data.get("checksum"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,11 @@ class CreateLeafluxIrradianceRequest(BaseModel):
default=None,
description=(
"(optional) ID of a completed 2D terrain grid (with an `elevation` "
"band) in the same domain, used to drape the surface irradiance "
"band over real terrain instead of a flat plane."
"band) in the same domain and on the LAD grid's exact horizontal "
"lattice (equivalent CRS, shape, and affine transform), used to "
"drape the surface irradiance band over real terrain instead of a "
"flat plane. Resample the terrain with the LAD grid as its "
"alignment target when their lattices differ."
),
)
bands: list[LeafluxBand] = Field(
Expand Down
70 changes: 69 additions & 1 deletion services/api/api/resources/grids/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Shared validation and computation utilities for grid endpoints.
"""

from math import ceil
from math import ceil, isclose

from fastapi import HTTPException, status

Expand All @@ -21,6 +21,7 @@
from api.resources.grids.schema import BandType, GridDataChunkMetadata
from api.resources.modifications import stringify_modification_coordinates
from lib.config import FEATURES_COLLECTION, GRIDS_COLLECTION
from lib.crs import crs_equal
from lib.domain_utils import parse_domain_gdf
from lib.fuel_models import UnknownFuelModelError, resolve_fuel_model_value
from lib.landfire import CoverageStatus, covers_annual, covers_seasonal
Expand Down Expand Up @@ -241,6 +242,73 @@ def validate_grid_dimensionality(grid_data: dict, grid_id: str, expected: int) -
)


def validate_grids_share_horizontal_lattice(
reference_grid: dict,
candidate_grid: dict,
) -> None:
"""Validate that two grids have the same horizontal raster lattice.

A 3D reference and 2D candidate may differ along z; their CRS, trailing
``(y, x)`` shape, and six affine-transform coefficients must match. Derived
operations rely on this invariant so processing services can address both
inputs with the same pixel indices without resampling.

Raises:
HTTPException(422): If either grid lacks a georeference or their
horizontal lattices differ.
"""
reference_grid_id = reference_grid["id"]
candidate_grid_id = candidate_grid["id"]
validate_grid_has_georeference(reference_grid, reference_grid_id)
validate_grid_has_georeference(candidate_grid, candidate_grid_id)

reference = reference_grid["georeference"]
candidate = candidate_grid["georeference"]
remediation = (
f"Resample grid {candidate_grid_id} with alignment.target='grid' and "
f"alignment.grid_id='{reference_grid_id}' before using these grids together."
)

if not crs_equal(reference.get("crs"), candidate.get("crs")):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Grid {candidate_grid_id} does not share grid "
f"{reference_grid_id}'s horizontal CRS. {remediation}"
),
)

reference_shape = tuple(reference.get("shape", ())[-2:])
candidate_shape = tuple(candidate.get("shape", ())[-2:])
if reference_shape != candidate_shape:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Grid {candidate_grid_id} has horizontal shape "
f"{candidate_shape}, but grid {reference_grid_id} has "
f"{reference_shape}. {remediation}"
),
)

reference_transform = tuple(reference.get("transform", ()))
candidate_transform = tuple(candidate.get("transform", ()))
if (
len(reference_transform) != 6
or len(candidate_transform) != 6
or any(
not isclose(a, b, rel_tol=0.0, abs_tol=1e-9)
for a, b in zip(reference_transform, candidate_transform, strict=True)
)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Grid {candidate_grid_id} does not share grid "
f"{reference_grid_id}'s horizontal transform. {remediation}"
),
)


# Export formats that cannot represent a volumetric (3D) voxel grid.
_2D_ONLY_EXPORT_FORMATS = {GridExportFormat.geotiff}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
_TRANSFORM = [2.0, 0.0, 720226.0, 0.0, -2.0, 5190646.0]


def _grid(crs: str) -> dict:
def _grid(crs: str, grid_id: str = "grid") -> dict:
return {
"georeference": {"shape": [50, 100], "transform": list(_TRANSFORM), "crs": crs}
"id": grid_id,
"georeference": {"shape": [50, 100], "transform": list(_TRANSFORM), "crs": crs},
}


Expand All @@ -24,18 +25,39 @@ def _inputs(aliases: list[str]) -> dict[str, ComposeInput]:

def test_equivalent_crs_spellings_pass():
# Same CRS spelled two ways (EPSG vs OGC URN) — must not be rejected.
source_grids = {"a": _grid("EPSG:32611"), "b": _grid("urn:ogc:def:crs:EPSG::32611")}
source_grids = {
"a": _grid("EPSG:32611", "a"),
"b": _grid("urn:ogc:def:crs:EPSG::32611", "b"),
}
_validate_alignment(source_grids, _inputs(["a", "b"]))


def test_matching_crs_passes():
source_grids = {"a": _grid("EPSG:32611"), "b": _grid("EPSG:32611")}
source_grids = {"a": _grid("EPSG:32611", "a"), "b": _grid("EPSG:32611", "b")}
_validate_alignment(source_grids, _inputs(["a", "b"]))


def test_different_crs_rejected():
source_grids = {"a": _grid("EPSG:32611"), "b": _grid("EPSG:4326")}
source_grids = {"a": _grid("EPSG:32611", "a"), "b": _grid("EPSG:4326", "b")}
with pytest.raises(HTTPException) as exc:
_validate_alignment(source_grids, _inputs(["a", "b"]))
assert exc.value.status_code == 422
assert "CRS" in exc.value.detail


def test_different_shape_rejected():
source_grids = {"a": _grid("EPSG:32611", "a"), "b": _grid("EPSG:32611", "b")}
source_grids["b"]["georeference"]["shape"] = [25, 50]
with pytest.raises(HTTPException) as exc:
_validate_alignment(source_grids, _inputs(["a", "b"]))
assert exc.value.status_code == 422
assert "shape" in exc.value.detail


def test_different_transform_rejected():
source_grids = {"a": _grid("EPSG:32611", "a"), "b": _grid("EPSG:32611", "b")}
source_grids["b"]["georeference"]["transform"][2] += 1.0
with pytest.raises(HTTPException) as exc:
_validate_alignment(source_grids, _inputs(["a", "b"]))
assert exc.value.status_code == 422
assert "transform" in exc.value.detail
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from tests.fixtures import make_grid_data

SURFACE = "irradiance.surface.relative"
TRANSFORM = (2.0, 0.0, 500000.0, 0.0, -2.0, 5201000.0)


@pytest.fixture
Expand All @@ -28,10 +29,16 @@ def _make(
bands=("slope", "aspect"),
shape=(40, 40),
checksum="src-checksum",
crs="EPSG:32611",
transform=TRANSFORM,
):
data = make_grid_data(domain_id=domain_id, name="source grid", status=status)
data["bands"] = [{"key": key} for key in bands]
data["georeference"] = {"shape": list(shape)}
data["georeference"] = {
"crs": crs,
"transform": list(transform),
"shape": list(shape),
}
data["checksum"] = checksum
firestore_client.collection(GRIDS_COLLECTION).document(data["id"]).set(data)
created.append(data["id"])
Expand Down Expand Up @@ -237,6 +244,50 @@ def test_irradiance_missing_surface_band_returns_422(
assert response.status_code == 422
assert SURFACE in response.json()["detail"]

@pytest.mark.parametrize(
("irradiance_overrides", "detail_fragment"),
[
({"shape": (20, 20)}, "shape"),
(
{"transform": (2.0, 0.0, 500001.0, 0.0, -2.0, 5201000.0)},
"transform",
),
({"crs": "EPSG:4326"}, "CRS"),
],
)
def test_irradiance_not_aligned_with_topography_returns_422(
self,
client,
domain_for_testing,
topography_grid,
grid_factory,
irradiance_overrides,
detail_fragment,
):
irr = grid_factory(
domain_for_testing["id"],
bands=(SURFACE,),
shape=irradiance_overrides.get("shape", (40, 40)),
crs=irradiance_overrides.get("crs", "EPSG:32611"),
transform=irradiance_overrides.get("transform", TRANSFORM),
)
body = self._body(topography_grid, irr)
response = client.post(self.route(domain_for_testing["id"]), json=body)
assert response.status_code == 422
assert detail_fragment in response.json()["detail"]

def test_3d_irradiance_sharing_horizontal_lattice_succeeds(
self, client, domain_for_testing, topography_grid, grid_factory
):
"""A canopy+surface irradiance grid is 3D; only its y/x lattice must
match the 2D topography grid."""
irr_3d = grid_factory(
domain_for_testing["id"], bands=(SURFACE,), shape=(6, 40, 40)
)
body = self._body(topography_grid, irr_3d)
response = client.post(self.route(domain_for_testing["id"]), json=body)
assert response.status_code == 201

# --- Request body validation ---

@pytest.mark.parametrize(
Expand Down
Loading
Loading