From de0d6436a39f7e582d2108646466ceee469adfa1 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 13 Mar 2026 12:38:24 +0000 Subject: [PATCH 1/5] Remove html and json from query options --- ogc/edr/edr_provider.py | 6 +----- ogc/edr/test/conftest.py | 4 ++-- ogc/settings.py | 6 +++--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index a1766a3..d235bc8 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -234,11 +234,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): self.check_query_condition(len(dataset) == 0, "No matching parameters found.") - if ( - output_format == settings.COVERAGE_JSON.lower() - or output_format == settings.JSON.lower() - or output_format == settings.HTML.lower() - ): + if output_format == settings.COVERAGE_JSON.lower(): layers = self.get_layers(self.base_url, self.collection_id) return self.to_coverage_json(layers, dataset, self.collection_id, crs) diff --git a/ogc/edr/test/conftest.py b/ogc/edr/test/conftest.py index b87b3e5..2c36dd2 100644 --- a/ogc/edr/test/conftest.py +++ b/ogc/edr/test/conftest.py @@ -59,7 +59,7 @@ def single_layer_cube_args() -> Dict[str, Any]: """ return { - "f": "json", + "f": "coveragejson", "bbox": "-180, -90, 180, 90", "datetime": str(time[0]), "parameter-name": [layer1.identifier], @@ -77,7 +77,7 @@ def single_layer_cube_args_internal() -> Dict[str, Any]: """ return { - "format_": "json", + "format_": "coveragejson", "instance": str(time[0]), "bbox": [-180, -90, 180, 90], "datetime_": str(time[0]), diff --git a/ogc/settings.py b/ogc/settings.py index f6ef46a..6859455 100755 --- a/ogc/settings.py +++ b/ogc/settings.py @@ -45,9 +45,9 @@ COVERAGE_JSON = "CoverageJSON" HTML = "HTML" EDR_QUERY_FORMATS = { - "cube": [GEOTIFF, COVERAGE_JSON, JSON, HTML], - "area": [GEOTIFF, COVERAGE_JSON, JSON, HTML], - "position": [COVERAGE_JSON, JSON, HTML], + "cube": [GEOTIFF, COVERAGE_JSON], + "area": [GEOTIFF, COVERAGE_JSON], + "position": [COVERAGE_JSON], } EDR_QUERY_DEFAULTS = { "cube": GEOTIFF, From 27c47f0e5b1a64c030403bdfbd9394901a755d90 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Fri, 13 Mar 2026 12:38:55 +0000 Subject: [PATCH 2/5] Update default description and title --- ogc/edr/config/default.json | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ogc/edr/config/default.json b/ogc/edr/config/default.json index cf2a734..6aea353 100644 --- a/ogc/edr/config/default.json +++ b/ogc/edr/config/default.json @@ -24,12 +24,9 @@ }, "metadata": { "identification": { - "title": "OGC Server", - "description": "An example OGC Server", - "keywords": [ - "geospatial", - "podpac" - ], + "title": "EDR Server", + "description": "Environmental Data Retrieval Server", + "keywords": ["geospatial", "podpac"], "keywords_type": "theme", "terms_of_service": "http://www.apache.org/licenses/LICENSE-2.0", "url": "https://github.com/creare-com/ogc" @@ -47,4 +44,4 @@ "url": "https://github.com/creare-com" } } -} \ No newline at end of file +} From 4ebfc83a642f077399bc068e2979b6398f056385 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 16 Mar 2026 13:26:30 -0400 Subject: [PATCH 3/5] Move extent generation out of configuration --- ogc/edr/edr_api.py | 192 +++++++++++++++++++++++++++++++--------- ogc/edr/edr_config.py | 135 ++-------------------------- ogc/edr/edr_provider.py | 4 +- 3 files changed, 162 insertions(+), 169 deletions(-) diff --git a/ogc/edr/edr_api.py b/ogc/edr/edr_api.py index ff13604..ed18663 100644 --- a/ogc/edr/edr_api.py +++ b/ogc/edr/edr_api.py @@ -1,4 +1,5 @@ import json +import pyproj import numpy as np import pygeoapi.api import pygeoapi.api.environmental_data_retrieval as pygeoedr @@ -11,6 +12,7 @@ from pygeoapi.api import API, APIRequest from pygeoapi.linked_data import jsonldify from .edr_provider import EdrProvider +from .. import settings class EdrAPI: @@ -142,26 +144,17 @@ def describe_collections(api: API, request: APIRequest, dataset: str | None = No provider = get_provider_by_type(collection_configuration[collection_id]["providers"], "edr") provider_plugin = load_plugin("provider", provider) provider_parameters = provider_plugin.get_fields() - - extents = collection.get("extent", {}) - if "vertical" in collection_configuration[collection_id]["extents"]: - extents = extents | {"vertical": collection_configuration[collection_id]["extents"]["vertical"]} - if "temporal" in collection_configuration[collection_id]["extents"]: - times = collection_configuration[collection_id]["extents"]["temporal"].get("values", []) - trs = collection_configuration[collection_id]["extents"]["temporal"].get("trs") - temporal_extents = EdrAPI._temporal_extents(times, trs) - extents = extents | temporal_extents - collection["extent"] = extents - + collection_layers = EdrProvider.get_layers(provider["base_url"], collection_id) + collection["extent"] = EdrAPI._generate_extents(collection_layers, None) collection["output_formats"] = collection_configuration[collection_id].get("output_formats", []) collection_queryable = EdrProvider.is_collection_queryable(provider["base_url"], collection_id) + collection_without_queryables = EdrAPI._remove_query_metadata(collection) + collection["links"] = collection_without_queryables["links"] # Always remove unnecessary query links 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", []) + height_units = EdrAPI._vertical_units(collection_layers) query_formats = collection_configuration[collection_id].get("query_formats", {}) for query_type in collection["data_queries"]: data_query_additions = { @@ -216,27 +209,11 @@ def get_collection_edr_instances( collection_layers = EdrProvider.get_layers(provider["base_url"], dataset) for instance in instances: - extents = instance.get("extent", {}) - if "vertical" in collection_configuration[dataset]["extents"]: - extents = extents | {"vertical": collection_configuration[dataset]["extents"]["vertical"]} - - times = EdrProvider.get_datetimes(collection_layers, instance["id"]) - if len(times) > 0: - trs = collection_configuration[dataset]["extents"]["temporal"].get("trs") - temporal_extents = EdrAPI._temporal_extents(times, trs) - extents = extents | temporal_extents - - bbox = collection_configuration[dataset]["extents"]["spatial"]["bbox"] - if not isinstance(bbox[0], list): - bbox = [bbox] - crs = collection_configuration[dataset]["extents"]["spatial"].get("crs") - spatial_extents = EdrAPI._spatial_extents(bbox, crs) - extents = extents | spatial_extents - instance["extent"] = extents - + instance_id = instance["id"] + instance["extent"] = EdrAPI._generate_extents(collection_layers, instance_id) instance["output_formats"] = collection_configuration[dataset].get("output_formats", []) - height_units = collection_configuration[dataset].get("height_units") + height_units = EdrAPI._vertical_units(collection_layers) query_formats = collection_configuration[dataset].get("query_formats", {}) for query_type in instance["data_queries"]: data_query_additions = { @@ -300,7 +277,7 @@ def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | No Returns ------- Dict[str, Any] - The temporal extent object. + The temporal extent object or an empty dictionary.. """ iso_times = [] for time in sorted(times): @@ -311,13 +288,45 @@ def _temporal_extents(times: List[Union[np.datetime64, datetime]], trs: str | No time_utc = dt.astimezone(timezone.utc) iso_times.append(time_utc.isoformat().replace("+00:00", "Z")) - return { - "temporal": { - "interval": [iso_times[0], iso_times[-1]] if len(iso_times) > 0 else [], - "values": iso_times, - "trs": trs, + return ( + { + "temporal": { + "interval": [iso_times[0], iso_times[-1]], + "values": iso_times, + "trs": trs, + } } - } + if len(iso_times) > 0 + else {} + ) + + @staticmethod + def _vertical_extents(vertical_levels: List[float], vrs: str | None) -> Dict[str, Any]: + """Get the vertical extents for the provided levels and reference system. + + Parameters + ---------- + vertical_levels : List[float] + Vertical levels used to create the vertical extent. + vrs : str | None + The reference system for the vertical levels. + + Returns + ------- + Dict[str, Any] + The vertical extent object or an empty dictionary. + """ + return ( + { + "vertical": { + "interval": [vertical_levels[0], vertical_levels[-1]], + "values": vertical_levels, + "vrs": vrs, + } + } + if len(vertical_levels) > 0 + else {} + ) @staticmethod def _spatial_extents(bbox: List[float], crs: str | None) -> Dict[str, Any]: @@ -342,6 +351,109 @@ def _spatial_extents(bbox: List[float], crs: str | None) -> Dict[str, Any]: } } + @staticmethod + def _vertical_units(layers: List[pogc.Layer]) -> List[str]: + """Retrieve the vertical units for the layers. + + Parameters + ---------- + layer : List[pogc.Layer] + The layers from which to get the vertical units. + + Returns + ------- + List[str] + The vertical units available for the layers. + """ + vertical_units = set() + for layer in layers: + coordinates = layer.get_coordinates() + if coordinates is not None and coordinates.alt_units: + vertical_units.add(coordinates.alt_units) + + return list(vertical_units) + + @staticmethod + def _crs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]: + """Retrieve the bounding box for the layer with a default fallback. + + Parameters + ---------- + layer : pogc.Layer + The layer from which to get the bounding box coordinates. + + Returns + ------- + Tuple[float, float, float, float] + Lower-left longitude, lower-left latitude, upper-right longitude, upper-right latitude. + """ + try: + return ( + layer.grid_coordinates.LLC.lon, + layer.grid_coordinates.LLC.lat, + layer.grid_coordinates.URC.lon, + layer.grid_coordinates.URC.lat, + ) + except Exception: + crs_extents = settings.EDR_CRS[settings.crs_84_uri_format] + return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) + + @staticmethod + def _generate_extents(layers: List[pogc.Layer], instance: str | None) -> Dict[str, Any]: + """Generate the extents for the provided layers. + + Parameters + ---------- + layers : List[pogc.Layer] + The layers for temporal and spatial extent generation. + instance : str | None + The instance for extent generation or None for collection extents. + + Returns + ------- + Dict[str, Any] + The extents dictionary for the provided layers. + """ + bbox = [] + crs = pyproj.CRS(settings.crs_84_uri_format).to_wkt() + + time_range = set() + vertical_range = set() + + for layer in layers: + coordinates = layer.get_coordinates() + if coordinates is not None: + llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp = EdrAPI._crs84_bounding_box(layer) + if len(bbox) != 4: + bbox = [llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp] + else: + llc_lon = min(bbox[0], llc_lon_tmp) + llc_lat = min(bbox[1], llc_lat_tmp) + urc_lon = max(bbox[2], urc_lon_tmp) + urc_lat = max(bbox[3], urc_lat_tmp) + bbox = [llc_lon, llc_lat, urc_lon, urc_lat] + + if "alt" in coordinates.udims: + vertical_range.update(coordinates["alt"].coordinates) + + if "time" in coordinates.udims: + if instance in layer.time_instances() and "forecastOffsetHr" in coordinates.udims: + instance_datetime = np.datetime64(instance) + instance_coordinates = coordinates.select({"time": [instance_datetime, instance_datetime]}) + selected_offset_coordinates = instance_coordinates["forecastOffsetHr"].coordinates + time_range.update([instance_datetime + offset for offset in selected_offset_coordinates]) + elif not instance and "forecastOffsetHr" not in coordinates.udims: + time_range.update(coordinates["time"].coordinates) + + sorted_time_range = sorted(time_range) + sorted_vertical_range = sorted(vertical_range) + + return { + **(EdrAPI._spatial_extents(bbox, crs)), + **(EdrAPI._temporal_extents(sorted_time_range, "https://www.opengis.net/def/uom/ISO-8601/0/Gregorian")), + **(EdrAPI._vertical_extents(sorted_vertical_range, "https://www.opengis.net/def/uom/EPSG/0/9001")), + } + @staticmethod def _instance_parameters( collection_layers: List[pogc.Layer], provider_parameters: Dict[str, Any], instance: str diff --git a/ogc/edr/edr_config.py b/ogc/edr/edr_config.py index 58a032a..2d9716f 100644 --- a/ogc/edr/edr_config.py +++ b/ogc/edr/edr_config.py @@ -1,9 +1,7 @@ import os import json import logging -import traitlets as tl -from collections import defaultdict -from typing import List, Dict, Tuple, Any +from typing import List, Dict, Any from ogc import podpac as pogc from .. import settings @@ -82,14 +80,10 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, """ resources = {} - groups = defaultdict(list) - - # Organize the data into groups - for layer in layers: - groups[layer.group].append(layer) + groups = {layer.group for layer in layers} # Generate collection resources based on groups - for group_name, group_layers in groups.items(): + for group_name in groups: resource = { group_name: { "type": "collection", @@ -97,8 +91,12 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, "title": group_name, "description": f"Collection of data related to {group_name}", "keywords": ["podpac"], - "extents": EdrConfig._generate_extents(group_layers), - "height_units": EdrConfig._vertical_units(group_layers), + "extents": { + "spatial": { + "bbox": [-180, -90, 180, 90], # Placeholder extents + "crs": settings.crs_84_uri_format, + } + }, "output_formats": list({item for values in settings.EDR_QUERY_FORMATS.values() for item in values}), "query_formats": EdrConfig.data_query_formats(), "providers": [ @@ -131,121 +129,6 @@ def _resources_definition(base_url: str, layers: List[pogc.Layer]) -> Dict[str, return resources - @staticmethod - def _generate_extents(layers: List[pogc.Layer]) -> Dict[str, Any]: - """Generate the extents dictionary for provided layers. - - Parameters - ---------- - layers : List[pogc.Layer] - The layers to create the temporal and spatial extents for. - - Returns - ------- - Dict[str, Any] - The extents dictionary for the layers. - """ - llc_lon, llc_lat, urc_lon, urc_lat = None, None, None, None - time_range = set() - vertical_range = set() - # Determine bounding box which holds all layers - for layer in layers: - llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp = EdrConfig._wgs84_bounding_box(layer) - if any(coord is None for coord in [llc_lon, llc_lat, urc_lon, urc_lat]): - llc_lon, llc_lat, urc_lon, urc_lat = llc_lon_tmp, llc_lat_tmp, urc_lon_tmp, urc_lat_tmp - else: - llc_lon = min(llc_lon, llc_lon_tmp) - llc_lat = min(llc_lat, llc_lat_tmp) - urc_lon = max(urc_lon, urc_lon_tmp) - urc_lat = max(urc_lat, urc_lat_tmp) - - 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) - - sorted_time_range = sorted(time_range) - sorted_vertical_range = sorted(vertical_range) - - return { - "spatial": { - "bbox": [llc_lon, llc_lat, urc_lon, urc_lat], # minx, miny, maxx, maxy - "crs": settings.crs_84_uri_format, - }, - **( - { - "temporal": { - "begin": sorted_time_range[0], # start datetime in RFC3339 - "end": sorted_time_range[-1], # end datetime in RFC3339 - "values": sorted_time_range, - "trs": "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian", - } - } - if len(sorted_time_range) > 0 - else {} - ), - **( - { - "vertical": { - "interval": [sorted_vertical_range[0], sorted_vertical_range[-1]], - "values": sorted_vertical_range, - "vrs": "http://www.opengis.net/def/uom/EPSG/0/9001", - } - } - if len(sorted_vertical_range) > 0 - else {} - ), - } - - @staticmethod - def _wgs84_bounding_box(layer: pogc.Layer) -> Tuple[float, float, float, float]: - """Retrieve the bounding box for the layer with a default fallback. - - Parameters - ---------- - layer : pogc.Layer - The layer from which to get the bounding box coordinates. - - Returns - ------- - Tuple[float, float, float, float] - Lower-left longitude, lower-left latitude, upper-right longitude, upper-right latitude. - """ - try: - return ( - layer.grid_coordinates.LLC.lon, - layer.grid_coordinates.LLC.lat, - layer.grid_coordinates.URC.lon, - layer.grid_coordinates.URC.lat, - ) - except Exception: - crs_extents = settings.EDR_CRS[settings.crs_84_uri_format] - return (crs_extents["minx"], crs_extents["miny"], crs_extents["maxx"], crs_extents["maxy"]) - - @staticmethod - def _vertical_units(layers: List[pogc.Layer]) -> List[str]: - """Retrieve the vertical units for the layers. - - Parameters - ---------- - layer : List[pogc.Layer] - The layers from which to get the vertical units. - - Returns - ------- - str | None - The vertical units available for the layers. - """ - vertical_units = set() - for layer in layers: - coordinates = layer.get_coordinates() - if coordinates is not None and coordinates.alt_units: - vertical_units.add(coordinates.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. diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index d235bc8..ce32cb6 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -661,9 +661,7 @@ def get_datetimes(layers: List[pogc.Layer], instance_time: str | None) -> List[n instance_datetime = np.datetime64(instance_time) 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] - ) + available_times.update([instance_datetime + offset for offset in selected_offset_coordinates]) elif not instance_time and "forecastOffsetHr" not in coordinates.udims: # Retrieve layer times for non-instance requests available_times.update(coordinates["time"].coordinates) From ad1ea0bb6ae2e956af6f7f9eb18c64479d8c9043 Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 23 Mar 2026 10:04:58 -0400 Subject: [PATCH 4/5] Use unstacked coordinates for requesting data --- ogc/edr/edr_provider.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ogc/edr/edr_provider.py b/ogc/edr/edr_provider.py index ce32cb6..57fb836 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -860,9 +860,7 @@ def interpret_time_coordinates( if instance_time: offsets = [np.timedelta64(time - np.datetime64(instance_time), "h") for time in times] - return podpac.Coordinates( - [[[instance_time] * len(offsets), offsets]], dims=[["time", "forecastOffsetHr"]], crs=crs - ) + return podpac.Coordinates([[instance_time], offsets], dims=["time", "forecastOffsetHr"], crs=crs) return podpac.Coordinates([times], dims=["time"], crs=crs) From 2315db3babee3c6d6003707620a31a2da7165bdc Mon Sep 17 00:00:00 2001 From: Sam Cranford Date: Mon, 23 Mar 2026 10:34:44 -0400 Subject: [PATCH 5/5] Update temporal check for unstacked forecast offset hour --- 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 57fb836..0b94842 100644 --- a/ogc/edr/edr_provider.py +++ b/ogc/edr/edr_provider.py @@ -202,7 +202,7 @@ def handle_query(self, requested_coordinates: podpac.Coordinates, **kwargs): if time_coords is not None: self.check_query_condition( - len(time_coords["time"].coordinates) > 1 and output_format == settings.GEOTIFF.lower(), + any(dimension > 1 for dimension in time_coords.shape) and output_format == settings.GEOTIFF.lower(), "GeoTIFF output currently only supports single time requests.", ) requested_coordinates = podpac.coordinates.merge_dims([time_coords, requested_coordinates])