From 03c0493bbc8a55dd9eea994b256c5e84e3a8f289 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Thu, 4 Jan 2024 16:45:04 +0100 Subject: [PATCH 01/10] STAC / OGC collections: implement RangeType schema Addresses #916 --- test/webapi/ows/stac/demo-collection.json | 6 ++++ test/webapi/ows/stac/test_controllers.py | 36 +++++++++++++++++++ test/webapi/ows/stac/test_routes.py | 7 ++++ xcube/webapi/ows/stac/controllers.py | 42 +++++++++++++++++++++-- xcube/webapi/ows/stac/routes.py | 21 ++++++++++-- 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/test/webapi/ows/stac/demo-collection.json b/test/webapi/ows/stac/demo-collection.json index 9e97fa5d9..645564124 100644 --- a/test/webapi/ows/stac/demo-collection.json +++ b/test/webapi/ows/stac/demo-collection.json @@ -177,6 +177,12 @@ "href": "http://localhost:8080/ogc/collections/demo/coverage?f=geotiff", "type": "image/tiff; application=geotiff", "title": "Coverage for the dataset \"demo\" using OGC API \u2013 Coverages, as GeoTIFF" + }, + { + "rel": "http://www.opengis.net/def/rel/ogc/1.0/schema", + "href": "http://localhost:8080/ogc/collections/demo/schema?f=json", + "type": "application/json", + "title": "Schema (as JSON)" } ], "providers": [], diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py index b19ca7a63..4bed8153c 100644 --- a/test/webapi/ows/stac/test_controllers.py +++ b/test/webapi/ows/stac/test_controllers.py @@ -53,6 +53,8 @@ from xcube.webapi.ows.stac.controllers import get_collections from xcube.webapi.ows.stac.controllers import get_conformance from xcube.webapi.ows.stac.controllers import get_root +from xcube.webapi.ows.stac.controllers import get_collection_schema + from .test_context import get_stac_ctx PATH_PREFIX = '/ogc' @@ -103,6 +105,7 @@ (['get'], '/collections/{collectionId}/items/{featureId}'), (['get', 'post'], '/search'), (['get'], '/collections/{collectionId}/queryables'), + (['get'], '/collections/{collectionId}/schema'), ], [], ) @@ -386,6 +389,39 @@ def test_get_collection_queryables(self): result, ) + def test_get_collection_schema(self): + self.assertEqual({ + '$id': f'{BASE_URL}{PATH_PREFIX}/demo/schema', + '$schema': 'https://json-schema.org/draft/2020-12/schema', + 'properties': { + 'c2rcc_flags': { + 'title': 'C2RCC quality flags', + 'type': 'number', + 'x-ogc-property-seq': 1}, + 'conc_chl': { + 'title': 'Chlorophyll concentration', + 'type': 'number', + 'x-ogc-property-seq': 2}, + 'conc_tsm': { + 'title': 'Total suspended matter dry weight concentration', + 'type': 'number', + 'x-ogc-property-seq': 3}, + 'kd489': { + 'title': 'Irradiance attenuation coefficient at 489 nm', + 'type': 'number', + 'x-ogc-property-seq': 4}, + 'quality_flags': { + 'title': 'Classification and quality flags', + 'type': 'number', + 'x-ogc-property-seq': 5}}, + 'title': 'demo', + 'type': 'object', + }, + get_collection_schema( + get_stac_ctx().datasets_ctx, BASE_URL, 'demo' + ) + ) + def test_get_datacube_dimensions(self): dim_name_0 = 'new_dimension_0' dim_name_1 = 'new_dimension_1' diff --git a/test/webapi/ows/stac/test_routes.py b/test/webapi/ows/stac/test_routes.py index e148f8c5b..2047d3166 100644 --- a/test/webapi/ows/stac/test_routes.py +++ b/test/webapi/ows/stac/test_routes.py @@ -86,6 +86,13 @@ def test_fetch_collection_queryables(self): ) self.assertResponseOK(response) + def test_fetch_collection_schema(self): + response = self.fetch( + f'{PATH_PREFIX}/collections/demo/schema', + method='GET' + ) + self.assertResponseOK(response) + class StacRoutesTestCog(RoutesTestCase): diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index ff73000d3..69e330477 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -280,7 +280,7 @@ def get_datasets_collection_items( """ _assert_valid_collection(ctx, collection_id) all_configs = ctx.get_dataset_configs() - configs = all_configs[cursor : (cursor + limit)] + configs = all_configs[cursor: (cursor + limit)] features = [] for dataset_config in configs: dataset_id = dataset_config["Identifier"] @@ -376,7 +376,7 @@ def get_collection_queryables( :param ctx: a datasets context :param collection_id: the ID of a collection :return: a JSON schema of queryable parameters, if the collection was found - :raises: ApiError.NotFOund, if the collection was not round + :raises: ApiError.NotFound, if the collection was not found """ _assert_valid_collection(ctx, collection_id) schema = JsonObjectSchema( @@ -385,6 +385,37 @@ def get_collection_queryables( return schema.to_dict() +def get_collection_schema( + ctx: DatasetsContext, base_url: str, collection_id: str +) -> dict: + if collection_id == DEFAULT_COLLECTION_ID: + # The default collection contains multiple datasets, so a range + # schema doesn't make sense. + raise ValueError(f'Invalid collection ID {DEFAULT_COLLECTION_ID}') + _assert_valid_collection(ctx, collection_id) + + ml_dataset = ctx.get_ml_dataset(collection_id) + dataset = ml_dataset.base_dataset + + def get_title(var_name: str) -> str: + attrs = dataset[var_name].attrs + return attrs['long_name'] if 'long_name' in attrs else var_name + + return { + '$schema': 'https://json-schema.org/draft/2020-12/schema', + '$id': f'{base_url}{PATH_PREFIX}/{collection_id}/schema', + 'title': collection_id, # TODO use actual title, if defined + 'type': 'object', + 'properties': { + var_name: { + 'title': get_title(var_name), + 'type': 'number', + 'x-ogc-property-seq': index + 1, + } for index, var_name in enumerate(dataset.data_vars.keys()) + } + } + + # noinspection PyUnusedLocal def search(ctx: DatasetsContext, base_url: str): # TODO: implement me! @@ -532,6 +563,13 @@ def _get_single_dataset_collection( 'title': f'Coverage for the dataset "{dataset_id}" using ' f'OGC API – Coverages, as GeoTIFF', }, + { + 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/schema', + 'href': f'{base_url}{PATH_PREFIX}/collections/' + f'{dataset_id}/schema?f=json', + 'type': 'application/json', + 'title': 'Schema (as JSON)', + }, ], 'providers': [], 'stac_version': STAC_VERSION, diff --git a/xcube/webapi/ows/stac/routes.py b/xcube/webapi/ows/stac/routes.py index 303e3f198..1f5617571 100644 --- a/xcube/webapi/ows/stac/routes.py +++ b/xcube/webapi/ows/stac/routes.py @@ -24,7 +24,7 @@ from .api import api from .context import StacContext from .controllers import get_collection, get_collection_queryables, \ - get_single_collection_items + get_single_collection_items, get_collection_schema from .controllers import get_collection_item from .controllers import get_datasets_collection_items from .controllers import get_collections @@ -160,7 +160,7 @@ async def post(self): @api.route(PATH_PREFIX + "/collections/{collectionId}/queryables/") class QueryablesHandler(ApiHandler[StacContext]): # noinspection PyPep8Naming - @api.operation(operation_id="searchCatalogByKeywords", + @api.operation(operation_id='queryables', summary='Return a JSON Schema defining the supported ' 'metadata filters (also called "queryables") ' 'for a specific collection.') @@ -169,3 +169,20 @@ async def get(self, collectionId): ctx=self.ctx.datasets_ctx, collection_id=collectionId ) return await self.response.finish(schema) + + +# noinspection PyAbstractClass,PyMethodMayBeStatic +@api.route(PATH_PREFIX + "/collections/{collectionId}/schema") +@api.route(PATH_PREFIX + "/collections/{collectionId}/schema/") +class SchemaHandler(ApiHandler[StacContext]): + # noinspection PyPep8Naming + @api.operation(operation_id='schema', + summary='Return a JSON Schema defining the range ' + '(i.e. data variables) of a specific collection.') + async def get(self, collectionId): + schema = get_collection_schema( + ctx=self.ctx.datasets_ctx, + base_url=self.request.reverse_base_url, + collection_id=collectionId + ) + return await self.response.finish(schema) From a47a2698042a67cec00fd77f9ebae61ddc4a7417 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Fri, 5 Jan 2024 14:57:29 +0100 Subject: [PATCH 02/10] STAC / OGC collections: add grid to description The collection description returned by the collection endpoint now includes details of the data cube's spatial and temporal grids. Addresses #916 --- test/webapi/ows/stac/demo-collection.json | 22 ++++++++++++++- xcube/webapi/ows/stac/controllers.py | 34 +++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/test/webapi/ows/stac/demo-collection.json b/test/webapi/ows/stac/demo-collection.json index 645564124..a93e97a12 100644 --- a/test/webapi/ows/stac/demo-collection.json +++ b/test/webapi/ows/stac/demo-collection.json @@ -117,6 +117,16 @@ 50.0, 5.0, 52.5 + ], + "grid": [ + { + "cellsCount": 2000, + "resolution": 0.0025 + }, + { + "cellsCount": 1000, + "resolution": 0.0025 + } ] }, "temporal": { @@ -125,7 +135,17 @@ "2017-01-16T10:09:21Z", "2017-01-30T10:46:33Z" ] - ] + ], + "grid": { + "cellsCount": 5, + "coordinates": [ + "2017-01-16T10:09:21.834255872", + "2017-01-25T09:35:51.060063488", + "2017-01-26T10:50:16.686192896", + "2017-01-28T09:58:11.350386176", + "2017-01-30T10:46:33.836892416" + ] + } } }, "id": "demo", diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index 69e330477..380565738 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -25,6 +25,7 @@ import itertools import numpy as np +import pandas as pd import pyproj import xarray as xr @@ -503,12 +504,22 @@ def _get_single_dataset_collection( ] if storage_crs not in available_crss: available_crss.append(storage_crs) + gm = GridMapping.from_dataset(dataset) result = { 'assets': _get_assets(ds_ctx, base_url, dataset_id), 'description': dataset_id, 'extent': { - 'spatial': {'bbox': grid_bbox.as_bbox()}, - 'temporal': {'interval': [time_interval]} + 'spatial': { + 'bbox': grid_bbox.as_bbox(), + 'grid': [ + {'cellsCount': gm.size[0], 'resolution': gm.xy_res[0]}, + {'cellsCount': gm.size[1], 'resolution': gm.xy_res[1]} + ] + }, + 'temporal': { + 'interval': [time_interval], + 'grid': _get_time_grid(dataset) + } }, 'id': dataset_id, 'keywords': [], @@ -625,6 +636,25 @@ def as_geometry(self) -> dict[str, Union[str, list]]: } +def _get_time_grid(ds: xr.Dataset) -> dict[str, Any]: + if 'time' not in ds: + return {} + + if ds.dims['time'] < 2: + time_is_regular = False + else: + time_diffs = ds.time.diff(dim='time').astype('uint64') + time_is_regular = np.allclose(time_diffs[0], time_diffs) + + return dict([ + ('cellsCount', ds.dims['time']), + ('resolution', + pd.Timedelta((ds.time[1] - ds.time[0]).values).isoformat()) + if time_is_regular else + ('coordinates', [pd.Timestamp(t.values).isoformat() for t in ds.time]) + ]) + + # noinspection PyUnusedLocal def _get_dataset_feature( ctx: DatasetsContext, From de5e27a4fe6c7e560696918d16e85d7a84d2226a Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Fri, 5 Jan 2024 16:38:52 +0100 Subject: [PATCH 03/10] STAC / OGC: exclude grid mapping vars from range The OGC / STAC / Coverages schema and rangetype endpoints were including the 0-dimensional grid mapping variables in their responses. These are now filtered out. --- xcube/webapi/ows/coverages/controllers.py | 11 ++++++++--- xcube/webapi/ows/stac/controllers.py | 11 +++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/xcube/webapi/ows/coverages/controllers.py b/xcube/webapi/ows/coverages/controllers.py index 940ba9bbd..e7f14ab99 100644 --- a/xcube/webapi/ows/coverages/controllers.py +++ b/xcube/webapi/ows/coverages/controllers.py @@ -603,14 +603,19 @@ def get_coverage_rangetype( """ ds = get_dataset(ctx, collection_id) result = dict(type='DataRecord', field=[]) - for var_name in ds.data_vars: + for var_name, variable in ds.data_vars.items(): + if variable.dims == (): + # A 0-dimensional variable is probably a grid mapping variable; + # in any case, it doesn't have the dimensions of the cube, so + # isn't part of the range. + continue result['field'].append( dict( type='Quantity', name=var_name, - description=get_dataarray_description(ds[var_name]), + description=get_dataarray_description(variable), encodingInfo=dict( - dataType=dtype_to_opengis_datatype(ds[var_name].dtype) + dataType=dtype_to_opengis_datatype(variable.dtype) ), ) ) diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index 380565738..7b2d19906 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -396,23 +396,26 @@ def get_collection_schema( _assert_valid_collection(ctx, collection_id) ml_dataset = ctx.get_ml_dataset(collection_id) - dataset = ml_dataset.base_dataset + ds = ml_dataset.base_dataset def get_title(var_name: str) -> str: - attrs = dataset[var_name].attrs + attrs = ds[var_name].attrs return attrs['long_name'] if 'long_name' in attrs else var_name return { '$schema': 'https://json-schema.org/draft/2020-12/schema', '$id': f'{base_url}{PATH_PREFIX}/{collection_id}/schema', - 'title': collection_id, # TODO use actual title, if defined + 'title': ds.attrs['title'] if 'title' in ds.attrs else collection_id, 'type': 'object', 'properties': { var_name: { 'title': get_title(var_name), 'type': 'number', 'x-ogc-property-seq': index + 1, - } for index, var_name in enumerate(dataset.data_vars.keys()) + } for index, var_name in enumerate( + # Exclude 0-dimensional vars (usually grid mapping variables) + {k: v for k, v in ds.data_vars.items() if v.dims != ()}.keys() + ) } } From bd89573d0485346ea80abb12301169589ead035f Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Fri, 5 Jan 2024 18:45:49 +0100 Subject: [PATCH 04/10] STAC/OGC: range/domain links; refuse CRS requests - In the collection description, add links to the range type and domain set endpoints. - If a client requests a coverage for a 0-dimensional variable (generally a grid mapping variable specifying the CRS), give a 400 response (rather than 500 as previously). --- test/webapi/ows/stac/demo-collection.json | 12 ++++++++++++ xcube/webapi/ows/coverages/controllers.py | 5 ++++- xcube/webapi/ows/stac/controllers.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/test/webapi/ows/stac/demo-collection.json b/test/webapi/ows/stac/demo-collection.json index a93e97a12..590df96be 100644 --- a/test/webapi/ows/stac/demo-collection.json +++ b/test/webapi/ows/stac/demo-collection.json @@ -203,6 +203,18 @@ "href": "http://localhost:8080/ogc/collections/demo/schema?f=json", "type": "application/json", "title": "Schema (as JSON)" + }, + { + "rel": "http://www.opengis.net/def/rel/ogc/1.0/coverage-rangetype", + "href": "http://localhost:8080/ogc/collections/demo/coverage/rangetype?f=json", + "type": "application/json", + "title": "Range type of the coverage" + }, + { + "rel": "http://www.opengis.net/def/rel/ogc/1.0/coverage-domainset", + "href": "http://localhost:8080/ogc/collections/demo/coverage/domainset?f=json", + "type": "application/json", + "title": "Domain set of the coverage" } ], "providers": [], diff --git a/xcube/webapi/ows/coverages/controllers.py b/xcube/webapi/ows/coverages/controllers.py index e7f14ab99..4630a907a 100644 --- a/xcube/webapi/ows/coverages/controllers.py +++ b/xcube/webapi/ows/coverages/controllers.py @@ -195,7 +195,10 @@ def get_coverage_data( def _apply_properties(collection_id, ds, properties): requested_vars = set(properties) - data_vars = set(map(str, ds.data_vars)) + data_vars = set(map( + # Filter out 0-dimensional variables (usually grid mapping variables) + str, {k: v for k, v in ds.data_vars.items() if v.dims != ()} + )) unrecognized_vars = requested_vars - data_vars if unrecognized_vars == set(): ds = ds.drop_vars( diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index 7b2d19906..53fd52c58 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -584,6 +584,22 @@ def _get_single_dataset_collection( 'type': 'application/json', 'title': 'Schema (as JSON)', }, + { + 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/' + 'coverage-rangetype', + 'href': f'{base_url}{PATH_PREFIX}/collections/' + f'{dataset_id}/coverage/rangetype?f=json', + 'type': 'application/json', + 'title': 'Range type of the coverage', + }, + { + 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/' + 'coverage-domainset', + 'href': f'{base_url}{PATH_PREFIX}/collections/' + f'{dataset_id}/coverage/domainset?f=json', + 'type': 'application/json', + 'title': 'Domain set of the coverage', + }, ], 'providers': [], 'stac_version': STAC_VERSION, From fd72e081b69b4beb446b0aa0f06a404713c59617 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Mon, 8 Jan 2024 15:28:57 +0100 Subject: [PATCH 05/10] STAC/OGC: improve test coverage --- test/webapi/ows/coverages/test_controllers.py | 23 +++++++++++++++++- test/webapi/ows/stac/test_controllers.py | 24 +++++++++++++++++-- xcube/webapi/ows/coverages/controllers.py | 10 +++++++- xcube/webapi/ows/stac/controllers.py | 4 ++-- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/test/webapi/ows/coverages/test_controllers.py b/test/webapi/ows/coverages/test_controllers.py index 35898056b..cd6aeb0e0 100644 --- a/test/webapi/ows/coverages/test_controllers.py +++ b/test/webapi/ows/coverages/test_controllers.py @@ -39,7 +39,7 @@ get_dataarray_description, get_units, is_xy_order, - transform_bbox, + transform_bbox, get_coverage_rangetype_for_dataset, ) @@ -324,3 +324,24 @@ def test_transform_bbox_same_crs(self): bbox := [1, 2, 3, 4], transform_bbox(bbox, crs := pyproj.CRS('EPSG:4326'), crs), ) + + def test_get_coverage_rangetype_for_dataset(self): + self.assertEqual({ + 'type': 'DataRecord', + 'field': [{ + 'description': 'v', + 'encodingInfo': + {'dataType': 'http://www.opengis.net/def/' + 'dataType/OGC/0/signedLong'}, + 'name': 'v', + 'type': 'Quantity'} + ]}, + get_coverage_rangetype_for_dataset( + xr.Dataset({ + 'x': [1, 2, 3], + 'v': (['x'], [0, 0, 0]), + 'dimensionless1': ([], None), + 'dimensionless2': ([], None) + }) + ) + ) diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py index 4bed8153c..6a62b29a2 100644 --- a/test/webapi/ows/stac/test_controllers.py +++ b/test/webapi/ows/stac/test_controllers.py @@ -24,6 +24,7 @@ from pathlib import Path import functools +import pandas as pd import pyproj import xarray as xr import xcube @@ -44,8 +45,8 @@ STAC_VERSION, get_collection_queryables, get_datacube_dimensions, - get_single_collection_items, - crs_to_uri_or_wkt, + get_single_collection_items, + crs_to_uri_or_wkt, get_time_grid, ) from xcube.webapi.ows.stac.controllers import get_collection from xcube.webapi.ows.stac.controllers import get_collection_item @@ -422,6 +423,25 @@ def test_get_collection_schema(self): ) ) + def test_get_collection_schema_invalid(self): + with self.assertRaises(ValueError): + get_collection_schema( + get_stac_ctx().datasets_ctx, BASE_URL, DEFAULT_COLLECTION_ID + ) + + def test_get_time_grid(self): + self.assertEqual( + {}, + get_time_grid(xr.Dataset({'not_time': [1, 2, 3]})) + ) + self.assertEqual( + {'cellsCount': 1, 'coordinates': ['2000-01-01T00:00:00']}, + get_time_grid( + xr.Dataset({'time': [pd.Timestamp('2000-01-01T00:00:00Z')]}) + ) + ) + + def test_get_datacube_dimensions(self): dim_name_0 = 'new_dimension_0' dim_name_1 = 'new_dimension_1' diff --git a/xcube/webapi/ows/coverages/controllers.py b/xcube/webapi/ows/coverages/controllers.py index 4630a907a..2d40c86dd 100644 --- a/xcube/webapi/ows/coverages/controllers.py +++ b/xcube/webapi/ows/coverages/controllers.py @@ -604,7 +604,15 @@ def get_coverage_rangetype( The range type describes the data types of the dataset's variables """ - ds = get_dataset(ctx, collection_id) + return get_coverage_rangetype_for_dataset(get_dataset(ctx, collection_id)) + + +def get_coverage_rangetype_for_dataset(ds): + """ + Return the range type of a dataset + + The range type describes the data types of the dataset's variables + """ result = dict(type='DataRecord', field=[]) for var_name, variable in ds.data_vars.items(): if variable.dims == (): diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index 53fd52c58..793258f42 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -521,7 +521,7 @@ def _get_single_dataset_collection( }, 'temporal': { 'interval': [time_interval], - 'grid': _get_time_grid(dataset) + 'grid': get_time_grid(dataset) } }, 'id': dataset_id, @@ -655,7 +655,7 @@ def as_geometry(self) -> dict[str, Union[str, list]]: } -def _get_time_grid(ds: xr.Dataset) -> dict[str, Any]: +def get_time_grid(ds: xr.Dataset) -> dict[str, Any]: if 'time' not in ds: return {} From 9d962fbbbaaaa211063f57f92f34d0d3d00acc6f Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Mon, 8 Jan 2024 16:22:17 +0100 Subject: [PATCH 06/10] STAC/OGC: add some docstrings --- xcube/webapi/ows/coverages/controllers.py | 17 ++++++++++++----- xcube/webapi/ows/stac/controllers.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/xcube/webapi/ows/coverages/controllers.py b/xcube/webapi/ows/coverages/controllers.py index 2d40c86dd..2669e3914 100644 --- a/xcube/webapi/ows/coverages/controllers.py +++ b/xcube/webapi/ows/coverages/controllers.py @@ -599,19 +599,26 @@ def get_crs_from_dataset(ds: xr.Dataset) -> pyproj.CRS: def get_coverage_rangetype( ctx: DatasetsContext, collection_id: str ) -> dict[str, list]: - """ - Return the range type of a dataset + """Return the range type of a dataset The range type describes the data types of the dataset's variables + using a format defined in https://docs.ogc.org/is/09-146r6/09-146r6.html + + :param ctx: datasets context + :param collection_id: ID of the dataset in the supplied context + :return: a dictionary representing the specified dataset's range type """ return get_coverage_rangetype_for_dataset(get_dataset(ctx, collection_id)) -def get_coverage_rangetype_for_dataset(ds): - """ - Return the range type of a dataset +def get_coverage_rangetype_for_dataset(ds) -> dict[str, list]: + """Return the range type of a dataset The range type describes the data types of the dataset's variables + using a format defined in https://docs.ogc.org/is/09-146r6/09-146r6.html + + :param ds: a dataset + :return: a dictionary representing the supplied dataset's range type """ result = dict(type='DataRecord', field=[]) for var_name, variable in ds.data_vars.items(): diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index 793258f42..be342a5e1 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -389,6 +389,17 @@ def get_collection_queryables( def get_collection_schema( ctx: DatasetsContext, base_url: str, collection_id: str ) -> dict: + """Return a JSON schema for a dataset's data variables + + See links in + https://docs.ogc.org/DRAFTS/19-087.html#_collection_schema_response_collectionscollectionidschema + for links to a metaschema defining the schema. + + :param ctx: a datasets context + :param base_url: the base URL at which this API is being served + :param collection_id: the ID of a dataset in the provided context + :return: a JSON schema representing the specified dataset's data variables + """ if collection_id == DEFAULT_COLLECTION_ID: # The default collection contains multiple datasets, so a range # schema doesn't make sense. @@ -656,6 +667,15 @@ def as_geometry(self) -> dict[str, Union[str, list]]: def get_time_grid(ds: xr.Dataset) -> dict[str, Any]: + """Return a dictionary representing the grid for a dataset's time variable + + The dictionary format is defined by the schema at + https://github.com/opengeospatial/ogcapi-coverages/blob/master/standard/openapi/schemas/common-geodata/extent.yaml + + :param ds: a dataset + :return: a dictionary representation of the grid of the dataset's + time variable + """ if 'time' not in ds: return {} From 49ed2248578a7974b55941116749e9954209fc14 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Mon, 8 Jan 2024 16:36:15 +0100 Subject: [PATCH 07/10] STAC/OGC: update changelog --- CHANGES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 2b5482fa3..92909808c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,11 @@ * STAC * Provide links for multiple coverages data formats * Add `crs` and `crs_storage` properties to STAC data + * Add spatial and temporal grid data to collection descriptions + * Add a schema endpoint returning a JSON schema of a dataset's data + variables + * Add links to domain set, range type, and range schema to collection + descriptions * OGC Coverages: * Support scaling parameters `scale-factor`, `scale-axes`, and `scale-size` * Improve handling of bbox parameters From 83f471430488fca46a822e05cfe522822abca244 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Tue, 9 Jan 2024 09:16:33 +0100 Subject: [PATCH 08/10] STAC/OGC: code style fixes from review Co-authored-by: Tonio Fincke --- test/webapi/ows/coverages/test_controllers.py | 3 ++- test/webapi/ows/stac/test_controllers.py | 3 ++- xcube/webapi/ows/stac/routes.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/webapi/ows/coverages/test_controllers.py b/test/webapi/ows/coverages/test_controllers.py index cd6aeb0e0..e900b4b4f 100644 --- a/test/webapi/ows/coverages/test_controllers.py +++ b/test/webapi/ows/coverages/test_controllers.py @@ -39,7 +39,8 @@ get_dataarray_description, get_units, is_xy_order, - transform_bbox, get_coverage_rangetype_for_dataset, + transform_bbox, + get_coverage_rangetype_for_dataset, ) diff --git a/test/webapi/ows/stac/test_controllers.py b/test/webapi/ows/stac/test_controllers.py index 6a62b29a2..425c1e274 100644 --- a/test/webapi/ows/stac/test_controllers.py +++ b/test/webapi/ows/stac/test_controllers.py @@ -46,7 +46,8 @@ get_collection_queryables, get_datacube_dimensions, get_single_collection_items, - crs_to_uri_or_wkt, get_time_grid, + crs_to_uri_or_wkt, + get_time_grid, ) from xcube.webapi.ows.stac.controllers import get_collection from xcube.webapi.ows.stac.controllers import get_collection_item diff --git a/xcube/webapi/ows/stac/routes.py b/xcube/webapi/ows/stac/routes.py index 1f5617571..cf193975a 100644 --- a/xcube/webapi/ows/stac/routes.py +++ b/xcube/webapi/ows/stac/routes.py @@ -24,7 +24,8 @@ from .api import api from .context import StacContext from .controllers import get_collection, get_collection_queryables, \ - get_single_collection_items, get_collection_schema + get_single_collection_items, + get_collection_schema from .controllers import get_collection_item from .controllers import get_datasets_collection_items from .controllers import get_collections From 15dafaca2e5def8fff1c9668f46f82d6c25bd2c6 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Tue, 9 Jan 2024 09:35:53 +0100 Subject: [PATCH 09/10] STAC/OGC: fix syntax error --- xcube/webapi/ows/stac/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xcube/webapi/ows/stac/routes.py b/xcube/webapi/ows/stac/routes.py index cf193975a..a7ce6a8a2 100644 --- a/xcube/webapi/ows/stac/routes.py +++ b/xcube/webapi/ows/stac/routes.py @@ -24,7 +24,7 @@ from .api import api from .context import StacContext from .controllers import get_collection, get_collection_queryables, \ - get_single_collection_items, + get_single_collection_items, \ get_collection_schema from .controllers import get_collection_item from .controllers import get_datasets_collection_items From 48fffd5ee1b7db1b9d3df9f387cc0a4fcf9d1588 Mon Sep 17 00:00:00 2001 From: Pontus Lurcock Date: Tue, 9 Jan 2024 10:39:38 +0100 Subject: [PATCH 10/10] STAC/OGC: introduce some constants Suggestions from PR review --- xcube/webapi/ows/stac/controllers.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/xcube/webapi/ows/stac/controllers.py b/xcube/webapi/ows/stac/controllers.py index be342a5e1..bf1700748 100644 --- a/xcube/webapi/ows/stac/controllers.py +++ b/xcube/webapi/ows/stac/controllers.py @@ -46,6 +46,11 @@ from ..coverages.controllers import get_crs_from_dataset from ...datasets.context import DatasetsContext +_REL_DOMAINSET = 'http://www.opengis.net/def/rel/ogc/1.0/coverage-domainset' +_REL_RANGETYPE = 'http://www.opengis.net/def/rel/ogc/1.0/coverage-rangetype' +_REL_SCHEMA = 'http://www.opengis.net/def/rel/ogc/1.0/schema' +_JSON_SCHEMA_METASCHEMA = 'https://json-schema.org/draft/2020-12/schema' + STAC_VERSION = '1.0.0' STAC_EXTENSIONS = [ "https://stac-extensions.github.io/datacube/v2.1.0/schema.json" @@ -414,7 +419,7 @@ def get_title(var_name: str) -> str: return attrs['long_name'] if 'long_name' in attrs else var_name return { - '$schema': 'https://json-schema.org/draft/2020-12/schema', + '$schema': _JSON_SCHEMA_METASCHEMA, '$id': f'{base_url}{PATH_PREFIX}/{collection_id}/schema', 'title': ds.attrs['title'] if 'title' in ds.attrs else collection_id, 'type': 'object', @@ -589,23 +594,21 @@ def _get_single_dataset_collection( f'OGC API – Coverages, as GeoTIFF', }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/schema', + 'rel': _REL_SCHEMA, 'href': f'{base_url}{PATH_PREFIX}/collections/' f'{dataset_id}/schema?f=json', 'type': 'application/json', 'title': 'Schema (as JSON)', }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/' - 'coverage-rangetype', + 'rel': _REL_RANGETYPE, 'href': f'{base_url}{PATH_PREFIX}/collections/' f'{dataset_id}/coverage/rangetype?f=json', 'type': 'application/json', 'title': 'Range type of the coverage', }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/' - 'coverage-domainset', + 'rel': _REL_DOMAINSET, 'href': f'{base_url}{PATH_PREFIX}/collections/' f'{dataset_id}/coverage/domainset?f=json', 'type': 'application/json',