From 25a7b97299c7c6495a92cd58edebd62f522d0a8e Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Sun, 1 Mar 2026 19:11:53 +0000 Subject: [PATCH 01/12] Separate format options to per query type --- ogc/edr/edr_api.py | 4 ++++ ogc/edr/edr_config.py | 20 +++++++++++++++++++- ogc/edr/edr_provider.py | 31 +++++++++++++++++++++++-------- ogc/edr/test/test_edr_provider.py | 26 ++++++++++++++++++++++++++ ogc/servers.py | 11 ++++++----- ogc/settings.py | 11 ++++++++++- 6 files changed, 88 insertions(+), 15 deletions(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index 8c27dfb..c240319 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -156,9 +156,11 @@ def describe_collections(api: API, request: APIRequest, dataset: str | None = No collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) height_units = collection_configuration[collection_id].get("height_units", []) + query_formats = collection_configuration[collection_id].get("query_formats", {}) for query_type in collection["data_queries"]: data_query_additions = { "query_type": query_type, + **(query_formats.get(query_type) if query_formats.get(query_type) is not None else {}), **({"height_units": height_units} if query_type == "cube" else {}), } variables = collection["data_queries"][query_type]["link"].get("variables", {}) @@ -229,9 +231,11 @@ def get_collection_edr_instances( instance["output_formats"] = collection_configuration[dataset].get("output_formats", []) height_units = collection_configuration[dataset].get("height_units") + query_formats = collection_configuration[dataset].get("query_formats", {}) for query_type in instance["data_queries"]: data_query_additions = { "query_type": query_type, + **(query_formats.get(query_type) if query_formats.get(query_type) is not None else {}), **({"height_units": height_units} if query_type == "cube" else {}), } variables = instance["data_queries"][query_type]["link"].get("variables", {}) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 4aa2a0b..92b6f18 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -99,7 +99,8 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "keywords": ["podpac"], "extents": EdrConfig._generate_extents(group_layers), "height_units": EdrConfig._vertical_units(group_layers), - "output_formats": settings.EDR_QUERY_FORMATS, + "output_formats": list({item for values in settings.EDR_QUERY_FORMATS.values() for item in values}), + "query_formats": EdrConfig.data_query_formats(), "providers": [ { "type": "edr", @@ -244,3 +245,20 @@ def _vertical_units(layers: List[pogc.Layer]) -> List[str]: vertical_units.add(coordinates_list[0].alt_units) return list(vertical_units) + + @staticmethod + def data_query_formats() -> Dict[str, Any]: + """Get data related to the available query output formats and the default format. + + Returns + ------- + Dict[str, Any] + Query format data for each query type. + """ + query_formats = {} + for query_type, formats in settings.EDR_QUERY_FORMATS.items(): + query_formats[query_type] = { + "output_formats": formats, + "default_output_format": settings.EDR_QUERY_DEFAULTS.get(query_type), + } + return query_formats diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 1c14d6b..efc652c 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -88,7 +88,6 @@ def __init__(self, provider_def: Dict[str, Any]): raise ProviderConnectionError("Data not found.") self.collection_id = str(collection_id) - self.native_format = provider_def["format"]["name"] self.base_url = provider_def.get("base_url", "") if not self.base_url: @@ -137,8 +136,6 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): Raised if an invalid instance is provided. ProviderInvalidQueryError Raised if an invalid parameter is provided. - ProviderInvalidQueryError - Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if a datetime string is provided but cannot be interpreted. ProviderInvalidQueryError @@ -164,7 +161,6 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): instance = self.validate_instance(instance) requested_parameters = self.validate_parameters(requested_parameters) - output_format = self.validate_output_format(output_format) resolution_x, resolution_y = self.validate_resolution(resolution_x, resolution_y) crs = self.interpret_crs(requested_coordinates.crs) @@ -228,6 +224,8 @@ def position(self, **kwargs): WKT geometry crs : str The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -236,6 +234,8 @@ def position(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the wkt string is not provided. ProviderInvalidQueryError @@ -245,6 +245,7 @@ def position(self, **kwargs): wkt = kwargs.get("wkt") crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "position") if not isinstance(wkt, BaseGeometry): msg = "Invalid WKT string provided for the position query." @@ -268,6 +269,8 @@ def cube(self, **kwargs): Bbox geometry (for cube queries) crs : str The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -276,12 +279,15 @@ def cube(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the bounding box is invalid. """ bbox = kwargs.get("bbox") crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "cube") if not isinstance(bbox, List) or (len(bbox) != 4 and len(bbox) != 6): msg = ( @@ -312,6 +318,8 @@ def area(self, **kwargs): WKT geometry crs : str The requested CRS for the return coordinates and data. + format_ : str + The requested output format of the data. Returns ------- @@ -320,6 +328,8 @@ def area(self, **kwargs): Raises ------ + ProviderInvalidQueryError + Raised if an invalid output format is provided. ProviderInvalidQueryError Raised if the wkt string is not provided. ProviderInvalidQueryError @@ -329,6 +339,7 @@ def area(self, **kwargs): wkt = kwargs.get("wkt") crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) + kwargs["format_"] = self.validate_output_format(kwargs["format_"], "area") if not isinstance(wkt, BaseGeometry): msg = "Invalid WKT string provided for the area query." @@ -390,7 +401,7 @@ def get_fields(self) -> Dict[str, Any]: } return fields - def validate_output_format(self, output_format: str | None) -> str: + def validate_output_format(self, output_format: str | None, query_type: str) -> str: """Validate the output format for a query. If None provided, return the default. @@ -400,6 +411,8 @@ def validate_output_format(self, output_format: str | None) -> str: ---------- output_format : str | None The specified output format which needs to be validated. + query_type: str + The query type to validate output formats against. Returns ------- @@ -412,10 +425,12 @@ def validate_output_format(self, output_format: str | None) -> str: Raised if the provided output format is invalid. """ if output_format is None: - return self.native_format.lower() + return settings.EDR_QUERY_DEFAULTS.get(query_type, "") - if output_format.lower() not in [key.lower() for key in settings.EDR_QUERY_FORMATS]: - msg = f"Invalid format provided, expected one of {', '.join(settings.EDR_QUERY_FORMATS)}" + if output_format.lower() not in [key.lower() for key in settings.EDR_QUERY_FORMATS.get(query_type, [])]: + msg = ( + f"Invalid format provided, expected one of {', '.join(settings.EDR_QUERY_FORMATS.get(query_type, []))}" + ) raise ProviderInvalidQueryError(msg, user_msg=msg) return output_format.lower() diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index fe68e97..dcae3ad 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -217,6 +217,32 @@ def test_edr_provider_position_request_invalid_wkt( provider.position(**args) +def test_edr_provider_position_request_invalid_format( + layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] +): + """Test the position method of the EDR Provider class with an invalid format. + + Parameters + ---------- + layers : List[pogc.Layer] + Layers provided by a test fixture. + + single_layer_cube_args_internal : Dict[str, Any] + Single layer arguments with internal pygeoapi keys provided by a test fixture. + """ + base_url = "/" + args = single_layer_cube_args_internal + del args["bbox"] + args["wkt"] = Point(5.2, 52.1) + args["format_"] = settings.GEOTIFF + + provider = EdrProvider(provider_def=get_provider_definition(base_url)) + provider.set_layers(base_url, layers) + + with pytest.raises(ProviderInvalidQueryError): + provider.position(**args) + + def test_edr_provider_position_request_invalid_property( layers: List[pogc.Layer], single_layer_cube_args_internal: Dict[str, Any] ): diff --git a/ogc/servers.py b/ogc/servers.py index efa6213..b60af15 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -194,7 +194,7 @@ def method(): self.add_url_rule( f"/{endpoint}/edr/collections//instances//", endpoint=f"{endpoint}_instance_query", - view_func=self.edr_render(ogc.edr_routes.collection_query, default_format=settings.GEOTIFF), + view_func=self.edr_render(ogc.edr_routes.collection_query), methods=["GET"], strict_slashes=strict_slashes, ) @@ -259,7 +259,7 @@ def ogc_render(self, ogc_idx): ee = WCSException() return respond_xml(ee.to_xml(), status=500) - def edr_render(self, callable: Callable, default_format: str | None = "json") -> Callable: + def edr_render(self, callable: Callable) -> Callable: """Function which returns a wrapper for the provided callable. Filters arguments and handles any necessary exceptions. @@ -267,9 +267,6 @@ def edr_render(self, callable: Callable, default_format: str | None = "json") -> ---------- callable : Callable The callable request handler to be wrapped. - default_format: str | None - The optional default data output format, by default "json". - Returns ------- Callable @@ -305,6 +302,10 @@ def wrapper(*args, **kwargs) -> Response: for (k, v) in request.args.items() } # Replace format with its lowercase version to match pygeoapi expectations + query_type = kwargs.get("query_type") + default_format = settings.JSON + if query_type is not None: + default_format = settings.EDR_QUERY_DEFAULTS.get(query_type, default_format) format_argument = filtered_args.get("f", default_format) if format_argument is not None: filtered_args["f"] = format_argument.lower() diff --git a/ogc/settings.py b/ogc/settings.py index 8bdf166..f6ef46a 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -44,7 +44,16 @@ JSON = "JSON" COVERAGE_JSON = "CoverageJSON" HTML = "HTML" -EDR_QUERY_FORMATS = [GEOTIFF, COVERAGE_JSON, JSON, HTML] +EDR_QUERY_FORMATS = { + "cube": [GEOTIFF, COVERAGE_JSON, JSON, HTML], + "area": [GEOTIFF, COVERAGE_JSON, JSON, HTML], + "position": [COVERAGE_JSON, JSON, HTML], +} +EDR_QUERY_DEFAULTS = { + "cube": GEOTIFF, + "area": GEOTIFF, + "position": COVERAGE_JSON, +} # WMS Capabilities timestamp format USE_TIMES_LIST = False From 2264d4a4be76bcaea75314bcfe4694118e7ef19e Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Sun, 1 Mar 2026 19:13:23 +0000 Subject: [PATCH 02/12] Remove query information for instance only collections --- ogc/edr/edr_api.py | 30 ++++++++++++++++++++++++++++++ ogc/edr/edr_provider.py | 32 +++++++++++++++++++++++++++++--- ogc/edr/test/test_edr_routes.py | 2 +- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index c240319..d00ec10 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -155,6 +155,12 @@ def describe_collections(api: API, request: APIRequest, dataset: str | None = No collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) + collection_queryable = EdrProvider.is_collection_queryable(provider["base_url"], collection_id) + if not collection_queryable: + collection_without_queryables = EdrAPI._remove_query_metadata(collection) + collection["links"] = collection_without_queryables["links"] + collection["data_queries"] = collection_without_queryables["data_queries"] + height_units = collection_configuration[collection_id].get("height_units", []) query_formats = collection_configuration[collection_id].get("query_formats", {}) for query_type in collection["data_queries"]: @@ -378,6 +384,30 @@ def _instance_parameters( } return instance_parameters + @staticmethod + def _remove_query_metadata(data: Dict[str, Any]) -> Dict[str, Any]: + """Remove metadata from the dictionary which relates to query types such as position and cube. + + Parameters + ---------- + data : Dict[str, Any] + The dictionary to remove query metadata from. + + Returns + ------- + Dict[str, Any] + The updated dictionary with metadata removed. + """ + filtered_data = data.copy() + data_queries = data.get("data_queries", {}) + filtered_data["data_queries"] = {"instances": data_queries.get("instances")} + filtered_data["links"] = [] + for link in data["links"]: + if link.get("rel") != "data" or "/instances" in link.get("href", ""): + filtered_data["links"].append(link) + + return filtered_data + @staticmethod def _openapi_update(api: Dict[str, Any]) -> Dict[str, Any]: """Update the default OpenAPI definition to a custom format. diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index efc652c..253e302 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -67,6 +67,29 @@ def get_layers(cls, base_url: str, group: str | None = None) -> List[pogc.Layer] else: return layers + @classmethod + def is_collection_queryable(cls, base_url: str, group: str) -> bool: + """Determine whether a collection contains directly queryable data or is only queryable through instances. + + Parameters + ---------- + base_url : str + The base URL for the layers. + group : str + Collection to check if direct querying is possible. + + Returns + ------- + bool + True if the collection can be queried directly, false otherwise. + """ + layers = cls.get_layers(base_url, group) + for layer in layers: + coordinates = layer.get_coordinates_list() + if len(coordinates) > 0 and "forecastOffsetHr" not in coordinates[0].udims: + return True + return False + def __init__(self, provider_def: Dict[str, Any]): """Construct the provider using the provider definition. @@ -543,8 +566,6 @@ def validate_parameters(self, parameters: List[str] | None) -> Dict[str, pogc.La def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) -> podpac.UnitsDataArray | None: """Evaluate a layer using the requested coordinates. - Temporal coordinates are ignored if the layer does not include them. - Parameters ---------- requested_coordinates : podpac.Coordinates @@ -560,7 +581,12 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) coordinates = layer.get_coordinates_list() layer_requested_coordinates = requested_coordinates units_data_array = None - if len(coordinates) > 0 and "time" not in coordinates[0].udims: + layer_has_instances = "forecastOffsetHr" in coordinates[0].udims + request_has_instances = "forecastOffsetHr" in requested_coordinates.udims + if len(coordinates) <= 0 or (layer_has_instances ^ request_has_instances): + return units_data_array + + if "time" not in coordinates[0].udims: layer_requested_coordinates = layer_requested_coordinates.udrop( ["time", "forecastOffsetHr"], ignore_missing=True ) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index ca0aaaf..9cc918a 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -149,7 +149,7 @@ def test_edr_routes_describe_collection(layers: List[pogc.Layer]): assert status == HTTPStatus.OK assert response["id"] == collection_id assert list(response["parameter_names"].keys()) == [layer.identifier for layer in collection_layers] - assert list(response["data_queries"].keys()) == ["position", "cube", "area", "instances"] + assert list(response["data_queries"].keys()) == ["instances"] def test_edr_routes_describe_instances(layers: List[pogc.Layer]): From f771285cd8de9c8f31ef6a3a4f66165111caccaf Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Sun, 1 Mar 2026 19:13:47 +0000 Subject: [PATCH 03/12] Remove resolution from position requests --- ogc/edr/edr_provider.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 253e302..8e7a14c 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -147,6 +147,10 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): The requested datetime/datetimes for data retrieval. z : str The requested vertical level/levels for data retrieval. + resolution-x : str + The number of requested data points, as a string, in the x-direction. + resolution-y : str + The number of requested data points, as a string, in the y-direction. Returns ------- @@ -179,8 +183,8 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): output_format = kwargs.get("format_") datetime_arg = kwargs.get("datetime_") z_arg = kwargs.get("z") - resolution_x = self._extra_args.get("resolution-x") - resolution_y = self._extra_args.get("resolution-y") + resolution_x = kwargs.get("resolution-x") + resolution_y = kwargs.get("resolution-y") instance = self.validate_instance(instance) requested_parameters = self.validate_parameters(requested_parameters) @@ -311,6 +315,8 @@ def cube(self, **kwargs): crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) kwargs["format_"] = self.validate_output_format(kwargs["format_"], "cube") + kwargs["resolution-x"] = self._extra_args.get("resolution-x") + kwargs["resolution-y"] = self._extra_args.get("resolution-y") if not isinstance(bbox, List) or (len(bbox) != 4 and len(bbox) != 6): msg = ( @@ -363,6 +369,8 @@ def area(self, **kwargs): crs = kwargs.get("crs") crs = EdrProvider.interpret_crs(crs) kwargs["format_"] = self.validate_output_format(kwargs["format_"], "area") + kwargs["resolution-x"] = self._extra_args.get("resolution-x") + kwargs["resolution-y"] = self._extra_args.get("resolution-y") if not isinstance(wkt, BaseGeometry): msg = "Invalid WKT string provided for the area query." From 81e47a1efd7dbe4c74c3bd6046a5fa4f10d625f7 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 2 Mar 2026 18:40:50 -0500 Subject: [PATCH 04/12] Ensure coverage data is valid for JSON conversion --- ogc/edr/edr_provider.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 8e7a14c..34a7fa2 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -975,6 +975,8 @@ def to_coverage_json( } } coverage_json["domain"]["parameters"].update(parameter_definition) + + data = [None if np.isnan(x) or np.isinf(x) else x for x in data_array.values.flatten()] coverage_json["domain"]["ranges"].update( { param: { @@ -982,7 +984,7 @@ def to_coverage_json( "dataType": "float", "axisNames": list(data_array.coords.keys()), "shape": data_array.shape, - "values": list(data_array.values.flatten()), # Row Major Order + "values": data, # Row Major Order } } ) From c4264c8044596d0fbb8a51c6e87884f800f3bbbf Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Tue, 3 Mar 2026 11:18:04 -0500 Subject: [PATCH 05/12] Use max dimension coordinates as layer coordinates --- ogc/edr/edr_config.py | 12 ++++++------ ogc/edr/edr_provider.py | 39 +++++++++++++++++++++------------------ ogc/podpac.py | 37 +++++++++++++++++++++---------------- 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 92b6f18..58a032a 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -159,9 +159,9 @@ def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: urc_lon = max(urc_lon, urc_lon_tmp) urc_lat = max(urc_lat, urc_lat_tmp) - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: - vertical_range.update(coordinates_list[0]["alt"].coordinates) + coordinates = layer.get_coordinates() + if coordinates is not None and "alt" in coordinates.udims: + vertical_range.update(coordinates["alt"].coordinates) if hasattr(layer, "valid_times") and layer.valid_times is not tl.Undefined and len(layer.valid_times) > 0: time_range.update(layer.valid_times) @@ -240,9 +240,9 @@ def _vertical_units(layers: List[pogc.Layer]) -> List[str]: """ vertical_units = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and coordinates_list[0].alt_units: - vertical_units.add(coordinates_list[0].alt_units) + coordinates = layer.get_coordinates() + if coordinates is not None and coordinates.alt_units: + vertical_units.add(coordinates.alt_units) return list(vertical_units) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 34a7fa2..8c139f3 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -85,8 +85,8 @@ def is_collection_queryable(cls, base_url: str, group: str) -> bool: """ layers = cls.get_layers(base_url, group) for layer in layers: - coordinates = layer.get_coordinates_list() - if len(coordinates) > 0 and "forecastOffsetHr" not in coordinates[0].udims: + coordinates = layer.get_coordinates() + if coordinates is not None and "forecastOffsetHr" not in coordinates.udims: return True return False @@ -212,11 +212,11 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): requested_coordinates = podpac.coordinates.merge_dims([altitude_coords, requested_coordinates]) # Handle defining native coordinates for the query, these should match between each layer - coordinates_list = next(iter(requested_parameters.values())).get_coordinates_list() - self.check_query_condition(len(coordinates_list) == 0, "Native coordinates not found.") + coordinates = next(iter(requested_parameters.values())).get_coordinates() + self.check_query_condition(coordinates is None, "Native coordinates not found.") resolution_lon, resolution_lat = self.crs_converter(resolution_x, resolution_y, crs) requested_native_coordinates = self.get_native_coordinates( - requested_coordinates, coordinates_list[0], resolution_lat, resolution_lon + requested_coordinates, coordinates, resolution_lat, resolution_lon ) self.check_query_condition( @@ -586,12 +586,15 @@ def evaluate_layer(requested_coordinates: podpac.Coordinates, layer: pogc.Layer) podpac.UnitsDataArray The units data array returned from evaluation or None if the node was not found. """ - coordinates = layer.get_coordinates_list() + coordinates = layer.get_coordinates() layer_requested_coordinates = requested_coordinates units_data_array = None - layer_has_instances = "forecastOffsetHr" in coordinates[0].udims + if coordinates is None: + return units_data_array + + layer_has_instances = "forecastOffsetHr" in coordinates.udims request_has_instances = "forecastOffsetHr" in requested_coordinates.udims - if len(coordinates) <= 0 or (layer_has_instances ^ request_has_instances): + if layer_has_instances ^ request_has_instances: return units_data_array if "time" not in coordinates[0].udims: @@ -635,9 +638,9 @@ def get_altitudes(layers: List[pogc.Layer]) -> List[float]: available_altitudes = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "alt" in coordinates_list[0].udims: - available_altitudes.update(coordinates_list[0]["alt"].coordinates) + coordinates = layer.get_coordinates() + if coordinates is not None and "alt" in coordinates.udims: + available_altitudes.update(coordinates["alt"].coordinates) return list(available_altitudes) @@ -659,19 +662,19 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n available_times = set() for layer in layers: - coordinates_list = layer.get_coordinates_list() - if len(coordinates_list) > 0 and "time" in coordinates_list[0].udims: - if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates_list[0].udims: + coordinates = layer.get_coordinates() + if coordinates is not None and "time" in coordinates.udims: + if instance_time in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: # Retrieve available forecastOffSetHr and instance time combinations instance_datetime = np.datetime64(instance_time) - instance_coordinates = coordinates_list[0].select({"time": [instance_datetime, instance_datetime]}) + instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates available_times.update( [np.datetime64(instance_time) + offset for offset in selected_offset_coordinates] ) - elif not instance_time and "forecastOffsetHr" not in coordinates_list[0].udims: + elif not instance_time and "forecastOffsetHr" not in coordinates.udims: # Retrieve layer times for non-instance requests - available_times.update(coordinates_list[0]["time"].coordinates) + available_times.update(coordinates["time"].coordinates) return list(available_times) @@ -976,7 +979,7 @@ def to_coverage_json( } coverage_json["domain"]["parameters"].update(parameter_definition) - data = [None if np.isnan(x) or np.isinf(x) else x for x in data_array.values.flatten()] + data = [x if np.isfinite(x) else None for x in data_array.values.flatten()] coverage_json["domain"]["ranges"].update( { param: { diff --git a/ogc/podpac.py b/ogc/podpac.py index 4ef1e3e..d9fe213 100755 --- a/ogc/podpac.py +++ b/ogc/podpac.py @@ -65,35 +65,40 @@ def time_instances(self) -> List[str]: List of available time instances as a strings. """ time_instances = set() - coordinates_list = self.get_coordinates_list() + coordinates = self.get_coordinates() # Time instances are created if a node has both time and offsets. - if ( - len(coordinates_list) > 0 - and "time" in coordinates_list[0].udims - and "forecastOffsetHr" in coordinates_list[0].udims - ): + if coordinates is not None and "time" in coordinates.udims and "forecastOffsetHr" in coordinates.udims: time_instances.update( - [ - time.astype("datetime64[ms]").astype(datetime).isoformat() - for time in coordinates_list[0]["time"].coordinates - ] + [time.astype("datetime64[ms]").astype(datetime).isoformat() for time in coordinates["time"].coordinates] ) return list(time_instances) - def get_coordinates_list(self) -> List[Coordinates]: - """Retrieve the coordinates list from the node. + def get_coordinates(self) -> Coordinates | None: + """Retrieve the coordinates from the node. Returns ------- - List[Coordinates] - List of coordinates from the node. + Coordinates | None + Coordinates from the node or None if not found. """ if self.node is None: - return [] + return None - return self.node.find_coordinates() + coordinates_list = self.node.find_coordinates() + dimension_set = set() + coordinates = None + + for coords in coordinates_list: + dimension_set.update(coords.udims) + if coordinates is None or len(coords.udims) > len(coordinates.udims): + coordinates = coords + + if coordinates is not None and not all(dim in coordinates.udims for dim in dimension_set): + raise ValueError("Not all node coordinate dimensions contained in the layer coordinates.") + + return coordinates def get_units(self) -> str | None: """Retrieve the units from the node. From 232a9abdf56185bab160fef75e82a799cf798342 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Wed, 4 Mar 2026 14:12:18 +0000 Subject: [PATCH 06/12] Use wkt for crs format and bypass intersection for position queries --- ogc/edr/edr_provider.py | 42 ++++++++++++++++++------------- ogc/edr/test/test_edr_provider.py | 8 ++++-- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 8c139f3..5e7d8c4 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -2,9 +2,11 @@ import io import numpy as np import zipfile +import pyproj from datetime import datetime from collections import defaultdict from typing import List, Dict, Tuple, Any +from pyproj.exceptions import CRSError from shapely.geometry.base import BaseGeometry from pygeoapi.provider.base import ProviderConnectionError, ProviderInvalidQueryError from pygeoapi.provider.base_edr import BaseEDRProvider @@ -249,8 +251,6 @@ def position(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str - The requested CRS for the return coordinates and data. format_ : str The requested output format of the data. @@ -270,7 +270,7 @@ def position(self, **kwargs): """ lat, lon = [], [] wkt = kwargs.get("wkt") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) kwargs["format_"] = self.validate_output_format(kwargs["format_"], "position") @@ -294,8 +294,6 @@ def cube(self, **kwargs): ---------- bbox : List[float] Bbox geometry (for cube queries) - crs : str - The requested CRS for the return coordinates and data. format_ : str The requested output format of the data. @@ -312,7 +310,7 @@ def cube(self, **kwargs): Raised if the bounding box is invalid. """ bbox = kwargs.get("bbox") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) kwargs["format_"] = self.validate_output_format(kwargs["format_"], "cube") kwargs["resolution-x"] = self._extra_args.get("resolution-x") @@ -345,8 +343,6 @@ def area(self, **kwargs): ---------- wkt : shapely.geometry WKT geometry - crs : str - The requested CRS for the return coordinates and data. format_ : str The requested output format of the data. @@ -366,7 +362,7 @@ def area(self, **kwargs): """ lat, lon = [], [] wkt = kwargs.get("wkt") - crs = kwargs.get("crs") + crs = self._extra_args.get("crs") crs = EdrProvider.interpret_crs(crs) kwargs["format_"] = self.validate_output_format(kwargs["format_"], "area") kwargs["resolution-x"] = self._extra_args.get("resolution-x") @@ -680,7 +676,7 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n @staticmethod def interpret_crs(crs: str | None) -> str: - """Interpret the CRS id string into a valid PyProj CRS format. + """Interpret the CRS id string into a valid WKT CRS format. If None provided, return the default. If the provided CRS is invalid, raise an error. @@ -693,7 +689,7 @@ def interpret_crs(crs: str | None) -> str: Returns ------- str - Pyproj CRS string. + WKT CRS string. Raises ------ @@ -701,13 +697,20 @@ def interpret_crs(crs: str | None) -> str: Raised if the provided CRS string is unknown. """ if crs is None: - return settings.crs_84_uri_format # Pyproj acceptable format + return pyproj.CRS(settings.crs_84_uri_format).to_wkt() # Pyproj acceptable format - if crs.lower() not in [key.lower() for key in settings.EDR_CRS.keys()]: - msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" - raise ProviderInvalidQueryError(msg, user_msg=msg) + try: + wkt_crs = pyproj.CRS(crs).to_wkt() + wkt_options = [pyproj.CRS(key).to_wkt() for key in settings.EDR_CRS.keys()] + except CRSError: + wkt_crs = None + wkt_options = [] + + if wkt_crs is None or wkt_crs not in wkt_options: + error_msg = msg = f"Invalid CRS provided, expected one of {', '.join(settings.EDR_CRS.keys())}" + raise ProviderInvalidQueryError(msg, user_msg=error_msg) - return crs + return wkt_crs @staticmethod def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: @@ -727,7 +730,9 @@ def crs_converter(x: Any, y: Any, crs: str) -> Tuple[Any, Any]: Tuple[Any, Any] The X,Y as Longitude/Latitude data. """ - if crs.lower() == settings.epsg_4326_uri_format.lower(): + wkt_crs = pyproj.CRS(crs).to_wkt() + wkt_epsg_4326 = pyproj.CRS(settings.epsg_4326_uri_format).to_wkt() + if wkt_crs == wkt_epsg_4326: return (y, x) return (x, y) @@ -1072,6 +1077,9 @@ def get_native_coordinates( podpac.Coordinates The converted coordinates source coordinates intersecting with the target coordinates. """ + if len(source_coordinates["lat"].coordinates) == 1 and len(source_coordinates["lon"].coordinates) == 1: + return source_coordinates + latitudes = ( target_coordinates["lat"].coordinates if resolution_lat == 0 diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index dcae3ad..a9bff8b 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -4,6 +4,7 @@ import base64 import io import podpac +import pyproj from shapely import Point, Polygon from typing import Dict, List, Any from ogc import settings @@ -678,12 +679,15 @@ def test_edr_provider_altitude_invalid_string(): def test_edr_provider_crs_interpreter_default_value(): """Test the CRS interpretation returns a default value when the argument is None.""" - assert EdrProvider.interpret_crs(None) == settings.crs_84_uri_format + + assert EdrProvider.interpret_crs(None) == pyproj.CRS(settings.crs_84_uri_format).to_wkt() def test_edr_provider_crs_interpreter_valid_value(): """Test the CRS interpretation returns a valid value when the argument is acceptable.""" - assert EdrProvider.interpret_crs(settings.epsg_4326_uri_format) == settings.epsg_4326_uri_format + assert ( + EdrProvider.interpret_crs(settings.epsg_4326_uri_format) == pyproj.CRS(settings.epsg_4326_uri_format).to_wkt() + ) def test_edr_provider_crs_interpreter_invalid_value(): From b4b2aaf23cacb1c51217c6741ad4950de128b855 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 5 Mar 2026 13:58:12 -0500 Subject: [PATCH 07/12] Improve coverage JSON performance --- ogc/edr/edr_provider.py | 42 +++++++++------- ogc/edr/edr_routes.py | 81 ++++++++++++++++++++++++++----- ogc/edr/test/test_edr_provider.py | 53 ++++++++++++++++++-- ogc/edr/test/test_edr_routes.py | 32 ++++++++++++ ogc/servers.py | 9 +--- 5 files changed, 177 insertions(+), 40 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 5e7d8c4..76aaf85 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -1,5 +1,5 @@ -import base64 -import io +import json +import tempfile import numpy as np import zipfile import pyproj @@ -240,7 +240,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): or output_format == settings.HTML.lower() ): layers = self.get_layers(self.base_url, self.collection_id) - return self.to_coverage_json(layers, dataset, crs) + return self.to_coverage_json(layers, dataset, self.collection_id, crs) return self.to_geotiff_response(dataset, self.collection_id) @@ -874,7 +874,7 @@ def interpret_time_coordinates( @staticmethod def to_coverage_json( - layers: List[pogc.Layer], dataset: Dict[str, podpac.UnitsDataArray], crs: str + layers: List[pogc.Layer], dataset: Dict[str, podpac.UnitsDataArray], collection_id: str, crs: str ) -> Dict[str, Any]: """Generate a CoverageJSON of the data for the provided parameters. @@ -884,13 +884,15 @@ def to_coverage_json( Layers which were used in the dataset creation for metadata information. dataset : Dict[str, podpac.UnitsDataArray] Data in an units data array format with matching parameter key. + collection_id : str + The collection id of the data used in naming the output file. crs : str The CRS associated with the requested coordinates and data response. Returns ------- Dict[str, Any] - A dictionary of the CoverageJSON data. + A dictionary of the desired output file name and data path. """ # Determine the bounding coordinates, assume they all are the same @@ -997,7 +999,12 @@ def to_coverage_json( } ) - return coverage_json + encoder = json.JSONEncoder() + with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as named_file: + for chunk in encoder.iterencode(coverage_json): + named_file.write(chunk) + + return {"fp": named_file.name, "fn": f"{collection_id}.json"} @staticmethod def check_query_condition(conditional: bool, message: str): @@ -1032,25 +1039,26 @@ def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id Returns ------- Dict[str, Any] - A dictionary the file name and data with a Base64 encoding. + A dictionary of the desired output file name and data path. """ if len(dataset) == 1: units_data_array = next(iter(dataset.values())) geotiff_bytes = units_data_array.to_format("geotiff").read() + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".tif", delete=False) as named_file: + named_file.write(geotiff_bytes) return { - "fp": base64.b64encode(geotiff_bytes).decode("utf-8"), + "fp": named_file.name, "fn": f"{next(iter(dataset.keys()))}.tif", } else: - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: - for parameter, data_array in dataset.items(): - geotiff_memory_file = data_array.to_format("geotiff") - tiff_filename = f"{parameter}.tif" - zip_file.writestr(tiff_filename, geotiff_memory_file.read()) - - zip_buffer.seek(0) - return {"fp": base64.b64encode(zip_buffer.read()).decode("utf-8"), "fn": f"{collection_id}.zip"} + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".zip", delete=False) as named_file: + with zipfile.ZipFile(named_file, "w", zipfile.ZIP_DEFLATED) as zip_file: + for parameter, data_array in dataset.items(): + geotiff_memory_file = data_array.to_format("geotiff") + tiff_filename = f"{parameter}.tif" + zip_file.writestr(tiff_filename, geotiff_memory_file.read()) + + return {"fp": named_file.name, "fn": f"{collection_id}.zip"} @staticmethod def get_native_coordinates( diff --git a/ogc/edr/edr_routes.py b/ogc/edr/edr_routes.py index 827a0cd..b03435b 100644 --- a/ogc/edr/edr_routes.py +++ b/ogc/edr/edr_routes.py @@ -1,13 +1,12 @@ import os import mimetypes import json -import base64 -import io import traitlets as tl +import weakref import pygeoapi.l10n import pygeoapi.plugin import pygeoapi.api -from typing import Tuple, Any, Dict +from typing import Tuple, Any, Dict, Generator from http import HTTPStatus from copy import deepcopy from pygeoapi.openapi import get_oas @@ -255,13 +254,73 @@ def collection_query( content = json.loads(content) if "fn" in content and "fp" in content: - # Return the file name in the header and the content as only the binary data - filename = content["fn"] - headers["Content-Type"] = "image/tiff" - headers["Content-Disposition"] = f"attachment; filename={filename}" - # Decode the content string which is the Base64 representation of the data - content = io.BytesIO(base64.b64decode(content["fp"])) - else: - headers["Content-Type"] = "application/prs.coverage+json" + data_path = content["fp"] + binary_mode = "rb" + text_mode = "r" + mode = text_mode + if data_path.endswith(".tif"): + mode = binary_mode + headers["Content-Type"] = "image/tiff" + elif data_path.endswith(".zip"): + mode = binary_mode + headers["Content-Type"] = "application/zip" + elif data_path.endswith(".json"): + mode = text_mode + headers["Content-Type"] = "application/prs.coverage+json" + + headers["Content-Disposition"] = f"attachment; filename={content["fn"]}" + content = self.file_generator_with_cleanup(data_path, mode) return headers, http_status, content + + @staticmethod + def file_generator_with_cleanup(path: str, mode: str, chunk_size=1024 * 1024) -> Generator[str | bytes, None, None]: + """Create a generator for file data with cleanup. + + Parameters + ---------- + path : str + The path to the file. + mode : str + The mode to open the file with. + chunk_size : int, optional + The character or byte size of each chunk read from the file, by default 1024*1024 + + Yields + ------ + Generator[str | bytes, None, None] + Chunks of data from the file. + """ + + def _cleanup(path: str): + """Cleanup a file if it exists. + + Parameters + ---------- + path : str + The path to cleanup. + """ + if os.path.exists(path): + os.remove(path) + + def _generator() -> Generator[str | bytes, None, None]: + """Create the generator for reading file data. + + Yields + ------ + Generator[str | bytes, None, None] + Chunks of file data. + """ + try: + with open(path, mode) as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + yield chunk + finally: + _cleanup(path) + + generator = _generator() + weakref.finalize(generator, _cleanup, path) # Ensure file removal even if generator is never used + return generator diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index a9bff8b..9fcb3dc 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -1,8 +1,9 @@ import pytest import numpy as np import zipfile -import base64 import io +import json +import os import podpac import pyproj from shapely import Point, Polygon @@ -13,6 +14,46 @@ from pygeoapi.provider.base import ProviderInvalidQueryError +def get_json_with_cleanup(path: str) -> Dict[str, Any]: + """Get JSON data from a path and remove the file after retrieval. + + Parameters + ---------- + path : str + The JSON file path. + + Returns + ------- + Dict[str, Any] + JSON data from the path. + """ + with open(path, "r") as f: + response = json.load(f) + + os.remove(path) + return response + + +def get_bytes_with_cleanup(path: str) -> bytes: + """Get byte data from a path and remove the file after retrieval. + + Parameters + ---------- + path : str + The file path. + + Returns + ------- + bytes + Binary data from the path. + """ + with open(path, "rb") as f: + response = f.read() + + os.remove(path) + return response + + def get_provider_definition(base_url: str) -> Dict[str, Any]: """Define the provider definition which is typically handled by pygeoapi. @@ -186,6 +227,7 @@ def test_edr_provider_position_request_valid_wkt( provider.set_layers(base_url, layers) response = provider.position(**args) + response = get_json_with_cleanup(response["fp"]) assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( @@ -291,6 +333,7 @@ def test_edr_provider_cube_request_valid_bbox( provider.set_layers(base_url, layers) response = provider.cube(**args) + response = get_json_with_cleanup(response["fp"]) assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( @@ -325,6 +368,7 @@ def test_edr_provider_cube_request_valid_bbox_with_resolution( provider.set_extra_query_args({"resolution-x": resolution_x, "resolution-y": resolution_y}) response = provider.cube(**args) + response = get_json_with_cleanup(response["fp"]) assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == resolution_x * resolution_y @@ -426,6 +470,7 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la provider.set_layers(base_url, layers) response = provider.area(**args) + response = get_json_with_cleanup(response["fp"]) assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( @@ -504,9 +549,10 @@ def test_edr_provider_cube_request_valid_geotiff_format( provider.set_layers(base_url, layers) response = provider.cube(**args) + data = get_bytes_with_cleanup(response["fp"]) assert response["fn"] == f"{parameter_name}.tif" - assert len(base64.b64decode(response["fp"])) > 0 + assert len(data) > 0 def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( @@ -535,8 +581,7 @@ def test_edr_provider_cube_request_valid_geotiff_format_multiple_parameters( provider.set_layers(base_url, layers) response = provider.cube(**args) - buffer = io.BytesIO(base64.b64decode(response["fp"])) - + buffer = io.BytesIO(get_bytes_with_cleanup(response["fp"])) assert response["fn"] == f"{group}.zip" assert zipfile.is_zipfile(buffer) with zipfile.ZipFile(buffer, "r") as zf: diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 9cc918a..0f9b350 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -1,5 +1,7 @@ +import os import json import numpy as np +import tempfile from pygeoapi.api import APIRequest from http import HTTPStatus from typing import Dict, List, Any @@ -222,6 +224,7 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube instance_id=instance_id, query_type="cube", ) + content = json.loads("".join(content)) assert status == HTTPStatus.OK @@ -333,6 +336,7 @@ def test_edr_routes_collection_query_missing_parameter( instance_id=next(iter(layers[0].time_instances())), query_type="cube", ) + content = json.loads("".join(content)) assert status == HTTPStatus.OK assert content["domain"]["ranges"].keys() == {layer.identifier for layer in layers} @@ -349,3 +353,31 @@ def test_edr_routes_request_url_updates_configuration_url(): assert status == HTTPStatus.OK assert edr_routes.api.config["server"]["url"] == expected_config_url + + +def test_edr_routes_file_cleanup_unused_generator(): + """Test the EDR routes properly cleans up files if the generator goes unused.""" + named_file = tempfile.NamedTemporaryFile(mode="w+", delete=False) + generator = EdrRoutes.file_generator_with_cleanup(named_file.name, "r") + + assert os.path.exists(named_file.name) + + del generator + + assert not os.path.exists(named_file.name) + + +def test_edr_routes_file_cleanup_generator_error(): + """Test the EDR routes properly cleans up files if the generator has an error.""" + named_file = tempfile.NamedTemporaryFile(mode="w+", delete=False) + generator = EdrRoutes.file_generator_with_cleanup(named_file.name, "r") + + assert os.path.exists(named_file.name) + + try: + next(generator) + generator.close() + except StopIteration: + pass + + assert not os.path.exists(named_file.name) diff --git a/ogc/servers.py b/ogc/servers.py index b60af15..4b93993 100755 --- a/ogc/servers.py +++ b/ogc/servers.py @@ -320,14 +320,7 @@ def wrapper(*args, **kwargs) -> Response: response = make_response(content, status) if headers: response.headers = headers - # Check Content Disposition for attachment downloads - match = re.search(r'filename="?([^"]+)"?', headers.get("Content-Disposition", "")) - if match: - filename = match.group(1) - as_attach = True if filename.endswith("zip") or filename.endswith("tif") else False - return send_file(content, as_attachment=as_attach, download_name=filename) - else: - return response + return response except Exception as e: logger.error("OGC: server.edr_render Exception: %s", str(e), exc_info=True) ee = WCSException() From 86586170ddf8e1d004a56cd2efdc0bc834e83006 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 5 Mar 2026 14:08:08 -0500 Subject: [PATCH 08/12] Update error messaging for coordinates to include time --- ogc/edr/edr_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 76aaf85..3b78164 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -223,7 +223,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): self.check_query_condition( bool(requested_native_coordinates.size > settings.MAX_GRID_COORDS_REQUEST_SIZE), - "Grid coordinates x_size * y_size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, + "Coordinates size must be less than %d" % settings.MAX_GRID_COORDS_REQUEST_SIZE, ) dataset = {} From dec24f76b2d9530bdf2bd02f18decbe09cc534ef Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 5 Mar 2026 14:48:57 -0500 Subject: [PATCH 09/12] Remove extra parameter names property from instance data --- ogc/edr/edr_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index d00ec10..ff13604 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -362,7 +362,7 @@ def _instance_parameters( Dict[str, Any] The metadata for available parameters in the instance. """ - instance_parameters = {"parameter_names": {}} + instance_parameters = {} for key, value in provider_parameters.items(): layer = next((layer for layer in collection_layers if layer.identifier == key), None) if layer is not None and instance in layer.time_instances(): From db899f99a3276e0103f64a31af14a044da8343eb Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Thu, 5 Mar 2026 15:18:21 -0500 Subject: [PATCH 10/12] Update axis names to use x,y,t,z --- ogc/edr/edr_provider.py | 5 ++++- ogc/edr/test/test_edr_provider.py | 8 ++++---- ogc/edr/test/test_edr_routes.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index 3b78164..b2fe4d6 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -903,6 +903,9 @@ def to_coverage_json( x_arr = list(x_arr.flatten()) y_arr = list(y_arr.flatten()) + lon_map_value, lat_map_value = EdrProvider.crs_converter("x", "y", crs) + dimension_map = {"time": "t", "alt": "z", "lon": lon_map_value, "lat": lat_map_value} + coverage_json = { "type": "Coverage", "domain": { @@ -992,7 +995,7 @@ def to_coverage_json( param: { "type": "NdArray", "dataType": "float", - "axisNames": list(data_array.coords.keys()), + "axisNames": [dimension_map.get(str(key), str(key)) for key in data_array.coords.keys()], "shape": data_array.shape, "values": data, # Row Major Order } diff --git a/ogc/edr/test/test_edr_provider.py b/ogc/edr/test/test_edr_provider.py index 9fcb3dc..fddd6bc 100644 --- a/ogc/edr/test/test_edr_provider.py +++ b/ogc/edr/test/test_edr_provider.py @@ -229,7 +229,7 @@ def test_edr_provider_position_request_valid_wkt( response = provider.position(**args) response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -335,7 +335,7 @@ def test_edr_provider_cube_request_valid_bbox( response = provider.cube(**args) response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) @@ -370,7 +370,7 @@ def test_edr_provider_cube_request_valid_bbox_with_resolution( response = provider.cube(**args) response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == resolution_x * resolution_y assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] @@ -472,7 +472,7 @@ def test_edr_provider_area_request_valid_wkt(layers: List[pogc.Layer], single_la response = provider.area(**args) response = get_json_with_cleanup(response["fp"]) - assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(response["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(response["domain"]["ranges"][parameter_name]["shape"])) == len( response["domain"]["ranges"][parameter_name]["values"] ) diff --git a/ogc/edr/test/test_edr_routes.py b/ogc/edr/test/test_edr_routes.py index 0f9b350..e9eb11f 100644 --- a/ogc/edr/test/test_edr_routes.py +++ b/ogc/edr/test/test_edr_routes.py @@ -228,7 +228,7 @@ def test_edr_routes_collection_query(layers: List[pogc.Layer], single_layer_cube assert status == HTTPStatus.OK - assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == {"lat", "lon", "time"} + assert set(content["domain"]["ranges"][parameter_name]["axisNames"]) == {"x", "y", "t"} assert np.prod(np.array(content["domain"]["ranges"][parameter_name]["shape"])) == len( content["domain"]["ranges"][parameter_name]["values"] ) From ece9e8a4dec8d2c5ee656d54909bcf0fb05aa02c Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 6 Mar 2026 09:18:00 -0500 Subject: [PATCH 11/12] Pin pygeoapi to 0.22.0 version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2cf8ea1..e104951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ 'lxml', 'webob', 'traitlets', - 'pygeoapi', + 'pygeoapi==0.22.0', 'shapely', 'numpy', 'traitlets', From 28753494c7b4f3d69d0d8097a81642c3d6e9a617 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 6 Mar 2026 22:05:35 +0000 Subject: [PATCH 12/12] Add comment for temporary file usage --- ogc/edr/edr_provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index b2fe4d6..a1766a3 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -878,6 +878,8 @@ def to_coverage_json( ) -> Dict[str, Any]: """Generate a CoverageJSON of the data for the provided parameters. + The returned object must be serializable, so a temporary file is returned to reference the data. + Parameters ---------- layers : List[pogc.Layer] @@ -1032,6 +1034,8 @@ def check_query_condition(conditional: bool, message: str): def to_geotiff_response(dataset: Dict[str, podpac.UnitsDataArray], collection_id: str) -> Dict[str, Any]: """Generate a geotiff of the data for the provided parameters. + The returned object must be serializable, so a temporary file is returned to reference the data. + Parameters ---------- dataset : Dict[str, podpac.UnitsDataArray]