From 50221d4363a369312f3033ac8545b8f56352a835 Mon Sep 17 00:00:00 2001 From: Norman Fomferra Date: Wed, 11 Sep 2024 17:52:04 +0200 Subject: [PATCH 1/4] Transforming geometry if CRS is not geographic --- xcube/webapi/statistics/controllers.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/xcube/webapi/statistics/controllers.py b/xcube/webapi/statistics/controllers.py index 4edfcf263..fdc727dc6 100644 --- a/xcube/webapi/statistics/controllers.py +++ b/xcube/webapi/statistics/controllers.py @@ -1,12 +1,15 @@ -from collections.abc import Mapping from typing import Any, Union import numpy as np -import xarray as xr +import pyproj import shapely +import shapely.ops +import xarray as xr +from xcube.constants import CRS_CRS84 from xcube.constants import LOG from xcube.core.geom import get_dataset_geometry +from xcube.core.geom import normalize_geometry from xcube.core.geom import mask_dataset_by_geometry from xcube.core.varexpr import VarExprContext from xcube.core.varexpr import split_var_assignment @@ -73,9 +76,17 @@ def _compute_statistics( else: compact_mode = False try: - geometry = shapely.geometry.shape(geometry) + geometry = normalize_geometry(geometry) except (TypeError, ValueError, AttributeError) as e: - raise ApiError.BadRequest("Invalid GeoJSON geometry encountered") from e + raise ApiError.BadRequest( + f"Invalid GeoJSON geometry encountered: {e}" + ) from e + + if geometry is not None and not grid_mapping.crs.is_geographic: + project = pyproj.Transformer.from_crs( + CRS_CRS84, grid_mapping.crs, always_xy=True + ).transform + geometry = shapely.ops.transform(project, geometry) nan_result = NAN_RESULT_COMPACT if compact_mode else NAN_RESULT From c24c5f25d3a90d8c5e29af7580d505413fc858f0 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Baljeet Singh Date: Tue, 17 Sep 2024 16:55:21 +0200 Subject: [PATCH 2/4] Update and add new tests for CRS conversion --- test/webapi/statistics/test_routes.py | 112 ++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/test/webapi/statistics/test_routes.py b/test/webapi/statistics/test_routes.py index 1c11ef574..aa4e88470 100644 --- a/test/webapi/statistics/test_routes.py +++ b/test/webapi/statistics/test_routes.py @@ -2,6 +2,8 @@ # Permissions are hereby granted under the terms of the MIT License: # https://opensource.org/licenses/MIT. +import json + from ..helpers import RoutesTestCase @@ -14,11 +16,32 @@ def get_config_filename(self) -> str: def test_fetch_post_statistics_ok(self): response = self.fetch( - "/statistics/demo/conc_chl?time=2017-01-16+10:09:21", + "/statistics/demo/conc_chl?time=2017-01-30+10:46:34", method="POST", - body='{"type": "Point", "coordinates": [1.768, 51.465]}', + body='{"type": "Point", "coordinates": [1.262, 50.243]}', + ) + self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert parsed_data["result"]["count"] == 1 + assert round(parsed_data["result"]["minimum"], 3) == 9.173 + assert round(parsed_data["result"]["maximum"], 3) == 9.173 + assert round(parsed_data["result"]["mean"], 3) == 9.173 + assert round(parsed_data["result"]["deviation"], 3) == 0.0 + + response = self.fetch( + "/statistics/cog_local/band_1", + method="POST", + body='{"type": "Point", "coordinates": [-105.810, 35.771]}', ) self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert parsed_data["result"]["count"] == 1 + assert round(parsed_data["result"]["minimum"], 3) == 102.0 + assert round(parsed_data["result"]["maximum"], 3) == 102.0 + assert round(parsed_data["result"]["mean"], 3) == 102.0 + assert round(parsed_data["result"]["deviation"], 3) == 0.0 def test_fetch_post_statistics_missing_time_with_time_dimension_dataset(self): response = self.fetch( @@ -30,7 +53,7 @@ def test_fetch_post_statistics_missing_time_with_time_dimension_dataset(self): def test_fetch_post_statistics_missing_time_without_time_dimension_dataset(self): response = self.fetch( - "/statistics/cog_local/band-1", + "/statistics/cog_local/band_1", method="POST", body='{"type": "Point", "coordinates": [-105.591, 35.751]}', ) @@ -38,7 +61,7 @@ def test_fetch_post_statistics_missing_time_without_time_dimension_dataset(self) def test_fetch_post_statistics_with_time_without_time_dimension_dataset(self): response = self.fetch( - "/statistics/cog_local/band-1?time=2017-01-16+10:09:21", + "/statistics/cog_local/band_1?time=2017-01-16+10:09:21", method="POST", body='{"type": "Point", "coordinates": [-105.591, 35.751]}', ) @@ -52,39 +75,64 @@ def test_fetch_post_statistics_invalid_geometry(self): response = self.fetch( "/statistics/demo/conc_chl?time=2017-01-16+10:09:21", method="POST", - body="[1.768, 51.465]", + body="[1.768, 51.465, 11.542]", + ) + self.assertBadRequestResponse( + response, "Invalid " "GeoJSON geometry encountered" + ) + response = self.fetch( + "/statistics/demo/conc_chl?time=2017-01-16+10:09:21", + method="POST", + body="[1.768]", ) self.assertBadRequestResponse( response, "Invalid " "GeoJSON geometry encountered" ) - def test_fetch_get_statistics_ok(self): + def test_crs_conversion_post_statistics_with_coordinates_outside_bounds(self): response = self.fetch( - "/statistics/demo/conc_chl?" - "lat=1.786&lon=51.465&time=2017-01-16+10:09:21", - method="GET", + "/statistics/cog_local/band_1", + method="POST", + body='{"type": "Point", "coordinates": [-125.810, 35.771]}', + ) + self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert parsed_data["result"]["count"] == 0 + + def test_crs_conversion_post_statistics_with_coordinates_inside_bounds(self): + response = self.fetch( + "/statistics/cog_local/band_1", + method="POST", + body='{"type": "Point", "coordinates": [-105.810, 35.171]}', ) self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert parsed_data["result"]["count"] == 1 + assert round(parsed_data["result"]["minimum"], 3) == 220.0 + assert round(parsed_data["result"]["maximum"], 3) == 220.0 + assert round(parsed_data["result"]["mean"], 3) == 220.0 + assert round(parsed_data["result"]["deviation"], 3) == 0.0 def test_fetch_get_statistics_missing_time_with_time_dimension_dataset(self): response = self.fetch( - "/statistics/demo/conc_chl?lat=1.786&lon=51.465", method="GET" + "/statistics/demo/conc_chl?lon=1.786&lat=51.465", method="GET" ) self.assertBadRequestResponse(response, "Missing " "query parameter 'time'") def test_fetch_get_statistics_missing_time_without_time_dimension_dataset(self): response = self.fetch( - "/statistics/cog_local/band-1?lat=-105.591&" "lon=35.751&type=Point", + "/statistics/cog_local/band_1?lon=-105.591&" "lat=35.751&type=Point", method="GET", ) self.assertResponseOK(response) def test_fetch_get_statistics_with_time_without_time_dimension_dataset(self): response = self.fetch( - "/statistics/cog_local/band-1?lat=-105.591&lon=35.751&" + "/statistics/cog_local/band_1?lon=-105.591&lat=35.751&" "type=Point&time=2017-01-16+10:09:21", method="GET", - body='{"type": "Point", "coordinates": [-105.591, 35.751]}', ) self.assertBadRequestResponse( response, @@ -92,10 +140,42 @@ def test_fetch_get_statistics_with_time_without_time_dimension_dataset(self): "dataset does not contain a 'time' dimension", ) - def test_fetch_get_statistics_invalid_geometry(self): + def test_fetch_get_statistics(self): + response = self.fetch( + "/statistics/demo/conc_chl?time=2017-01-30+10:46:34&" + "lon=1.262&lat=50.243", + method="GET", + ) + self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert round(parsed_data["result"]["value"], 3) == 9.173 + + response = self.fetch( + "/statistics/cog_local/band_1?lon=-105.810&lat=35.771&type=Point", + method="GET", + ) + self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert round(parsed_data["result"]["value"], 3) == 102.0 + + def test_crs_conversion_get_statistics_with_coordinates_outside_bounds(self): + response = self.fetch( + "/statistics/cog_local/band_1?lon=-125.810&lat=35.771&type=Point", + method="GET", + ) + self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert parsed_data["result"] == {} + + def test_crs_conversion_get_statistics_with_coordinates_inside_bounds(self): response = self.fetch( - "/statistics/demo/conc_chl?time=2017-01-16+10:09:21&" - "lon=1.768&lat=51.465", + "/statistics/cog_local/band_1?lon=-105.810&lat=35.171&type=Point", method="GET", ) self.assertResponseOK(response) + decoded_data = response.data.decode("utf-8") + parsed_data = json.loads(decoded_data) + assert round(parsed_data["result"]["value"], 3) == 220.0 From 088a237d6e400dadaa1727e45e53e9dc2e43a940 Mon Sep 17 00:00:00 2001 From: Yogesh Kumar Baljeet Singh Date: Wed, 18 Sep 2024 12:22:11 +0200 Subject: [PATCH 3/4] Update changelog --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d457e7b18..4c8b08f9c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,9 @@ * The `time` query parameter of the `/statistics` endpoint of xcube server has now been made optional. (#1066) +* The `/statistics` endpoint now supports datasets using `non-WGS84` grid systems, + expanding its compatibility with a wider range of geospatial datasets. + (#1069) ## Changes in 1.7.0 From cf1c0775c1f4787ecec5408559770dc4a1c28df0 Mon Sep 17 00:00:00 2001 From: yogeshkumar94 Date: Wed, 18 Sep 2024 13:41:36 +0200 Subject: [PATCH 4/4] Update CHANGES.md Co-authored-by: Norman Fomferra --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 4c8b08f9c..d4c356333 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ * The `time` query parameter of the `/statistics` endpoint of xcube server has now been made optional. (#1066) -* The `/statistics` endpoint now supports datasets using `non-WGS84` grid systems, +* The `/statistics` endpoint now supports datasets using non-WGS84 grid systems, expanding its compatibility with a wider range of geospatial datasets. (#1069)