From ee34dc9e44aa22475c79c0de874185428932794e Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 12:39:56 -0600 Subject: [PATCH] Reject source grids that don't share a horizontal lattice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LeafLux surface irradiance and Fosberg dead fuel moisture each derive an output by combining two source grids cell-for-cell in a processing service (treevox, griddle). Both assumed the grids were already on the same horizontal lattice and passed the raw arrays straight through, so a mismatch (e.g. a 2 m LAD grid paired with a 30 m terrain grid) surfaced only as an async worker crash with an UNEXPECTED_FAILURE and no actionable message — this was blocking end-to-end validation of the Fosberg grid (#540). Add a shared `validate_grids_share_horizontal_lattice` in grids/utils that compares two grids' CRS (semantically), trailing (y, x) shape, and affine transform, returning 422 with resample guidance. A 3D grid may pair with a 2D grid; only the horizontal lattice must match. Apply it in the LeafLux and Fosberg routers before the output document is created or a task dispatched, and also scope both LeafLux source lookups to the domain (cross-domain source now 404s). Compose already enforced this inline; it now delegates to the shared validator (behavior-preserving — compose grids are always 2D). The processing services keep their pre-aligned-input contract; the mismatch is caught synchronously at the API boundary instead. Fixes #547 --- .../api/api/resources/grids/compose/router.py | 23 ++--- .../fuel_moisture/dead/fosberg/router.py | 9 ++ .../fuel_moisture/dead/fosberg/schema.py | 7 +- .../grids/solar/irradiance/leaflux/router.py | 8 ++ .../grids/solar/irradiance/leaflux/schema.py | 7 +- services/api/api/resources/grids/utils.py | 70 ++++++++++++++- .../grids/compose/test_validate_alignment.py | 32 +++++-- .../fuel_moisture/dead/fosberg/test_router.py | 53 ++++++++++- .../solar/irradiance/leaflux/test_router.py | 88 ++++++++++++++++++- .../api/tests/resources/grids/test_utils.py | 62 +++++++++++++ 10 files changed, 331 insertions(+), 28 deletions(-) diff --git a/services/api/api/resources/grids/compose/router.py b/services/api/api/resources/grids/compose/router.py index 41da16ec..e68d6820 100644 --- a/services/api/api/resources/grids/compose/router.py +++ b/services/api/api/resources/grids/compose/router.py @@ -1,6 +1,5 @@ """Router for grid compose endpoints.""" -import math import uuid from datetime import datetime from typing import Annotated, Any @@ -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 @@ -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 @@ -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( diff --git a/services/api/api/resources/grids/fuel_moisture/dead/fosberg/router.py b/services/api/api/resources/grids/fuel_moisture/dead/fosberg/router.py index 12511a13..7f898083 100644 --- a/services/api/api/resources/grids/fuel_moisture/dead/fosberg/router.py +++ b/services/api/api/resources/grids/fuel_moisture/dead/fosberg/router.py @@ -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 @@ -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"), diff --git a/services/api/api/resources/grids/fuel_moisture/dead/fosberg/schema.py b/services/api/api/resources/grids/fuel_moisture/dead/fosberg/schema.py index a0476883..9255241c 100644 --- a/services/api/api/resources/grids/fuel_moisture/dead/fosberg/schema.py +++ b/services/api/api/resources/grids/fuel_moisture/dead/fosberg/schema.py @@ -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." ), ) diff --git a/services/api/api/resources/grids/solar/irradiance/leaflux/router.py b/services/api/api/resources/grids/solar/irradiance/leaflux/router.py index d53e3bcf..172d3c50 100644 --- a/services/api/api/resources/grids/solar/irradiance/leaflux/router.py +++ b/services/api/api/resources/grids/solar/irradiance/leaflux/router.py @@ -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 @@ -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", ) @@ -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() @@ -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"), diff --git a/services/api/api/resources/grids/solar/irradiance/leaflux/schema.py b/services/api/api/resources/grids/solar/irradiance/leaflux/schema.py index 6ba2e133..d0d7ea46 100644 --- a/services/api/api/resources/grids/solar/irradiance/leaflux/schema.py +++ b/services/api/api/resources/grids/solar/irradiance/leaflux/schema.py @@ -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( diff --git a/services/api/api/resources/grids/utils.py b/services/api/api/resources/grids/utils.py index 29ac4f1b..1bca00ab 100644 --- a/services/api/api/resources/grids/utils.py +++ b/services/api/api/resources/grids/utils.py @@ -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 @@ -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 @@ -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} diff --git a/services/api/tests/resources/grids/compose/test_validate_alignment.py b/services/api/tests/resources/grids/compose/test_validate_alignment.py index 81dbc811..b73efe4b 100644 --- a/services/api/tests/resources/grids/compose/test_validate_alignment.py +++ b/services/api/tests/resources/grids/compose/test_validate_alignment.py @@ -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}, } @@ -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 diff --git a/services/api/tests/resources/grids/fuel_moisture/dead/fosberg/test_router.py b/services/api/tests/resources/grids/fuel_moisture/dead/fosberg/test_router.py index 58923902..8951d76e 100644 --- a/services/api/tests/resources/grids/fuel_moisture/dead/fosberg/test_router.py +++ b/services/api/tests/resources/grids/fuel_moisture/dead/fosberg/test_router.py @@ -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 @@ -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"]) @@ -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( diff --git a/services/api/tests/resources/grids/solar/irradiance/leaflux/test_router.py b/services/api/tests/resources/grids/solar/irradiance/leaflux/test_router.py index 5a760e75..c331c291 100644 --- a/services/api/tests/resources/grids/solar/irradiance/leaflux/test_router.py +++ b/services/api/tests/resources/grids/solar/irradiance/leaflux/test_router.py @@ -17,6 +17,7 @@ DATE_TIME = "2025-07-01T19:00:00Z" CANOPY = "irradiance.canopy.relative" SURFACE = "irradiance.surface.relative" +TRANSFORM = (2.0, 0.0, 500000.0, 0.0, -2.0, 5201000.0) # --- Fixtures --- @@ -39,10 +40,16 @@ def _make( bands=("leaf_area_density",), shape=(6, 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"]) @@ -221,6 +228,19 @@ def test_source_grid_not_completed_returns_422( response = client.post(self.route(domain_for_testing["id"]), json=body) assert response.status_code == 422 + def test_source_grid_in_another_domain_returns_404( + self, client, domain_for_testing, second_domain, grid_factory + ): + other_domain_lad = grid_factory( + second_domain["id"], bands=("leaf_area_density",), shape=(6, 40, 40) + ) + body = { + "source_lad_grid_id": other_domain_lad["id"], + "date_time": DATE_TIME, + } + response = client.post(self.route(domain_for_testing["id"]), json=body) + assert response.status_code == 404 + def test_source_grid_missing_lad_band_returns_422( self, client, domain_for_testing, grid_factory ): @@ -275,6 +295,26 @@ def test_terrain_grid_not_completed_returns_422( response = client.post(self.route(domain_for_testing["id"]), json=body) assert response.status_code == 422 + def test_terrain_grid_in_another_domain_returns_404( + self, + client, + domain_for_testing, + second_domain, + source_lad_grid, + grid_factory, + ): + other_domain_terrain = grid_factory( + second_domain["id"], bands=("elevation",), shape=(40, 40) + ) + body = { + "source_lad_grid_id": source_lad_grid["id"], + "source_terrain_grid_id": other_domain_terrain["id"], + "bands": [SURFACE], + "date_time": DATE_TIME, + } + response = client.post(self.route(domain_for_testing["id"]), json=body) + assert response.status_code == 404 + def test_terrain_grid_missing_elevation_band_returns_422( self, client, domain_for_testing, source_lad_grid, grid_factory ): @@ -305,6 +345,52 @@ def test_terrain_grid_not_2d_returns_422( response = client.post(self.route(domain_for_testing["id"]), json=body) assert response.status_code == 422 + @pytest.mark.parametrize( + ("terrain_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_terrain_grid_not_aligned_with_lad_returns_422( + self, + client, + domain_for_testing, + source_lad_grid, + grid_factory, + terrain_overrides, + detail_fragment, + ): + terrain = grid_factory( + domain_for_testing["id"], + bands=("elevation",), + shape=terrain_overrides.get("shape", (40, 40)), + crs=terrain_overrides.get("crs", "EPSG:32611"), + transform=terrain_overrides.get("transform", TRANSFORM), + ) + body = { + "source_lad_grid_id": source_lad_grid["id"], + "source_terrain_grid_id": terrain["id"], + "bands": [SURFACE], + "date_time": DATE_TIME, + } + response = client.post(self.route(domain_for_testing["id"]), json=body) + assert response.status_code == 422 + assert detail_fragment in response.json()["detail"] + # --- Request body validation --- def test_missing_source_lad_grid_id_returns_422(self, client, domain_for_testing): diff --git a/services/api/tests/resources/grids/test_utils.py b/services/api/tests/resources/grids/test_utils.py index 7b437e34..b5771da5 100644 --- a/services/api/tests/resources/grids/test_utils.py +++ b/services/api/tests/resources/grids/test_utils.py @@ -12,6 +12,7 @@ from api.resources.exports.schema import GridExportFormat from api.resources.grids.utils import ( validate_format_supports_grid, + validate_grids_share_horizontal_lattice, validate_lfps_coverage, ) from fastapi import HTTPException @@ -25,6 +26,67 @@ def _grid(shape): return {"georeference": {"shape": shape}} +_TRANSFORM = (2.0, 0.0, 500000.0, 0.0, -2.0, 5201000.0) + + +def _lattice_grid( + grid_id="grid", + shape=(40, 40), + crs="EPSG:32611", + transform=_TRANSFORM, +): + return { + "id": grid_id, + "georeference": { + "shape": shape, + "crs": crs, + "transform": transform, + }, + } + + +class TestValidateGridsShareHorizontalLattice: + def test_matching_3d_and_2d_grids_pass(self): + validate_grids_share_horizontal_lattice( + _lattice_grid("lad-grid", shape=(6, 40, 40)), + _lattice_grid("terrain-grid", shape=(40, 40)), + ) + + def test_equivalent_crs_spellings_pass(self): + validate_grids_share_horizontal_lattice( + _lattice_grid("lad-grid", crs="EPSG:32611"), + _lattice_grid("terrain-grid", crs="urn:ogc:def:crs:EPSG::32611"), + ) + + def test_different_crs_raises_422(self): + with pytest.raises(HTTPException) as exc: + validate_grids_share_horizontal_lattice( + _lattice_grid("lad-grid", crs="EPSG:32611"), + _lattice_grid("terrain-grid", crs="EPSG:4326"), + ) + assert exc.value.status_code == 422 + assert "CRS" in exc.value.detail + + def test_different_horizontal_shape_raises_422(self): + with pytest.raises(HTTPException) as exc: + validate_grids_share_horizontal_lattice( + _lattice_grid("lad-grid", shape=(6, 40, 40)), + _lattice_grid("terrain-grid", shape=(20, 20)), + ) + assert exc.value.status_code == 422 + assert "shape" in exc.value.detail + + def test_different_transform_raises_422(self): + shifted = (*_TRANSFORM[:2], 500001.0, *_TRANSFORM[3:]) + with pytest.raises(HTTPException) as exc: + validate_grids_share_horizontal_lattice( + _lattice_grid("lad-grid"), + _lattice_grid("terrain-grid", transform=shifted), + ) + assert exc.value.status_code == 422 + assert "transform" in exc.value.detail + + class TestValidateFormatSupportsGrid: """validate_format_supports_grid rejects 3D grids for 2D-only formats."""