diff --git a/.gitignore b/.gitignore index 542c62222..3a821670b 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ ENV/ mydask.png .cfgs + +# Alternative xcube demo +examples/serve/demo2/ diff --git a/CHANGES.md b/CHANGES.md index c62c4b891..d457e7b18 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ ## Changes in 1.7.1 (in development) +### Fixes + +* The `time` query parameter of the `/statistics` endpoint of xcube server has + now been made optional. (#1066) ## Changes in 1.7.0 diff --git a/test/webapi/res/config-stats.yml b/test/webapi/res/config-stats.yml new file mode 100644 index 000000000..a4e8f1f9b --- /dev/null +++ b/test/webapi/res/config-stats.yml @@ -0,0 +1,128 @@ +DatasetAttribution: + - © by Brockmann Consult GmbH 2020, contains modified Copernicus Data 2019, processed by ESA + +Datasets: + - Identifier: demo + Title: xcube-server Demonstration L2C Cube + GroupTitle: Demo + Tags: ["demo", "zarr"] + Path: ../../../examples/serve/demo/cube-1-250-250.zarr + Variables: + - "conc_chl" + - "chl_category" + - "conc_tsm" + - "chl_tsm_sum" + - "kd489" + - "*" + Style: default + Attribution: © by EU H2020 CyanoAlert project + + - Identifier: demo-1w + Title: xcube-server Demonstration L3 Cube + GroupTitle: Demo + Tags: ["demo", "zarr", "computed"] + FileSystem: memory + Path: script.py + Variables: + - "conc_chl" + - "chl_category" + - "conc_tsm" + - "chl_tsm_sum" + - "kd489" + - "*" + Function: compute_dataset + InputDatasets: ["demo"] + InputParameters: + period: "1W" + incl_stdev: True + Style: default + + - Identifier: cog_local + Title: COG example + GroupTitle: GeoTIFF + FileSystem: file + Path: ../../../examples/serve/demo/sample-cog.tif + Style: tif_style + +PlaceGroups: + - Identifier: inside-cube + Title: Points inside the cube + Path: places/inside-cube.geojson + - Identifier: outside-cube + Title: Points outside the cube + Path: places/outside-cube.geojson + +Styles: + - Identifier: default + ColorMappings: + conc_chl: + ColorBar: my_cmap + ValueRange: [0., 20.] + conc_tsm: + ColorBar: cmap_bloom_risk + ValueRange: [0., 1.] + kd489: + ColorBar: jet + ValueRange: [0., 6.] + +CustomColorMaps: + - Identifier: my_cmap + Type: continuous # or categorical, stepwise + Colors: + - Value: 0 + Color: red + Label: low + - Value: 12 + Color: "#0000FF" + Label: medium + - Value: 18 + Color: [0, 255, 0] + Label: mediumhigh + - Value: 24 + Color: [0, 1, 0, 0.3] + Label: high + - Identifier: cmap_bloom_risk + Type: categorical + Colors: + - [ 0, [0, 1, 0., 0.5]] + - [ 1, orange] + - [ 2, [1, 0, 0]] + - Identifier: s2_l2_scl + Type: categorical + Colors: + - [ 0, red, no data] + - [ 1, yellow, defective] + - [ 2, black, dark area pixels] + - [ 3, gray, cloud shadows] + - [ 4, green, vegetation] + - [ 5, tan, bare soils] + - [ 6, blue, water] + - [ 7, "#aaaabb", clouds low prob ] + - [ 8, "#bbbbcc", clouds medium prob] + - [ 9, "#ccccdd", clouds high prob] + - [10, "#ddddee", cirrus] + - [11, "#ffffff", snow or ice] + - [11, "#ffffff", snow or ice] + +Viewer: + Configuration: + Path: viewer + +ServiceProvider: + ProviderName: "Brockmann Consult GmbH" + ProviderSite: "https://www.brockmann-consult.de" + ServiceContact: + IndividualName: "Norman Fomferra" + PositionName: "Senior Software Engineer" + ContactInfo: + Phone: + Voice: "+49 4152 889 303" + Facsimile: "+49 4152 889 330" + Address: + DeliveryPoint: "HZG / GITZ" + City: "Geesthacht" + AdministrativeArea: "Herzogtum Lauenburg" + PostalCode: "21502" + Country: "Germany" + ElectronicMailAddress: "norman.fomferra@brockmann-consult.de" + diff --git a/test/webapi/statistics/test_routes.py b/test/webapi/statistics/test_routes.py index 2dd65b923..1c11ef574 100644 --- a/test/webapi/statistics/test_routes.py +++ b/test/webapi/statistics/test_routes.py @@ -6,7 +6,13 @@ class StatisticsRoutesTest(RoutesTestCase): - def test_fetch_statistics_ok(self): + + def get_config_filename(self) -> str: + """Get configuration filename. + Default impl. returns ``'config.yml'``.""" + return "config-stats.yml" + + def test_fetch_post_statistics_ok(self): response = self.fetch( "/statistics/demo/conc_chl?time=2017-01-16+10:09:21", method="POST", @@ -14,20 +20,82 @@ def test_fetch_statistics_ok(self): ) self.assertResponseOK(response) - def test_fetch_statistics_missing_time(self): + def test_fetch_post_statistics_missing_time_with_time_dimension_dataset(self): response = self.fetch( "/statistics/demo/conc_chl", method="POST", body='{"type": "Point", "coordinates": [1.768, 51.465]}', ) + self.assertBadRequestResponse(response, "Missing " "query parameter 'time'") + + def test_fetch_post_statistics_missing_time_without_time_dimension_dataset(self): + response = self.fetch( + "/statistics/cog_local/band-1", + method="POST", + body='{"type": "Point", "coordinates": [-105.591, 35.751]}', + ) + self.assertResponseOK(response) + + 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", + method="POST", + body='{"type": "Point", "coordinates": [-105.591, 35.751]}', + ) self.assertBadRequestResponse( - response, "Missing required query parameter 'time'" + response, + "Query parameter 'time' must not be given since " + "dataset does not contain a 'time' dimension", ) - def test_fetch_statistics_invalid_geometry(self): + 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]", ) - self.assertBadRequestResponse(response, "Invalid GeoJSON geometry encountered") + self.assertBadRequestResponse( + response, "Invalid " "GeoJSON geometry encountered" + ) + + def test_fetch_get_statistics_ok(self): + response = self.fetch( + "/statistics/demo/conc_chl?" + "lat=1.786&lon=51.465&time=2017-01-16+10:09:21", + method="GET", + ) + self.assertResponseOK(response) + + 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" + ) + 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", + 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&" + "type=Point&time=2017-01-16+10:09:21", + method="GET", + body='{"type": "Point", "coordinates": [-105.591, 35.751]}', + ) + self.assertBadRequestResponse( + response, + "Query parameter 'time' must not be given since " + "dataset does not contain a 'time' dimension", + ) + + def test_fetch_get_statistics_invalid_geometry(self): + response = self.fetch( + "/statistics/demo/conc_chl?time=2017-01-16+10:09:21&" + "lon=1.768&lat=51.465", + method="GET", + ) + self.assertResponseOK(response) diff --git a/xcube/webapi/statistics/controllers.py b/xcube/webapi/statistics/controllers.py index a9c61d55a..9b6d659fa 100644 --- a/xcube/webapi/statistics/controllers.py +++ b/xcube/webapi/statistics/controllers.py @@ -47,10 +47,22 @@ def _compute_statistics( dataset = ml_dataset.get_dataset(0) grid_mapping = ml_dataset.grid_mapping - try: - time = np.array(time_label, dtype=dataset.time.dtype) - except (TypeError, ValueError) as e: - raise ApiError.BadRequest("Invalid 'time'") from e + dataset_contains_time = "time" in dataset + + if dataset_contains_time: + if time_label is not None: + try: + time = np.array(time_label, dtype=dataset.time.dtype) + dataset = dataset.sel(time=time, method="nearest") + except (TypeError, ValueError) as e: + raise ApiError.BadRequest("Invalid query parameter " "'time'") from e + else: + raise ApiError.BadRequest("Missing query parameter 'time'") + elif time_label is not None: + raise ApiError.BadRequest( + "Query parameter 'time' must not be given" + " since dataset does not contain a 'time' dimension" + ) if isinstance(geometry, tuple): compact_mode = True @@ -60,12 +72,10 @@ def _compute_statistics( try: geometry = shapely.geometry.shape(geometry) except (TypeError, ValueError, AttributeError) as e: - raise ApiError.BadRequest("Invalid GeoJSON geometry encountered") from e + raise ApiError.BadRequest("Invalid GeoJSON geometry " "encountered") from e nan_result = NAN_RESULT_COMPACT if compact_mode else NAN_RESULT - dataset = dataset.sel(time=time, method="nearest") - x_name, y_name = grid_mapping.xy_dim_names if isinstance(geometry, shapely.geometry.Point): bounds = get_dataset_geometry(dataset) diff --git a/xcube/webapi/statistics/routes.py b/xcube/webapi/statistics/routes.py index a18369c95..202a537ea 100644 --- a/xcube/webapi/statistics/routes.py +++ b/xcube/webapi/statistics/routes.py @@ -30,7 +30,7 @@ "name": "time", "in": "query", "description": 'Timestamp using format "YYYY-MM-DD hh:mm:ss"', - "required": True, + "required": False, "schema": {"type": "string", "format": "datetime"}, } @@ -54,7 +54,7 @@ class StatisticsHandler(ApiHandler[StatisticsContext]): async def get(self, datasetId: str, varName: str): lon = self.request.get_query_arg("lon", type=float, default=UNDEFINED) lat = self.request.get_query_arg("lat", type=float, default=UNDEFINED) - time = self.request.get_query_arg("time", type=str, default=UNDEFINED) + time = self.request.get_query_arg("time", type=str, default=None) trace_perf = self.request.get_query_arg( "debug", default=self.ctx.datasets_ctx.trace_perf ) @@ -89,7 +89,7 @@ async def get(self, datasetId: str, varName: str): ], ) async def post(self, datasetId: str, varName: str): - time = self.request.get_query_arg("time", type=str, default=UNDEFINED) + time = self.request.get_query_arg("time", type=str, default=None) trace_perf = self.request.get_query_arg( "debug", default=self.ctx.datasets_ctx.trace_perf )